深入SpringMVC聊跨域问题的最佳实践

原文标题:深入SpringMVC聊跨域问题的最佳实践

原文作者:阿里云开发者

冷月清谈:

**跨域问题簡介**

瀏覽器基於同源政策,限制了不同網站之間的資源交互,稱為跨域問題。解決跨域有CORS和Jsonp兩種方式,其中Jsonp更早提出,目前已逐漸被CORS替代。

問題分析

在系統技術升級過程中,Jsonp接口出現跨域問題,表現為CORS相關http header缺失,且Jsonp接口返回時,http header中的Content-Type為“application/json”而非“application/javascript”。

原因定位

原實作方案中,CORS相關header設定位置在攔截器的afterCompletion方法,但Spring MVC中處理響應內容和設定header的時間點較早,導致afterCompletion中的設置無效。

解決方案

  1. 將CORS相關header設置位置調整至攔截器的preHandle方法。
  2. 僅讓自定義Message Convertor支援MediaType為“application/javascript”的類型,過濾非Jsonp請求。
  3. 調整Message Convertor在Spring MVC中的註冊順序,使自定義Convertor排在第一位。

最佳實踐

比較CORS和Jsonp,CORS較為簡單。使用第三方庫或框架(如fastjson提供的JSONPResponseBodyAdvice)也能更方便地支援Jsonp。開發者可根據實際情況選擇合適的方案。




怜星夜思:


1、在Spring MVC中,如何區分並處理Json和Jsonp的返回值?
2、Response的Committed在Spring MVC中扮演什麼角色,它如何影響header的設置?
3、在SpringBoot環境下,如何實作Jsonp的跨域解決方案?




原文内容



阿里妹导读


本文将深度剖析SpringMVC中的跨域问题及其最佳实践,尤其聚焦于Jsonp接口在技术升级中遇到的跨域难题。结合一个具体的案例,展示在技术升级过程中,原有JSONP接口出现跨域问题的表现及原因,以及如何通过自定义SpringMVC的拦截器和MessageConvertor来解决这个问题。

一、写在最前面,跨域和Jsonp

浏览器为了防止恶意网站进行跨站点的请求伪造,会限制不同站点之间的资源交互,这种行为被称为浏览器的同源策略(Same Origin Policy)。简单来说,在站点A的页面中的请求,请求url中的域名一般不能是其他站点。在不同站点之间进行资源访问,称为跨域(即Cross-Origin)。

当产生跨域请求时,即使响应结果从服务端返回了,浏览器也会将其拦截,产生CORS异常,提示:“Access has been blocked by CORS policy”。

然而,在多个安全的站点之间,互相访问数据是非常普遍的,比如在商家工作台页面(域名是i.alibaba.com)会访问其他子系统(比如数参data.alibaba.com)的接口,因此,需要为跨域问题提供解决方案。解决跨域问题的方法有两种:CORS和Jsonp。


CORS(Cross-Origin Resouce Sharing)

CORS是W3C官方提出的跨域解决方案。当在站点A的页面访问站点B时,若站点B的服务在响应中添加以下header,则浏览器不拦截对应的结果。header包括:

Access-Control-Allow-Origin

允许的站点(ip或域名),也就是原站点A

Access-Control-Allow-Credentials

是否可以携带Cookie

Access-Control-Allow-Methods

允许的HTTP方法,GET/POST等

Access-Control-Allow-Headers

允许的Header



JSONP(JSON with Padding)

Jsonp并不是官方提供的跨域解决方案,但是因为用的早,现在仍有非常多的历史接口还是基于jsonp。它主要的思路是:<script>标签中的脚本内容不受浏览器的同源策略限制,所以可以将资源数据“伪装”成js脚本。

对于服务端来说,用jsonp来处理跨域,需要做两件事:

1. 填充json

1.首先,当前端发起jsonp请求时,会动态插入一个<script>脚本用于请求数据,并提供一个回调函数,函数名作为查询参数callback,函数体是得到数据后的回调逻辑。如下图为jsonp_1718436528810_81650。





2.其次,服务端填充json。服务端的原始结果为json格式,将其填充后,会得到一条函数调用语句。函数名为上一步的callback入参,函数实参是原始的json结果。这个padding的过程也是jsonp名称的由来。





3.最后,前端执行填充后的结果,于是在回调函数jsonp_1718436528810_81650中可以获取原始json数据。

2. 设置响应的Content-Type

为了绕过同源策略,必须让浏览器认为这次请求返回的内容是一个script脚本,因此需要让响应的Content-Type是“application/javascript”。

如果返回的内容是jsonp,但Content-Type是application/json”,浏览器会无法识别,并产生ORB(Opaque Response Blocking)异常,提示:“No data found for resource with given identifier”。





对比两种跨域的解决方案,CORS清晰简单,正在逐步替代Jsonp。很多框架和三方库(如spring mvc,fastjson等)也在逐步废弃jsonp的相关实现。但是,后者的使用非常广泛,基于现实情况,很多系统里是两种方式并存的。

二、问题背景

2.1 问题表现

在对某个Web系统做技术升级的过程中,有Jsonp接口出现跨域问题。现象是:

1.CORS相关的http header缺失(即“Access-Control-Allow-Credentials”等)。虽然在这个场景并不必要,但这些header被设置过,却未生效。

2.Jsonp接口返回时,http header中的Content-Type值为“application/json”,而不是“application/javascript”,导致出现ORB错误。

2.2 原实现方案

出问题的接口为jsonp接口,它通过自定义Spring MVC的拦截器(Interceptor)和MessageConvertor实现json padding并设置content-type,原实现方案如下。





1.声明自定义拦截器JsonpInterceptor,若请求URI以jsonp结尾,则需经过该拦截器

@Configuration
public class MvcConfiguration implements WebMvcConfigurer {

@Autowired
private JsonpInterceptor jsonpInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jsonpInterceptor)
.addPathPatterns(“/**/*.jsonp”);
}
}

2.声明类JsonpWrapper。

3.在controller中,对所有的返回值做统一处理:若是jsonp请求,则将结果对象包裹在JsonpWrapper中。

public static Object getJsonOrJsonpObj(String callback, Object obj) {
if (StringUtils.isBlank(callback)) {
return obj;
} else {
JsonpWrapper jsonp = new JsonpWrapper();
// callback是一个入参,且会返回到浏览器,因此需要做处理,防止脚本注入
jsonp.setCallback(SecurityUtil.escapeHtml(callback));
jsonp.setValue(obj);
return jsonp;
}
}

4.自定义JavascriptConvertor,并替换SpringMVC默认的json convertorMessage ConvertorSpring MVC用于处理请求和返回数据的类,更多的细节先按下不表。

5.当返回结果为JsonpWrapper时,按照jsonp的格式,在结果前后填充数据,输出最终结果。AbstractJackson2HttpMessageConverter是SprigMVC提供用于处理json类数据的Message Convertor的父类。

public class JavaScriptMessageConverter extends AbstractJackson2HttpMessageConverter {
// 省略其他

@Override
protected void writePrefix(JsonGenerator generator, Object object) throws IOException {
String callback = (object instanceof JsonpWrapper ? ((JsonpWrapper) object).getCallback() : null);
if (callback != null) {
generator.writeRaw(“/**/”);
generator.writeRaw(callback + “(”);
}
}

@Override
protected void writeSuffix(JsonGenerator generator, Object object) throws IOException {
String callback = (object instanceof JsonpWrapper ? ((JsonpWrapper) object).getCallback() : null);
if (callback != null) {
generator.writeRaw(“);”);
}
}
}

6.在拦截的后置处理中,向response中写入CORS请求,并修改ContentTypeapplication/javascript

public class JsonpInterceptor extends HandlerInterceptorAdapter {

public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable Exception ex) {
response.setHeader(“Access-Control-Allow-Credentials”, “true”);
response.setHeader(“Access-Control-Allow-Headers”, “*”);
response.setHeader(“Access-Control-Allow-Methods”, “POST, GET, PUT, DELETE, OPTIONS”);
// 省略safeOrigin获取过程
response.setHeader(“Access-Control-Allow-Origin”, safeOrigin);
response.setHeader(“Content-Type”, “application/javascript”);
}
}

上面提到的JsonpInterceptor,JsonpWrapper,JavascriptConvertor均为自定义实现。这个方案在很长的时间里也能正常工作。但请注意,设置Http Header的位置是在拦截器的afterCompletion方法中。

2.3 问题定义

将问题表现和原实现方案结合看,流程中,最后一步的setHeader和问题直接相关。所以从这里开始,只需要解答一个问题,那就是:

为什么自定义的interceptor中,对response的header设置不生效了?

三、Spring MVC的工作流程

拦截器(Interceptor)是Spring MVC提供的工具。因此下面简要回顾Spring MVC的工作流程,且重点放在Response的Header和内容写入上。

3.1 一张古老的时序图

图片

上图展示了Spring MVC的几个关键组件:

1.DispatcherServlet:Spring MVC的前端控制器,负责接受请求,并分发到合适的处理器(Controller)上
2.HandlerMapping:根据请求的URL找到对应的处理器和方法
3.Controller:执行业务逻辑,返回视图或者响应体。
4.ViewResolver:通过该组件,找到对应视图
5.View:根据Controller返回的Model数据,渲染页面。
6.HandlerAdapter:对调用逻辑的一个代理,用于处理不同的场景。

7.Interceptor:在调用Controller前后拦截,可以添加处理逻辑,用于日志,鉴权等。拦截器是可选的。

上图同时也描述了Spring MVC工作中的一个标准流程,可以简要概括为:

1.HTTP请求首先到达Spring MVC的核心——DispatcherSerlvet;
2.DispatcherSerlvet根据请求信息(比如URL)查询HandlerMapping,这一步返回的结果,我们可以先简单理解为要执行的实际controller;
3.Controller执行完业务逻辑,返回视图ModelAndView
4.DispatcherSerlvet请求ViewResolver获取实际解析的View;

5.View被调用,页面被解析并返回给浏览器;

3.2 规范越多,责任越大

上图的时序图比较简洁,从中能鸟瞰Spring MVC的工作流程。但是它不能反映框架里所有的工作场景,且跟本篇讨论的流程也不完全匹配。因为请求可以返回视图页面,也可以返回对象,但无论json还是jsonp对象都不是视图页面,所以在这里View相关的逻辑并不会被运行。

究其原因,是HTTP的规范内容众多,为此,Spring MVC需要支持各种类型的协议,以处理对应的逻辑。正如返回值可以是页面View,也可以是json对象,甚至在SSE协议中,还可以是HttpEmitter对应的长连接。而上一节中,被刻意忽略的组件HandlerAdapter,正是Spring MVC处理这一工作的核心角色,因此责任重大。

面向协议的抽象,HandlerAdapter

HandlerAdapter是处理业务的核心角色,业务逻辑的处理器——controller是被该类的实例代理执行,为了处理不同的HTTP场景,HandlerAdapter(以下简称HA)除了调用最终的controller外,还负责参数解析,返回值处理。因此,在HA中维护了所有场景的参数解析,返回值处理,甚至ControllerAdvice切面,而不同场景的差异,则在HA中被选择并处理。

RequestMappingHandlerAdapter是默认的HandlerAdapter实现。

图片

上图将重点放在返回值的处理和解析上,流程可概括为:

1.HA用ServletInvocableHandlerMethod(简称HM),来代理后续的步骤(但请先忽略这个代理步骤)
2.HM通过invokeForRequest调用了controller,得到了返回值。
3.HM调用HA中维护的ReturnValueHandlers,处理返回值
4.ReturnValueHandlers选择处理这个返回值的ValueHandler。

5.该ValueHandler调用handleReturnValue,处理这个返回值。

从第3步开始,就进入到返回值的解析和处理环节。以下将详细说明3-5步具体干了什么。

处理返回值的组合模式

在HandlerAdapter中维护了所有返回值的处理逻辑,这些逻辑都实现了HandlerMethodReturnValueHandler,而HA要统一管理这些Handlers,使用了一种称为Composite的设计模式(组合模式)。

public class HandlerMethodReturnValueHandlerComposite implements HandlerMethodReturnValueHandler {

private final List<HandlerMethodReturnValueHandler> returnValueHandlers = new ArrayList<>();

HandlerMethodReturnValueHandler selectHandler(Object value, MethodParameter returnType) {
// …
for (HandlerMethodReturnValueHandler handler : this.returnValueHandlers) {
// …
if (handler.supportsReturnType(returnType)) {
return handler;
}
}
// …
}

void handleReturnValue
}

简单来说,存在一个Composite对象,维护了所有ReturnValueHandler的列表,其中最重要的方法有两个:

1.selectHandler根据controller的返回值和方法签名,选择一个ReturnValueHandler。每个handler需要自己实现一个名为supportsReturnType的方法,来说明自己能否解析某种controller的返回值。

2.handleReturnValue处理返回值。先选择一个ReturnValueHandler,再调用该handler的解析方法。

用方法签名选择处理器

不同的handler,根据要求实现各自的supportsReturnType,就可以解决不同HTTP场景下的返回值处理。

本文的问题场景:JSON和JSONP格式的返回值,均被RequestResponseBodyMethodProcessor所处理。原因就在于他的supportsReturnType方法的实现:若controller的返回值被ResponseBody注解所修饰,则可被该类处理。

public boolean supportsReturnType(MethodParameter returnType) {
return (AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class) ||
returnType.hasMethodAnnotation(ResponseBody.class));
}

public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
// …
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
}

最终,RequestResponseBodyMethodProcessor的方法handleReturnValue被用来处理json/jsonp的返回值。

显然,“如何区分并处理json/jsonp的返回值”是问题的关键所在(将在5.2节详细描述),但在陷入细节之前,我们跳出来看一下跟Spring MVC相关的另外一个问题,那就是拦截器。

执行的责任链,Interceptor和HandlerExecutionChain

在那张古老的时序图里,除了HandleAdapter外,还有一个被刻意忽视的角色——Interceptor。

当DispatcherServlet查询HandlerMapping时,返回的对象并不是Controller,而是HandlerExecutionChain。该对象根据请求的url构造,维护了该请求需要运行的Spring MVC拦截器。

HandlerExecutionChain getHandlerExecutionChain(Object handler, HttpServletRequest request) {
// ...
String lookupPath = this.urlPathHelper.getLookupPathForRequest(request);
for (HandlerInterceptor interceptor : this.adaptedInterceptors) {
MappedInterceptor mappedInterceptor = (MappedInterceptor) interceptor;
// 通过匹配该interceptor和请求的url,来判断是否应该加入到执行链中
if (mappedInterceptor.matches(lookupPath, this.pathMatcher)) {
chain.addInterceptor(mappedInterceptor.getInterceptor());
}
}
// ...
}

DispatcherServlet的执行过程中,会依次使用chain去调用对应的interceptor。Spring提供的拦截器有三个可实现的方法,分别对应执行过程的三个阶段:

preHandle

controller调用前

postHandle

controller调用后,视图渲染前

afterCompletion

视图完成渲染后

因此,3.1中对“Controller执行完业务逻辑,返回视图ModelAndView”这段描述应该被细化成:

1.HandlerExecutionChain调用对应的interceptor中的preHandle;
2.HandlerAdapter调用invokeAndHandle,执行业务逻辑,并返回结果;
3.HandlerExecutionChain调用对应的interceptor中的postHandle;

4.视图返回后(也可能根本不存在视图),调用interceptor中的afterCompletion;

由此可见:HandlerAdapter和HandlerExecutionChain(或者说Interceptor)是两个平级的角色,它们各司其职,需要被独立看待。

postHandle和afterCompletion的区别

从时序图来看,拦截器里的postHandle和afterCompletion方法之间有诸多差异,通常认为:

postHandle:在控制器方法(Controller的处理方法)执行完毕并且视图对象已经确定(但还未进行视图渲染)之后被调用。这意味着你在这个阶段仍然有机会修改模型数据(Model)或者视图(View)。

afterCompletion:在完整的请求处理完毕之后被调用,包括视图渲染完成和响应数据已经发送给客户端之后。这意味着所有响应处理都已经完成,包括视图渲染和流的关闭。

而原方案中,设置CORS和ContentType header的位置均在afterCompletion方法中,那么问题的答案好像呼之欲出:响应的处理已经完成,再设置任何Http Header都无法生效了。

四、被误解的拦截器

再次回忆那张古老的时序图,里面有个根本不会在本文场景里出现的重要角色——视图(View)。但是,每当提到拦截器中两个方法(postHandle和afterCompletion)的区别时,均是在视图的场景下进行探讨。颇有“用前朝的剑斩本朝的官”的意思。

那么,afterCompletion方法被调用时,响应是否已经完成,不可以再设置header了呢?根据实际的观察,不一定。也就是说,有可能已完成,也有可能未完成。这涉及到一个概念叫做 response的committed。

4.1 Response的Committed

调用response的write方法,只会将内容写到缓冲区。如果一个响应被提交(committed),那响应的内容才从缓冲区发送到客户端(比如浏览器)上。

如果响应被committed,那此时再设置响应的header是无效的。

以下是tomcat-embed-core-9.0.31版本的Response实现(org.apache.catalina.connector.Response),当isCommitted为true, setHeader方法会立即返回:

public void setHeader(String name, String value) {
//...
if (isCommitted()) {
return;
}
//...
char cc=name.charAt(0);
if (cc=='C' || cc=='c') {
if (checkSpecialHeader(name, value)) {
return;
}
}

getCoyoteResponse().setHeader(name, value);
}

本文所提及的部分jsonp接口中,由于在HandleAdapter的处理范围里,并没有组件主动commit响应,因此无论在拦截器里的postHandle还是afterCompletion方法里,响应都未committed,此时setHeader都可以生效。在整个请求流程的末尾(超出了Spring MVC的作用范围),才由tomcat处理缓冲区,将响应发送到浏览器。





而这就是为什么原实现方案的setHeader写在afterCompletion里,但接口却一直能正常工作的原因。

4.2 preHandle才是setHeader的合理位置

但既然发生了问题,说明某些jsonp接口并不能如上所述地正常setHeader。

通常Request和Response都会被框架层层包装,下面的逻辑导致Response的commit操作是无法被预期的。

public abstract class OnCommittedResponseWrapper extends HttpServletResponseWrapper {

@Override
public void write(char buf, int off, int len) {
checkContentLength(len);
this.delegate.write(buf, off, len);
}

private void checkContentLength(long contentLengthToWrite) {
this.contentWritten += contentLengthToWrite;
boolean isBodyFullyWritten = this.contentLength > 0
&& this.contentWritten >= this.contentLength;
int bufferSize = getBufferSize();
boolean requiresFlush = bufferSize > 0 && this.contentWritten >= bufferSize;
if (isBodyFullyWritten || requiresFlush) {
doOnResponseCommitted();
}
}
}

上述代码,反映了HandlerAdapter将controller的返回值写入response的逻辑片段:

1.在HandlerAdapter的实现里,当处理返回值的过程中,会通过write方法,不断往response里写数据。
2.Response被spring-security提供的OnCommittedResponseWrapper所包装(org.springframework.security.web.util.OnCommittedResponseWrapper)。

3.该类在写数据前,会checkContentLength,一旦超过缓冲区,则触发Response的commit(第16行)。

因此引入了该Response的实现,或者缓冲区被调整,就会导致某些接口在HandlerAdapter的处理过程中被commit。

而由于HandlerAdapter处理返回值的过程在postHandle和afterCompletion被调用之前,因此,此时在这两个方法中setHeader均不会生效。

出问题的jsonp接口的作用是获取全量美杜莎文案,返回数据量较大,恰好对应了缓冲区满的可能。而到底是因为技术升级后,新引入了OnCommittedResponseWrapper还是因为缓冲区被隐性调整,已经无从考证了。

综上,因为无论在postHandle和afterCompletion中setHeader都可能不生效,所以setHeader的合理位置是interceptor的preHandle方法内。

作出上述调整后,CORS相关的Header被成功设置,但是Content-Type则始终还是application/json,依旧不符合预期。

五、特立独行的响应类型

5.1 Content-Type,不可思议的脱节

首先,陈述一个事实,HandleAdapter中的HandlerMethodReturnValueHandler会将header写入Response。

其次,提出一个假设,因为Interceptor的preHandle在该环节之前,那么前一个步骤设置了content type为“application/javascript”,在后续的处理中应该以设置的为准。

由于最终设置的,并不是上述假设的结果,所以需要确认header的读取和写入两个步骤是如何发生的。

1.读取阶段,HandlerMethodReturnValueHandler是如何获取content-type

从处理返回值的父类AbstractMessageConverterMethodProcessor可见,HandleAdapter获取ContentType,是从传入的Response的header中拿到的,见下方第4行。

void writeWithMessageConverters(@Nullable T value, MethodParameter returnType,
ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage)
//...
MediaType contentType = outputMessage.getHeaders().getContentType();
//...
List<MediaType> acceptableTypes = getAcceptableMediaTypes(request);
List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
if (genericConverter != null ?
((GenericHttpMessageConverter) converter).canWrite(targetType, valueType, selectedMediaType) :
converter.canWrite(valueType, selectedMediaType)) {
body = getAdvice().beforeBodyWrite(body, returnType, selectedMediaType,
(Class<? extends HttpMessageConverter<?>>) converter.getClass(),
inputMessage, outputMessage);
// ...
genericConverter.write(body, targetType, selectedMediaType, outputMessage);
//...
return;
}
}

getHeaders仅仅是对Response的代理,由此可知,contentType确实从传入的Response的header里获取。

public MediaType getContentType() {
// getFirst是从Header中获取该name的第一个Header
String value = getFirst(CONTENT_TYPE);
return (StringUtils.hasLength(value) ? MediaType.parseMediaType(value) : null);
}

但是这里却出现了一个难以理解的现象:在Interceptor中被设置过的Content-Type无法在此处被获取到。

2.写入阶段,content-type是如何被设置的

显然,content-type是在拦截器的preHandle方法中,通过调用Response的setHeader设置的。但是,深入到具体实现,也就是tomcat response中设置content-type的位置能发现:若header是content-type,是无法通过setHeader被设置的(第13-15行)。

public void setHeader(String name, String value) {
//...
char cc=name.charAt(0);
if (cc=='C' || cc=='c') {
if (checkSpecialHeader(name, value)) {
return;
}
}
//...
}

private boolean checkSpecialHeader(String name, String value) {
if (name.equalsIgnoreCase(“Content-Type”)) {
setContentType(value);
return true;
}
return false;
}

也就是说,:tomcat设置content-type header的实现和spring mvc获取content type的实现产生了脱节。

1.在tomcat的checkSpecialHeader方法中,若是Content-Type,则不会设置header。

2.下游的spring mvc则又是从header中获取Content-Type。

这种不可思议的脱节,导致在拦截器的preHandle方法中,无论如何设置Content-Type,在HA处理结果时,都无法获取人为设置的结果。

这里引申出两个问题:

1.在拦截器的postHandle中设置是否可以呢?

在上一节已经说过,是否可以在postHandle中设置header是无法预期的。所以可能有些接口能设置成功,有些结果返回内容较多(达到缓冲区上限),则无法设置成功。

2.既然无法主动设置response的Content-Type,那所有的Http请求岂不是都有问题?

答案也是显然的,那就是“没有问题”。

因为response的content-type不应该由人为决定,而是Spring MVC的自主选择。

5.2 MessageConvertor,Spring MVC的自主选择

在3.2节中遗留了一个关键问题:“如何区分并处理json/jsonp的返回值”。同时5.1节也得到结论,response的Content-Type应该由Spring MVC自主选择。而Message Convertor则是实现这一选择的关键角色。

待候选的Message Convertor

由于在controller的方法签名中,返回值被ResponseBody所修饰,所以HandlerAdapter(更具体的,就是RequestMappingHandlerAdapter)将结果的处理逻辑交给了RequestResponseBodyMethodProcessor。

所以“区分并处理json/jsonp的返回值”的控制权就交给了它。从下图看具体的处理逻辑:

图片

首先,涉及的几个角色有:

1.RequestResponseBodyMethodProcessor:处理ResponseBody类结果的Handler,负责和HandlerAdapter交互。(比较有意思的是,其他的Handler都是叫ReturnValueHandler,它却叫Processor,说明它不只可以做结果的处理,不过这跟本文问题无关,就不深入了)
2.AbstractMessageConverterMethodProcessor:Processor的父类。在该类中实现对ContentType的选择,以及控制convertor写入。
3.MessageConverter:输出结果的处理类。将json做填充的逻辑也是在convertor中实现
4.MediaType:内容的类型,该对象跟content-type的结果直接相关

5.UTF8JsonGenerator:使用到的输出工具类,convertor会使用该工具类和Response交互,将结果写入。

其次,解析controller返回对象并写入Response的流程为:

1.获取Acceptable的内容类型。解析request中的Accept字段,查看浏览器可以接收的类型。如果是“*/*”表示所有类型都可以接收。

2.获取Producible的内容类型。遍历系统中的所有Message Convertor,看针对该响应能产生的所有类型。具体是:依次调用Convertor的canWrite方法,判断该Convertor是否能处理该响应。若能处理则返回改Convertor能支持的所有类型。

// clazz为返回对象的类型
boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType)

3.判断Producible的类型是否可被accept,仅保留可接收的类型

4.对可处理的类型进行排序。

5.遍历Message Convertor,按照顺序进行输出。

总结一下就是:controller返回的对象所对应的content-type,以及输出的方式,取决于哪一个Message Convertor能够处理该对象。

比较理想的情况是,对于每一种类型,Spring MVC中仅注册了一个对其处理的Message Convertor。但是在本文描述的原实现方案中,情况变得复杂。特别是上面的第4-5步,这所谓的排序,开始变得不清不楚了。

MediaType顺序很重要

在最后的一节中,我们先给出结论:MediaType的顺序是有错误的,未被正确设置。

在原方案中,自定义的Convertor所支持的MediaType为:json和javascript,导致HA输出的content-type始终为json,但是由于拦截器在afterCompletion方法中会再重新设置content-type为javascript,所以jsonp的请求一直可以正常返回。

如今设置content-type的位置被改到preHandle中,MediaType的错误顺序问题被暴露了出来。

排序的方法为MediaType.sortBySpecificityAndQuality中,逻辑是:

1.通配的MediaType(也就是MediaType中是否包含通配符*),排在最后
2.q-value较大Media Type,优先级较高。q-value是类型的参数,比如HTTP的accept Header可以是:"application/json:q=1",默认的q-value是1,即最大,这完全依赖请求的参数。
3.type不同的MediaType,互不影响。type为参数中/的前半部分,比如text/plain和application/json的type分别是text和application。它们的顺序根据系统定义。

4.subType不同的MediaType,互不影响。type为参数中/的后半部分,比如application/json和application/javascript的subType分别是json和application。它们的顺序也根据系统定义。

根据上述原则,因为自定义的Convertor所支持的MediaType为:json和javascript,它们的subType不同,q-value也都是1,因此顺序就是代码里定义的顺序。

经过自定义的convetor处理的响应,最终输出的content-type始终是json,也就是第一个。

六、问题解决

基于上述的分析,为了解决2.1中的问题,最终的方案为:

1.对CORS的setHeader,位置被放置在拦截器的preHandle方法中

2.自定义的Message Convertor只支持MediaType为application/javascript的类型,通过改写canWrite方法,过滤非jsonp的请求。

boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType) {
// 当返回值为JsonpWrapperObject类型时,才是jsonp的请求
// 其他的请求,交由spring mvc默认对json的Message Convertor处理
return clazz == JsonpWrapperObject.class;
}

3.调整Message Convertor在Spring MVC中的注册顺序,自定义对jsonp处理的Convertor排第一个。

七、跨域问题的最佳实践

综上会发现,为了实现Jsonp,这里做了非常多tricky的操作(拦截器+自定义的convertor)。看起来,这并不是一个最佳的实践方案。

是的,对比跨域的实现方式来说,使用CORS相比jsonp要简单太多。

同时就算是jsonp,一些开源代码也提供了更好的支持。比如:fastjson提供的JSONPResponseBodyAdvice,实现全局的controller切面。在5.2节的时序图中,也可以看到AbstractMessageConverterMethodProcessor中有一个步骤是通过Advice来在响应体写入前做一些处理。

JSONPResponseBodyAdvice的实现如下:

@ControllerAdvice
public class JSONPResponseBodyAdvice implements ResponseBodyAdvice<Object> {
//...
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return FastJsonHttpMessageConverter.class.isAssignableFrom(converterType)
&&
(returnType.getContainingClass().isAnnotationPresent(ResponseJSONP.class) || returnType.hasMethodAnnotation(ResponseJSONP.class));
}

public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request,
ServerHttpResponse response) {
ResponseJSONP responseJsonp = returnType.getMethodAnnotation(ResponseJSONP.class);
// …
}
// …
}

但是这就要求controller的返回值,需要被fastjson的ResponseJSONP注解所修饰。

出于开发和回归成本的考虑,本文并没有使用这种方式,然是继续沿用原有的拦截器和Message Convertor。

希望以后再用到jsonp的时候,能够使用更简单的实现方案。哦,不对,下次解决跨域问题时,应该不会再用jsonp了。

高可用及共享存储 Web 服务


随着业务规模的增长,数据请求和并发访问量增大、静态文件高频变更,企业需要搭建一个高可用和共享存储的网站架构,以确保网站服务能够7*24小时运行的同时,可保障数据一致性和共享性,并降低数据重复存储的成本。快点击阅读原文体验吧~



使用 Nginx 等 Web 伺服器,透過調整設定檔配置,讓伺服器自動將 Jsonp 格式的回傳值加上 CORS header。

使用自定義攔截器和Message Converter的方式,在攔截器中設置 CORS 相關header,並在 Message Converter 中針對 Jsonp 格式的回傳值進行特殊處理。

可以利用Spring提供的Advice進行切面處理,例如使用fastjson提供的JSONPResponseBodyAdvice。透過在Controller的回傳值上使用@ResponseJSONP註解,在回應寫入前進行處理,將結果填充成符合Jsonp格式的輸出。

當Response被committed後,表示Response的內容已準備從緩衝區送至客戶端。此時再嘗試透過header設置進行修改將會無效。

在Spring MVC的處理流程中,即使是在攔截器的afterCompletion方法中,若Response已committed,設定header仍會無效。因此,設定header的合理位置應在afterCompletion被呼叫之前,建議調整至preHandle方法中。

在RequestResponseBodyMethodProcessor中,處理返回值的RequestResponseBodyMethodProcessor根據Controller方法回傳值的ResponseBody註解,選擇HandlerMethodReturnValueHandler。每個handler需自行實現supportsReturnType方法,用以說明自己是否能解析某種回傳值類型。

由於Json和Jsonp的回傳值都由RequestResponseBodyMethodProcessor處理,使用supportsReturnType方法的實作內容來判斷是否能處理,其中ResponseBody註解和方法註解都可以成為判斷依據。

若判斷結果為可解析,再由RequestResponseBodyMethodProcessor呼叫handler的handleReturnValue方法進行處理。Spring MVC為了解決不同HTTP場景下的回傳值處理,使用Composite設計模式,將各種HandlerMethodReturnValueHandler集中管理。

Spring MVC處理回傳值過程中,HandlerAdapter會逐漸將內容寫入Response。Response預設會透過OnCommittedResponseWrapper進行封裝,在遇到緩衝區已滿的情況下,將會觸發Response的commit。