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

本文涉及的产品
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 本文将深度剖析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。

image.png

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

image.png

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”。

image.png

对比两种跨域的解决方案,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,原实现方案如下。

image.png


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请求,并修改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 一张古老的时序图


image.png

上图展示了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实现。

image.png

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


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处理缓冲区,将响应发送到浏览器。

image.png

而这就是为什么原实现方案的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的返回值”的控制权就交给了它。从下图看具体的处理逻辑:

image.png

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


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了。



来源  |  阿里云开发者公众号
作者  |  
高登



相关文章
|
5月前
|
安全 Java 数据库
后端进阶之路——万字总结Spring Security与数据库集成实践(五)
后端进阶之路——万字总结Spring Security与数据库集成实践(五)
|
5月前
|
前端开发 API
uniapp中为什么会出现跨域问题,如何解决
uniapp中为什么会出现跨域问题,如何解决
1909 0
|
5月前
|
Java
SpringBoot解决跨域问题 几种方案
SpringBoot解决跨域问题 几种方案
98 0
|
缓存 安全 前端开发
【从原理到实战】彻底搞懂跨域问题 (一)(2)
3、预检请求的优化 复杂请求会发预检请求, 相当于每个接口会发两次请求, 比较消耗资源, 那么是可以对预检请求进行优化, 可以采用以下两种方式 设置预检请求的缓存时长
92 0
|
XML JSON 安全
【从原理到实战】彻底搞懂跨域问题 (一)(1)
前言 什么是跨域: 浏览器为了安全性,设置同源策略导致的, 或者说是一种浏览器的限制 同源策略: 是一种约定,WEB 应用只能请求同一个源的资源 什么时候会跨域: 协议名、域名、端口号 不同 本文将从原理, 到最简代码实现, 演示解决跨域的方法和流程,纸上得来终觉浅 绝知此事要躬行, 只有自己手敲实现过, 才能对其原理理解更加深刻。
409 0
|
前端开发 Java
实战,SpringBoot中如何解决CORS跨域问题~(文末送书)
实战,SpringBoot中如何解决CORS跨域问题~(文末送书)
305 0
|
前端开发 Java 数据库连接
【后端】黑马MVC案例详解
【后端】黑马MVC案例详解
|
前端开发 JavaScript Java
记录一次艰难的云服务器部署前后端项目springBoot+mybatis+vue(两天解决的前后端跨域问题多种方式)...
记录一次艰难的云服务器部署前后端项目springBoot+mybatis+vue(两天解决的前后端跨域问题多种方式)...
386 0
|
Java Spring
【实战】Spring生成beanName冲突的解决之道:附源码分析
【实战】Spring生成beanName冲突的解决之道:附源码分析
749 0
【实战】Spring生成beanName冲突的解决之道:附源码分析
下一篇
无影云桌面