背景
在对一个遗留老系统使用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<MediaType> requestedMediaTypes = getAcceptableMediaTypes(request);
List<MediaType> producibleMediaTypes = getProducibleMediaTypes(request, valueType, declaredType);
if (outputValue != null && producibleMediaTypes.isEmpty()) {
throw new IllegalArgumentException("No converter found for return value of type: " + valueType);
}
Set<MediaType> compatibleMediaTypes = new LinkedHashSet<MediaType>();
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<MediaType> getAcceptableMediaTypes(HttpServletRequest request) throws HttpMediaTypeNotAcceptableException {
List<MediaType> mediaTypes = this.contentNegotiationManager.resolveMediaTypes(new ServletWebRequest(request));
return (mediaTypes.isEmpty() ? Collections.singletonList(MediaType.ALL) : mediaTypes);
}复制
这就走到了org.springframework.web.accept.ContentNegotiationManager
的resolveMediaTypes
方法
@Override
public List<MediaType> resolveMediaTypes(NativeWebRequest request)
throws HttpMediaTypeNotAcceptableException {
for (ContentNegotiationStrategy strategy : this.strategies) {
List<MediaType> 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<ContentNegotiationStrategy> strategies = new ArrayList<ContentNegotiationStrategy>();
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<MediaType> 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<MediaType> 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).
* <p>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<Object, Object> entry : mediaTypes.entrySet()) {
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<String, MediaType> 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 anAccept
header. With an extension like*.htm
, Spring will use aorg.springframework.web.accept.ServletPathExtensionContentNegotiationStrategy
and resolve that the acceptable media type to return istext/html
which does not match whatMappingJacksonHttpMessageConverter
produces, ie.application/json
and 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 differentContentNegotiationStrategy
based on headers will resolve the value of theAccept
header.The complicated solution is to change the order of the
ContentNegotiationStrategy
objects registered with theRequestResponseBodyMethodProcessor
which handles your@ResponseBody
.