深入剖析跨域请求问题及对应解决方案

1.什么是跨域请求?

 由于浏览器同源策略,发起请求的域与该请求指向的资源所在的域不一样,即发送请求url的协议、域名、端口三者之间任何一样与当前页面地址不同即为跨域,跨域的安全限制都是对浏览器端来说的,服务器端是不存在跨域安全限制的。同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互,是一个用于隔离潜在恶意文件的重要安全机制。
 下面我罗列一些我们实际应用过程中可能出现的情况。

当前URL 请求URL 请求结果 结果原因
http://blog.csdn.net http://blog.csdn.net/cross.js 成功 同协议同域同端口
http://blog.csdn.net/u013985664 http://blog.csdn.net/static/cross.js 成功 同协议同域同端口,不同资源目录
http://blog.csdn.net:8080 http://blog.csdn.net:8081/cross.js 失败 同协议同域,不同端口
http://blog.csdn.net/ https://blog.csdn.net/cross.js 失败 同域同端口,不同协议
http://blog.csdn.net/ http://blog.csdn.cross.net/cross.js 失败 同协议同端口,不同域

 浏览器制定同源策略并不是无中生有,这也是浏览器出于安全角度设计,例如可以防止部分CSRF(跨站请求伪造)攻击,在前后端分离开发的架构中,相信这类跨域的问题大家都遇见过,相信解决过这类问题的同学也能够发现其实这种策略对于真正有意攻击的攻击方来说并不是无懈可击的,不过也确实能很大程度上避免一些低级攻击手段造成的损失,从而可以增加攻击所消耗的成本,也就降低了攻击所带来的的利益。
 这里我们主要还是针对开发过程中如何解决跨域请求做出的一些我个人的理解和分享。

2.产生跨域请求的原因?

 在之前我们已经知道了跨域问题是由于浏览器所制定的同源策略造成的,这里大家先看一个示例,该文章内容都是以SpringBoot为基础简单搭建的测试项目,不了解SpringBoot的同学可以稍作简单了解即可,这里我先构建了一个提供接口的服务端cross_domain_server以及调用接口的客户端cross_domain_client

2.1 cross_domain_server 接口服务端

 这个项目我们自定义构建的只需一个返回处理对象以及一个提供接口的Controller类。

ResultData.java

package com.ithzk.cross.entity;

/** * @author hzk * @date 2019/4/2 */
public class ResultData {

    private int code = 200;

    private String data;

    public ResultData(String data) {
        this.data = data;
    }

    public int getCode() {
        return code;
    }

    public void setCode(int code) {
        this.code = code;
    }

    public String getData() {
        return data;
    }

    public void setData(String data) {
        this.data = data;
    }
}

ApiController.java

package com.ithzk.cross.controller;

import com.ithzk.cross.entity.ResultData;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/** * @author hzk * @date 2019/4/2 */
@RestController
@RequestMapping("/api")
public class ApiController {

    @GetMapping("/getApi")
    public ResultData getApi(){
        System.out.println("ApiController.getApi");
        return new ResultData("ApiController.getApi");
    }
}

2.2 cross_domain_client 调用客户端

 在客户端项目这里我只做了修改服务端口以及提供了一个html页面去调用接口服务。

application.yml

server:
  port: 8081

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="jquery-2.1.0.js"></script>
</head>
<body>
    <a href="#" onclick="ajaxRequest()">Ajax请求</a>
</body>

<script> var baseUrl = "http://localhost:8080"; function ajaxRequest() { $.getJSON(baseUrl + "/api/getApi").then( function (result) { console.log(result); } ); } </script>
</html>

2.3 分析跨域请求原因

 这里我们通过请求http://localhost:8081/,果然十分流畅地就造就了一个我们所期望的跨域请求结果。

 首先我们先检查后台是否出现问题。

 这里很清楚我们可以看出,后台并没有抛出任何异常,并且我们通过前端响应可以看出当前请求成功响应。
 我们这里再来看一下我们发送请求的Type,此时可以看到该请求Type为XHR(xmlHttpRequest),这里为什么单独提出这个大家应该可以猜测到,其实当前端发送的请求为XHR类型时浏览器会启用同源策略,接下来我们来做一个简单验证是否是因为Type为XHR的请求才会导致跨域问题。

 这里我们在index.html中添加一个图片标签<img src="http://localhost:8080/api/getApi" alt="">,然后我们再去请求该地址。

 这个时候可以发现我们的资源访问中多出了一个Type为Json类型的接口调用,这时候我们再去查看控制台,发现仍然只有一个跨域错误,如果各位小伙伴不够确定,可以将Ajax请求删除只保留图片标签验证。
 通过以上的一些试验,我们知道了跨域问题造成的根本是因为我们发送Type为XHR请求时,服务器响应结果被浏览器拦截并进行了同源策略检测所造成的。那么如何去解决跨域问题,我们已经有了一个大概的思路。

  1. 第一是从浏览器本身出发考虑,由于每一个客户端,也就是每一个用户都可以作为一个服务的调用方,从浏览器层面去解决只能解决个人情况,并不能共同处理,显然不符合实际。
  2. 第二既然我们已经知道了当发送Type为XHR类型时,浏览器会做成同源策略检测,那么我们可以通过修改请求Type类型去避免跨域问题。
  3. 第三我们知道页面标签例如<a>, <form>, <img>, <script>等是可以通过不同源加载资源而不受同源策略检测,但是我们真正开发中类似Ajax等技术不可避免,JSONP数据交互协议便出现了,这种协议本质是通过动态插入script标签去避免跨域问题。
  4. 第四点就是从跨域本身去解决,而跨域本身又分为被调用方支持跨域和调用方隐藏跨域。被调用方支持跨域这种解决办法大家很常见而且可能用过,就是在后台服务器配置响应头的一些参数,使浏览器接收到响应之后根据响应头中一些参数去支持跨域;而调用方隐藏跨域则是通过代理的方式将自身域或请求域代理之后达到隐藏跨域的目的,一样可以解决跨域问题。
  5. 通过以上这些了解,我们其实已经对跨域问题以及解决方案有了一个大致的方向,下面就来看一下我们如何去通过这些方法解决跨域问题。

3.如何解决跨域请求?

 这里开始我们开始学习如何从各种层面上去解决跨域问题。

3.1 浏览器禁止检测

 第一种是解决方式是从浏览器去禁止检测,即只针对单个用户可以处理跨域问题,这里找到Chrome所在目录并启用命令行模式,通过添加参数启动浏览器。chrome --disable-web-security --user-data-dir=c:\Temp

 这里再去请求localhost:8081发现已经可以正常拿到响应数据并且控制台输出,这种方式是从浏览器本身去解决了跨域问题,但是缺点也十分明显,首先作为开发产品来说并不是所有用户都会通过这种方式启动浏览器,大大提升了用户使用成本,其次该方法只能片面解决单个用户问题,覆盖范围有限。

3.2 Jsonp

 在之前我们了解到了当请求TypeXHR时,浏览器会做出同源策略检测,由此JSONP(JSON with Padding)协议就诞生了。 JSONP本质上是利用动态构建<script>标签来避免同源策略检测,从而达到避免跨域问题。
 这里我们在编写示例时用到了前端测试框架【Jasmine】,这个框架在我们测试用例中可以一定程度上提供更高效并且更丰富的功能,入门使用比较简单,大家可以做一个大概的了解,阅读起来也不会有太大阻碍。这里我们在ajax请求时只需指定dataType : "jsonp"即可。

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="jquery-2.1.0.js"></script>
    <link rel="stylesheet" type="text/css" href="jasmine-standalone-3.3.0/lib/jasmine-3.3.0/jasmine.css">
    <script src="jasmine-standalone-3.3.0/lib/jasmine-3.3.0/jasmine.js"></script>
    <script src="jasmine-standalone-3.3.0/lib/jasmine-3.3.0/jasmine-html.js"></script>
    <script src="jasmine-standalone-3.3.0/lib/jasmine-3.3.0/boot.js"></script>
</head>
<body>
   
</body>

<script> var baseUrl = "http://localhost:8080"; //设置测试用例超时时间 jasmine.DEFAULT_TIMEOUT_INTERVAL = 3000; //测试模块 describe("跨域请求测试用例",function () { it("JSONP请求",function (done) { var result; $.ajax({ url : baseUrl + "/api/getApi", dataType : "jsonp", success: function (json) { result = json; } }); //异步请求 延迟比较结果 setTimeout(function () { console.log("setTimeout:"+result); //expect(result).toBe("ApiController.getApi"); expect(result).toEqual({ "code" : 200, "data" : "ApiController.getApi" }); //测试结束 通知jasmine done(); },2000); }) }) </script>
</html>

 通过JSONP解决跨域问题,我们服务端代码也需要响应改动,这里我们先实现后分析结果。构建一个JsonpAdvice类统一处理API接口响应结果。这里只需要继承AbstractJsonpResponseBodyAdvice调用父类构造方法。需要注意的是,Spring Mvc从4.2版本开始支持CORS跨域资源共享,这个类也就废弃了,也就是说高版本的对应的SpringBoot应用程序可能无法引用该类(目前SpringBoot测试版本为1.5.14.RELEASE,仍未移除该类),可以通过@CrossOrigin注解实现跨域资源共享,这个我们之后会稍作介绍。

JsonpAdvice.java

package com.ithzk.cross.controller;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.AbstractJsonpResponseBodyAdvice;

/** * @author hzk * @date 2019/4/2 */
@ControllerAdvice
public class JsonpAdvice extends AbstractJsonpResponseBodyAdvice{

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

 修改为JSONP之后我们再次去访问http://localhost:8081/#,可以发现我们的测试用例通过,即说明通过JSONP方式可以处理跨域请求问题。

 这里我们看一下请求资源的一些数据,可以发现此时Type已经变为script,并且Content-Type也以application/javascrip形式处理。

 我们直接调用该接口可以发现此时返回的数据已经不再是之前的Json格式,如果对Jquery源码有所研究的同学可以发现,在服务器返回特定script数据后,会根据该数据通过动态创建script的方式去获取资源,避免了跨域问题。
 我们可以很清楚地观察到此时请求的资源路径自动添加上了两个参数callback_,这里我们应该还有映像,在修改服务端代码时,我们曾指定过一个callback参数作为构造方法调用参数。这里我们修改callback参数名为callback2,再次发现返回数据变回了Json格式数据。

 这里我们需要对JSONP的概念回顾一下,它是一种非官方的数据交互协议,既然这是一种协议,那必然存在某种约定,其实这里就是服务端和调用段之间他们有了一个约定,在这里也就是callback这个参数,如果服务端接收到了callback参数则会返回一段javascrip代码,否则按照正常Json格式处理数据。这里我们如果需要修改这个参数名,则需要同时修改服务端和调用端。

JsonpAdvice.java

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

index.html

 $.ajax({
   url : baseUrl + "/api/getApi",
     dataType : "jsonp",//指定服务器返回的数据类型
     jsonp : "callback2",//指定参数名
     //jsonpCallback: "resultCallback", //指定回调函数名
     success: function (json) {
         result = json;
     }
 });

 这里我们再来看看另一个参数_,这个参数的值是一串动态的数字,主要作用是用来防止缓存,我们在ajax请求时设置cachetrue之后再访问http://localhost:8081/#,此时请求资源地址后就不再带有这个参数。

index.html

 $.ajax({
   url : baseUrl + "/api/getApi",
     dataType : "jsonp",
     jsonp : "callback2",
     cache : true,
     success: function (json) {
         result = json;
     }
 });

 我们给ajax请求加上type : "post",发现使用JSONP仍然只会发送GET请求。

 通过这些示例,我们可以知道通过JSONP协议,我们可以解决跨域问题,并且知道了它的本质是动态创建script去避免检测,但是同样也暴露了很多缺点,这也是SpringBoot官方文档认为JSONP是一种安全性较低且功能较弱的解决方法的原因之一。它的主要缺点是,若采用这种方法服务端和调用端都修改代码,其次它仅支持GET请求,并且不能够使用XHR作为Type发送请求,这些都会限制很多实际开发场景中的需求,所以大多数情况下并不能作为最好的解决方法。

3.3 被调用方解决跨域(支持跨域)

 上图是我简易画的一个目前Java WEB项目常见的架构模式。之前我们分析了当我们不想要去修改请求本质内容或者响应类型时,我们可以通过被调用方配置支持跨域来处理跨域问题,那么被调用方支持跨域其实本质上是在响应头中添加一些支持跨域的信息响应给浏览器即可,这里我们分析架构大概可以分析出被调用方添加这些响应头参数主要可以在两个地方:第一是直接在被调用方应用程序中添加,第二就是在代理服务器(Nginx/Apache)中添加,这里我们对每种方式都做一个介绍。

3.3.1 服务端支持跨域

 我们先来看一下我们发生跨域请求时,我们的一些请求头和响应头参数,这里可以看到有一个Origin属性的,这个属性的主要目的就是用来标识当前请求是从哪里发起的。而这个属性在我们跨域配置中也是一个很重要的角色。

 这里我们服务端支持跨域只需在响应中增加支持跨域的响应头参数,我们这里在服务端构建一个Filter过滤器去实现这个效果。

CorsFilter.java(跨域请求处理过滤器)

package com.ithzk.cross.filter;

import javax.servlet.*;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/** * 跨域请求处理过滤器 * @author hzk * @date 2019/4/3 */
public class CorsFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
        httpServletResponse.addHeader("Access-Control-Allow-Origin", "*");
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE");
        filterChain.doFilter(servletRequest,servletResponse);
    }

    @Override
    public void destroy() {

    }
}

CrossDomainServerApplication.java(入口类中注册过滤器)

@SpringBootApplication
public class CrossDomainServerApplication {

	public static void main(String[] args) {
		SpringApplication.run(CrossDomainServerApplication.class, args);
	}
	
	/** * 注册过滤器 * @return */
	@Bean
	public FilterRegistrationBean registrationBean(){
		FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
		filterRegistrationBean.addUrlPatterns("/*");
		filterRegistrationBean.setFilter(new CorsFilter());
		return filterRegistrationBean;
	}
}

 这里我们再次访问,可以看到响应头中将这两个我们设置的参数返回给了浏览器,跨域问题也不存在可以正常拿到数据,也就是说跨域请求问题我们是可以通过服务端程序去避免的,这里我们在响应头中设置的这些参数Access-Control-Allow-我们这里先给大家介绍下每个参数的含义,在之后可能会使用到,可以稍作了解。

响应头属性 响应头属性值 请求结果
Access-Control-Allow-Origin <origin> 指定允许访问该资源的域。可指定该字段值为通配符*,表示允许所有域的请求
Access-Control-Allow-Methods <method> 指定请求所允许使用的HTTP 方法
Access-Control-Allow-Headers <field-name> 指明请求中允许携带的头部属性
Access-Control-Max-Age <time> 指定preflight(预检请求)结果缓存时间
Access-Control-Expose-Headers <head-name> 除基本响应头外,指定需要公开暴露的响应头
Access-Control-Allow-Credentials true | false 跨域请求时是否允许携带cookie
3.3.1.1 简单请求与非简单请求

 这里我们先了解一下简单请求和非简单请求这两个概念。

a) 简单请求

 简单请求必须满足以下两个条件:

  1. 方法类型为:GET、POST、HEAD
  2. 请求Header中必须无自定义头,且Content-Type只能是text/plain、multipart/form-data、application/x-www-form-urlencoded
b) 非简单请求(复杂请求)

 非简单请求,也就是复杂请求,就是指对服务器有特殊要求的一类请求,比如请求方式为PUT或者DELETE,Content-Type字段的类型是application/json,发送json格式的ajax请求。
 非简单请求的跨域请求会在正式通信前,发送一次请求方式为OPTIONS的请求,也就是预检请求(preflight request),预检请求,用于向服务器询问是否支持当前所需发送的请求,只有当预检请求通过时才会发出真实请求去请求所需数据,而之前我们接触的简单请求即是先发送请求给服务器,然后服务器再做出检测。

 了解了简单请求和非简单请求之后,我们来看一看实际上是不是同样的一回事。这里我们在服务端新增一个POST方法,然后在ajax请求时指定以Json格式发送数据。

ApiController.java(接口类)

package com.ithzk.cross.controller;

import com.ithzk.cross.entity.Book;
import com.ithzk.cross.entity.ResultData;
import org.springframework.web.bind.annotation.*;

/** * @author hzk * @date 2019/4/2 */
@RestController
@RequestMapping("/api")
public class ApiController {

    @GetMapping("/getApi")
    public ResultData getApi(){
        System.out.println("ApiController.getApi");
        return new ResultData("ApiController.getApi");
    }

    @PostMapping("/postApi")
    public ResultData postApi(@RequestBody Book book){
        System.out.println("ApiController.postApi " + book.getName() + " " + book.getPrice());
        return new ResultData("ApiController.postApi " + book.getName() + " " + book.getPrice());
    }
}

index.html(只贴出js部分)

var baseUrl = "http://localhost:8080";

    //设置测试用例超时时间
    jasmine.DEFAULT_TIMEOUT_INTERVAL = 3000;

    //测试模块
    describe("跨域请求测试用例",function () {

        it("POST非简单请求",function (done) {
            var result;

            $.ajax({
                url : baseUrl + "/api/postApi",
                type : "post",
                contentType: "application/json;charset=utf-8",
                data: JSON.stringify({ msg : "post"}),
                success: function (json) {
                    result = json;
                }
            });

            //异步请求 延迟比较结果
            setTimeout(function () {
                console.log("setTimeout:"+result);
                expect(result).toEqual({
                    "code" : 200,
                    "data" : "ApiController.postApi post"
                });

                //测试结束 通知jasmine
                done();
            },2000);
        })
    })

 再次去请求我们的项目,会发现此时只发出了一个方法为OPTIONS的请求,并且控制台抛出了错误。根据这个错误我们很清楚就能得出结论,在预检响应时Access-Control-Allow-Headers中的Content-Type不被允许。


 这里我们根据需要在过滤器中添加上该设置。

CorsFilter.java(跨域请求处理过滤器)

package com.ithzk.cross.filter;

import javax.servlet.*;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/** * 跨域请求处理过滤器 * @author hzk * @date 2019/4/3 */
public class CorsFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
        httpServletResponse.addHeader("Access-Control-Allow-Origin", "*");
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", "Content-Type");
        filterChain.doFilter(servletRequest,servletResponse);
    }

    @Override
    public void destroy() {

    }
}

 此时重新访问,控制台正常,Jasmine测试用例通过。可以验证确定非简单请求会先发送一个预检请求再去实际请求数据。

3.3.1.2 预检命令缓存

 我们在之前也了解到有一个参数Access-Control-Max-Age是指预检结果缓存时间。那么其实预检请求也是可以缓存的,这里我们可以设置一下试试效果。

CorsFilter.java

  @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
        httpServletResponse.addHeader("Access-Control-Allow-Origin", "*");
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
        httpServletResponse.setHeader("Access-Control-Max-Age", "3600");
        filterChain.doFilter(servletRequest,servletResponse);
    }

 这里我们将缓存结果设置为了60分钟,然后我们去重新请求。我们可以发现第一次仍然会有两个请求,一个OPTIONS预检命令请求,一个实际POST请求。然后我们再次刷新,结果发现只剩下一个POST请求了。


 我们再进一步验证缓存是否有效,这里我们先查看一下当前请求服务端返回的响应头数据,正是我们在应用程序内设置的属性和值。

 我们在服务中把后面添加的Access-Control-Allow-Headers以及Access-Control-Max-Age注释,只保留Access-Control-Allow-OriginAccess-Control-Allow-Methods。再次请求,发现仍然只含有一个POST实际请求,这时我们去核对响应头,发现此时响应头返回的属性和我们修改后的一致,那么预检命令实现缓存的目的我们就达到了。

3.3.1.3 带有Cookie的跨域请求

 我们来验证一下是否我们之前的配置,在带有cookie的请求时也能够正常处理跨域问题。我们在服务端编写一个新的接口用于接收处理cookie信息,同时调用方修改为携带cookie请求服务端。

ApiController.java

    @GetMapping("/getCookie")
    public ResultData getCookie(@CookieValue(value="api_cookie") String apiCookie){
        System.out.println("ApiController.getCookie " + apiCookie);
        return new ResultData("ApiController.getCookie " + apiCookie);
    }

index.html

 	var baseUrl = "http://localhost:8080";

    //设置测试用例超时时间
    jasmine.DEFAULT_TIMEOUT_INTERVAL = 3000;

    //测试模块
    describe("跨域请求测试用例",function () {

        it("带有Cookie请求",function (done) {
            var result;

            $.ajax({
                url : baseUrl + "/api/getCookie",
                type : "get",
                xhrFields : { withCredentials : true },
                success: function (json) {
                    result = json;
                }
            });

            //异步请求 延迟比较结果
            setTimeout(function () {
                console.log("setTimeout:"+result);
                expect(result).toEqual({
                    "code" : 200,
                    "data" : "ApiController.getCookie msg"
                });

                //测试结束 通知jasmine
                done();
            },2000);
        })
    })

 这里需要注意的是当发起跨域请求时,若需要携带cookie数据,则需要配置xhrFields : { withCredentials : true }。再次请求会出现以下错误。

 这个错误是由于当我们携带cookie数据跨域请求时,"Access-Control-Allow-Origin属性必须符合完全匹配,而此时我们服务端设置为*,在大多数地方查阅资料时解决跨域问题这里都会直接设置为*,通过我们试验可以发现当带有cookie数据请求时,*不能处理跨域问题,这里我们先暂时修改为指定域``。

httpServletResponse.addHeader("Access-Control-Allow-Origin", "http://localhost:8081");

 果然福无双至祸不单行,我们再去请求出现了一个新的问题,这里大概的意思是此时我们Access-Control-Allow-Credentials属性必须设置为true,在上面我们了解到这个属性是检测请求方是否允许携带cookie的作用,这里我们也设置上对应的值。

httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true");

 我们在请求前,再做一件事,将我们需要的cookie设置上,最简单的方式通过控制台document.cookie="api_cookie=msg"即可,设置完成后再次访问,测试用例通过达到了我们携带cookie跨域的目的。

 这里我们虽然解决了带cookie的跨域问题,但是我们还需要处理一个东西,那就是开始我们在Access-Control-Allow-Origin属性上固定了域,这里在实际开发过程中肯定是不可能的,抛开分布式集群架构不说,单从localhost:8081127.0.0.1:8081上就无法满足实际情况,这里我们稍作改装即可。
CorsFilter.java

@Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String origin = httpServletRequest.getHeader("Origin");
        if(!StringUtils.isEmpty(origin)){
            //带cookie时,该响应头必须完全匹配,不能使用*
            httpServletResponse.addHeader("Access-Control-Allow-Origin", origin);
        }
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
        httpServletResponse.setHeader("Access-Control-Max-Age", "3600");

        //
        httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true");
        filterChain.doFilter(servletRequest,servletResponse);
    }
3.3.1.4 带有自定义头的跨域请求

 在特定情况下,我们在请求时需要自定义一些头部信息,我们这里来看一下当自定义头部信息时如何解决跨域问题。这里我们有两种在Ajax请求时设置头部信息的方式。

ApiController.java

  @GetMapping("/getHeader")
    public ResultData getHeader(@RequestHeader(value = "X-Header-1") String header1,@RequestHeader(value = "X-Header-2") String header2){
        System.out.println("ApiController.getHeader " + header1 + " " + header2);
        return new ResultData("ApiController.getHeader " + header1 + " " + header2);
    }

index.html.

  var baseUrl = "http://localhost:8080";

    //设置测试用例超时时间
    jasmine.DEFAULT_TIMEOUT_INTERVAL = 3000;

    //测试模块
    describe("跨域请求测试用例",function () {

        it("带有自定义头的请求",function (done) {
            var result;

            $.ajax({
                url : baseUrl + "/api/getHeader",
                type : "get",
                headers : {"X-Header-1" : "x1"},
                beforeSend : function (xhr) {
                  xhr.setRequestHeader("X-Header-2","x2");
                },
                success: function (json) {
                    result = json;
                }
            });

            //异步请求 延迟比较结果
            setTimeout(function () {
                console.log("setTimeout:"+result);
                expect(result).toEqual({
                    "code" : 200,
                    "data" : "ApiController.getHeader x1 x2"
                });

                //测试结束 通知jasmine
                done();
            },2000);
        })
    })

 这时我们去请求控制台依然给我们如愿以偿地抛出了一个错误。这里是说我们没有在Access-Control-Allow-Headers中设置允许携带X-Header-1,X-Header-2请求头。

 我们在过滤器中添加上这两个响应头。

httpServletResponse.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept,X-Header-1,X-Header-2");

 这样设置之后,我们再去请求就不会存在跨域问题了,那么我们自定义请求头的头部名称不可能保持不变,我们需要和之前处理Origin类似的方法去稍微改装一下我们的过滤器。

@Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;

        //支持所有域
        String origin = httpServletRequest.getHeader("Origin");
        if(!StringUtils.isEmpty(origin)){
            //带cookie时,该响应头必须完全匹配,不能使用*
            httpServletResponse.addHeader("Access-Control-Allow-Origin", origin);
        }

        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE");

        //支持所有自定义头
        String headers = httpServletRequest.getHeader("Access-Control-Request-Headers");
        if(!StringUtils.isEmpty(headers)){
            httpServletResponse.setHeader("Access-Control-Allow-Headers", headers);
        }
        
		//设置预检命令缓存时间
        httpServletResponse.setHeader("Access-Control-Max-Age", "3600");

        //支持跨域携带cookie
        httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true");
        filterChain.doFilter(servletRequest,servletResponse);
    }
3.3.1.5 Spring框架支持跨域

 我们之前在介绍JSONP解决方案时,提及了Spring在4.2版本开始已经支持跨域,可以通过@CrossOrigin注解就能很轻易的实现,这里我们将过滤器去除,在我们ApiController类上直接添加@CrossOrigin试一试效果。

package com.ithzk.cross.controller;

import com.ithzk.cross.entity.Book;
import com.ithzk.cross.entity.ResultData;
import org.springframework.web.bind.annotation.*;

/** * @author hzk * @date 2019/4/2 */
@RestController
@RequestMapping("/api")
@CrossOrigin
public class ApiController {

    @GetMapping("/getApi")
    public ResultData getApi(){
        System.out.println("ApiController.getApi");
        return new ResultData("ApiController.getApi");
    }

    @PostMapping("/postApi")
    public ResultData postApi(@RequestBody Book book){
        System.out.println("ApiController.postApi " + book.getName() + " " + book.getPrice());
        return new ResultData("ApiController.postApi " + book.getName() + " " + book.getPrice());
    }

    @GetMapping("/getCookie")
    public ResultData getCookie(@CookieValue(value="api_cookie") String apiCookie){
        System.out.println("ApiController.getCookie " + apiCookie);
        return new ResultData("ApiController.getCookie " + apiCookie);
    }

    @GetMapping("/getHeader")
    public ResultData getHeader(@RequestHeader(value = "X-Header-1") String header1,@RequestHeader(value = "X-Header-2") String header2){
        System.out.println("ApiController.getHeader " + header1 + " " + header2);
        return new ResultData("ApiController.getHeader " + header1 + " " + header2);
    }
}

 我们通过请求可以发现,跨域问题已经不存在,也就是说Spring高版本可以通过该注解很好地支持跨域,当然这里我们只是对@CrossOrigin注解做了一个很简单的使用,更多使用方法和细节大家可以自己通过文档以及资料了解。

3.3.2 Nginx配置支持跨域

 上面我们介绍了如何直接从应用服务程序中去设置响应头支持跨域,这里我们看一看如何通过Nginx代理去设置响应头达到支持跨域的目的。
 首先我们修改本机的hosts文件添加127.0.0.1 server.com保存,实现域名映射的目的。我们这里可以通过请求http://server.com:8080/api/getApi验证是否配置成功。
 接下来我们准备一个windows版本的【nginx】,解压之后打开conf目录,新建一个vhost目录用于待会存放我们自定义的配置文件,然后我们需要先修改以下nginx.conf,在http{}节点下加上下面属性用于加载自定义配置文件。

nginx.conf

# 加载自定义配置文件
include vhost/*.conf;

 我们在建好的vhost目录下新建一个server.conf文件,添加下面配置。

server.conf

server{
	listen 80;
	server_name server.com;

	location /{
		proxy_pass http://localhost:8080/;
	}
}

 配置完成后,我们找到nginx根目录下的nginx.exe文件,通过命令行执行.\nginx.exe -t检验是否配置成功通过测试。

 这里如果和上面提示类似即配置成功。现在我们通过命令start .\nginx.exe启动nginx,现在我们修改以下请求端调用接口。

index.html

var baseUrl = "http://server.com";

 修改成功后我们再次请求,发现可以正常完成整个流程,这就说明我们nginx配置启动成功了。现在我们将服务端入口类注册过滤器去除,再次请求页面,控制台会抛出和之前一样跨域问题的错误,我们要的就是这个效果。现在我们通过配置nginx来解决跨域问题,同样是server.conf

server.conf

server{
	listen 80;
	server_name server.com;

	location /{
		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;

		add_header Access-Control-Allow-Origin $http_origin;
		add_header Access-Control-Allow-Headers $http_access_control_request_headers;

		if ($request_method = OPTIONS){
			return 204;
		}
	}
}

 我们这里可以看到,这里其实就是将之前我们在服务端配置的响应头移至nginx中,一些nginx的基本语法大家可以自己去做更详细的了解,这里需要注意的是15行左右if判断和括号之间需要有空格,否则会无法成功加载。配置完成后我们可以通过命令.\nginx.exe -s reload重新加载新的配置文件应用到nginx中,此时再去访问已经可以得到我们想要的结果了,之后大家可以通过.\nginx.exe -s stop停止nginx

3.3.3 Apache配置支持跨域

 这里我们看一下使用Apache HTTP服务我们如何去解决跨域问题,这里其实也很简单,与之前Nginx类似,只需要修改添加一些配置即可。这里我们需要先准备一个Apache服务,可以通过【Apache Server】下载到对应版本的服务,这里我下载了一个Windows版64位的,这里给大家安利一篇比较清晰的文章如何从Apache官网下载windows版apache服务器,大家下载安装的时候可以参考。

 下载完成后解压,这个时候我们打开conf目录下的httpd.conf文件将以下几行命令注释去除,这里是因为我们需要配置虚拟主机去做代理所以需要借助该模块。

httpd.conf

LoadModule vhost_alias_module modules/mod_vhost_alias.so
Include conf/extra/httpd-vhosts.conf

LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_http_module modules/mod_proxy_http.so

 修改保存后,我们进入conf/extra目录找到httpd-vhosts.conf配置文件,在最后添加以下配置。

<VirtualHost *:80>
    ServerName server.com
    ErrorLog "logs/server.com-error.log"
    CustomLog "logs/server.com-access.log" common
    ProxyPass / http://localhost:8080/
</VirtualHost>

 保存完成后,我这里通过命令行启动httpd服务,并没有成功,抛出以下错误ServerRoot must be a valid directory

 这里是由于我们配置的服务路径错误,再次打开httpd.conf找到Define SRVROOT "/Apache24"或者ServerRoot "${SRVROOT}"可以发现此时的路径确实并不是我们本地服务的路径,做出相应修改后启动服务,启动成功。

Define SRVROOT "G:\JavaTools\Apache Http\Apache24"
ServerRoot "${SRVROOT}"

 此时我们再去请求server.com发现成功访问。

 但是此时我们只是做到了简单的代理目的,通过调用端请求可以发现跨域问题存在,这里我们再次打开httpd-vhosts.conf进行以下内容配置。

<VirtualHost *:80>
    ServerName server.com
    ErrorLog "logs/server.com-error.log"
    CustomLog "logs/server.com-access.log" common
    ProxyPass / http://localhost:8080/

    # 动态设置Access-Control-Allow-Origin 为请求头的Origin
    Header always set Access-Control-Allow-Origin "expr=%{req:origin}"

    # 动态设置Access-Control-Allow-Headers 为请求头的Access-Control-Request-Headers
    Header always set Access-Control-Allow-Headers "expr=%{req:Access-Control-Request-Headers}"

    Header always set Access-Control-Allow-Methods "*"
    Header always set Access-Control-Allow-Credentials "true"
    Header always set Access-Control-Allow-Max-Age "3600"
    
    # 处理预检命令OPTIONS
    RewriteEngine On
    RewriteCond %{REQUEST_METHOD} OPTIONS
    RewriteRule ^(.*)$ "/" [R=204,L]
</VirtualHost>

 由于这里我们用到了rewrite以及headers模块,所以我们仍然需要在httpd.conf中开启加载这两个模块。

LoadModule headers_module modules/mod_headers.so
LoadModule rewrite_module modules/mod_rewrite.so

 这个时候我们再去请求,可以看到跨域问题已经解决,这就是Apache Server解决跨域问题的方法。这里稍微提一点,我们可以看到对于预检命令的处理我们直接返回响应码204而不是200,这是因为预检命令的目的是为了检测无需返回内容,返回200或者204对于预检命令都属于通过检测,其他的大家可以自己更加深入去了解。

3.4 调用方解决跨域(隐藏跨域)

 这里我们我们来简单介绍下调用方通过隐藏跨域来解决跨域问题的大致思路,这张图和之前3.3的图虽然只有一个细微的区别,但是解决思想却是截然不同的。这里我们主要是通过调用方的代理服务器去反向代理我们需要调用的服务,使得浏览器最终认为调用方与被调用方属于同一个域,从而解决跨域问题,这里我们也来介绍一下如何利用NginxApache反向代理我们的请求。

3.4.2 Nginx配置隐藏跨域

 看过上面内容的情况下,大致已经了解了如何去简单配置启用Nginx,这里我们不对其操作做过多介绍。首先我们先保证服务端应用程序中支持跨域过滤器以及Spring提供的@CrossOrigin都已去除,我们可以通过请求调用方程序确定是否可以重现跨域问题。
 确保跨域问题存在之后,我们先修改hosts文件,由于这里是从调用方出发考虑,所以稍作修改即可127.0.0.1 server.com -> 127.0.0.1 client.com ,修改之后保存。然后我们在nginxconf/vhost目录下新建一个client.conf,添加以下内容。

server{
	listen 80;
	server_name client.com;

	location /{
		proxy_pass http://localhost:8081/;
	}

	location /server{
		proxy_pass http://localhost:8080/;
	}
}

 此时我们已经可以通过client.com去请求调用方内容,但是目前跨域问题肯定是存在的,这里我们需要对调用方内容稍作修改。

index.html

var baseUrl = "/server";

 没错,只需要修改我们请求的地址即可,此时我们再去请求,跨域问题已经解决,干净利落,这里我们来看一下请求的信息大家就能够知道为什么跨域问题已经不存在了。

 这个时候我们请求的服务对于调用端以及浏览器已经从某些层面上不属于跨域,这就是隐藏跨域的本质。

3.4.3 Apache配置隐藏跨域

 这里和上面nginx本质是一样的,只需要修改配置做好代理即可。这里我们找到conf/extra目录下的httpd-vhosts.conf添加以下配置。

<VirtualHost *:80>
    ServerName client.com
    ErrorLog "logs/client.com-error.log"
    CustomLog "logs/client.com-access.log" common
    ProxyPass /server_apache http://localhost:8080/
    ProxyPass / http://localhost:8081/
</VirtualHost>

 当我们对跨域请求本质有了一定的了解之后,其实对于解决这个问题就变得十分轻松,此时我们去请求调用端,跨域问题解决。

 这里贴出该章代码示例【cross_domain】,若有疑问或者文章错误的地方还希望大家提出,一起交流学习。