问题背景
项目使用SpringMVC4.1.X作为web框架,序列化框架选择Jackson。出于使用习惯以及性能考虑,将其切换到了fastjson。配置如下:
1 <mvc:annotation-driven> 2 <mvc:message-converters register-defaults="true"> 3 <!-- 避免IE执行AJAX时,返回JSON出现下载文件 --> 4 <bean id="fastJsonHttpMessageConverter" class="com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter"> 5 <property name="supportedMediaTypes"> 6 <list> 7 <value>application/json;charset=UTF-8</value> 8 <value>text/html;charset=UTF-8</value> 9 <value>application/x-www-form-urlencoded;charset=UTF-8</value> 10 </list> 11 </property> 12 <property name="features"> 13 <list> 14 <value>WriteMapNullValue</value> 15 <value>WriteNullStringAsEmpty</value> 16 <value>WriteNullListAsEmpty</value> 17 <value>WriteNullNumberAsZero</value> 18 </list> 19 </property> 20 </bean> 21 </mvc:message-converters> 22 </mvc:annotation-driven>
问题表现
如上配置后,一段时间后,线上出现故障。对接方反馈其请求成功,但是解析响应报文失败。故障表现如下:
如上所示,响应的Content-Type为表单。对接方说之前是application/json。乍看之下有点蒙,更改一个序列化框架会导致响应Content-Type发生变化。
问题原因
仔细看了下其请求header,发觉了有点不太对的地方。其请求的Accpet是application/x-www-form-urlencoded,那我响应的Content-Type是这个就没有问题才对。但之前说这样的请求我们响应的是application/json,那回过头去看之前的配置如下:
1 <bean id="contentNegotiationManager" class="org.springframework.web.accept.ContentNegotiationManagerFactoryBean"> 2 <!-- 扩展名至mimeType的映射,即 /user.json => application/json --> 3 <property name="favorPathExtension" value="true"/> 4 <!-- 用于开启 /userinfo/123?format=json 的支持 --> 5 <property name="favorParameter" value="true"/> 6 <property name="parameterName" value="format"/> 7 <!-- 是否忽略Accept Header --> 8 <property name="ignoreAcceptHeader" value="false"/> 9 <property name="mediaTypes"> <!--扩展名到MIME的映射;favorPathExtension, favorParameter是true时起作用 --> 10 <value> 11 json=application/json 12 </value> 13 </property> 14 </bean> 15 16 <bean class="org.springframework.web.servlet.view.ContentNegotiatingViewResolver" p:order="0"> 17 <!-- 内容协商管理器 用于决定media type --> 18 <property name="contentNegotiationManager" ref="contentNegotiationManager"/> 19 <!-- 默认视图 放在解析链最后 --> 20 <property name="defaultViews"> 21 <list> 22 <bean class="org.springframework.web.servlet.view.json.MappingJackson2JsonView"/> 23 </list> 24 </property> 25 </bean>
之前的配置是基于Spring的内容协商机制来实现内容响应控制。按上述配置,会组装出基于一组基于:
- 路径扩展
- 参数扩展
- accept
的内容协商策略。这组策略会按照上述顺序解析请求的媒体类型,如果某个策略可以识别请求的媒体类型,则不再继续后续的识别。如下ContentNegotiationManager代码:
1 public List<MediaType> resolveMediaTypes(NativeWebRequest webRequest) throws HttpMediaTypeNotAcceptableException { 2 for (ContentNegotiationStrategy strategy : this.contentNegotiationStrategies) { 3 List<MediaType> mediaTypes = strategy.resolveMediaTypes(webRequest); 4 if (mediaTypes.isEmpty() || mediaTypes.equals(MEDIA_TYPE_ALL)) { 5 continue; 6 } 7 return mediaTypes; 8 } 9 return Collections.emptyList(); 10 }
那么为啥之前的配置可以在客户端的accept设置为application/x-www-form-urlencoded时仍然可以返回application/json呢?缘由在于请求参数有一个format=json,匹配了这里的参数扩展策略以及对应的媒体类型mediaTypes中的key。
去掉内容协商后,就从请求header中读取accept来确认响应content-type。
后面调整的配置指定了FastJsonHttpMessageConverter作为第一个转换器。设置其支持的转换媒体类型有:
-
application/json;charset=UTF-8
-
text/html;charset=UTF-8
-
application/x-www-form-urlencoded;charset=UTF-8
其中就有application/x-www-form-urlencoded;charset=UTF-8这种媒体类型。当前端的header中指定accept的类型为此时,后端响应请求做消息转换时,会match消息转换器配置的支持类型与前端要求的响应类型,寻找交集。很不幸的是application/x-www-form-urlencoded;charset=UTF-8正好能匹配,故此时通过FastJsonHttpMessageConverter返回json的数据,然后content-type是application/x-www-form-urlencoded;charset=UTF-8。
这块的逻辑在SpringMVC的AbstractMessageConverterMethodProcessor如下片段:
1 HttpServletRequest servletRequest = inputMessage.getServletRequest(); 2 List<MediaType> requestedMediaTypes = getAcceptableMediaTypes(servletRequest); 3 List<MediaType> producibleMediaTypes = getProducibleMediaTypes(servletRequest, returnValueClass); 4 5 Set<MediaType> compatibleMediaTypes = new LinkedHashSet<MediaType>(); 6 for (MediaType r : requestedMediaTypes) { 7 for (MediaType p : producibleMediaTypes) { 8 if (r.isCompatibleWith(p)) { 9 compatibleMediaTypes.add(getMostSpecificMediaType(r, p)); 10 } 11 } 12 } 13 if (compatibleMediaTypes.isEmpty()) { 14 throw new HttpMediaTypeNotAcceptableException(producibleMediaTypes); 15 } 16 17 List<MediaType> mediaTypes = new ArrayList<MediaType>(compatibleMediaTypes); 18 MediaType.sortBySpecificityAndQuality(mediaTypes); 19 20 MediaType selectedMediaType = null; 21 for (MediaType mediaType : mediaTypes) { 22 if (mediaType.isConcrete()) { 23 selectedMediaType = mediaType; 24 break; 25 } 26 else if (mediaType.equals(MediaType.ALL) || mediaType.equals(MEDIA_TYPE_APPLICATION)) { 27 selectedMediaType = MediaType.APPLICATION_OCTET_STREAM; 28 break; 29 } 30 } 31 32 if (selectedMediaType != null) { 33 selectedMediaType = selectedMediaType.removeQualityValue(); 34 for (HttpMessageConverter<?> messageConverter : messageConverters) { 35 if (messageConverter.canWrite(returnValueClass, selectedMediaType)) { 36 ((HttpMessageConverter<T>) messageConverter).write(returnValue, selectedMediaType, outputMessage); 37 if (logger.isDebugEnabled()) { 38 logger.debug("Written [" + returnValue + "] as "" + selectedMediaType + "" using [" + 39 messageConverter + "]"); 40 } 41 return; 42 } 43 } 44 }
解决方案
如上所述,根本问题是后端响应的content-type设置有一个优先级顺序。优先基于后端策略控制来处理,然后基于前端的请求header的accept来控制。那去掉后端的内容决策逻辑后,响应内容就依赖前端的accpet。故存在响应的content-type是application/x-www-form-urlencoded;的情况。
解决方案有2种方式:
- 客户端的声明需要内容时,accept设置正确;
- 服务端使用内容决策策略来控制响应格式时,客户端的accpet也不可以随便处理,虽然优先级不一样,但是以防万一调整导致未知失败。