问题背景
由于项目需要,需要将某个SpringMvc的Rest接口响应修改为json类型,结果发现原来正常的请求会报HTTP 406,这里记录一下追踪的过程。
先简单介绍一下HTTP 406。
HTTP 406 (Not Acceptable)
The requested resource is only capable of generating content not acceptable according to the Accept headers sent in the request.
Accept
Accept代表发送端(客户端)希望接收的数据类型,*/*表示可以接收任何类型。
Content-Type
代表响应端(服务器)发送的实体数据的数据类型
如果双方不一致,也就是说客户端请求的accept和服务端响应的content-type不兼容,就会出现前面提到的406错误。
问题复现
原有接口示例
@RequestMapping(value = "/hello/**")
@ResponseBody
public String helloWorld(HttpServletRequest httpServletRequest) {
return "hello world";
}
客户端对应的请求连接
localhost:8080/hello/test.htm
使用postman模拟请求,可以看到请求时的Accept是*/*,而服务端返回的Content-Type是text/html。
接口改造
按照业务需求,需要将响应统一修改为appication/json类型,对于SpringMvc的Rest请求,我们做了如下修改,增加produces标识响应类型为application/json。
关于produces属性的含义
Narrows the primary mapping by media types that can be produced by the mapped handler(限制该方法的MediaType)
@RequestMapping(value = "/hello/**", produces = {MediaType.APPLICATION_JSON_VALUE})
@ResponseBody
public String helloWorld(HttpServletRequest httpServletRequest) {
return "hello world";
}
改造后重新使用Postman测试,发现响应的Content-Type虽然变成了application/json类型,但是出现了HTTP 406。如果将请求uri中的htm后缀去掉后,请求就变为正常了。
显然,问题出现在请求后缀上,需要进一步排查问题原因。
对于SpringMvc的请求过程,首先需要DispatchServlet根据请求HttpServletRequest,利用HandlerMapping获取到对应的HandlerChain。
获取HandlerChain
这里对于采用了@RequestMapping注解的方法,会使用RequestMappingHandlerMapping方法。
当然,其中有些方法会存在于父类AbstractHandlerMethodMapping中,我们断点到lookupHandlerMethod方法。
可以看到当前请求的类就是RequestMappingHandlerMapping,它的mappingRegistry中包含了我们的请求接口"/hello/**",对应的Produces是application/json类型。
然后会进入到该类的addMatchingMappings方法中,寻找满足条件的mapping信息。
继续进入getMatchingMappings方法,最终会进入到RequestMappingInfo的getMatchingCondition方法。这个方法会对请求中的很多属性进行校验,包括请求方法、参数、header,consumers以及produces,这里我们重点关注producesCondition的getMatchingCondition方法,通过后续的分析也可以得到,这是出问题的根本所在。
关于这个方法,可以先看一下javaDoc的注释。
Checks if any of the contained media type expressions match the given request ‘Content-Type’ header and returns an instance that is guaranteed to contain matching expressions only.
方法内部会先根据request获取到acceptedMediaTypes,即getAcceptedMediaTypes方法。然后将获取到的同当前produces提供的进行匹配。
获取请求的acceptedMediaTypes
在getAccepedMediaTypes内部会调用核心的ContentNegotiationManager解析请求的MediaTypes,这个类中会注册一些ContentNegotiationStrategy。在当前断点条件下有HeaderContentNegotiationStrategy和ServletPathExtensionNegotiationStrategy。
我们进入到该方法内部,会循环遍历所有的Strategy解析到MediaTypes。
首先会进入ServletPathExtensionNegotiationStrategy的解析,会先进入到父类中的resolveMediaTypes方法。
注意到上面的getMediaTypeKey方法,该方法是一个抽象方法,拥有两个实现。
当前情况下会进入PathExtensionContentNegotiationStrategy中
这里会返回htm,然后进入到前面的AbstractMappingContentNegotiationStrategy的resolveMediaTypeKey方法。
其中lookupMediaType位于MappingMediaTypeFileExtensionResolver中。
@Nullable
protected MediaType lookupMediaType(String extension) {
return this.mediaTypes.get(extension.toLowerCase(Locale.ENGLISH));
}
该方法的MediaType中只有xml和json,所以对于htm返回空。进而会进入到AbstractMappingContentNegotiationStrategy的handleNoMatch方法,这里会进入到ServletPathExtensionContentNegotiationStrategy的handleNoMatch方法中。它会根据文件后缀,得到MediaType为text/html类型。
不匹配情况下抛出HttpMediaTypeNotAcceptableException
现在我们可以回到ProducesCondition方法中,获取到acceptMediaType后,和Produces的进行匹配,进入到getMatchingExpressions方法。可以看到当前类的expression是application/json,但是accepted是text/html,这里会返回空。进一步,produceCondition会返回null。
继续向上返回RequestMapping.getMatchingCondition也会返回空。
继续返回,会回到AbstractHandlerMethodMapping的lookupHandlerMethod方法
由于上述的matches是空的,所以方法会执行到handleNoMatch方法,该方法是抽象方法。RequestMappingInfoHandlerMapping对该方法进行了重写。方法开始的PartialMathHelper初始化的时候,会对各种Condition进行校验,可以看到这里又执行了一遍之前的getMatchingCondition方法,并且同理producesMatch的结果是false。而我们看到在第267行,如果有produces不匹配的情况下,就会抛出HttpMediaTypeNotAcceptableException异常。
到这里问题已经基本明确了,那么对于原始的,没有添加produces属性的接口,为什么是可以的呢?
我们可以直接定位到ProducesRequesetCondition,直接debug到getMatchingCondition,可以看到它的expression是空的,isEmpty如果发现expression是空的,不会对accept的contentType做校验,后续也就不会抛出HttpMediaTypeNotAcceptableException异常了。
解决办法
针对这种情况,目前最好的解决方法是禁掉根据后缀类型匹配MediaType。
该配置可以通过查看ContentNegotiationManagerFactoryBean这个类中的favorPathExtension属性。
在Spring-webmvc的5.3.5中,该配置是默认关闭的。
但是在4.3.16中,该配置是开启的。
关闭配置的方法
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.favorPathExtension(false);
super.configureContentNegotiation(configurer);
}
}
问题总结
在本次排查过程中,有几点需要注意。
-
不同版本的Spring ContentNegotiation配置存在差异。 -
在前面提到的MappingMediaTypeFileExtensionResolverd的lookupMediaType中,存在mediaTypes取值为application/json和application/xml,他们的来源在哪里呢? 这里可以查看WebMvcConfigurationSupport中的getDefaultMediaTypes,可以看到这里会根据一些变量做一些初始化的工作。
? 而上述变量的取值情况如下,也就是会根据classpath中的类情况做初始化工作
问题延伸
还有一种情况接口返回HTTP 406的情况,这种会出现在使用到了HttpMessageConverter时。
接口会返回对象,如以下case
@RequestMapping(value = "/listPerson")
@ResponseBody
public List<Person> listPerson() {
Person person = new Person();
person.setId(1L);
person.setName("zhangsan");
return Lists.newArrayList(person);
}
具体可以跟进到AbstractMessageConverterMethodProcessor的writeWithMessageConverters方法中。
首先可以看到body中是有正常值的,上图中的逻辑和之前有些类似,218行先根据request获取到acceptedMediaType,最终也会调用this.contentNegotiationManager.resolveMediaTypes(new ServletWebRequest(request));
然后再获取produceMediaType即230行代码,然后在237行到241行,进行匹配。如果匹配失败,则会在246行抛出HttpMediaTypeNotAcceptableException。
这里再着重看一下getProducibleMediaTypes方法。
主要就是根据HttpMessageConverter的canWrite方法判断是否可以对返回结果进行write,可以的话,添加getSupportedMediaTypes即可。
这里有很多的HttpMessageConverter,最终会利用MappingJackson2HttpMessageConvertoer增加applicaiton/json和applicaiton*+json。
而它的getSupportedMediaTypes会进入AbstractJackson2HttpMessageConverter中,如果有自定义的objectMapper,那就使用自定义的。
否则的话,调用AbstractHttpMessageConverter的getSupportedMediaTypes方法。
? 还是需要看一下this.supportedMediaTypes的来源。
? 可以直接看一下MappingJackson2HttpMessageConverter的初始化函数,终于找到你。
?
? 所以,如果在这种情况下,客户端的请求accept如果是application/xml,也会返回HTTP 406。
?
? 最后,如果真要返回application/xml,怎么办呢? 还是需要看一下完成的WebMvcConfigurationSupport类。
? 这次的方法是addDefaultHttpMessageConverters,添加默认的messageConverter(代码有些长,截取了前半部分)。
?
? 可以看到xml解析的条件是!shoudIgnoreXml,该值默认是false,那另外一个条件就是jackson2XmlPresent。是的,这个配置在第二个关注点中有描述,即
? ? 所以需要先添加maven依赖
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.11.4</version>
</dependency>
? 此时AbstractMessageConverterMethodProcessor的getProducibleMediaTypes终于看到了xml类型啦。
?
再用Postman试一下,大功告成!
最后再啰嗦一句,如果此时Accept为*/*的话,会以xml形式返回,因为它对应的HttpMessageConvertor先被加载到。尽管在AbstractMessageConverterMethodProcessor->writeWithMessageConverters的最后,如果有匹配多个mediaTypesToUse,会利用MediaType.sortBySpecifityAndQuality进行排序。
针对这个方法,主要是两个comparator,
mediaTypes.sort(MediaType.SPECIFICITY_COMPARATOR.thenComparing(MediaType.QUALITY_VALUE_COMPARATOR));
? 通过debug可以看到,对于application/json和application/xml,他们属于Type一致,并且quality一致,但是子类型不一致的情况,会返回0,即排序认为是相等的,不会交换顺序,也就是以进入list的顺序为准。
|