【工作篇】了解升级 Spring 版本导致的跨域问题

一、背景

最近需要统一升级 Spring 的版本,避免 common 包和各个项目间的 Spring 版本冲突问题。这次升级主要是从 Spring 4.1.9.RELEASE 升级到 Spring 4.3.22RELEASE。

预备知识点

升级前相关环境

项目采用的方式是通过实现过滤器 Filter,在 Response 返回头文件添加跨域资源共享(CORS) 相关的参数。采用打 war 包部署到 Tomcat6.0.48,但是本地开发配置的 tomcat 版本是 Tomcat8.0.48(这里一般要与服务器环境一致,不然有不可预知问题出现)。

public class CrossFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.addHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, CMALL_TOKEN"); //这里自定义的请求头不规范,应该使用"-",CMALL-TOKEN,不然需要配置nignx识别
        response.setHeader("Access-Control-Allow-Credentials", "true"); // cookie
        String origin = request.getHeader("Origin");
        response.setHeader("Access-Control-Allow-Origin", origin);  //注意:这里以前并没有限制合法的 域名

        //注意:这里如果是预检请求,还是会执行下一个Filter,最好是直接返回响应前端
        chain.doFilter(request, response);
    }
}

二、排查问题

在本地开发环境升级了 Spring 版本为后 Spring 4.3.22RELEASE 后,没有修改 CorsFilter 相关的参数,运行测试没有跨域问题,其它功能正常。 然后部署到测试环境,发现了跨域问题。

通过排查,发现本地的 Tomcat 版本是 Tomcat8.0.48,而测试环境的版本是 Tomcat6.0.48,大意了,平常开发环境也没有注意规范,要与线上,测试等环境保持一致。本地重新配置 Tomcat6.0.48 后重现了跨域问题。

2.1、初步分析

开始排查具体的失败问题,发现

1、Spring4.3.22RELEASE tomcat 6.048 会出现跨域问题

2、Spring 4.1.9RELEASE (Tomcat6.0.48、Tomcat 8.0.48 ) 不会出现跨域问题

3、Spring4.3.22RELEASE (Tomcat8.048) 不会出现跨域问题

从而得出以下疑问?

1、Spring 4.1.9RELEASE 到 Spring4.3.22RELEASE 版本,针对 CORS,有什么新特性发布?

2、Tomcat6.0.48、Tomcat 8.0.48 有什么区别?

2.1.1、首先查看 Spring 版本的差异

通过查看 SpringMVC 官方文档,从 4.2.0 版本开始,SpringMVC 开始支持 CORS 跨域解决方案,主要表现是通过简单的配置,就可以支持 CORS

主要可以通过以下方式配置跨域支持

  • 1、通过注解 @CrossOrigin 为单独的请求配置跨域
@RestController
@RequestMapping("/account")
public class AccountController {

@CrossOrigin
@RequestMapping("/{id}")
public Account retrieve(@PathVariable Long id) {
    // ...
}

@RequestMapping(method = RequestMethod.DELETE, path = "/{id}")
public void remove(@PathVariable Long id) {
    // ...
}
}
  • 2、全局配置方式

    • Java Config  配置方式
@Configuration
@EnableWebMvc
public class WebConfig extends WebMvcConfigurerAdapter {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("http://domain2.com")
            .allowedMethods("PUT", "DELETE")
            .allowedHeaders("header1", "header2", "header3")
            .exposedHeaders("header1", "header2")
            .allowCredentials(false).maxAge(3600);
    }
}
  • Xml 配置方式
<mvc:cors>

    <mvc:mapping path="/api/**"
        allowed-origins="http://domain1.com, http://domain2.com"
        allowed-methods="GET, PUT"
        allowed-headers="header1, header2, header3"
        exposed-headers="header1, header2" allow-credentials="false"
        max-age="123" />

    <mvc:mapping path="/resources/**"
        allowed-origins="http://domain1.com" />

</mvc:cors>
2.1.2、Tomcat 版本的关键区别

查看 Tomcat 版本的发布信息:

得出对于这次跨域问题,可能有影响的区别是:

  • Tomcat 6.0 支持的 Servlet 版本为 2.5
  • Tomcat 8.0 支持的 Servlet 版本为 3.1

2.2、得出解决方案

对于上面的查找资料的过程,其实已经可以得出解决方案了(升级到 Spring4.3.22RELEASE):

因为我们使用的是自实现 Filter 过滤器的方式来处理跨域问题的,是不涉及框架问题才对,这里主要是我们没有对预检请求进行拦截并响应告知前端通过跨域请求。

  • 方法一、为了不怎么改动代码,我们还是采用在原来的过滤器中处理预检请求
public class CorsFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
    response.setHeader("Access-Control-Max-Age", "3600");
    response.addHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, CMALL-TOKEN");
    response.setHeader("Access-Control-Allow-Credentials", "true"); // cookie

    response.setHeader("Access-Control-Allow-Origin", "http://localhost:63342");

    String origin = request.getHeader("Origin");
//响应预检请求
//不让过滤器执行下去,Spring默认配置的cors跨域处理器就没法处理处理OPTIONS请求
    if (origin != null &&
            HttpMethod.OPTIONS.matches(request.getMethod()) &&
            request.getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD) != null) {
        response.setStatus(HttpServletResponse.SC_OK);
        return;
    }
    filterChain.doFilter(request, response);
}
}
  • 方法二、抛弃原先写的过滤器,使用 Spring 提供的方案
@Configuration
@EnableWebMvc
public class CorsConfig extends WebMvcConfigurerAdapter {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:63342")
                .allowedMethods("POST", "GET", "OPTIONS", "DELETE", "PUT")
                .allowedHeaders("Origin", "X-Requested-With", "Content-Type", "Accept")
                .exposedHeaders("CMALL-TOKEN")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

2.3、深入源码分析

虽然解决了这个跨域问题,但是还是要看看没有修改代码前为什么升级到 Spring4.3.22RELEASE,部署到 Tomcat 6.0.48 会出现跨域问题,而部署到 Tomcat 8.048 则不会。

2.3.1、回顾一下 SpringMVC 的执行过程
image
  • 用户发送请求经过 Filter 过滤器,Spring 拦截器,到达前端处理器 DispatchServlet
  • DispatcherServlet 收到请求调用 HandlerMapping(处理器映射器)
  • HandlerMapping 找到具体的处理器(Controller) 和 处理器拦截器(HandlerInterceptor)组成处理器执行链对象
  • DispatcherServlet 通过处理器(Controller)找到对应的处理器适配器(HandlerAdapter)
  • 处理器适配器(HandlerAdapter)执行具体的处理器(Controller)
  • Controller 执行完成返回 ModelAndView 对象。
  • DispatcherServlet 将 ModelAndView 传给 ViewReslover(视图解析器)。
  • ViewReslover 解析后返回具体 View(视图)。
  • DispatcherServlet 根据 View 进行渲染视图(即将模型数据填充至视图中)。
  • DispatcherServlet 响应用户。
2.3.2、Spring 是如何提供 CORS 支持的?

SpringMVC 的入口文件 DispatcherServlet,默认情况下 DispatcherServlet 继承自 FrameworkServlet,FrameworkServlet 处理了所有的 http 请求,调用 processRequest() 方法。

SpringMVC 处理 Option 请求源码

@Override
protected void doOptions(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {
    //dispatchOptionsRequest 是否开启对options请求的处理,默认值false
    //CorsUtils.isPreFlightRequest(request) 判断是否是预检请求
    if (this.dispatchOptionsRequest || CorsUtils.isPreFlightRequest(request)) {
        //处理 OPTIONS 请求
        processRequest(request, response);
        //包含 Allow响应头部,则请求已被正常处理,直接返回
        if (response.containsHeader("Allow")) {
            // Proper OPTIONS response coming from a handler - we're done.
            return;
        }
    }
    //调用父类的doOptions()方法,用于设置 Allow 响应头部
    // Use response wrapper for Servlet 2.5 compatibility where
    // the getHeader() method does not exist
    super.doOptions(request, new HttpServletResponseWrapper(response) {
        @Override
        public void setHeader(String name, String value) {
            if ("Allow".equals(name)) {
                value = (StringUtils.hasLength(value) ? value + ", " : "") + HttpMethod.PATCH.name();
            }
            super.setHeader(name, value);
        }
    });
}

在执行 processRequest 方法时的执行链是: FrameworkServlet.processRequest()->DispatcherServlet.doService()->DispatcherServlet.doDispatch()。

        ...
try {
ModelAndView mv = null;
Exception dispatchException = null;

try {
    processedRequest = checkMultipart(request);
    multipartRequestParsed = (processedRequest != request);

    // Determine handler for the current request.
    // 获取HandlerMapping(处理器映射器)
    mappedHandler = getHandler(processedRequest);
    if (mappedHandler == null || mappedHandler.getHandler() == null) {
        noHandlerFound(processedRequest, response);
        return;
    }

    // Determine handler adapter for the current request.
    //处理器适配器(HandlerAdapter)
    HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

    // Process last-modified header, if supported by the handler.
    String method = request.getMethod();
    boolean isGet = "GET".equals(method);
    if (isGet || "HEAD".equals(method)) {
        long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
        if (logger.isDebugEnabled()) {
            logger.debug("Last-Modified value for [" + getRequestUri(request) + "] is: " + lastModified);
        }
        if (new ServletWebRequest(request, response).checkNotModified(lastModified) && isGet) {
            return;
        }
    }
    //执行拦截器的前置方法
    if (!mappedHandler.applyPreHandle(processedRequest, response)) {
        return;
    }

    // Actually invoke the handler.
    //执行具体的控制器(Controller)
    mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

    if (asyncManager.isConcurrentHandlingStarted()) {
        return;
    }

    applyDefaultViewName(processedRequest, mv);

    mappedHandler.applyPostHandle(processedRequest, response, mv);
}
catch (Exception ex) {
    dispatchException = ex;
}
catch (Throwable err) {
    // As of 4.3, we're processing Errors thrown from handler methods as well,
    // making them available for @ExceptionHandler methods and other scenarios.
    dispatchException = new NestedServletException("Handler dispatch failed", err);
}
            ...

继续查看 CORS 的实现原理,getHandler 方法源码

protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
        for (HandlerMapping hm : this.handlerMappings) {
            if (logger.isTraceEnabled()) {
                logger.trace(
                        "Testing handler map [" + hm + "] in DispatcherServlet with name '" + getServletName() + "'");
            }
            HandlerExecutionChain handler = hm.getHandler(request);
            if (handler != null) {
                return handler;
            }
        }
        return null;
    }

针对请求 request,在 handlerMappings 这个 Map 中相应的处理器,在 SpringMVC 执行 init 方法时,已经预加载处理器 Map。处理器映射器实现了 HandlerMapping 接口的 getHandler 方法。看到默认 AbstractHandlerMapping 抽象类实现了该方法。

@Override
public final HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
    Object handler = getHandlerInternal(request);
    if (handler == null) {
        handler = getDefaultHandler();
    }
    if (handler == null) {
        return null;
    }
    // Bean name or resolved handler?
    if (handler instanceof String) {
        String handlerName = (String) handler;
        handler = getApplicationContext().getBean(handlerName);
    }
    //获取处理器执行链
    HandlerExecutionChain executionChain = getHandlerExecutionChain(handler, request);
    //判断是否是跨域请求
    if (CorsUtils.isCorsRequest(request)) {
        //获取 cors 配置
        CorsConfiguration globalConfig = this.globalCorsConfigSource.getCorsConfiguration(request);
        CorsConfiguration handlerConfig = getCorsConfiguration(handler, request);
        CorsConfiguration config = (globalConfig != null ? globalConfig.combine(handlerConfig) : handlerConfig);
        executionChain = getCorsHandlerExecutionChain(request, executionChain, config);
    }
    return executionChain;
}

如果是预检请求,则使用在 AbstractHandlerMapping 定义的内部类 PreFlightHandler 处理器处理预检请求

protected HandlerExecutionChain getCorsHandlerExecutionChain(HttpServletRequest request,
        HandlerExecutionChain chain, CorsConfiguration config) {

    if (CorsUtils.isPreFlightRequest(request)) {
        HandlerInterceptor[] interceptors = chain.getInterceptors();
        chain = new HandlerExecutionChain(new PreFlightHandler(config), interceptors);
    }
    else {
        chain.addInterceptor(new CorsInterceptor(config));
    }
    return chain;
}

而 PreFlightHandler 又委托给 CorsProcessor 处理

private CorsProcessor corsProcessor = new DefaultCorsProcessor();
private class PreFlightHandler implements HttpRequestHandler, CorsConfigurationSource {
    private final CorsConfiguration config;
    public PreFlightHandler(CorsConfiguration config) {
        this.config = config;
    }
    @Override
    public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws IOException {
        corsProcessor.processRequest(this.config, request, response);
    }
    @Override
    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
        return this.config;
    }
}

CorsProcessor 的 processRequest 方法是 SpringMVC 支持 Cors 的具体实现,到此已经了解了 Spring 对 Cors 支持的源码实现。但是为什么升级到 Spring4.3.22RELEASE,部署到 Tomcat 6.0.48 会出现跨域问题,而部署到 Tomcat 8.048 则不会这个问题,我们继续看 ServletServerHttpResponse 类

@Override
@SuppressWarnings("resource")
public boolean processRequest(CorsConfiguration config, HttpServletRequest request, HttpServletResponse response)
        throws IOException {

    if (!CorsUtils.isCorsRequest(request)) {
        return true;
    }

    ServletServerHttpResponse serverResponse = new ServletServerHttpResponse(response);
    //如果设置了 Access-Control-Allow-Origin 响应头,则直接返回
    if (responseHasCors(serverResponse)) {
        logger.debug("Skip CORS processing: response already contains \"Access-Control-Allow-Origin\" header");
        return true;
    }

    ServletServerHttpRequest serverRequest = new ServletServerHttpRequest(request);
    if (WebUtils.isSameOrigin(serverRequest)) {
        logger.debug("Skip CORS processing: request is from same origin");
        return true;
    }

    boolean preFlightRequest = CorsUtils.isPreFlightRequest(request);
    if (config == null) {
        if (preFlightRequest) {
            rejectRequest(serverResponse);
            return false;
        }
        else {
            return true;
        }
    }

    return handleInternal(serverRequest, serverResponse, config, preFlightRequest);
}

前面项目中有自定义 Filter 来处理跨域问题,而设置了对应的跨域响应头。在 ServletServerHttpResponse 类 的构造方法里,会根据 servlet 版本实例化不同的 headers。

public ServletServerHttpResponse(HttpServletResponse servletResponse) {
        Assert.notNull(servletResponse, "HttpServletResponse must not be null");
        this.servletResponse = servletResponse;
        this.headers = (servlet3Present ? new ServletResponseHttpHeaders() : new HttpHeaders());
    }

ServletResponseHttpHeaders 与 HttpHeaders 的区别是?

  • ServletResponseHttpHeaders 是 HttpHeaders 的子类
  • ServletResponseHttpHeaders 在获取响应头时,会先从当前响应中获取,也会从由外部传入的 header Map 中获取
  • 在实例化 ServletServerHttpResponse 类时,并没有传入 header ,所以在 servlet3 以下版本下,获取不到 Access-Control-Allow-Origin 响应头,没有跳过 Cors 请求处理
//ServletResponseHttpHeaders.get方法
@Override
public List<String> get(Object key) {
    Assert.isInstanceOf(String.class, key, "Key must be a String-based header name");
    //从当前响应中获取响应头
    Collection<String> values1 = servletResponse.getHeaders((String) key);
    boolean isEmpty1 = CollectionUtils.isEmpty(values1);
    //再调用父类HttpHeaders.get方法获取响应头
    List<String> values2 = super.get(key);
    boolean isEmpty2 = CollectionUtils.isEmpty(values2);

    if (isEmpty1 && isEmpty2) {
        return null;
    }
    List<String> values = new ArrayList<String>();
    if (!isEmpty1) {
        values.addAll(values1);
    }
    if (!isEmpty2) {
        values.addAll(values2);
    }
    return values;
}

三、总结

  • 在设置 Access-Control-Allow-Origin 时,要注意验证请求域名合法问题
  • 平常要注意与正式环境配置一置,在小公司很多问题都没有意识到
  • 虽然这次的问题很简单,但是要多问为什么? 多研究一下,才能提升自己

相关实践代码

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容