什么是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