记一次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错误。

问题复现
原有接口示例
@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
接口改造

按照业务需求,需要将响应统一修改为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 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中。

	@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

关闭配置的方法

@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

@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依赖

<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,

mediaTypes.sort(MediaType.SPECIFICITY_COMPARATOR.thenComparing(MediaType.QUALITY_VALUE_COMPARATOR));
image-20211115210236647 image-20211115205842645

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

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

51iwowo

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值