微信号:QunarTL

介绍:Qunar技术沙龙是去哪儿网工程师小伙伴以及业界小伙伴们的学习交流平台.我们会分享Qunar和业界最前沿的热门技术趋势和话题;为中高端技术同学提供一个自由的技术交流和学习分享平台.

AJAX 跨域请求方案详细介绍

2018-09-28 08:00 童健

国庆节快乐~点击上方文字关注我们哦

童健


个人介绍:童健,2018 年加入去哪儿网技术团队。目前在火车票事业部/技术部。个人对分布式微服务架构、高并发的系统很感兴趣,对编写 CleanCode 有执着的追求。

一、前言

目前在开发中前后端分离的模式比较普遍,那么跨域问题也就时常会遇到。网上资料都很片面,不全面,以及都没有说为什么这么解决。

本文会通过前端 AJAX 访问 JAVA 后端接口的场景,分别从浏览器、后端响应头设置、代理服务器 Apache 和 Nginx 配置、调用端反向代理等方面考虑跨域解决方案。从简单请求、非简单请求和带 cookie 的请求等多种请求方式逐步分析如何规避跨域限制。

二、能获得什么

加深对跨域的理解,清楚解决跨域的思路,获得更多的解决方式。

三、内容详情

什么是跨域?狭义的理解跨域是指受到浏览器同源策略限制的一类请求,通常我们说的跨域就是指的这一类请求。当协议、域名(包含子域名(参考 https://en.wikipedia.org/wiki/Subdomain))、端口号中任意一个不相同时,都属于不同域。不同域之间相互请求资源,就会受到浏览器的同源策略限制。

3.1 同源策略

同源策略(参考 https://en.wikipedia.org/wiki/Same-origin_policy)是一种约定,由 Netscape 公司 1995 年引入浏览器,是浏览器最核心也最基本的安全功能。保证用户信息的安全,防止恶意的网站窃取数据。比较常见的就是XSS(参考 https://en.wikipedia.org/wiki/Cross-site_scripting)、CSFR(参考 https://en.wikipedia.org/wiki/Cross-site_request_forgery)等攻击。

既然有安全问题,那为什么又要跨域呢? 举个例子,假如公司内部有多个不同的子域,一个是 location.company.com,另一个是 app.company.com,这时想从 app.company.com 去访问 location.company.com 的资源就需要跨域。

3.2 AJAX 跨域请求

下面,通过 AJAX 访问不同域的后端 JAVA 接口的案例来分析,如何规避这种限制。(后面我们把这个案例称为案例一)

1. 首先新建 spring boot 项目 A,端口号使用默认的 8080,快速开发一个 IAVA 接口如下:

 
           
  1. @RestController

  2. @RequestMapping("/getData ")

  3. public class GetDataController {

  4. @GetMapping("/getFirstData")

  5. private ResultBean getFirstData(){

  6.  System.out.println("getFirstData success");

  7.  return new ResultBean("getFirstData success");

  8. }

  9. }

2. 再次新建一个 spring boot 项目 B,端口号设置为 8081,编辑前端页面如下:

 
           
  1. <head>

  2. <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">

  3. <title>Insert title here</title>

  4. <script src="jquery-1.9.1.min.js"></script>

  5. <script type="text/javascript">

  6. function getFirstData(){

  7.   $.ajax({

  8.       type : "GET",

  9.       url:"http://localhost:8080/getData/getFirstData",

  10.       success:function(json){

  11.         console.log(json);

  12.      }

  13.   });

  14. }

  15. </script>

  16. </head>

  17. <body>

  18. <a href="#" onclick="getFirstData()">发送getFirstData请求</a>

  19. </body>

  20. </html>

3. 然后启动两个项目,浏览器中访问 http://localhost:8080/getData/getFirstData,正常返回 JSON 数据{"data":"getFirstData success"},此时说明 JAVA 接口正常。

4. 接着浏览器先访问项目 B 的页面 http://localhost:8081/index.html,点击页面上的 a 标签,结果浏览器控制台并没有打印出接口预期返回的 JSON 数据。

5. 查看 A 项目的控制台,输出了"getFirstData success"。在浏览器开发者模式下查看网络,发现请求的状态为 200 ,但是出现了以下错误提示。

结论:跨域并不是请求发不出去,请求能发出去,服务端能收到请求并正常返回结果,只是结果被浏览器拦截了。

3.2.1 浏览器端解决跨域

根据上面的结论,我们可以让浏览器不做限制,以 chrome 浏览器为例, 如果设置为支持跨域模式,只需以下几步。

  1. 在电脑上新建一个目录,例如:C:\MyChromeDevUserData。这是保存个人信息的目录,是 chrome 浏览器防止用户使用跨域模式泄露自己的个人信息采用的措施,这里不详细探讨。

  2. 在属性页面中的目标输入框里加上 "--disable-web-security --user-data-dir=C:\MyChromeDevUserData","--user-data-dir" 的值就是刚才新建的目录。

  3. 点击应用和确定后关闭属性页面,并打开 chrome 浏览器。发现有“--disable-web-security”相关的提示,说明 chrome 能正常跨域工作了。

说明:这种解决方式意义不大,因为需要所有的客户端做改动,并且每个用户使用的浏览器也各不相同,处理的方式也不同。在这里介绍这种解决方式,只是为了更进一步说明,产生跨域访问限制的根源就是浏览器。

3.2.2 Script 标签解决跨域

其实通过 Script 标签可以跨域请求数据的,如下,在 B 项目的页面中访问百度的一个接口:

效果如下:

以上是在案例一中 B 项目的域里,通过动态创建 Script 标签,跨域访问百度的接口,并成功获取到了资源。这种请求属于 Script 请求,可见浏览器并不会限制这类请求。详细原因可以查看同源策略文档。 但是这种方式需要服务端提供一种约定,约定请求的参数里面如果包含指定的参数(比如上面的 cb 参数),会把原来的返回对象先转变成 JS 代码然后返回给浏览器解析,而其中 JS 代码是函数调用的形式,比如上例中百度接口返回的函数的函数名是 cb 的值,函数的参数就是原来需要返回的结果对象。 假如没有传递 cb 参数,请求可以正常访问到数据并返回,但是浏览器判断是 Script 请求,所以仍然会以解析脚本的格式解析返回的结果,后果就是无法解析(Uncaught TypeError)。 通过案例一的请求过程,我们发现 AJAX 的请求类型是 XHR(XMLHttpRequest)类型,如下:

注:XHR 获取数据的目的是为了持续修改一个加载过的页面,是 AJAX 设计的底层概念,想了解 XHR 可以参考这里(https://en.wikipedia.org/wiki/XMLHttpRequest)。

大胆的猜想一下,是否可以让 AJAX 发出的请求封装成 Script,然后由 Script 向后端发出请求,这样浏览器就不会做限制了。 那么这也正是接下来要介绍的 JSONP 的解决思路。

3.2.3 JSONP 解决跨域

1. 什么是 ISONP

 (1)全称是 JSON Padding.,请求时通过动态创建一个 Script,在 Script 中发出请求,通过这种变通的方式让请求资源可以跨域。

 (2)它不是一个官方协议,是一个约定,约定请求的参数里面如果包含指定的参数(默认是 callback),就说明是一个 JSONP 请求,服务器发现是 JSONP 请求,就会把原来的返回对象变成 JS 代码。JS 代码是函数调用的形式,它的函数名是 callback 的值,它的函数的参数就是原来需要返回的结果。

2. JSONP的实现方式

 (1)通过例子来说明 JSONP 的请求方式,如下:

 
           
  1. $.ajax({

  2.  url:"http://localhost:8080/getData/getSecondData",

  3.  dataType:"jsonp",

  4.  jsonp:"callback",

  5.  success:function(json){

  6.    console.log(json);

  7. }

  8. });

 (2)查看浏览器网络,发现 JSONP发出去的请求是 Script 类型。因为动态创建的 Script 标签在发送请求以后会马上被删除,所以在浏览器中无法查看的到,我们可以采用断点查看。

 (3)当 jquery-1.9.1.js 中的代码执行完如上的位置时,在页面上动态创建了一个 Script。如下:

 
           
  1. <script async="" src="http://localhost:8080/getData/getSecondData? callback=jQuery19102645927304195774_1530774759805&_=1530774759806">

  2. </script>

可以看到 JSONP 在请求 URL 后面追加了两个参数,callback 和一个下划线作为参数名的参数,这个 callback 就是上面提到的约定参数,而下划线作为参数名的参数值是一个随机数,作用是为了防止请求的结果被缓存了,如果想让结果被缓存可以添加 cache:true,如:

 
           
  1. $.ajax({

  2.     url:"http://localhost:8080/getData/getSecondData",

  3.     dataType:"jsonp",

  4.     jsonp:"callback",

  5.     cache:true,

  6.     success:function(json){

  7.         console.log(json);

  8.     }

  9. });

 (4)此时后台不做改动的话返回的还是 JSON 对象,浏览器把对象当做 Script 对象解析,所以会报错。如下可以看到返回的参数类型:

接下来修改后台代码,给提供接口的 controller 提供一个切面,返回“callback”,编码如下:

 
           
  1. @ControllerAdvicepublic

  2. class JsonpAdvice extends AbstractJsonpResponseBodyAdvice{

  3. public JsonpAdvice(){

  4. super("callback"); //其中参数“callback”可以修改,但是必须与页面请求的回调参数对应

  5. }

  6. }

浏览器再次访问,就可以请求到资源了。

3.2.4 CORS 解决跨域方案

(一) 引言

回到案例一,来看看跨域所报的错误,大概意思是说请求的资源上没有“Access-Control-Allow-Origin”头信息(此处说的是响应头)。

那么可以从这个地方考虑,在返回资源的时候加上这个头信息。这也就是接下来说的 CORS 请求的解决思路。

CORS 是 W3C 标准, 全名叫跨域资源共享 Cross-origin resource sharing,允许浏览器向跨域服务器发出 XMLHttpRequest 请求。

(二) 简单请求跨域

当我们在做跨域请求资源的时候,会发现多了 Origin 的字段(此字段指定了当前页的域名和端口号,它的值是由浏览器自动获取的,无法通过手动修改) ,如下:

然后在响应的时候,浏览器会判断响应头里面有没有跨域信息,如果没有就会报错。

那我们尝试修改后台代码,在响应头里添加这个信息。

 
           
  1. // 通过下面方法注册过滤器

  2. @Bean

  3. public FilterRegistrationBean registerFilter(){

  4.      FilterRegistrationBean frBean = new FilterRegistrationBean();

  5.      frBean.addUrlPatterns("/*");

  6.      frBean.setFilter(new CrosFilter());

  7.      return frBean;

  8.   }

以下是过滤器实现部分。

 
           
  1. public class CrosFilter implements Filter {

  2.   @Override

  3.     public void destroy() {  }

  4.   @Override

  5.     public void doFilter(ServletRequest request, ServletResponse response,  FilterChain filterChain) throws IOException, ServletException {

  6.      HttpServletResponse hsr =(HttpServletResponse)response;

  7.          hsr.addHeader("Access-Control-Allow-Origin","http://localhost:8081");  //添加Origin

  8.          filterChain.doFilter(request,response);

  9.   }

  10.  @Override

  11. public void init(FilterConfig arg0) throws ServletException { }

  12. }

此时就可以通过 AJAX 访问到资源了,这种情况只能允许一种域名访问,如果想让多个域名都访问到此接口,可以用*号代替上面设置的参数。如:

 
           
  1. hsr.addHeader("Access-Control-Allow-Origin", "*");

(三) 预检命令

其实浏览器将 CORS 分为两类, 简单请求和非简单请求,每次请求会先判断是否为简单请求。 1. 如果是简单请求就先执行后判断资源信息是否允许跨域,这也是案例一中为什么请求的状态是 200,但是无法获取数据的原因了。 2. 如果不是简单请求会先发一个预检命令,检查通过以后才会把跨域请求发送过去。 3. 像 PUT,DELETE 方法的 AJAX 请求就属于非简单请求,而像 GET、HEAD、POST 方法的 AJAX 请求,如果不考虑其他因素都属于简单请求,但是带 JSON 参数或者自定义头的 AJAX 请求就属于非简单请求。

如下,实现一种带 JSON 参数的 AJAX 请求:

 
           
  1. var params={username :"user", password:"123"};

  2. function getFirstData(){

  3. $.ajax({

  4.    type : "POST",

  5.    data: JSON.stringify(params),

  6.    url:"http://localhost:8080/getData/postUser",

  7.    contentType:"application/json;charset=UTF-8",

  8.   success:function(json){

  9.               console.log(json);

  10.     }

  11.  });

  12. }

后台代码:

 
           
  1. @PostMapping("/postUser")

  2. private DataSource postUser(@RequestBody User user){

  3.     System.out.println("postUser success");

  4.     return new DataSource("postUser success");

  5.  }

如下,访问了一次,出现了两条请求数据。

第一条 OPTIONS 方法的请求就是预检请求,通过实例测试会发现,在预检的时候,请求头里面会出现一个头信息  Access-Control-Request-Headers:content-type  意思是说它会询问一下后台服务器是否允许这个头,如果响应头里没有对应的信息就会报错,所以跨域请求就失败了。如下:

因此我们需要在过滤器中加上对应的响应头,如下: hsr.addHeader("Access-Control-Allow-Headers", "Content-Type")

到这里有个问题了,如果每次请求都会预检未免多此一举,那么我们可以利用下面这个响应头设置预检结果缓存时间,单位为秒。 hsr.addHeader("Access-Control-Max-Age", "3600"); 这样设置以后,浏览器再次访问此域名时,一个小时内都不用预检。

(四) 携带 cookie 跨域请求

还有一种情况,在请求资源的时候往往需要带上 cookie 信息,cookie 中记录了用户的信息以及 session 会话的 ID 等。可以用下面这种方式,在 AJAX 请求中携带 cookie 信息。

 
           
  1. $.ajax({                    

  2.    type : "GET",

  3.    url:"http://localhost:8080/getData/getCookie",

  4.  xhrFields{

  5.           withCredentials:true

  6.   },

  7.   success:function(json){

  8.           console.log(json);

  9.     }

  10. });

后台代码:

 
           
  1. @GetMapping("/getCookie")

  2. private DataSource getCookie(@CookieValue(value="name")String cookie){

  3.   System.out.println("getCookie success");

  4.   return new DataSource("getCookie success");

  5.  }

然后在过滤器中还需要设置响应头,如:hsr.addHeader(“Access-Control-Allow-Credentials”,”true”);

接着在浏览器控制台下添加一个cookie信息,如:

document.cookie=“name=tj”

测试发现,如果响应头里是设置了 hsr.addHeader("Access-Control-Allow-Origin", ""),是不允许通过的,需要设置全匹配,不能用通配符。

如果我们需要支持多个域名可以访问,服务端可以先从 request 中将 origin 中的域名信息取出来,然后赋值给响应头的 origin 就可以了。

 
           
  1. HttpServletRequest req =(HttpServletRequest) request;

  2. String origin = req.getHeader("origin");

  3. if(origin!=null){

  4.       hsr.addHeader("Access-Control-Allow-Origin",origin);

  5. }

(五) 自定义头的跨域请求

还有一种自定义头的跨域也属于非简单跨域,解决方式和 cookie 的类似。

添加头的操作:

 
           
  1. type:"get",

  2.   url:"http://localhost:8080/getData/getFourthData",                          

  3.   headers:{

  4.         "myheader":"qunar"                          

  5.  },                    

  6. success:function(json){

  7.         console.log(json);                

  8.    }            

  9. });

后台先从 request 中取出头信息,然后判断是否为空,然后赋值给响应头。

 
           
  1. String headers =req.getHeader("Access-Control-Request-Headers");

  2. if(headers!=null){

  3.     hsr.addHeader("Access-Control-Allow-Headers",headers);

  4. }

如此便成功完成了跨域请求。下面是拦截器中完整的配置。

 
           
  1. @Override

  2.    public void doFilter(ServletRequest request, ServletResponse response,

  3.            FilterChain filterChain) throws IOException, ServletException {

  4.        HttpServletResponse hsr = (HttpServletResponse)response;

  5.        HttpServletRequest req = (HttpServletRequest) request;

  6.        String origin = req.getHeader("origin");

  7.        String headers = req.getHeader("Access-Control-Request-Headers");

  8.        if(origin!=null){

  9.            hsr.addHeader("Access-Control-Allow-Origin", origin);

  10.        }

  11.        if(headers!=null){

  12.            hsr.addHeader("Access-Control-Allow-Headers", headers);

  13.        }

  14.        hsr.addHeader("Access-Control-Max-Age", "3600");

  15.        hsr.addHeader("Access-Control-Allow-Credentials","true");

  16.        hsr.addHeader("Acccess-Control-Allow-Methods", "POST, GET, OPTIONS,DELETE,PUT");

  17.        filterChain.doFilter(request, response);

  18.    }

(六) springMVC 注解实现跨域请求

其实后端实现跨域请求并不用这么麻烦,springMVC 的 4.2 版本以后提供了注解的方式解决跨域问题,在类上添加 CrossOrigin 注解,如下:

 
           
  1. @CrossOrigin(origins = "*", maxAge = 3600)

  2. @RestController

  3. @RequestMapping("/getData")

  4. public class GetDataController {

  5.   ...//此处省略

  6. }

注释掉前面过滤器的配置,上面的所有请求也都能访问了。

小结: 有如此利器,还要介绍前面的解决方案,是为了更好的理解跨域解决思路。在不满足注解的框架中也能很好的实现跨域请求。

(七) 代理服务器实现跨域

到这里,基本上已经了解了 JAVA 后端解决跨域的办法。而实际应用的部署环境往往会添加代理服务器,如下:

那么我们可以考虑从代理服务器端解决跨域问题。解决的思路相同,直接说配置方式。

1. 被调用端支持跨域配置

这种场景配置的是被调用端的代理服务器。在浏览器某个域(http://location.company.com)中请求不同域(http://app.company.com)的资源,首先将请求发送给被调用端的代理服务器,由代理服务器将请求路由到相应的资源服务器,而资源服务器并不用管请求方是谁,这种代理方式我们称之为正向代理。

Nginx 中配置

在 nginx.conf 文件中配置如下。

注意:请求头的参数在这里都需要小写,并且“-”需要转成下划线, If 后面需要带上空格,否则语法会报错。

Apache 中配置

在 httpd-vhosts.conf 中配置虚拟主机相关配置。

注意:在 httpd.conf 中将 vhost 相关配置打开。并且将 proxy 模块、proxy http 模块、Heard 模块、rewrite 模块打开。

2. 调用端反向代理实现跨域

以上都是从被调用端来解决的,属于支持跨域。当无法修改被调用方的时候,可以配置调用端代理服务器来实现跨域。浏览器向同一域下的反向代理服务器发出请求,再由反向代理服务器转发,向其他域请求资源并返回给浏览器,浏览器不知道请求的资源在哪个服务器上,这种代理方式我们称之为反向代理。

Nginx 中配置

Apache 配置

四、总结

全文主要是通过 AJAX 请求不同域下接口资源的场景,详细的分析了跨域的原理以及如何规避这种跨域。首先介绍了浏览器端解决跨域,Script 标签解决跨域和 JSONP 的方式解决跨域,但是这些方式都有明显的缺陷,接着重点介绍了 CORS 如何解决跨域,相信通过本文,可以对跨域有一定的理解和解决思路了。

 
Qunar技术沙龙 更多文章 java 泛型解析 第四届 Hackathon 大赛 CodeCode 小组 JSON 从人肉到智能,阿里运维体系经历了哪些变迁? 机器学习之 scikit-learn 开发入门(2) 机器学习之 scikit-learn 开发入门(1)
猜您喜欢 世界这么浪,扛起相机就能上 GitHub 使用 你应该知道的Android开发的好习惯 聊聊代码规范 向邓小平学管理(3)