作为Spring Boot中最常使用的的HTTP客户端,RestTemplate在各种Http通讯中都大量使用。
但是,对其原理缺不够熟悉。本文记录的是一次踩坑记录。
问题复现
出现问题的代码类似这样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16//通过RestTemplate给大汉三通短信网关发出请求,已发送短信
private SmsSinglePushResult doSendSms(String url, Object mobile, Object content, Map params){
try {
log.info("开始调用大汉三通的接口给用户:{} 发送短信:{}", mobile, content);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setAccept(Arrays.asList(MediaType.APPLICATION_JSON));
headers.setAcceptCharset(Arrays.asList(Charset.forName("utf-8")));
HttpEntity<Map> strEntity = new HttpEntity<>(params, headers);
//这里报错
DaHanSanTongResult strResult1 = restTemplate.postForObject(url, strEntity, DaHanSanTongResult.class);
}catch (Exception e){
log.error("调用大汉三通的发送短信接口出错。接收手机:"+mobile+" 内容:"+content, e);
}
}
上述例子在项目中被大量使用,几乎没有遇到过问题。RestTemplate的第3个参数指定了响应的类型被反序列化的类型。而在大汉三通的文档中也明确写到请求和响应都是UTF-8格式的JSON字符串,所以想当然的认为这样是没有问题的。
实际运行结果如下:
1 | org.springframework.web.client.RestClientException: Could not extract response: no suitable HttpMessageConverter found for response type [class com.ctspcl.sms.gateway.manager.sms.dahansantong.base.DaHanSanTongResult] and content type [application/octet-stream] |
问题追踪
上面的问题很明显,对于响应头application/octet-stream找不到对应的解析器。这里奇怪的是大汉三通的文档中明确写到响应是JSON格式字符串,但是为什么响应的Content-Type是application/octet-stream这个较为少见的头呢? 于是抓包看下:
抓包
请求抓包:
响应抓包:
可以看到,响应中HTTP头事实上是没有Content-Type字段的。那么这个application/octet-stream是哪里来的? 是因为我们请求时指定的返回Bean影响了这个头么?
看下代码。
源码分析
通过源码发现,RestTemplate把对响应结果的处理封装为一个HttpMessageConverterExtractor。然后在获取到响应后调用其中的
1 | //大部分用户调用的接口的实现都类似这样 |
由于通过抓包我们可以确定Http响应是没有Content-Type,所以基本可以确定application/octet-stream这个头是在这个Extractor中加上的。查看下这个方法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22public T extractData(ClientHttpResponse response) throws IOException {
MessageBodyClientHttpResponseWrapper responseWrapper = new MessageBodyClientHttpResponseWrapper(response);
if (!responseWrapper.hasMessageBody() || responseWrapper.hasEmptyMessageBody()) {
return null;
}
//根据响应获取ContentType
MediaType contentType = getContentType(responseWrapper);
for (HttpMessageConverter<?> messageConverter : this.messageConverters) {
if (messageConverter instanceof GenericHttpMessageConverter) {
GenericHttpMessageConverter<?> genericMessageConverter =
(GenericHttpMessageConverter<?>) messageConverter;
if (genericMessageConverter.canRead(this.responseType, null, contentType)) {
return (T) genericMessageConverter.read(this.responseType, null, responseWrapper);
}
}
if (this.responseClass != null) {
if (messageConverter.canRead(this.responseClass, contentType)) {
return (T) messageConverter.read((Class) this.responseClass, responseWrapper);
}
}
}
}
1 | private MediaType getContentType(ClientHttpResponse response) { |
上面可以看到代码中设置了默认的Content-Type为MediaType.APPLICATION_OCTET_STREAM,也就是application/octet-stream。 所以说,响应头变为这个也就可以解释了。
但是还有另外一个疑惑: 平常(Spring Boot项目)在这样使用1
restTemplate.postForObject(url, strEntity, DaHanSanTongResult.class);
的时候,为什么可以直接把响应转换为一个具体的POJO。服务器的响应都是JSON字符串,也都是UTF-8格式。通常,上述也可以通过返回String来处理1
restTemplate.postForObject(url, strEntity, String.class);
这两种都可以正常处理,而上面这种情况却无法处理。
上面代码中的extractData方法很明显是问题的关键。其中,遍历了当前所有的HttpMessageConverter,然后挨个调用其中的canRead方法。 该方法的签名:1
boolean canRead(Type type, Class<?> contextClass, MediaType mediaType);
可以看到,这个canRead方法接收一个目标类(Class)类型,一个上下文类型(不重要), 以及一个MediaType。 这也就是说,这个方法决定了 当前这个HttpMessageConverter支持哪种MediaType转换为哪种Class。
查看当前这个Extractor,发现它支持如下的HttpMessageConverter(默认的Spring Boot配置,没有另外添加):
ByteArrayHttpMessageConverterStringArrayHttpMessageConverterResourceHttpMessageConverterSourceHttpMessageConverterAllEncompassingFormHttpMessageConverterJaxb2RootElementHttpMessageConverterMappingJackson2HttpMessageConverter
对每个类查看,发现他们对于MediaType和Class的支持情况如下:
ByteArrayHttpMessageConverter
支持application/octet-stream, */*的MediaType与byte[]的ClassStringArrayHttpMessageConverter
支持application/text/plain, */*的MediaType与String的ClassResourceHttpMessageConverter
支持*/*的MediaType与Resource的ClassSourceHttpMessageConverter
支持application/xml,text/xml的MediaType与DOMSource,SAXSource,StAXSource,StreamSource,Source的ClassAllEncompassingFormHttpMessageConverter
这是一个复合类,它会根据当前的ClassLoader加载如下的一个HttpMessageConverter:- Jaxb2RootElementHttpMessageConverter
- MappingJackson2HttpMessageConverter
- GsonHttpMessageConverter
- MappingJackson2XmlHttpMessageConverter
Jaxb2RootElementHttpMessageConverter
支持application/xml,text/xml,application/+xml的MediaType与被XmlRootElement,XmlType注解的Class。 这是一个使用JAXB2的Converter,对于XML格式的响应会比较常用MappingJackson2HttpMessageConverter
支持application/json,application/+json的可以被JackSon序列化的Class。
结论及解决方式
所以在类似调用restTemplate.postForObject(url, strEntity, XXXBean.class);的时候,如果Http响应头的Content-Type为application/json,application/+json就使用JackSon来反序列化Http Body为对应的Java类。但是上面我们遇到的问题由于响应头不是这个,并且期望的返回类也不是上面7个HttpMessageConverter所支持的Class中任何一个,自然会报no suitable HttpMessageConverter found 这种错误。而我们发现String, byte[]的Class是可以被序列化的,所以改为其中任意一个即可解决该问题。
同时,正常情况下服务器对应响应是应该设置正确的Content Type的,上面这个服务器的行为对于用户就很不友好了。
后续
本文没有对RestTemplate对于请求的处理解析。关键逻辑几乎都在RestTemplate的内部类HttpEntityRequestCallback的doWithRequest方法中,一看就懂。内部逻辑与解析响应雷同。
这里也感慨下程序员的技术道路,现在有很多的公众号或者app提供了很多文章,对很多东西都有了总结性的介绍,但是如果一个开发者仅仅看这些,不深入到书本、深入到代码中去研究,是如同无根浮萍的。 往远了说,大厂为什么青睐招985/211的人,即使有些代码本身写的并不好。 其实也就是一个根基问题,而根基决定了可塑性。