RestTemplate踩坑记录

作为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
2
3
4
5
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]
at org.springframework.web.client.HttpMessageConverterExtractor.extractData(HttpMessageConverterExtractor.java:110)
at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:655)
at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:613)
at org.springframework.web.client.RestTemplate.postForObject(RestTemplate.java:380)

问题追踪

上面的问题很明显,对于响应头application/octet-stream找不到对应的解析器。这里奇怪的是大汉三通的文档中明确写到响应是JSON格式字符串,但是为什么响应的Content-Type
application/octet-stream这个较为少见的头呢? 于是抓包看下:

抓包

请求抓包:
请求抓包
响应抓包:
响应抓包

可以看到,响应中HTTP头事实上是没有Content-Type字段的。那么这个application/octet-stream是哪里来的? 是因为我们请求时指定的返回Bean影响了这个头么?

看下代码。

源码分析

通过源码发现,RestTemplate把对响应结果的处理封装为一个HttpMessageConverterExtractor。然后在获取到响应后调用其中的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//大部分用户调用的接口的实现都类似这样
public <T> T postForObject(String url, Object request, Class<T> responseType, Object... uriVariables)
throws RestClientException {
RequestCallback requestCallback = httpEntityCallback(request, responseType);
HttpMessageConverterExtractor<T> responseExtractor =
new HttpMessageConverterExtractor<T>(responseType, getMessageConverters(), logger);
return execute(url, HttpMethod.POST, requestCallback, responseExtractor, uriVariables);
}
//对于包含URL参数的请求的处理
public <T> T execute(String url, HttpMethod method, RequestCallback requestCallback,
ResponseExtractor<T> responseExtractor, Object... uriVariables) throws RestClientException {
URI expanded = getUriTemplateHandler().expand(url, uriVariables);
return doExecute(expanded, method, requestCallback, responseExtractor);
}
//实际处理部分
protected <T> T doExecute(URI url, HttpMethod method, RequestCallback requestCallback,
ResponseExtractor<T> responseExtractor) throws RestClientException {
ClientHttpResponse response = null;
try {
ClientHttpRequest request = createRequest(url, method);
if (requestCallback != null) {
requestCallback.doWithRequest(request);
}
response = request.execute();
handleResponse(url, method, response);
if (responseExtractor != null) {
//解析响应结果
return responseExtractor.extractData(response);
}
else {
return null;
}
}
catch (IOException ex) {
//忽略异常
}

}

由于通过抓包我们可以确定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
22
public 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
2
3
4
5
6
7
8
private MediaType getContentType(ClientHttpResponse response) {
MediaType contentType = response.getHeaders().getContentType();
if (contentType == null) {
//这里设置了默认的ContentType
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
return contentType;
}

上面可以看到代码中设置了默认的Content-TypeMediaType.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配置,没有另外添加):

  • ByteArrayHttpMessageConverter
  • StringArrayHttpMessageConverter
  • ResourceHttpMessageConverter
  • SourceHttpMessageConverter
  • AllEncompassingFormHttpMessageConverter
  • Jaxb2RootElementHttpMessageConverter
  • MappingJackson2HttpMessageConverter

对每个类查看,发现他们对于MediaTypeClass的支持情况如下:

  • ByteArrayHttpMessageConverter
    支持application/octet-stream, */*MediaTypebyte[]Class
  • StringArrayHttpMessageConverter
    支持application/text/plain, */*MediaTypeStringClass
  • ResourceHttpMessageConverter
    支持*/*MediaTypeResourceClass
  • SourceHttpMessageConverter
    支持application/xml,text/xmlMediaTypeDOMSource,SAXSource,StAXSource,StreamSource,SourceClass
  • AllEncompassingFormHttpMessageConverter
    这是一个复合类,它会根据当前的ClassLoader加载如下的一个HttpMessageConverter:
    • Jaxb2RootElementHttpMessageConverter
    • MappingJackson2HttpMessageConverter
    • GsonHttpMessageConverter
    • MappingJackson2XmlHttpMessageConverter
  • Jaxb2RootElementHttpMessageConverter
    支持application/xml,text/xml,application/+xmlMediaType与被XmlRootElement,XmlType注解的Class。 这是一个使用JAXB2Converter,对于XML格式的响应会比较常用
  • MappingJackson2HttpMessageConverter
    支持application/json,application/+json的可以被JackSon序列化的Class

结论及解决方式

所以在类似调用restTemplate.postForObject(url, strEntity, XXXBean.class);的时候,如果Http响应头的Content-Typeapplication/json,application/+json就使用JackSon来反序列化Http Body为对应的Java类。但是上面我们遇到的问题由于响应头不是这个,并且期望的返回类也不是上面7个HttpMessageConverter所支持的Class中任何一个,自然会报no suitable HttpMessageConverter found 这种错误。而我们发现String, byte[]Class是可以被序列化的,所以改为其中任意一个即可解决该问题。
同时,正常情况下服务器对应响应是应该设置正确Content Type的,上面这个服务器的行为对于用户就很不友好了。

后续

本文没有对RestTemplate对于请求的处理解析。关键逻辑几乎都在RestTemplate的内部类HttpEntityRequestCallbackdoWithRequest方法中,一看就懂。内部逻辑与解析响应雷同。

这里也感慨下程序员的技术道路,现在有很多的公众号或者app提供了很多文章,对很多东西都有了总结性的介绍,但是如果一个开发者仅仅看这些,不深入到书本、深入到代码中去研究,是如同无根浮萍的。 往远了说,大厂为什么青睐招985/211的人,即使有些代码本身写的并不好。 其实也就是一个根基问题,而根基决定了可塑性。