记一次SpringMvc下HTTP 406问题排查

问题背景

由于项目需要,需要将某个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错误。

问题复现

原有接口示例
1
2
3
4
5
java复制代码@RequestMapping(value = "/hello/**")
@ResponseBody
public String helloWorld(HttpServletRequest httpServletRequest) {
return "hello world";
}

客户端对应的请求连接

1
html复制代码localhost:8080/hello/test.htm

使用postman模拟请求,可以看到请求时的Accept是*/*,而服务端返回的Content-Type是text/html。

image-20211115132848248

接口改造

按照业务需求,需要将响应统一修改为application/json类型,对于SpringMvc的Rest请求,我们做了如下修改,增加produces标识响应类型为application/json。

关于produces属性的含义

Narrows the primary mapping by media types that can be produced by the mapped handler(限制该方法的MediaType)

1
2
3
4
5
6
7
java复制代码// 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
image-20211115134633093
显然,问题出现在请求后缀上,需要进一步排查问题原因。

对于SpringMvc的请求过程,首先需要DispatchServlet根据请求HttpServletRequest,利用HandlerMapping获取到对应的HandlerChain。

获取HandlerChain

这里对于采用了@RequestMapping注解的方法,会使用RequestMappingHandlerMapping方法。

image-20211115135844714
当然,其中有些方法会存在于父类AbstractHandlerMethodMapping中,我们断点到lookupHandlerMethod方法。

可以看到当前请求的类就是RequestMappingHandlerMapping,它的mappingRegistry中包含了我们的请求接口”/hello/**“,对应的Produces是application/json类型。

image-20211115140626302
然后会进入到该类的addMatchingMappings方法中,寻找满足条件的mapping信息。

image-20211115140941065
继续进入getMatchingMappings方法,最终会进入到RequestMappingInfo的getMatchingCondition方法。这个方法会对请求中的很多属性进行校验,包括请求方法、参数、header,consumers以及produces,这里我们重点关注producesCondition的getMatchingCondition方法,通过后续的分析也可以得到,这是出问题的根本所在。

image-20211115141730032
关于这个方法,可以先看一下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

获取请求的acceptedMediaTypes

在getAccepedMediaTypes内部会调用核心的ContentNegotiationManager解析请求的MediaTypes,这个类中会注册一些ContentNegotiationStrategy。在当前断点条件下有HeaderContentNegotiationStrategy和ServletPathExtensionNegotiationStrategy。

image-20211115143039199
我们进入到该方法内部,会循环遍历所有的Strategy解析到MediaTypes。

image-20211115143341902
首先会进入ServletPathExtensionNegotiationStrategy的解析,会先进入到父类中的resolveMediaTypes方法。

image-20211115143709976
注意到上面的getMediaTypeKey方法,该方法是一个抽象方法,拥有两个实现。

image-20211115143858546
当前情况下会进入PathExtensionContentNegotiationStrategy中

image-20211115144016948
这里会返回htm,然后进入到前面的AbstractMappingContentNegotiationStrategy的resolveMediaTypeKey方法。

image-20211115144445418
其中lookupMediaType位于MappingMediaTypeFileExtensionResolver中。

1
2
3
4
java复制代码	@Nullable
protected MediaType lookupMediaType(String extension) {
return this.mediaTypes.get(extension.toLowerCase(Locale.ENGLISH));
}

image-20211115144607135
该方法的MediaType中只有xml和json,所以对于htm返回空。进而会进入到AbstractMappingContentNegotiationStrategy的handleNoMatch方法,这里会进入到ServletPathExtensionContentNegotiationStrategy的handleNoMatch方法中。它会根据文件后缀,得到MediaType为text/html类型。

image-20211115145130802

不匹配情况下抛出HttpMediaTypeNotAcceptableException

现在我们可以回到ProducesCondition方法中,获取到acceptMediaType后,和Produces的进行匹配,进入到getMatchingExpressions方法。可以看到当前类的expression是application/json,但是accepted是text/html,这里会返回空。进一步,produceCondition会返回null。

image-20211115145913855
继续向上返回RequestMapping.getMatchingCondition也会返回空。

image-20211115150145816
继续返回,会回到AbstractHandlerMethodMapping的lookupHandlerMethod方法

image-20211115150724820

由于上述的matches是空的,所以方法会执行到handleNoMatch方法,该方法是抽象方法。RequestMappingInfoHandlerMapping对该方法进行了重写。方法开始的PartialMathHelper初始化的时候,会对各种Condition进行校验,可以看到这里又执行了一遍之前的getMatchingCondition方法,并且同理producesMatch的结果是false。而我们看到在第267行,如果有produces不匹配的情况下,就会抛出HttpMediaTypeNotAcceptableException异常。

image-20211115151156323

image-20211115151511704
到这里问题已经基本明确了,那么对于原始的,没有添加produces属性的接口,为什么是可以的呢?

我们可以直接定位到ProducesRequesetCondition,直接debug到getMatchingCondition,可以看到它的expression是空的,isEmpty如果发现expression是空的,不会对accept的contentType做校验,后续也就不会抛出HttpMediaTypeNotAcceptableException异常了。

image-20211115152941901

解决办法

针对这种情况,目前最好的解决方法是禁掉根据后缀类型匹配MediaType。

image-20211115154715735
该配置可以通过查看ContentNegotiationManagerFactoryBean这个类中的favorPathExtension属性。

在Spring-webmvc的5.3.5中,该配置是默认关闭的。

image-20211115155606875
但是在4.3.16中,该配置是开启的。

image-20211115160306685
关闭配置的方法

1
2
3
4
5
6
7
8
9
java复制代码@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {

@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.favorPathExtension(false);
super.configureContentNegotiation(configurer);
}
}

问题总结

在本次排查过程中,有几点需要注意。

  1. 不同版本的Spring ContentNegotiation配置存在差异。
  2. 在前面提到的MappingMediaTypeFileExtensionResolverd的lookupMediaType中,存在mediaTypes取值为application/json和application/xml,他们的来源在哪里呢?

这里可以查看WebMvcConfigurationSupport中的getDefaultMediaTypes,可以看到这里会根据一些变量做一些初始化的工作。

image-20211115164726570

​ 而上述变量的取值情况如下,也就是会根据classpath中的类情况做初始化工作

image-20211115164831090

问题延伸

还有一种情况接口返回HTTP 406的情况,这种会出现在使用到了HttpMessageConverter时。

接口会返回对象,如以下case

1
2
3
4
5
6
7
8
java复制代码@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
首先可以看到body中是有正常值的,上图中的逻辑和之前有些类似,218行先根据request获取到acceptedMediaType,最终也会调用this.contentNegotiationManager.resolveMediaTypes(new ServletWebRequest(request));

然后再获取produceMediaType即230行代码,然后在237行到241行,进行匹配。如果匹配失败,则会在246行抛出HttpMediaTypeNotAcceptableException。

这里再着重看一下getProducibleMediaTypes方法。

主要就是根据HttpMessageConverter的canWrite方法判断是否可以对返回结果进行write,可以的话,添加getSupportedMediaTypes即可。

image-20211115172003721

image-20211115172048790
这里有很多的HttpMessageConverter,最终会利用MappingJackson2HttpMessageConvertoer增加applicaiton/json和applicaiton*+json。

而它的getSupportedMediaTypes会进入AbstractJackson2HttpMessageConverter中,如果有自定义的objectMapper,那就使用自定义的。

image-20211115172539003
否则的话,调用AbstractHttpMessageConverter的getSupportedMediaTypes方法。

image-20211115172742548
​ 还是需要看一下this.supportedMediaTypes的来源。

​ 可以直接看一下MappingJackson2HttpMessageConverter的初始化函数,终于找到你。

image-20211115173309240

​ 所以,如果在这种情况下,客户端的请求accept如果是application/xml,也会返回HTTP 406。

image-20211115173449827

​ 最后,如果真要返回application/xml,怎么办呢? 还是需要看一下完成的WebMvcConfigurationSupport类。

​ 这次的方法是addDefaultHttpMessageConverters,添加默认的messageConverter(代码有些长,截取了前半部分)。

image-20211115173838337

​ 可以看到xml解析的条件是!shoudIgnoreXml,该值默认是false,那另外一个条件就是jackson2XmlPresent。是的,这个配置在第二个关注点中有描述,即

image-20211115174226455
​ 所以需要先添加maven依赖

1
2
3
4
5
xml复制代码<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.11.4</version>
</dependency>

​ 此时AbstractMessageConverterMethodProcessor的getProducibleMediaTypes终于看到了xml类型啦。

image-20211115174641587

再用Postman试一下,大功告成!

image-20211115174912648
最后再啰嗦一句,如果此时Accept为*/*的话,会以xml形式返回,因为它对应的HttpMessageConvertor先被加载到。尽管在AbstractMessageConverterMethodProcessor->writeWithMessageConverters的最后,如果有匹配多个mediaTypesToUse,会利用MediaType.sortBySpecifityAndQuality进行排序。

image-20211115204301638
针对这个方法,主要是两个comparator,

1
java复制代码mediaTypes.sort(MediaType.SPECIFICITY_COMPARATOR.thenComparing(MediaType.QUALITY_VALUE_COMPARATOR));

image-20211115210236647
image-20211115205842645
​ 通过debug可以看到,对于application/json和application/xml,他们属于Type一致,并且quality一致,但是子类型不一致的情况,会返回0,即排序认为是相等的,不会交换顺序,也就是以进入list的顺序为准。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%