正在学习SpringBoot,在自定义MessageConverter时发现:为同一个返回值类型配置多个MessageConverter时,可能会发生响应数据格式错误,或406异常(客户端无法接收相应数据)。在此记录一下解决问题以及追踪源码的过程。此处的讨论场景为:基于请求参数的内容协商,详见需求描述。
一 需求描述- 前提条件:浏览器访问路径为http://localhost:8080/showPerson,服务器端对应的Controller如下:
@RestController
public class ParamController {
@GetMapping("/showPerson")
public Person showPerson(Person person){
person.setUserName("lisi");
person.setAge(35);
return person;
}
}
-
需求:建立两个自定义的MessageConverter,并利用URL请求路径中携带的format参数来控制使用哪个converter。举例来说:
当发送请求 http://localhost:8080/showPerson?format=something时,需要的响应格式为:lisi - something - 35
当发送请求 http://localhost:8080/showPerson?format=anything时,需要的响应格式为:lisi - anything - 35
- PS:用 POST方式请求或许更合理,这里为了省事儿直接用 GET了。
- 根据上面的需求,我们首先创建了如下的配置类:
@Configuration(proxyBeanMethods = false)
public class WebConfig {
//1、WebMvcConfigurer定制化SpringMVC的功能
@Bean
public WebMvcConfigurer webMvcConfigurer(){
return new WebMvcConfigurer() {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
// 创建Map,用于收集MediaType信息
Map mediaTypes = new HashMap<>();
mediaTypes.put("json",MediaType.APPLICATION_JSON);
mediaTypes.put("xml",MediaType.APPLICATION_XML);
// 添加自定义的媒体类型
mediaTypes.put("something", MediaType.parseMediaType("application/something"));
mediaTypes.put("anything", MediaType.parseMediaType("application/anything"));
// 指定支持解析哪些参数对应的哪些媒体类型
ParameterContentNegotiationStrategy parameterStrategy = new ParameterContentNegotiationStrategy(mediaTypes);
// 添加
HeaderContentNegotiationStrategy headeStrategy = new HeaderContentNegotiationStrategy();
configurer.strategies(Arrays.asList(parameterStrategy,headeStrategy));
}
@Override
public void extendMessageConverters(List> converters) {
converters.add(new SomethingMessageConverter());
converters.add(new AnythingMessageConverter());
}
};
}
}
- 随后定义对应something和anything的两个MessageConverter(最初版本,有bug):
// 将Person转换为"lisi - something - 35"格式的converter public class SomethingMessageConverter implements HttpMessageConverter{ @Override public boolean canRead(Class> clazz, MediaType mediaType) { // 我们不需要用MessageConverter处理参数,所以这里直接返回false return false; } @Override public boolean canWrite(Class> clazz, MediaType mediaType) { // 根据clazz和Person的关系进行判断。 return Person.class.isAssignableFrom(clazz); } @Override public List getSupportedMediaTypes() { return MediaType.parseMediaTypes("application/something"); } @Override public Person read(Class extends Person> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { // 不需要此功能,直接返回null return null; } // 决定最终输出格式的write方法. @Override public void write(Person person, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { String returnData = person.getUserName() + " - something - " + person.getAge(); OutputStream body = outputMessage.getBody(); body.write(returnData.getBytes(StandardCharsets.UTF_8)); } } // 将Person转换为"lisi - anything- 35"格式的converter public class AnythingMessageConverter implements HttpMessageConverter { @Override public boolean canRead(Class> clazz, MediaType mediaType) { return false; } @Override public boolean canWrite(Class> clazz, MediaType mediaType) { // 根据clazz和Person的关系进行判断。 return Person.class.isAssignableFrom(clazz); } @Override public List getSupportedMediaTypes() { return MediaType.parseMediaTypes("application/anything"); } @Override public Person read(Class extends Person> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { return null; } @Override public void write(Person person, MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { String returnData = person.getUserName() + " - anything - " + person.getAge(); OutputStream body = outputMessage.getBody(); body.write(returnData.getBytes(StandardCharsets.UTF_8)); } }
- 跑起来,似乎没什么问题:
- 再试试发送anything的请求参数,bug如约而至:
anything 请求返回的响应格式仍然是 something。 那么,接下来去追一下源码,看看错误是如何产生的。
-
IDEA以debug模式启动,然后向服务器发送anything的请求。debug肯定是从老朋友DispatcherServlet开始,找到它,把断点打在doDispatch()方法内部的ha.handle()方法调用处。
-
按顺序step into 以下名称的方法:
-
handle() -> handleInternal() -> invokeHandlerMethod() -> invokeAndHandle() -> handleReturnValue() -> …略… -> writeWithMessageConverters() -> getAcceptableMediaTypes() ->
-
按照上面的处理返回值的路线,一路追踪到这里(顺便补充SpringBoot版本为2.5.5):
可以看见strategies中包含了自定义的媒体类型,显然,我们在WebConfig中自定义的策略是有效的。继续step into这个do while 循环中的方法。
-
接下来该方法根据URL中的format参数(key: “anything”)来从请求域中取出对应的mediaType( request.getParameter(xxx) )并将其存储到acceptableTypes中(*补充说明:基于请求参数的内容协商需要yml的配置才能生效:spring.mvc.contentnegotiation.favor-parameter=true,设置之后,底层容器才会提供对应的参数解析策略ParameterContentNegotiationStrategy *):
这里获取到的mediaType对应着anything,是正确的。到这里一切正常,acceptableTypes是我们预料的样子,那么问题大概率出在producibleTypes身上。(因为Spring的内容协商就是基于这两个集合的)
-
继续step into,到messageConverters第一次出现在视野中:
可以看到,我们自定义的两个Converter存在于其中。那么接下来是关键点1——调用每个Converter的canWrite方法,判断是否是符合需求的媒体类型,如果判断通过,则将对应的mediaType添加到result当中,注意这里传入的第二个参数为null:
遍历完成后,返回到上一层调用栈,此时,返回的producibleTypes如图,包含了我们定义的两个媒体类型。
现在我们得到了acceptableTypes和producibleType。下面就对这两个集合的元素进行遍历,逐个比较,来确定要使用的mediaType(媒体类型)。最终我们得到的类型是对应application/anything的媒体类型。这里没有问题。
-
得到mediaType之后,一路向下走,直到下图中的循环体(关键点2),this.messageConverters再次登场:
这里做的事情并不复杂,就是调用this.messageConverters里面的每一个converter的canWrite()方法,来判断是否是我们要的converter。而break label 183的存在,则告诉我们:循环在找到第一个符合要求的converter之后就终止了。
我们之前遇到的bug是:本应该是anything的converter来处理返回值,但响应的却是something的converter的处理效果,这说明something的converter被程序判定为正确的converter了。所以我们一路放行到对应something的converter.canWrite()方法,看看里面执行的过程: -
进入canWrite()方法,实际上就是我们自定义的SomethingMessageConverter的canWrite()方法。那么这里的问题就一目了然了:在canWrite()中,只对一个参数(即clazz)进行了判断(如下图),所以这里返回true,通过了验证。那么如上面所说,break语句执行,我们定义的AnythingMessageConverter就根本没机会被遍历到。
值得一提的是:messageConverters被遍历了两次,这两次遍历中,有些第一轮就被淘汰的元素其实没必要再进行第二次遍历,这应该是一个性能上的优化点。
-
-
上面已经分析出来问题所在:canWrite()只对clazz参数进行了判断,那么解决办法自然就是:加上对mediaType的判断,如图中代码所示。接下来重新测试:
测试结果竟然为:406,客户端无法接收相应数据。笔者当时又debug了一会儿,这里省略过程,直接描述原因和解决办法:
-
我们回到上文关键点1的地方,也就是第一次对messageConverters进行遍历的地方。这里向canWrite()传入的第二个参数是null,这导致了自定义的canWrite()均不能判断通过。这意味着,程序找到的producibleMediaTypes中将会缺少我们自定义的MessageConverter,那么在后续的“内容协商”(即将acceptableTypes和producibleMediaTypes进行逐一对照的过程)中,就会找不到对应的converter,所以最终导致找不到页面。
-
那么此时,我们需要做的事情就很明显了:将canWrite的判断进行修改,来规避上面提到的,传入参数为null时造成的MessageConverter类型遗漏。修改后的代码如图:
-
-
再次运行并测试,成功:
The End,感谢指正。



