什么是Ajax跨域问题

这里通过一个示例来说明。
我们这里准备了2个Springboot工程。
crossdomain-server:
端口:8080

对外提供的接口如下:

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/test")
public class TestController {

    @RequestMapping("/get")
    public ResultBean get() {
        System.out.println("TestController.get().");
        
        return new ResultBean("hello,justin");
    }
}

通过浏览器请求http://localhost:8080/test/get得到如下结果:


crossdomain-client:
端口:8081
提供了一个简单的页面,用于Ajax请求crossdomain-server的接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<html>
<head>
    <meta charset="UTF-8">
    <title></title>
    <script type="text/javascript" src="/jquery.js"></script>
</head>
<body>
    <a href="#" onclick="get1();">get请求</a>

    <script type="text/javascript">
        function get1() {
            $.getJSON("http://localhost:8080/test/get",function(json) {
                alert(json);
            });
        }
    </script>
</body>
</html>

但是点击“get请求”后,发现控制台报错了。
如下:

这个就是Ajax跨域问题。

Ajax跨域的原因

产生跨域是由于浏览器的安全策略,JavaScript只能访问和操作自己域下的资源,不能访问和操作其他域下的资源。跨域问题是针对JS和ajax的,html本身没有跨域问题,比如a标签、script标签、甚至form标签(可以直接跨域发送数据并接收数据)等。所谓的同源,指的是域名、协议、端口均相等。

解决Ajax跨域的方式

1.jsonp

我们对crossdomain-server做些修改:
a.增加ControllerAdvice

1
2
3
4
5
6
7
8
@ControllerAdvice
public class JsonpAdvice extends AbstractJsonpResponseBodyAdvice {

    public JsonpAdvice() {
        super("callback");
    }
    
}

b.页面Ajax请求方式改为jsonp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 每个测试用例的超时时间
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000;
var base = "http://localhost:8080/test";

// 测试模块
describe("ajax跨域",function() {
    it("jsonp请求",function(done) {
        var result;
        
        $.ajax({
            url:base+"/get",
            dataType:"jsonp",
            success:function(callback) {
                result = callback;
                
                expect(result).toEqual({
                    "data": "hello,justin"
                })
                
                // 校验完成,通知jasmine框架
                done();
            }
        });
    });
});

浏览器输入http://localhost:8081可以看到测试通过,

看下jsonp请求:

这里使用了jasmine测试框架,具体使用方法可以执行百度。jasmine的github地址为:https://jasmine.github.io,可以在release中下载。使用可以参考:https://jasmine.github.io/2.3/introduction.html

jsonp虽然可以解决跨域问题,但jsonp只支持get请求,而且还需要修改前后台代码。
jsonp为什么只支持get,不支持post?
jsonp不是使用xhr发送的,是使用动态插入script标签实现的,当前无法指定请求的method,只能是get。
调用的地方看着一样,实际上和普通的ajax有2点明显差异:1. 不是使用xhr 2.服务器返回的不是json数据,而是js代码。

2.被调用方修改以支持跨域

我们这里使用Filter,在响应中增加Access-Control-Allow-Origin。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@SpringBootApplication
public class CrossdoaminServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(CrossdoaminServerApplication.class, args);
    }
    
    @Bean
    public FilterRegistrationBean crossFilter() {
        FilterRegistrationBean bean = new FilterRegistrationBean();
        bean.addUrlPatterns("/*");
        bean.setFilter(new CrossFilter());
        return bean;
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CrossFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        // 允许http://localhost:8081域访问
        resp.addHeader("Access-Control-Allow-Origin", "http://localhost:8081");
        
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
        
    }
}

crossdomain-client前端测试点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 测试模块
describe("ajax跨域",function() {
     it("get请求",function(done) {
        var result;
        
        $.getJSON(base+"/get",function(json){
            result = json;
            
            expect(result).toEqual({
                "data": "hello,justin"
            })
            
            // 校验完成,通知jasmine框架
            done();
        });
    }); 
    
    it("jsonp请求",function(done) {
        var result;
        
        $.ajax({
            method:"post",
            url:base+"/get",
            dataType:"jsonp",
            success:function(callback) {
                result = callback;
                
                expect(result).toEqual({
                    "data": "hello,justin"
                })
                
                // 校验完成,通知jasmine框架
                done();
            }
        });
    });
});

测试结果:

可以将Access-Control-Allow-Origin设置为*,这样任何域都可以访问。同时可以通过Access-Control-Allow-Methods指定允许访问的方法。
如允许GET请求:

1
2
// 同样可以将Access-Control-Allow-Methods设置为*,表示允许所有方法。
resp.addHeader("Access-Control-Allow-Methods", "GET");

带cookie的跨域

我们在crossdomain-server增加一个测试方法:

1
2
3
4
5
@GetMapping("/getCookie")
public ResultBean getCookie(@CookieValue(name="cookie1") String cookie1) {
    System.out.println("TestController.getCookie().cookie1=" + cookie1);
    return new ResultBean("cookie:" + cookie1);
}

然后浏览器访问crossdomain-server的任意一个请求,使用document.cookie="cookie1=justin"来增加一个名为cookie1,值为justin的cookie。

crossdomain-client增加一个测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
it("getCookie请求",function(done) {
    var result;
    
    $.ajax({
        type:"get",
        url:base+"/getCookie",
        xhrFields: {
            // 默认情况下,跨源请求不提供凭据(cookie、HTTP认证及客户端SSL证明等)。通过将withCredentials属性设置为true,可以指定某个请求应该发送凭据。如果服务器接收带凭据的请求,会用下面的HTTP头部来响应。
            withCredentials: true
        },  
        success:function(json) {
            result = json;
            
            expect(result).toEqual({
                "data": "cookie:justin"
            })
            
            // 校验完成,通知jasmine框架
            done();
        }
    });
});

我们访问http://localhsot:8081,

可以看到,请求是成功的(statuscode=200),请求也带上了cookie。但jasmine提示失败。
我们看下浏览器控制台:

提示信息很明确了:提示我们响应头需要设置Access-Control-Allow-Credentials为true。
我们到CrossFilter设置一下:

1
resp.addHeader("Access-Control-Allow-Credentials", "true");

ok,加上以后再次请求就成功了。

注意:这里不能设置Access-Control-Allow-Origin为*,否则会报下面的错误:

但是,我们不可能只有一个跨域的站,怎么处理?
我们观察浏览器的请求,可以发现,如果是跨域请求,会有Origin请求头,我们后台根据这个请求头设置即可。

1
2
3
4
5
String url = req.getHeader("Origin");
if (!StringUtils.isEmpty(url)) {
    resp.addHeader("Access-Control-Allow-Origin", url);
    resp.addHeader("Access-Control-Allow-Credentials", "true");
}

错误:Missing cookie ‘cookie1’ for method parameter of type String
最后发现是jquery版本太低,这里使用了jquery1.11.3后ok了。

带自定义请求头的跨域访问

在crossdomain-server增加一个请求方法:

1
2
3
4
5
@GetMapping("/customHeader")
public ResultBean getCustomHeader(@RequestHeader("X-My-Header") String myHeader) {
    System.out.println("TestController.getCustomHeader().myHeader=" + myHeader);
    return new ResultBean("header:" + myHeader);
}

在crossdomain-client增加一个测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
it("getCustomeHeader请求",function(done) {
    var result;
    
    $.ajax({
        type:"get",
        url:base+"/customHeader",
        headers:{
            'X-My-Header':'justin'
        },  
        success:function(json) {
            result = json;
            
            expect(result).toEqual({
                "data": "header:justin"
            })
            
            // 校验完成,通知jasmine框架
            done();
        }
    });
});

测试发现报错了

意思我们的Access-Control-Allow-Headers响应头没有包含这个自定义的请求头,所以我们在CrossFilter加上

1
resp.addHeader("Access-Control-Allow-Headers", "Content-Type,X-My-Header");

再次请求,成功。

Access-Control-Allow-Origin一样,我们也可以对Access-Control-Allow-Headers进行动态设置。
我们观察customHeader的预检命令的请求头中有Access-Control-Request-Headers:x-my-header

1
2
3
4
String headers = req.getHeader("Access-Control-Request-Headers");
if (!StringUtils.isEmpty(headers)) {
    resp.addHeader("Access-Control-Allow-Headers", headers);
}

预检命令

我们在crossdomain-server增加一个postJson方法:

1
2
3
4
5
@PostMapping("/postJson")
public ResultBean postJson(@RequestBody User user) {
    System.out.println("TestController.postJson()");
    return new ResultBean("hello," + user.getName());
}

在crossdomain-client增加一个测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
it("postJson请求",function(done) {
    var result;
    
    $.ajax({
        type:"post",
        url:base+"/postJson",
        contentType:"application/json;charset=utf-8",
        data:JSON.stringify({name:"justin"}),
        success:function(json) {
            result = json;
            
            expect(result).toEqual({
                "data": "hello,justin"
            })
            
            // 校验完成,通知jasmine框架
            done();
        }
    });
});

浏览器访问发现postJson失败了,如图:

而且,我要请求的是一个post请求的/postJson请求,但实际浏览器是发出了一个OPTIONS请求,这个就是预检命令。

看下浏览器的控制台:

意思是我们的响应头Access-Control-Allow-Headers中没有找到请求头Content-Type

所以,我们修改一下代码,增加Content-Type。
我们在crossdomain-server的CrossFilter中增加:

1
resp.addHeader("Access-Control-Allow-Headers", "Content-Type");

这次请求成功了

可以看到postJson实际发送了2个请求,第一个是OPTIONS,它返回200后,浏览器再次发送了我们要请求的。

简单请求:
请求方法为GET,POST,HEAD。
且请求header中无自定义请求头,且Content-type为下面几种:text/plain,multipart/form-data,application/x-www-form-urlencoded.

非简单请求:
put,delete方法的Ajax请求
发送json格式的Ajax请求
带自定义请求头的Ajax请求
比如一个post的json请求,实际浏览器先发出一个OPTIONS预检命令,然后才发送的POST请求。可以在Filter中增加请求头Access-Control-Max-Age:3600(数字秒)来缓存预检命令的结果,这样在指定的时间内浏览器不会再次发送预检命令。

3.服务器代理

使用nginx解决跨域

我们使用nginx帮我们对请求做了转发,将b.com的请求转发到http://localhost:8080,同时设置了相关的响应头。

1.修改本机hosts文件,将b.com映射到127.0.0.1;

1
127.0.0.1 b.com

2.在nginx.conf文件最后(最后一个}上面)增加:

1
include vhost/*.conf;

3.在nginx.conf同级目录增加vhost目录,并在下面创建b.com.conf文件。
b.com.conf文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
server{
    # 监听80端口
    listen 80;
    # 监控的域名
    server_name b.com;
    # 拦截所有请求
    location /{
        # 将请求转发给http://localhost:8080/
        proxy_pass http://localhost:8080/;
        
        # 允许访问所有的方法    
        add_header Access-Control-Allow-Methods *;
        # 设置预检命令的有效期
        add_header Access-Control-Max-Age 3600;
        # 允许凭据
        add_header Access-Control-Allow-Credentials true;
        # 使用$http_orgin获取请求头orgin的值
        add_header Access-Control-Allow-Origin $http_origin;
        # 使用$http_access_control_request_headers获取请求头Access-Control-Request-Headers的值
        add_header Access-Control-Allow-Headers $http_access_control_request_headers;

        # 如果是预检命令,直接返回200OK
        if ($request_method = OPTIONS){
            return 200;
        }
    }
}

4.crossdomain-server修改
注释掉CrossFilter的使用代码。

1
2
3
4
5
6
7
//  @Bean
//  public FilterRegistrationBean crossFilter() {
//      FilterRegistrationBean bean = new FilterRegistrationBean();
//      bean.addUrlPatterns("/*");
//      bean.setFilter(new CrossFilter());
//      return bean;
//  }

5.crossdomain-client修改
http://localhost:8080改成http://b.com

1
2
var base = "http://b.com/test";
`

6.测试
cmd切换到ningx所在目录,使用nginx -t先测试一下配置是否正确,没问题执行start nginx启动nginx服务。

所有请求都访问ok。

4.Spring框架解决方案

在Controller上增加@CrossOrigin注解即可。

5.调用方解决跨域——隐藏跨域

这里仍然借助nginx来实现。原理就是在调用方的页面请求使用相对路径,让浏览器认为跨域的请求(crossdomain-server)和调用发(crossdomain-client)是在同一个域(crossdomain-client的域)。而在nginx中,将跨域的请求转发给实际的被调用方(crossdomain-server)。

a.修改hosts,增加本机IP到a.com的映射;

1
127.0.0.1 a.com

b.在nginx的conf目录新建vhost目录,并在该目录下新建文件a.com.conf,文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server{
    # 监听80端口
    listen 80;
    # 监控a.com
    server_name a.com;

    # 将所有请求转发到http://localhost:8081/
    location /{
        proxy_pass http://localhost:8081/;
    }

    # 将/crossdomain-server请求转发到http://localhost:8080/
    location /crossdomain-server{
        proxy_pass http://localhost:8080/;
    }
}

c.nginx.conf修改
在文件最后(大括号上面)增加下面的配置:

1
include vhost/*.conf;

将vhost下面的所有.conf结尾的文件都加载进来。

d.crossdomain-server修改
去掉TestController上的@CrossOrigin注解。

e.crossdomain-client修改
var base = "http://localhost:8080/test";修改为var base = "/crossdomain-server/test";

测试:浏览器输入a.com,回车

所有请求全部成功。

总结

Ajax跨域的解决方法有很多,使用时要根据实际的情况选择合适的解决方法。

可以参考慕课网的课程https://www.imooc.com/video/16571讲的很详细。

代码:https://gitee.com/qincd/crossdomain-demo