暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

SpringBoot实战笔记:记一次接口406错误的解决

AggrxTech 2021-07-16
2007

背景

  在对一个遗留老系统使用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 an Accept
 header. With an extension like *.htm
, Spring will use a org.springframework.web.accept.ServletPathExtensionContentNegotiationStrategy
 and resolve that the acceptable media type to return is text/html
 which does not match what MappingJacksonHttpMessageConverter
 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 different ContentNegotiationStrategy
 based on headers will resolve the value of the Accept
 header.

The complicated solution is to change the order of the ContentNegotiationStrategy
 objects registered with the RequestResponseBodyMethodProcessor
 which handles your @ResponseBody
.


文章转载自AggrxTech,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论