一、背景
最近需要统一升级 Spring 的版本,避免 common 包和各个项目间的 Spring 版本冲突问题。这次升级主要是从 Spring 4.1.9.RELEASE 升级到 Spring 4.3.22RELEASE。
预备知识点
- OPTIONS 请求 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/OPTIONS
- CORS 跨域请求
- https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CORS
- https://www.ruanyifeng.com/blog/2016/04/cors.html
升级前相关环境
项目采用的方式是通过实现过滤器 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
- https://docs.spring.io/spring-framework/docs/4.2.0.RELEASE/spring-framework-reference/html/cors.html
- https://github.com/spring-projects/spring-framework/issues/13916
主要可以通过以下方式配置跨域支持
- 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 版本的发布信息:
- https://archive.apache.org/dist/tomcat/tomcat-6/v6.0.48/RELEASE-NOTES
- https://archive.apache.org/dist/tomcat/tomcat-8/v8.0.48/RELEASE-NOTES
得出对于这次跨域问题,可能有影响的区别是:
- 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 的执行过程
- 用户发送请求经过 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 时,要注意验证请求域名合法问题
- 平常要注意与正式环境配置一置,在小公司很多问题都没有意识到
- 虽然这次的问题很简单,但是要多问为什么? 多研究一下,才能提升自己
相关实践代码
参考
- 跨域资源共享 CORS 详解 阮一峰
- https://developer.mozilla.org/zh-TW/docs/Web/HTTP/CORS
- http://nginx.org/en/docs/http/ngx_http_core_module.html#underscores_in_headers
- https://www.ruanyifeng.com/blog/2019/09/cookie-samesite.html
- https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Set-Cookie/SameSite
- https://github.com/spring-projects/spring-framework/issues/13916
- https://www.cnblogs.com/wxw16/p/10674539.html
- Spring4.2 开始支持 CORS 跨域
- Tomcat