聊聊跨域

传统的javaee架构

下面是常见的 javaee 架构的简化版,客户端请求 apache/nginx 代理服务器,代理服务器接收到请求后将请求转发到后台的应用服务器(tomcat、jeety 等),后台应用服务器处理请求,将结果返回给代理服务器,代理服务器接收到请求后,再将请求返回给客户端。这就是一个完整的请求、响应的流程。


传统javaee架构图

什么是跨域请求

前台接口调用后台服务的时候,如果前台接口跟后台服务不是同源的,就会产生跨域问题。存在跨域的情况:

举例:如下图,当调用方A直接访问被调用方B的资源时,就会报跨域安全访问问题:


跨域请求示例图

模拟跨域请求

这里我将自己编写前后台代码,来模拟一个跨域请求,让大家对跨域请求有一个直观的感受。
后台代码用 spring boot 编写一个简单的接口:

@Controller
public class HelloController {
    
    @GetMapping("/test")
    public String test(){
        return "请求成功";
    }
 
}

然后在 application.yml 配置文件中,设置程序的启动端口为 8085:

server:
  port: 8085

运行项目,在浏览器地址栏输入 http://localhost:8085/get_name,浏览器显示 “请求成功”。
接下来是前台代码 index.html

<!DOCTYPE html>
<html>
    <head>
    <meta charset="utf-8" />
    <title></title>
    <script src="https://lib.sinaapp.com/js/jquery/2.0.2/jquery-2.0.2.min.js"></script>
    </head>
    <body>
    <button>发送</button>
 
    <script>
        var baseUrl = "http://localhost:8085";
        $("button").click(function() {
            $.get(baseUrl = "/test", function(result) {
                alert(test);
            });
        });
    </script>
    </body>
</html>

然后运行后端代码,前端代码放到 tomcat 的 webapps 目录下,然后启动 tomcat。这时,后端代码的运行端口是 8085,tomcat的运行端口是默认的 8080。运行完毕后,访问http://localhost:8080/index.html,点击发送按钮,浏览器控制台报错如下:

Failed to load http://localhost:8085/test: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://127.0.0.1:8080' is therefore not allowed access.

这就是跨域问题。

跨域问题产生原因

  • 浏览器限制:浏览器出于安全反面的考虑,会限制从脚本发起的跨源HTTP请求。而不是后台不给访问。

刚刚我们编写的请求浏览器控制台报了一个跨域请求的 error,那么我们到底调用到后台接口没有,还是在调用的时候就被浏览器拦截了呢?我们可以在浏览器的调试窗口的 network 监控台看看:


chrome调试窗口

从控制台可以看出,我们发出的 ajax 请求已经调用到后台接口,并成功返回了。但是浏览器识别到这是一个非同源的请求,所以将它拦截下来了,不给显示。

  • XHR(XMLHttpRequest)请求

只有当发出去的请求是 XMLHttpRequest 请求,浏览器才会报跨域安全访问错误。可以通过修改刚刚的前端代码来验证我们的观点:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title></title>
        <script src="https://lib.sinaapp.com/js/jquery/2.0.2/jquery-2.0.2.min.js"></script>
    </head>
    <body>
        <img src="http://img5.imgtn.bdimg.com/it/u=415293130,2419074865&fm=26&gp=0.jpg" width="200px"></img>
        <button>发送</button>
        <script>
            var baseUrl = "http://localhost:8085";
            $("button").click(function() {
                $.get(baseUrl + "/test", function(result) {
                    alert("你好," + result);
                });
            });
        </script>
    </body>
</html>

在这里我们加了个 img 标签,src 指向外网的一个地址。运行代码,控制台并没有报跨域访问安全问题。这是由于我们的 img 标签发送的请求类型是一个 jpg 请求,并不是 XHR 请求,所以浏览器不会报跨域安全访问问题。

chrome调试窗口

跨域请求解决方法

  • 被调用方解决方案

浏览器请求非同源接口的时候,会从返回头中查找是否存在允许跨域访问的头信息,如果存在则正常显示,不报跨域安全问题。基于这个原理,我们可以修改后端代码,添加指定的返回头信息:
在启动类上增加 @ServletComponentScan 注解:

@SpringBootApplication
@ServletComponentScan
public class FirstProjectApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(FirstProjectApplication.class, args);
    }
 
}

添加过滤器,拦截所有请求,CrosFilter.java:

@WebFilter(urlPatterns = "/*",filterName = "crosFilter")
public class CrosFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse res = (HttpServletResponse) response;
        //允许指定域跨域调用
        res.addHeader("Access-Control-Allow-Origin","http://localhost:8080");
        //允许GET方法跨域调用 
        res.addHeader("Access-Control-Allow-Methods","GET"); 
        chain.doFilter(request,response);
    }
}

前端代码不用修改,接着访问前端页面,点击发送按钮:


alt chrome调试窗口

从控制台可以看出,响应头上已经有我们增加的头信息,并且浏览器成功返回,不报跨域问题。但这里我只允许 localhost:8080 的 GET 方法跨域调用,那么如果我们想要所有域的所有方法都能跨域调用该怎么办呢?很简单,只需要将允许的域跟方法都指定为允许所有就可以了:

res.addHeader("Access-Control-Allow-Origin","http://localhost:8080");
res.addHeader("Access-Control-Allow-Methods","GET");

虽然现在的请求不报跨域问题,但是并非适合所有的场景,比如下面的例子:
在 HelloController 增加一个 postJson 方法

@PostMapping("/postJson")
public String postJson(@RequestBody User user){
    System.out.println(user);
    return "请求成功";
}

前端增加一个按钮,请求 postJson 方法:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title></title>
        <script src="https://lib.sinaapp.com/js/jquery/2.0.2/jquery-2.0.2.min.js"></script>
    </head>
    <body>
        <button id="test">发送</button>
        <button id="postJson">发送json请求</button>
        <script>
            var baseUrl = "http://localhost:8085";
            $("#test").click(function() {
                $.get(baseUrl + "/postJson",
                    function(result) {
                        alert("你好," + result);
                    });
                });
            $("#postJson").click(function() {
                $.ajax({
                    url: baseUrl + "/test",
                    type: "post",
                    contentType: "application/json;charset=utf-8",
                    data:JSON.stringify({name:"hxy"}),
                    success: function(result) {
                        alert(result);
                    }
                 })
             });
        </script>
    </body>
</html>

打开浏览器调试窗口,点击 “发送json请求” 按钮,发现浏览器还是报了跨域安全访问错误:

Access to XMLHttpRequest at 'http://localhost:8085/test' from origin 'http://127.0.0.1:8080' has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.

报错信息说的是请求头里面的 content-type 没有被允许,这是因为 post 请求时非简单请求,浏览器在发送非简单请求的时候,会先发送一个预检命令,询问服务器后台是否允许该请求头进行跨域访问。知道了这个原理之后,就能很简单的解决该问题了,只需要在后台允许该请求头的访问就可以了:

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    HttpServletResponse res = (HttpServletResponse) response;
    // 允许所有域跨域调用
    res.addHeader("Access-Control-Allow-Origin","*");
    // 允许所有方法跨域调用
    res.addHeader("Access-Control-Allow-Methods","*");
    // 允许content-type请求头
    res.addHeader("Access-Control-Allow-Headers","content-type");
    chain.doFilter(request,response);
}

再次发送 post 请求,可以看到浏览器实际上是发了两个请求,第一个是预检命令,当预检命令检查通过后,再发送真正的 post 请求:


chrome调试窗口

常见的简单请求:请求方法为 GET、HEAD、POST,并且 header 里面无自定义请求头,Content-Type 为以下几种:text/plain、multipart/form-data、application/x-www-form-urlencoded
常见的非简单请求:PUT、DELETE 请求,发送 json 的请求,带自定义请求头的请求。

这样的话每次发送非简单请求都会发送两个请求,这样会影响我们的效率,可以在响应头中增加 Access-Control-Max-Age 字段,告诉浏览器允许缓存预检命令:

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    HttpServletResponse res = (HttpServletResponse) response;
    // 允许所有域跨域调用
    res.addHeader("Access-Control-Allow-Origin","*");
    // 允许所有方法跨域调用
    res.addHeader("Access-Control-Allow-Methods","*");
    // 允许content-type请求头
    res.addHeader("Access-Control-Allow-Headers","content-type");
    // 允许缓存预检命令,单位为秒
    res.addHeader("Access-Control-Max-Age","3600");
    chain.doFilter(request,response);
}

这里我们指定缓存时间为 1 个小时,这样浏览器第一次发送非简单请求的时候,会先发送一条预检命令,当预检命令检查通过,才将真正的请求发送出去。当第二次请求的时候,会判断预检命令是否失效,如果还没失效,那么将不会发送预检命令,而是直接发送请求。大大提高了我们请求的效率。

工作中发送请求还会有另外两种情况,那就是带 cookie 的请求,跟自定义请求头的请求。那么,我们上面的写发是否也支持这两种请求呢?下面就用两个例子来验证一下:

前端添加一个带 cookie 的请求,跟一个带自定义请求头的请求:

// 发送带 cookie 的请求
$("#getCookie").click(function() {
    $.ajax({
        url: baseUrl + "/getCookie",
        type: "get",
        xhrFields:{
        withCredentials:true
        },
        success: function(result) {
            alert(result);
        }
    })
});
// 发送带自定义请求头的请求
$("#getHeader").click(function() {
    $.ajax({
        url: baseUrl + "/getHeader",
        type: "get",
        headers: {
            "myheader": "hxy"
        },
        success: function(result) {
            alert(result);
        }
    })
});

然后在后端中添加两个接口:

@GetMapping("/getCookie")
public String getCookie(@CookieValue(value = "mycookie") String cookie) {
    return "请求成功,接收到 cookie " + cookie;
}
 
@GetMapping("/getHeader")
public String getHeader(@RequestHeader(value = "myheader") String header) {
    return "请求成功,请求头" + header;
}

在后端服务器的域中添加 mycookie:


添加cookie

点击发送带 cookie 请求按钮,可以看到浏览器报了这样一个错误:

Access to XMLHttpRequest at 'http://localhost:8085/test' from origin 'http://127.0.0.1:8080' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

这句话的意思是说要发送带 cookie 的跨域请求,响应头中的 Access-Control-Allow-Origin 的值必须跟当前的域完全匹配,不能使用通配符 * 。这个问题很简单,我们只需要把后端的响应头中 Access-Control-Allow-Orgin 的值设置为 http://localhost:8080 就可以了,但是这样别的域的跨域访问就被限制了。这时可以用一个技巧让后端支持所有域的带 cookie 跨域请求,修改 CrossFilter 中的 doFilter 方法:

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    HttpServletResponse res = (HttpServletResponse) response;
 
    HttpServletRequest req = (HttpServletRequest) request;
    String orgin = req.getHeader("Origin");
    // 从reqeust中拿到请求域的地址,然后将Origin的值设置到Access-Control-Allow-Origin中
    if (!StringUtils.isEmpty(orgin)) {
        res.addHeader("Access-Control-Allow-Origin", orgin);
    }
 
    // 允许所有方法跨域调用
    res.addHeader("Access-Control-Allow-Methods", "*");
    // 允许content-type请求头
    res.addHeader("Access-Control-Allow-Headers", "content-type");
    chain.doFilter(request, response);
}

再次请求,发现浏览器报了另外一个错误:

Access to XMLHttpRequest at 'http://localhost:8085/test' from origin 'http://127.0.0.1:8080' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

这个错误说的是想要发送带 cookie 的请求,返回头 Access-Control-Allow-Credentials 中的值必须为 true ,那么我们只需在过滤器中加入该请求头,并将值设置为 true 即可:

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    HttpServletResponse res = (HttpServletResponse) response;
 
    HttpServletRequest req = (HttpServletRequest) request;
    String orgin = req.getHeader("Origin");
    // 从reqeust中拿到请求域的地址,然后将Origin的值设置到Access-Control-Allow-Origin中
    if (!StringUtils.isEmpty(orgin)) {
        res.addHeader("Access-Control-Allow-Origin", orgin);
    }
 
    // 允许所有方法跨域调用
    res.addHeader("Access-Control-Allow-Methods", "*");
    // 允许content-type请求头
    res.addHeader("Access-Control-Allow-Headers", "content-type");
    // 允许带cookie的跨域访问
    res.addHeader("Access-Control-Allow-Credentials", "true");
    chain.doFilter(request, response);
}

再次请求,这时候就能请求成功啦。
接下来测试带自定义请求头的请求,请求的时候,发现浏览器报错信息如下:

Access to XMLHttpRequest at 'http://localhost:8085/getHeader' from origin 'http://127.0.0.1:8080' has been blocked by CORS policy: Request header field myheader is not allowed by Access-Control-Allow-Headers in preflight response.

该报错信息说的是响应中请求头没有被允许,即必须在响应消息中 Access-Control-Allow-Headers 添加请求时带过去的请求头,解决方法很简单,跟解决带 cookie 的跨域访问差不多,在 CrossFilter 中添加响应头信息:

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    HttpServletResponse res = (HttpServletResponse) response;
 
    HttpServletRequest req = (HttpServletRequest) request;
    String orgin = req.getHeader("Origin");
    // 从reqeust中拿到请求域的地址,然后将Origin的值设置到Access-Control-Allow-Origin中
    if (!StringUtils.isEmpty(orgin)) {
        res.addHeader("Access-Control-Allow-Origin", orgin);
    }
 
    String headers = req.getHeader("Access-Control-Request-Headers");
    // 从reqeust中拿到请求头,然后将请求头设置回去
    if (!StringUtils.isEmpty(headers)) {
        res.addHeader("Access-Control-Allow-Headers", headers);
    }
 
    // 允许所有方法跨域调用
    res.addHeader("Access-Control-Allow-Methods", "*");
 
    // 允许带cookie的跨域访问
    res.addHeader("Access-Control-Allow-Credentials", "true");
    chain.doFilter(request, response);
}

再次请求问题就解决啦。

  • Nginx 解决方案

一个完整的跨域请求调用过程是客户端发起请求调用对方的 http 服务器,然后由 http 服务器将请求转发到特定的应用服务器,应用服务器返回结果到 http 服务器,然后再由 http 服务器将放回结果返回到客户端:

nginx解决方案架构图

这样的话,我们不仅能在后台的应用服务器上通过增加响应头的方式支持跨域,还可以在 http 服务器上增加响应头以支持跨域。由于我们现在是测试,没有正式域名,所以在 hosts 文件中加入 127.0.0.1 www.hxy.com,将 www.hxy.com 指向我们本地地址,然后在 nigix 的conf 目录下新建 vhost 目录,新建 www.hxy.com.conf 文件,文件的内容如下:

server{
    listen 80;
    server_name www.hxy.com;
    location /{
        proxy_pass http://localhost:8085/;
        # 支持所有域跨域调用
        add_header Access-Control-Allow-Methods *;
        # 缓存预检命令
        add_header Access-Control-Max-Age 3600;
        # 支持带cookie 调用
        add_header Access-Control-Allow-Credentials true;
        add_header Access-Control-Allow-Origin $http_origin;
        # 支持自定请求头跨域调用
        add_header Access-Control-Allow-Headers $http_access_control_request_headers;
        # 预检命令直接返回,不用经过应用服务器
        if ($request_method = OPTIONS){
            return 200;
        }
    }
}

然后修改 conf 目录下的 nginx.conf 配置文件,将上面的配置文件引入进来 include vhost/*.conf; 然后将前端请求地址都改为 http://www.hxy.com ,后端将过滤器代码注释掉,验证就能发现已经能支持跨域调用啦!

Spring 框架跨域解决方案

Spring 提供了一个支持跨域调用的注解 @CrossOrigin,我们只需要在允许跨域调用的 Controller 或者方法上加上该注解,就能支持跨域啦,修改后端代码,将过滤器注释掉,然后在 Controller 上加上该注解:

@RestController
@CrossOrigin
public class HelloController {
 
    @RequestMapping("/test")
    public String test() {
        return "请求成功";
    }
 
    //省略其他方法...
}

是不是很简单呢?

调用方解决方案

  • Nginx 解决方案

上面讲到的均是在服务器端也就是被调用端解决跨域的方法,但有时候服务器并不是我们开发的,可能由第三方服务提供商已经开发好的服务器。这时候服务器端的操作就进行不下去了。可以使用另一种方案,隐藏跨域的解决方案,我们可以使用 nginx 的反向代理将请求转发到被调用方的 http 服务器,然后再由调用方的 nginx 将请求相应到客户端,这样,对于客户端而言,接收到的响应都是由调用方的 nginx 返回的,浏览器并不知道该请求是一个跨域请求,也就不会报跨域安全问题:


alt nginx解决方案架构图

将后端代码支持跨域的代码注释掉,然后修改调用方的 nginx 代理服务器配置文件,在最后加入如下配置:

server{
    listen 8080;
    server_name 127.0.0.1;
 
    location /{
        proxy_pass http://localhost:8080/;
    }
 
    location /server{
        proxy_pass http://localhost:8085/;
    }
}

这个配置的意思是,代理 127.0.0.1 下的所有 /server 开头的请求,将其转发到 8085 端口。这时候前端的 baseUrl 需改为相对地址:

var baseUrl = "/server"

启动 nginx 服务器,此时客户端就已经支持跨域访问啦。

  • jsonp解决方案

调用方支持跨域请求还有一种解决方案就是 jsonp 解决方案,但是由于 jsonp 只支持 get 请求,而且客户端服务端代码都需要改动,发送的请求也不是 XHR 请求,所以在这里就不讲 jsonp 的解决方案啦,有兴趣的小伙伴可以自行百度哈。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266