问题背景
由于项目需要,需要将某个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。
![image-20211115132848248](https://i-blog.csdnimg.cn/blog_migrate/104efb241ab5f2af9097d8405f22faef.png)
接口改造
按照业务需求,需要将响应统一修改为appication/json类型,对于SpringMvc的Rest请求,我们做了如下修改,增加produces标识响应类型为application/json。
关于produces属性的含义
Narrows the primary mapping by media types that can be produced by the mapped handler(限制该方法的MediaType)
// see MediaType.java
// public static final String APPLICATION_JSON_VALUE = "application/json";
@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后缀去掉后,请求就变为正常了。
![image-20211115134407729](https://i-blog.csdnimg.cn/blog_migrate/ce3415e851ca5b059eb00bda4dadf517.png)
![image-20211115134633093](https://i-blog.csdnimg.cn/blog_migrate/b6bd4f7b76bc970ed7c7613224063cd6.png)
显然,问题出现在请求后缀上,需要进一步排查问题原因。
对于SpringMvc的请求过程,首先需要DispatchServlet根据请求HttpServletRequest,利用HandlerMapping获取到对应的HandlerChain。
获取HandlerChain
这里对于采用了@RequestMapping注解的方法,会使用RequestMappingHandlerMapping方法。
![image-20211115135844714](https://i-blog.csdnimg.cn/blog_migrate/a0f970c3a7b79e7df36c0eb7a1a1e997.png)
当然,其中有些方法会存在于父类AbstractHandlerMethodMapping中,我们断点到lookupHandlerMethod方法。
可以看到当前请求的类就是RequestMappingHandlerMapping,它的mappingRegistry中包含了我们的请求接口"/hello/**",对应的Produces是application/json类型。
![image-20211115140626302](https://i-blog.csdnimg.cn/blog_migrate/8fd4ff69ef77ced0d6b5c667490250cb.png)
然后会进入到该类的addMatchingMappings方法中,寻找满足条件的mapping信息。
![image-20211115140941065](https://i-blog.csdnimg.cn/blog_migrate/df644c23e0ea6653043e5c7ca92a9d7a.png)
继续进入getMatchingMappings方法,最终会进入到RequestMappingInfo的getMatchingCondition方法。这个方法会对请求中的很多属性进行校验,包括请求方法、参数、header,consumers以及produces,这里我们重点关注producesCondition的getMatchingCondition方法,通过后续的分析也可以得到,这是出问题的根本所在。
![image-20211115141730032](https://i-blog.csdnimg.cn/blog_migrate/70916e3d84300333a928592f332d1f6e.png)
关于这个方法,可以先看一下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提供的进行匹配。
![image-20211115142056568](https://i-blog.csdnimg.cn/blog_migrate/e7534c9cccba6030aa911093e222ba40.png)
获取请求的acceptedMediaTypes
在getAccepedMediaTypes内部会调用核心的ContentNegotiationManager解析请求的MediaTypes,这个类中会注册一些ContentNegotiationStrategy。在当前断点条件下有HeaderContentNegotiationStrategy和ServletPathExtensionNegotiationStrategy。
![image-20211115143039199](https://i-blog.csdnimg.cn/blog_migrate/5990d8f2df58980175bf467f4e0beae2.png)
我们进入到该方法内部,会循环遍历所有的Strategy解析到MediaTypes。
![image-20211115143341902](https://i-blog.csdnimg.cn/blog_migrate/707ebbaab60958d163b015170b461bbb.png)
首先会进入ServletPathExtensionNegotiationStrategy的解析,会先进入到父类中的resolveMediaTypes方法。
![image-20211115143709976](https://i-blog.csdnimg.cn/blog_migrate/40453d099e2ca209583b013dd1de7510.png)
注意到上面的getMediaTypeKey方法,该方法是一个抽象方法,拥有两个实现。
![image-20211115143858546](https://i-blog.csdnimg.cn/blog_migrate/261f770050ba713653cb1ac9955de98c.png)
当前情况下会进入PathExtensionContentNegotiationStrategy中
![image-20211115144016948](https://i-blog.csdnimg.cn/blog_migrate/f0736ebb9104a943ddb4a62e069b2f3b.png)
这里会返回htm,然后进入到前面的AbstractMappingContentNegotiationStrategy的resolveMediaTypeKey方法。
![image-20211115144445418](https://i-blog.csdnimg.cn/blog_migrate/46276bee98ab52cca5637c6c0eabdf9f.png)
其中lookupMediaType位于MappingMediaTypeFileExtensionResolver中。
@Nullable
protected MediaType lookupMediaType(String extension) {
return this.mediaTypes.get(extension.toLowerCase(Locale.ENGLISH));
}
![image-20211115144607135](https://i-blog.csdnimg.cn/blog_migrate/3e04171f2fb273c11970b7c0470dec77.png)
该方法的MediaType中只有xml和json,所以对于htm返回空。进而会进入到AbstractMappingContentNegotiationStrategy的handleNoMatch方法,这里会进入到ServletPathExtensionContentNegotiationStrategy的handleNoMatch方法中。它会根据文件后缀,得到MediaType为text/html类型。
![image-20211115145130802](https://i-blog.csdnimg.cn/blog_migrate/789d6c073b174217944065b1fd5d730b.png)
不匹配情况下抛出HttpMediaTypeNotAcceptableException
现在我们可以回到ProducesCondition方法中,获取到acceptMediaType后,和Produces的进行匹配,进入到getMatchingExpressions方法。可以看到当前类的expression是application/json,但是accepted是text/html,这里会返回空。进一步,produceCondition会返回null。
![image-20211115145913855](https://i-blog.csdnimg.cn/blog_migrate/88179f97e23f4e9bcc87b2dc18653f7e.png)
继续向上返回RequestMapping.getMatchingCondition也会返回空。
![image-20211115150145816](https://i-blog.csdnimg.cn/blog_migrate/64e0a8bb3895788686c2e18cea310157.png)
继续返回,会回到AbstractHandlerMethodMapping的lookupHandlerMethod方法
由于上述的matches是空的,所以方法会执行到handleNoMatch方法,该方法是抽象方法。RequestMappingInfoHandlerMapping对该方法进行了重写。方法开始的PartialMathHelper初始化的时候,会对各种Condition进行校验,可以看到这里又执行了一遍之前的getMatchingCondition方法,并且同理producesMatch的结果是false。而我们看到在第267行,如果有produces不匹配的情况下,就会抛出HttpMediaTypeNotAcceptableException异常。
![image-20211115151511704](https://i-blog.csdnimg.cn/blog_migrate/1d9ccbf9ec27c2d9afb27e1ecbff5c22.png)
到这里问题已经基本明确了,那么对于原始的,没有添加produces属性的接口,为什么是可以的呢?
我们可以直接定位到ProducesRequesetCondition,直接debug到getMatchingCondition,可以看到它的expression是空的,isEmpty如果发现expression是空的,不会对accept的contentType做校验,后续也就不会抛出HttpMediaTypeNotAcceptableException异常了。
![image-20211115152941901](https://i-blog.csdnimg.cn/blog_migrate/2938ea87e49bd254e540f706d5890358.png)
解决办法
针对这种情况,目前最好的解决方法是禁掉根据后缀类型匹配MediaType。
![image-20211115154715735](https://i-blog.csdnimg.cn/blog_migrate/62f39708ffb6d0c53246dd4177d6a477.png)
该配置可以通过查看ContentNegotiationManagerFactoryBean这个类中的favorPathExtension属性。
在Spring-webmvc的5.3.5中,该配置是默认关闭的。
![image-20211115155606875](https://i-blog.csdnimg.cn/blog_migrate/ea483bd5b4b76023ef4dc94ae6f8d965.png)
但是在4.3.16中,该配置是开启的。
![image-20211115160306685](https://i-blog.csdnimg.cn/blog_migrate/806b4f375be33d63f0d47b9ce51de45d.png)
关闭配置的方法
@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中的类情况做初始化工作
![image-20211115164831090](https://i-blog.csdnimg.cn/blog_migrate/601769c75dedcd32d98d5df16cf832ef.png)
问题延伸
还有一种情况接口返回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方法中。
![image-20211115171207241](https://i-blog.csdnimg.cn/blog_migrate/8f08f95e08fe6800cb4b176fd03b5cf6.png)
首先可以看到body中是有正常值的,上图中的逻辑和之前有些类似,218行先根据request获取到acceptedMediaType,最终也会调用this.contentNegotiationManager.resolveMediaTypes(new ServletWebRequest(request));
然后再获取produceMediaType即230行代码,然后在237行到241行,进行匹配。如果匹配失败,则会在246行抛出HttpMediaTypeNotAcceptableException。
这里再着重看一下getProducibleMediaTypes方法。
主要就是根据HttpMessageConverter的canWrite方法判断是否可以对返回结果进行write,可以的话,添加getSupportedMediaTypes即可。
![image-20211115172048790](https://i-blog.csdnimg.cn/blog_migrate/82dc8a88df0e5257e5420a09dbc939ee.png)
这里有很多的HttpMessageConverter,最终会利用MappingJackson2HttpMessageConvertoer增加applicaiton/json和applicaiton*+json。
而它的getSupportedMediaTypes会进入AbstractJackson2HttpMessageConverter中,如果有自定义的objectMapper,那就使用自定义的。
![image-20211115172539003](https://i-blog.csdnimg.cn/blog_migrate/a10674c6a7e9408fe477a9f21ddd58d3.png)
否则的话,调用AbstractHttpMessageConverter的getSupportedMediaTypes方法。
![image-20211115172742548](https://i-blog.csdnimg.cn/blog_migrate/574605f0202216809937b45f55f3d367.png)
还是需要看一下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试一下,大功告成!
![image-20211115174912648](https://i-blog.csdnimg.cn/blog_migrate/0987cdbd6e1eeea929fbbcb854576b1e.png)
最后再啰嗦一句,如果此时Accept为*/*的话,会以xml形式返回,因为它对应的HttpMessageConvertor先被加载到。尽管在AbstractMessageConverterMethodProcessor->writeWithMessageConverters的最后,如果有匹配多个mediaTypesToUse,会利用MediaType.sortBySpecifityAndQuality进行排序。
![image-20211115204301638](https://i-blog.csdnimg.cn/blog_migrate/2bd414376de9bf301a7b42cc7d35bd3c.png)
针对这个方法,主要是两个comparator,
mediaTypes.sort(MediaType.SPECIFICITY_COMPARATOR.thenComparing(MediaType.QUALITY_VALUE_COMPARATOR));
![image-20211115210236647](https://i-blog.csdnimg.cn/blog_migrate/37b336be25b957271f2baa9f6686bf97.png)
![image-20211115205842645](https://i-blog.csdnimg.cn/blog_migrate/55e761d7b05f8e67a78b389f0be7238b.png)
通过debug可以看到,对于application/json和application/xml,他们属于Type一致,并且quality一致,但是子类型不一致的情况,会返回0,即排序认为是相等的,不会交换顺序,也就是以进入list的顺序为准。