在对一个遗留老系统使用SpringBoot框架进行重写的过程中,遇到了一个奇怪的问题:即当服务使用SpringBoot的main入口独立启动的时候,接口访问一切正常,但是当项目被打成war包运行在Tomcat中时,调用接口就会返回406 Not Acceptable错误,而由于运维等层面考虑,服务仍然要在Tomcat中运行一段时间作为过渡,因此不管是从对技术追求的态度上,还是从实际需求出发,这都是个不得不解决的问题。
要解决问题,首先我们需要知道,406错误出现的直接原因是什么。在一次HTTP请求中,如果服务端对于body内容的类型(即Content-Type)处理上产生了冲突,即会返回406错误状态。
SpringBoot处理Content-Type的具体代码入口在org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor的方法writeWithMessageConverters中,其中一段如下:
- HttpServletRequest request = inputMessage.getServletRequest();
- List
requestedMediaTypes = getAcceptableMediaTypes(request); - List
producibleMediaTypes = getProducibleMediaTypes(request, valueType, declaredType); -
- if (outputValue != null && producibleMediaTypes.isEmpty()) {
- throw new IllegalArgumentException("No converter found for return value of type: " + valueType);
- }
-
- Set
compatibleMediaTypes = new LinkedHashSet(); - for (MediaType requestedType : requestedMediaTypes) {
- for (MediaType producibleType : producibleMediaTypes) {
- if (requestedType.isCompatibleWith(producibleType)) {
- compatibleMediaTypes.add(getMostSpecificMediaType(requestedType, producibleType));
- }
- }
- }
- if (compatibleMediaTypes.isEmpty()) {
- if (outputValue != null) {
- throw new HttpMediaTypeNotAcceptableException(producibleMediaTypes);
- }
- return;
- }
可看到,其逻辑是,在当前请求可选的MediaType和接口要生成的MediaType中进行匹配,如果匹配不到,即会抛出HttpMediaTypeNotAcceptableException异常从而导致返回406错误。回到我们的项目,接口都是@RestController注解,因此producibleMediaTypes只能是application/json这一类,所以我们需要看requestedMediaTypes是什么。
继续深入源码,getAcceptableMediaTypes方法如下:
- private List
getAcceptableMediaTypes(HttpServletRequest request) throws HttpMediaTypeNotAcceptableException { - List
mediaTypes = this.contentNegotiationManager.resolveMediaTypes(new ServletWebRequest(request)); - return (mediaTypes.isEmpty() ? Collections.singletonList(MediaType.ALL) : mediaTypes);
- }
这就走到了org.springframework.web.accept.ContentNegotiationManager的resolveMediaTypes方法
- @Override
- public List
resolveMediaTypes(NativeWebRequest request) - throws HttpMediaTypeNotAcceptableException {
-
- for (ContentNegotiationStrategy strategy : this.strategies) {
- List
mediaTypes = strategy.resolveMediaTypes(request); - if (mediaTypes.isEmpty() || mediaTypes.equals(MEDIA_TYPE_ALL)) {
- continue;
- }
- return mediaTypes;
- }
- return Collections.emptyList();
- }
这里的strategies集合通过org.springframework.web.accept.ContentNegotiationManagerFactoryBean进行初始化
- @Override
- public void afterPropertiesSet() {
- List
strategies = new ArrayList(); -
- if (this.favorPathExtension) {
- PathExtensionContentNegotiationStrategy strategy;
- if (this.servletContext != null && !isUseJafTurnedOff()) {
- strategy = new ServletPathExtensionContentNegotiationStrategy(
- this.servletContext, this.mediaTypes);
- }
- else {
- strategy = new PathExtensionContentNegotiationStrategy(this.mediaTypes);
- }
- strategy.setIgnoreUnknownExtensions(this.ignoreUnknownPathExtensions);
- if (this.useJaf != null) {
- strategy.setUseJaf(this.useJaf);
- }
- strategies.add(strategy);
- }
-
- if (this.favorParameter) {
- ParameterContentNegotiationStrategy strategy =
- new ParameterContentNegotiationStrategy(this.mediaTypes);
- strategy.setParameterName(this.parameterName);
- strategies.add(strategy);
- }
-
- if (!this.ignoreAcceptHeader) {
- strategies.add(new HeaderContentNegotiationStrategy());
- }
-
- if (this.defaultNegotiationStrategy != null) {
- strategies.add(this.defaultNegotiationStrategy);
- }
-
- this.contentNegotiationManager = new ContentNegotiationManager(strategies);
- }
按照SpringBoot的默认逻辑,如果运行在容器中,会产生ServletPathExtensionContentNegotiationStrategy, HeaderContentNegotiationStrategy策略集合,而如果独立运行的话,产生的策略集合是PathExtensionContentNegotiationStrategy, HeaderContentNegotiationStrategy,可以看到两者的默认首选策略不一样。
再回到我们的项目,由于是一个遗留老系统,可能前人是为了安全考虑,接口命名都是类似xxx_json.so这样的,而.so后缀通常代表类unix系统的库文件。在SpringBoot独立运行的时候,首选使用PathExtensionContentNegotiationStrategy来决定media type,这个类使用的是SpringBoot自带的org/springframework/mail/javamail/mime.types映射文件,里面没有针对.so的映射关系,所以接着调用HeaderContentNegotiationStrategy策略,这个策略顾名思义,就是读取请求方Accept头里面的内容,而这个头通常都是*/*全匹配,所以一切都能够正常运行。当SpringBoot运行在Tomcat中的时候,首选ServletPathExtensionContentNegotiationStrategy来进行media type判断,这个类是通过调用servletContext.getMimeType()方法交由容器来进行处理,而在Tomcat中,就没有那么幸运了,.so被视为二进制文件映射成了application/octet-stream,因此和接口返回格式不匹配,导致SpringBoot产生了406错误。
找到了问题根源,解决办法也就有了,在不修改接口命名的前提下,就是想办法人为的把.so映射到application/json就行了。那么回到上面的org.springframework.web.accept.ContentNegotiationStrategy接口的resolveMediaTypes方法中,看到在默认抽象类org.springframework.web.accept.AbstractMappingContentNegotiationStrategy中的实现方式如下:
- @Override
- public List
resolveMediaTypes(NativeWebRequest webRequest) - throws HttpMediaTypeNotAcceptableException {
-
- return resolveMediaTypeKey(webRequest, getMediaTypeKey(webRequest));
- }
-
- /**
- * An alternative to {@link #resolveMediaTypes(NativeWebRequest)} that accepts
- * an already extracted key.
- * @since 3.2.16
- */
- public List
resolveMediaTypeKey(NativeWebRequest webRequest, String key) - throws HttpMediaTypeNotAcceptableException {
-
- if (StringUtils.hasText(key)) {
- MediaType mediaType = lookupMediaType(key);
- if (mediaType != null) {
- handleMatch(key, mediaType);
- return Collections.singletonList(mediaType);
- }
- mediaType = handleNoMatch(webRequest, key);
- if (mediaType != null) {
- addMapping(key, mediaType);
- return Collections.singletonList(mediaType);
- }
- }
- return Collections.emptyList();
- }
这里需要提到的是,以上说的逻辑,无论PathExtensionContentNegotiationStrategy还是ServletPathExtensionContentNegotiationStrategy都是发生在handleNoMatch中的,如果lookupMediaType方法能直接查到的话,就可以避免这个问题了, 查看lookupMediaType方法如下:
- /**
- * Use this method for a reverse lookup from extension to MediaType.
- * @return a MediaType for the key, or {@code null} if none found
- */
- protected MediaType lookupMediaType(String extension) {
- return this.mediaTypes.get(extension.toLowerCase(Locale.ENGLISH));
- }
这其中的mediaTypes就是通过上面的ContentNegotiationManagerFactoryBean进行设置的,那么回到这个类中,可以看到有如下方法:
- /**
- * Add a mapping from a key, extracted from a path extension or a query
- * parameter, to a MediaType. This is required in order for the parameter
- * strategy to work. Any extensions explicitly registered here are also
- * whitelisted for the purpose of Reflected File Download attack detection
- * (see Spring Framework reference documentation for more details on RFD
- * attack protection).
- *
The path extension strategy will also try to use
- * {@link ServletContext#getMimeType} and JAF (if present) to resolve path
- * extensions. To change this behavior see the {@link #useJaf} property.
- * @param mediaTypes media type mappings
- * @see #addMediaType(String, MediaType)
- * @see #addMediaTypes(Map)
- */
- public void setMediaTypes(Properties mediaTypes) {
- if (!CollectionUtils.isEmpty(mediaTypes)) {
- for (Entry
- String extension = ((String)entry.getKey()).toLowerCase(Locale.ENGLISH);
- MediaType mediaType = MediaType.valueOf((String) entry.getValue());
- this.mediaTypes.put(extension, mediaType);
- }
- }
- }
-
- /**
- * An alternative to {@link #setMediaTypes} for use in Java code.
- * @see #setMediaTypes
- * @see #addMediaTypes
- */
- public void addMediaType(String fileExtension, MediaType mediaType) {
- this.mediaTypes.put(fileExtension, mediaType);
- }
-
- /**
- * An alternative to {@link #setMediaTypes} for use in Java code.
- * @see #setMediaTypes
- * @see #addMediaType
- */
- public void addMediaTypes(Map
mediaTypes) { - if (mediaTypes != null) {
- this.mediaTypes.putAll(mediaTypes);
- }
- }
这几个方法都可以去手动添加media type映射,那么就简单了,在SpringBoot启动的时候,获取ContentNegotiationManagerFactoryBean对象,手动添加映射就可以了
具体实现方式如下:
- @Configuration
- public static class MyWebMvcConfig extends WebMvcConfigurerAdapter {
- @Override
- public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
- configurer.mediaType("so", MediaType.APPLICATION_JSON_UTF8);
- }
- }
补充:
在stackoverflow上面,也有人提到了这个问题,参见http://stackoverflow.com/questions/21235472/http-status-406-spring-mvc-4-0-jquery-json/21236862#21236862,提供了一些不同的解决思路,大家也可以去参考
The main issue here is that the path
"/test.htm"is going to use content negotiation first before checking the value of anAcceptheader. With an extension like*.htm, Spring will use aorg.springframework.web.accept.ServletPathExtensionContentNegotiationStrategyand resolve that the acceptable media type to return istext/htmlwhich does not match whatMappingJacksonHttpMessageConverterproduces, ie.application/jsonand therefore a 406 is returned.The simple solution is to change the path to something like
/test, in which content negotiation based on the path won't resolve any content type for the response. Instead, a differentContentNegotiationStrategybased on headers will resolve the value of theAcceptheader.The complicated solution is to change the order of the
ContentNegotiationStrategyobjects registered with theRequestResponseBodyMethodProcessorwhich handles your@ResponseBody.