阿里妹导读
本文将深度剖析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。它主要的思路是:
对于服务端来说,用jsonp来处理跨域,需要做两件事:
1.首先,当前端发起jsonp请求时,会动态插入一个
2.其次,服务端填充json。服务端的原始结果为json格式,将其填充后,会得到一条函数调用语句。函数名为上一步的callback入参,函数实参是原始的json结果。这个padding的过程也是jsonp名称的由来。
3.最后,前端执行填充后的结果,于是在回调函数jsonp_1718436528810_81650中可以获取原始json数据。
为了绕过同源策略,必须让浏览器认为这次请求返回的内容是一个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 convertor。Message Convertor是Spring 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请求,并修改ContentType为application/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是处理业务的核心角色,业务逻辑的处理器——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相关的另外一个问题,那就是拦截器。
在那张古老的时序图里,除了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:在控制器方法(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则是实现这一选择的关键角色。
由于在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的顺序是有错误的,未被正确设置。
在原方案中,自定义的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小时运行的同时,可保障数据一致性和共享性,并降低数据重复存储的成本。快点击阅读原文体验吧~