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

发布于:2023-01-04 ⋅ 阅读:(254) ⋅ 点赞:(0)

背景

  在对一个遗留老系统使用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.ContentNegotiationManagerresolveMediaTypes方法

@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.

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到