Spring MVC 406
使用Spring MVC返回 JSON 数据有时候会在页面报出以下 406 错误。具体错误信息如下:
406.jpg最常见的问题就是缺少 Jackson 工具包,它的作用是把 Java 对象转换成 JSON 输出到页面。当然这是最常见的情况,下面我就来介绍一下项目中出现的问题。由于项目遗留原因,项目请求中 URI 都是以 .htm
结尾。之前都是使用 HttpServletResponse
操作原生 Servlet
来返回 JSON 数据,而不是使用 Spring MVC 提供的 @ResponseBody
注解。
public void out(Object obj, HttpServletResponse response) {
response.setContentType("text/html; charset=utf-8");
PrintWriter out = null;
try {
out = response.getWriter();
} catch (IOException e) {
e.printStackTrace();
}
out.print(JSON.toJSONString(obj));
}
重复的代码就是不好的
所以对于新添加的接口我打算使用 Spring MVC 提供的 @ResponseBody
来返回 JSON
数据。使用方式很简单,定义 @RequestMapping
方法返回值为任意的 POJO
对象,然后再这个方法上面添加 @ResponseBody
注解就好了。
@RequestMapping("uri路径")
@ResponseBody
public User user(){
User user = new User();
user.setId("1");
user.setName("carl");
return user;
}
之前一直使用这个注解都可以解决这个问题,但是公司项目中居然不成功。我检查了一下 pom 文件是引用了 Jackson Jar
包,排除这个原因。和之前使用 @ResponseBody
注解的的不同点就是请求 URI 里面包含了 .htm
,然后我就做了以下的小实验。
请求URI | 返回 |
---|---|
test | 成功返回JSON |
test.htm | 406 |
test.xxx | 成功返回JSON |
从上面的例子中我们可以看到请求 URI 的后缀对于 Spring MVC 的响应生成是有影响的。
我们知道在 Spring MVC 中 HandlerMethodArgumentResolver
接口负责将 HttpServletRequest 里面的请求参数绑定到标注了 @RequestMapping 的@Controller
的方法中;而对于 @RequestMapping
方法的返回值 Spring MVC 通过HandlerMethodReturnValueHandler
来处理。Spring MVC 通过 @RequestBody
、@ResposeBody
支持 restful,其实就是通过实现了以上两个接口的 RequestResponseBodyMethodProcessor
来实现的,而处理 restful 底层是通过 HttpMessageConverters
接口来实现的,对于这个接口这里我们就不过多介绍了。
下面我们就从源码的角度来分析一下返回 JSON 报406 这个错误的原因。
在Spring MVC 中处理 @ResponseBody
的入口是RequestResponseBodyMethodProcessor#handleReturnValue
,而主要核心处理逻辑是在AbstractMessageConverterMethodProcessor#writeWithMessageConverters
protected <T> void writeWithMessageConverters(T value, MethodParameter returnType,
ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage)
throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
Class<?> valueType = getReturnValueType(value, returnType);
Type declaredType = getGenericType(returnType);
HttpServletRequest request = inputMessage.getServletRequest();
List<MediaType> requestedMediaTypes = getAcceptableMediaTypes(request);
List<MediaType> producibleMediaTypes = getProducibleMediaTypes(request, valueType, declaredType);
if (value != 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 (value != null) {
throw new HttpMediaTypeNotAcceptableException(producibleMediaTypes);
}
return;
}
List<MediaType> mediaTypes = new ArrayList<MediaType>(compatibleMediaTypes);
MediaType.sortBySpecificityAndQuality(mediaTypes);
MediaType selectedMediaType = null;
for (MediaType mediaType : mediaTypes) {
if (mediaType.isConcrete()) {
selectedMediaType = mediaType;
break;
}
else if (mediaType.equals(MediaType.ALL) || mediaType.equals(MEDIA_TYPE_APPLICATION)) {
selectedMediaType = MediaType.APPLICATION_OCTET_STREAM;
break;
}
}
if (selectedMediaType != null) {
selectedMediaType = selectedMediaType.removeQualityValue();
for (HttpMessageConverter<?> messageConverter : this.messageConverters) {
if (messageConverter instanceof GenericHttpMessageConverter) {
if (((GenericHttpMessageConverter<T>) messageConverter).canWrite(
declaredType, valueType, selectedMediaType)) {
value = (T) getAdvice().beforeBodyWrite(value, returnType, selectedMediaType,
(Class<? extends HttpMessageConverter<?>>) messageConverter.getClass(),
inputMessage, outputMessage);
if (value != null) {
addContentDispositionHeader(inputMessage, outputMessage);
((GenericHttpMessageConverter<T>) messageConverter).write(
value, declaredType, selectedMediaType, outputMessage);
if (logger.isDebugEnabled()) {
logger.debug("Written [" + value + "] as \"" + selectedMediaType +
"\" using [" + messageConverter + "]");
}
}
return;
}
}
else if (messageConverter.canWrite(valueType, selectedMediaType)) {
value = (T) getAdvice().beforeBodyWrite(value, returnType, selectedMediaType,
(Class<? extends HttpMessageConverter<?>>) messageConverter.getClass(),
inputMessage, outputMessage);
if (value != null) {
addContentDispositionHeader(inputMessage, outputMessage);
((HttpMessageConverter<T>) messageConverter).write(value, selectedMediaType, outputMessage);
if (logger.isDebugEnabled()) {
logger.debug("Written [" + value + "] as \"" + selectedMediaType +
"\" using [" + messageConverter + "]");
}
}
return;
}
}
}
if (value != null) {
throw new HttpMediaTypeNotAcceptableException(this.allSupportedMediaTypes);
}
}
上面的代码看着很复杂其实逻辑很简单。简单来说就是处理 Media Type(互联网媒体类型,也叫做于MIME类型,有时在一些协议的消息头中叫做“Content-Type”。它使用两部分标识符来确定一个类型。
)
-
getAcceptableMediaTypes()
通过策略获取到请求可以接受的 MedieType -
getProducibleMediaTypes()
根据返回值获取到可产生哪些 MedieType -
isCompatibleWith()
匹配请求 MedieType 与 响应产生的 MedieType,如果匹配就添加到匹配的 MedieType 列表当中。 -
HttpMessageConverter#write()
根据在 Medie 列表中找到的最合适的 MedieType 把它写入 HttpServletResponse 中
我们可以看到有 3 个地方会影响最终响应的生成:也就是第1、2、4 这 4 个步骤。
而在Spring MVC 找不到 Jackson 就属于第 4 步,因为处理 JSON 对应的HttpMessageConverter
为 MappingJackson2HttpMessageConverter
。而添加这个类的处理逻辑在WebMvcConfigurationSupport#addDefaultHttpMessageConverters
。
它是根据jackson2Present
这个参数来添加 JSON 处理器的。
private static final boolean jackson2Present =
ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", WebMvcConfigurationSupport.class.getClassLoader()) &&
ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", WebMvcConfigurationSupport.class.getClassLoader());
上面这段代码的逻辑是在当前 ClassLoader 加载 ObjectMapper
或者 JsonGenerator
,如果加载成功就添加 MappingJackson2HttpMessageConverter
,而这两个类属于 Jackson
。
下面我们来看第一点:
其实获取请求可接受的 MedieType 是根据 ContentNegotiationManager#resolveMediaTypes
Spring MVC 内容协商来解析的。
默认有两种策略,也就是 ContentNegotiationManager#strategies
:
-
ServletPathExtensionContentNegotiationStrategy
:根据请求 URI 扩展名来获取 MedieType,它最终会调用javax.servlet.ServletContext#getMimeType
从ServletContext
里面获取支持的 URI 请求扩展,包含以下 170 种扩展:
"css" -> "text/css"
"ps" -> "application/postscript"
"movie" -> "video/x-sgi-movie"
"bin" -> "application/octet-stream"
"xspf" -> "application/xspf+xml"
"axa" -> "audio/annodex"
"jad" -> "text/vnd.sun.j2me.app-descriptor"
"xul" -> "application/vnd.mozilla.xul+xml"
"midi" -> "audio/midi"
"exe" -> "application/octet-stream"
"java" -> "text/x-java-source"
"texi" -> "application/x-texinfo"
"mov" -> "video/quicktime"
"dvi" -> "application/x-dvi"
"xml" -> "application/xml"
"jar" -> "application/java-archive"
"axv" -> "video/annodex"
"pict" -> "image/pict"
"mpa" -> "audio/mpeg"
"zip" -> "application/zip"
"oth" -> "application/vnd.oasis.opendocument.text-web"
"mpe" -> "video/mpeg"
"otg" -> "application/vnd.oasis.opendocument.graphics-template"
"qt" -> "video/quicktime"
"cdf" -> "application/x-cdf"
"mpg" -> "video/mpeg"
"ras" -> "image/x-cmu-raster"
"bcpio" -> "application/x-bcpio"
"tex" -> "application/x-tex"
"ai" -> "application/postscript"
"png" -> "image/png"
"eps" -> "application/postscript"
"mathml" -> "application/mathml+xml"
"otp" -> "application/vnd.oasis.opendocument.presentation-template"
"odb" -> "application/vnd.oasis.opendocument.database"
"oda" -> "application/oda"
"texinfo" -> "application/x-texinfo"
"ott" -> "application/vnd.oasis.opendocument.text-template"
"pnm" -> "image/x-portable-anymap"
"odc" -> "application/vnd.oasis.opendocument.chart"
"ots" -> "application/vnd.oasis.opendocument.spreadsheet-template "
"odf" -> "application/vnd.oasis.opendocument.formula"
"odg" -> "application/vnd.oasis.opendocument.graphics"
"au" -> "audio/basic"
"odi" -> "application/vnd.oasis.opendocument.image"
"pnt" -> "image/x-macpaint"
"doc" -> "application/msword"
"odm" -> "application/vnd.oasis.opendocument.text-master"
"odp" -> "application/vnd.oasis.opendocument.presentation"
"rm" -> "application/vnd.rn-realmedia"
"jsf" -> "text/plain"
"odt" -> "application/vnd.oasis.opendocument.text"
"aif" -> "audio/x-aiff"
"ods" -> "application/vnd.oasis.opendocument.spreadsheet"
"aim" -> "application/x-aim"
"xwd" -> "image/x-xwindowdump"
"vsd" -> "application/vnd.visio"
"flac" -> "audio/flac"
"mpega" -> "audio/x-mpeg"
"js" -> "application/javascript"
"mid" -> "audio/midi"
"mif" -> "application/x-mif"
"mac" -> "image/x-macpaint"
"cer" -> "application/pkix-cert"
"sh" -> "application/x-sh"
"pgm" -> "image/x-portable-graymap"
"wml" -> "text/vnd.wap.wml"
"jpeg" -> "image/jpeg"
"man" -> "text/troff"
"wmv" -> "video/x-ms-wmv"
"art" -> "image/x-jg"
"rtf" -> "application/rtf"
"svg" -> "image/svg+xml"
"snd" -> "audio/basic"
"mpv2" -> "video/mpeg2"
"ppm" -> "image/x-portable-pixmap"
"txt" -> "text/plain"
"pps" -> "application/vnd.ms-powerpoint"
"abs" -> "audio/x-mpeg"
"shar" -> "application/x-shar"
"t" -> "text/troff"
"xpm" -> "image/x-xpixmap"
"asf" -> "video/x-ms-asf"
"ppt" -> "application/vnd.ms-powerpoint"
"rdf" -> "application/rdf+xml"
"rtx" -> "text/richtext"
"z" -> "application/x-compress"
"dib" -> "image/bmp"
"cpio" -> "application/x-cpio"
"tr" -> "text/troff"
"swf" -> "application/x-shockwave-flash"
"bmp" -> "image/bmp"
"xht" -> "application/xhtml+xml"
"asx" -> "video/x-ms-asf"
"oga" -> "audio/ogg"
"roff" -> "text/troff"
"wspolicy" -> "application/wspolicy+xml"
"pic" -> "image/pict"
"body" -> "text/html"
"latex" -> "application/x-latex"
"hqx" -> "application/mac-binhex40"
"ogg" -> "audio/ogg"
"tif" -> "image/tiff"
"dv" -> "video/x-dv"
"me" -> "text/troff"
"wbmp" -> "image/vnd.wap.wbmp"
"html" -> "text/html"
"ogv" -> "video/ogg"
"svgz" -> "image/svg+xml"
"ogx" -> "application/ogg"
"tar" -> "application/x-tar"
"ms" -> "application/x-wais-source"
"qti" -> "image/x-quicktime"
"etx" -> "text/x-setext"
"nc" -> "application/x-netcdf"
"qtif" -> "image/x-quicktime"
"mpeg" -> "video/mpeg"
"spx" -> "audio/ogg"
"pbm" -> "image/x-portable-bitmap"
"psd" -> "image/vnd.adobe.photoshop"
"ulw" -> "audio/basic"
"xbm" -> "image/x-xbitmap"
"tiff" -> "image/tiff"
"aiff" -> "audio/x-aiff"
"gif" -> "image/gif"
"aifc" -> "audio/x-aiff"
"ief" -> "image/ief"
"rgb" -> "image/x-rgb"
"jspf" -> "text/plain"
"m3u" -> "audio/x-mpegurl"
"xsl" -> "application/xml"
"avi" -> "video/x-msvideo"
"dtd" -> "application/xml-dtd"
"htc" -> "text/x-component"
"sv4crc" -> "application/x-sv4crc"
"tsv" -> "text/tab-separated-values"
"vxml" -> "application/voicexml+xml"
"sv4cpio" -> "application/x-sv4cpio"
"json" -> "application/json"
"tcl" -> "application/x-tcl"
"class" -> "application/java"
"kar" -> "audio/midi"
"jpe" -> "image/jpeg"
"sit" -> "application/x-stuffit"
"htm" -> "text/html"
"jpg" -> "image/jpeg"
"pct" -> "image/pict"
"ustar" -> "application/x-ustar"
"avx" -> "video/x-rad-screenplay"
"src" -> "application/x-wais-source"
"anx" -> "application/annodex"
"wmls" -> "text/vnd.wap.wmlsc"
"hdf" -> "application/x-hdf"
"wav" -> "audio/x-wav"
"gtar" -> "application/x-gtar"
"mp2" -> "audio/mpeg"
"mp1" -> "audio/mpeg"
"xhtml" -> "application/xhtml+xml"
"mp4" -> "video/mp4"
"wrl" -> "model/vrml"
"mp3" -> "audio/mpeg"
"gz" -> "application/x-gzip"
"pdf" -> "application/pdf"
"pls" -> "audio/x-scpls"
"wmlscriptc" -> "application/vnd.wap.wmlscriptc"
"csh" -> "application/x-csh"
"jnlp" -> "application/x-java-jnlp-file"
"wmlc" -> "application/vnd.wap.wmlc"
"xslt" -> "application/xslt+xml"
"xls" -> "application/vnd.ms-excel"
因为 htm
后缀 对应 text/html
,所以如果请求是 xxx.htm
,不管第二步返回什么,服务端最多只能生成 html 页面。而使用test.xxx
,并不在支持的扩展参数里面,所以没有影响。
-
HeaderContentNegotiationStrategy
请求头策略,根据 http 的请求头来生成请求可接受的 MedieType。
第二步是获取到服务端支持的可响应的 MedieType,它的规则如下:
- 获取
@RequestMapping
注解的produces()
标注。 - 遍历所有的
HttpMessageConverter
获取支持@RequestMapping
返回值的 MedieType
因为是 URI 扩展参数惹的祸,所以我首先想到的解决方案就是移除 ServletPathExtensionContentNegotiationStrategy
这个策略。
因为是 Spring IOC 来创建对象,所以我想根据 Spring IOC 容器扩展 来解决这个问题。
方法一 : 使用BeanPostProcessor修改 Bean
因为是WebMvcConfigurationSupport#requestMappingHandlerAdapter
来创建 RequestMappingHandlerAdapter
并且WebMvcConfigurationSupport#mvcContentNegotiationManager
创建的 ContentNegotiationManager
。所以从容器中获取到 bean Id 为requestMappingHandlerAdapter
的 Bean 对象RequestMappingHandlerAdapter
,获取到 ContentNegotiationManager。获取直接根据 mvcContentNegotiationManager
获取到 ContentNegotiationManager
。 然后通过移除 ContentNegotiationManager.strategies
策略列表中的 URI 扩展参数策略就可以了。
因为 RequestMappingHandlerAdapter
对象里面没有 ContentNegotiationManager
的获取方法 且 ContentNegotiationManager
类中没有 策略列表的操作方法,所以这个方法不可行。
方法二: 使用BeanFactoryPostProcessor修改 Bean
可以通过 BeanFactoryPostProcessor#postProcessBeanFactory
来修改 BeanDefinition
的属性来移除 策略列表中的 URI 扩展参数策略。
因为 @Configuration
与 @Bean
生成的 BeanDefinition
是把这个 BeanDefinition
伪装成一个 Spring Factory Bean。创建实例直接调用这个方法,而不能通过 BeanDefinition
里面的参数来控制对象的创建。所以这个方法也不可行。
方法三:@EnableMvcConfig
在 WebMvcConfigurationSupport
类中调用 mvcContentNegotiationManager
方法生成 ContentNegotiationManager 对象的时候,最终会调用 ContentNegotiationManagerFactoryBean
的afterPropertiesSet()
而 favorPathExtension
参数可以控制是否添加 PathExtensionContentNegotiationStrategy
,如果这个值为 true 就会添加,反之而不会。这个值的默认值是 true,那么我们可以不可修改这个参数的值呢?
答案是有的,因为在调用ContentNegotiationManagerFactoryBean#afterPropertiesSet
方法之前,会调用 WebMvcConfigurationSupport#configureContentNegotiation
而我们可以通过继承 WebMvcConfigurerAdapter 类使用 @EnableWebMvc
注解来修改这个值。
下面就是我的测试代码工程结构:
spring-boot-demo.pngBootstrap.java
@SpringBootApplication
public class Bootstrap {
public static void main(String[] args) {
SpringApplication.run(Bootstrap.class, args);
}
}
MyMvcConfig.java
@Configuration
@EnableWebMvc
public class MyMvcConfig extends WebMvcConfigurerAdapter {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.favorPathExtension(false);
super.configureContentNegotiation(configurer);
}
}
TestController.java
@Controller
public class TestController {
@RequestMapping("URI地址")
@ResponseBody
public User user(){
User user = new User();
user.setId("1");
user.setName("carl");
return user;
}
}
然后再使用以上的请求 URI 做个实验:
请求URI | 返回 |
---|---|
test | 成功返回JSON |
test.htm | 成功返回JSON |
test.xxx | 成功返回JSON |
并且无论访问哪个 URI 生成的 requestedMediaTypes 都为:
request-media-types.png并且 http 的请求头如下:
http-header.png