下面是常见的 javaee 架构的简化版,客户端请求 apache/nginx 代理服务器,代理服务器接收到请求后将请求转发到后台的应用服务器(tomcat、jeety 等),后台应用服务器处理请求,将结果返回给代理服务器,代理服务器接收到请求后,再将请求返回给客户端。这就是一个完整的请求、响应的流程。
前台接口调用后台服务的时候,如果前台接口跟后台服务不是同源的,就会产生跨域问题。存在跨域的情况:
举例:如下图,当调用方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
然后运行后端代码,前端代码放到 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.
这就是跨域问题。
刚刚我们编写的请求浏览器控制台报了一个跨域请求的 error,那么我们到底调用到后台接口没有,还是在调用的时候就被浏览器拦截了呢?我们可以在浏览器的调试窗口的 network 监控台看看:
从控制台可以看出,我们发出的 ajax 请求已经调用到后台接口,并成功返回了。但是浏览器识别到这是一个非同源的请求,所以将它拦截下来了,不给显示。
只有当发出去的请求是 XMLHttpRequest 请求,浏览器才会报跨域安全访问错误。可以通过修改刚刚的前端代码来验证我们的观点:
在这里我们加了个 img 标签,src 指向外网的一个地址。运行代码,控制台并没有报跨域访问安全问题。这是由于我们的 img 标签发送的请求类型是一个 jpg 请求,并不是 XHR 请求,所以浏览器不会报跨域安全访问问题。
浏览器请求非同源接口的时候,会从返回头中查找是否存在允许跨域访问的头信息,如果存在则正常显示,不报跨域安全问题。基于这个原理,我们可以修改后端代码,添加指定的返回头信息:
在启动类上增加 @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);
}
}
前端代码不用修改,接着访问前端页面,点击发送按钮:
从控制台可以看出,响应头上已经有我们增加的头信息,并且浏览器成功返回,不报跨域问题。但这里我只允许 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 方法:
打开浏览器调试窗口,点击 “发送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 请求:
常见的简单请求:请求方法为 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 请求按钮,可以看到浏览器报了这样一个错误:
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);
}
再次请求问题就解决啦。
一个完整的跨域请求调用过程是客户端发起请求调用对方的 http 服务器,然后由 http 服务器将请求转发到特定的应用服务器,应用服务器返回结果到 http 服务器,然后再由 http 服务器将放回结果返回到客户端:
这样的话,我们不仅能在后台的应用服务器上通过增加响应头的方式支持跨域,还可以在 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 的反向代理将请求转发到被调用方的 http 服务器,然后再由调用方的 nginx 将请求相应到客户端,这样,对于客户端而言,接收到的响应都是由调用方的 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 只支持 get 请求,而且客户端服务端代码都需要改动,发送的请求也不是 XHR 请求,所以在这里就不讲 jsonp 的解决方案啦,有兴趣的小伙伴可以自行百度哈。