Day620.SpringRestTemplate常见错误 -Spring编程常见错误
SpringRestTemplate常见错误
微服务之间的通信大多都是使用 HTTP 方式进行的,这自然少不了使用 HttpClient。
在不使用 Spring 之前,我们一般都是直接使用 Apache HttpClient 和 Ok HttpClient 等,而一旦你引入 Spring,你就有了一个更好的选择,这就是我们这一讲的主角 RestTemplate。
那么在使用它的过程中,会遇到哪些错误呢?
一、参数类型是 MultiValueMap
@RestController public class HelloWorldController { @RequestMapping(path = "hi", method = RequestMethod.POST) public String hi(@RequestParam("para1") String para1, @RequestParam("para2") String para2){ return "helloorld:" + para1 + "," + para2; }; }
这里我们想完成的功能是接受一个 Form 表单请求,读取表单定义的两个参数 para1 和 para2,然后作为响应返回给客户端。
定义完这个接口后,我们使用 RestTemplate 来发送一个这样的表单请求,代码示例如下
RestTemplate template = ne RestTemplate(); MapparamMap = ne HashMap (); paramMap.put("para1", "001"); paramMap.put("para2", "002"); String url = "http://localhost:8080/hi"; String result = template.postForObject(url, paramMap, String.class); System.out.println(result);
上述代码定义了一个 Map,包含了 2 个表单参数,然后使用 RestTemplate 的 postForObject 提交这个表单。
测试后你会发现事与愿违,返回提示 400 错误,即请求出错
具体而言,就是缺少 para1 表单参数。为什么会出现这个错误呢?我们提交的表单又成了什么?
在具体解析这个问题之前,我们先来直观地了解下,当我们使用上述的 RestTemplate 提交表单,的提交请求长什么样?
这里我使用 Wireshark 抓包工具直接给你抓取出来
从上图可以看出,我们实际上是将定义的表单数据以 JSON 请求体(Body)的形式提交过去了,所以我们的接口处理自然取不到任何表单参数。
那么为什么会以 JSON 请求体来提交数据呢?
这里我们不妨扫一眼 RestTemplate 中执行上述代码时的关键几处代码调用。
,我们看下上述代码的调用栈
确实可以验证,我们最终使用的是 Jackson 工具来对表单进行了序列化。
使用到 JSON 的关键之处在于其中的关键调用 RestTemplate.HttpEntityRequestCallback#doWithRequest
public void doWithRequest(ClientHttpRequest httpRequest) thros IOException { super.doWithRequest(httpRequest); Object requestBody = this.requestEntity.getBody(); if (requestBody == null) { //省略其他非关键代码 } else { Class> requestBodyClass = requestBody.getClass(); Type requestBodyType = (this.requestEntity instanceof RequestEntity ? ((RequestEntity>)this.requestEntity).getType() : requestBodyClass); HttpHeaders httpHeaders = httpRequest.getHeaders(); HttpHeaders requestHeaders = this.requestEntity.getHeaders(); MediaType requestContentType = requestHeaders.getContentType(); for (HttpMessageConverter> messageConverter : getMessageConverters()) { if (messageConverter instanceof GenericHttpMessageConverter) { GenericHttpMessageConverter
上述代码看起来比较复杂,实际上功能很简单
根据当前要提交的 Body 内容,遍历当前支持的所有编解码器,如果找到合适的编解码器,就使用它来完成 Body 的转化。
这里我们不妨看下 JSON 的编解码器对是否合适的判断,参考 AbstractJackson2HttpMessageConverter#canWrite
可以看出,当我们使用的 Body 是一个 HashMap 时,是可以完成 JSON 序列化的。
所以在后续将这个表单序列化为请求 Body 也就不奇怪了。
这里你可能会有一个疑问,为什么适应表单处理的编解码器不行呢?
这里我们不妨继续看下对应的编解码器判断是否支持的实现,即 FormHttpMessageConverter#canWrite
public boolean canWrite(Class> clazz, @Nullable MediaType mediaType) { if (!MultiValueMap.class.isAssignableFrom(clazz)) { return false; } if (mediaType == null || MediaType.ALL.equals(mediaType)) { return true; } for (MediaType supportedMediaType : getSupportedMediaTypes()) { if (supportedMediaType.isCompatibleWith(mediaType)) { return true; } } return false; }
从上述代码可以看出,实际上,只有当我们发送的 Body 是 MultiValueMap 才能使用表单来提交。
学到这里,你可能会豁然开朗。
原来使用 RestTemplate 提交表单必须是 MultiValueMap,而我们案例定义的就是普通的 HashMap,最终是按请求 Body 的方式发送出去的。
解决方案
//错误 //MapparamMap = ne HashMap (); //paramMap.put("para1", "001"); //paramMap.put("para2", "002"); //修正代码 MultiValueMap paramMap = ne LinkedMultiValueMap (); paramMap.add("para1", "001"); paramMap.add("para2", "002");
二、当 URL 中含有特殊字符
接下来,我们再来看一个关于 RestTemplate 使用的问题。我们还是使用之前类型的接口定义,不过稍微简化一下,代码示例如下
@RestController public class HelloWorldController { @RequestMapping(path = "hi", method = RequestMethod.GET) public String hi(@RequestParam("para1") String para1){ return "helloorld:" + para1; }; }
不需要我多介绍,你大体应该知道我们想实现的功能是什么了吧,无非就是提供一个带“参数”的 HTTP 接口而已。
然后我们使用下面的 RestTemplate 相关代码来测试一下
String url = "http://localhost:8080/hi?para1=1#2"; HttpEntity> entity = ne HttpEntity<>(null); RestTemplate restTemplate = ne RestTemplate(); HttpEntityresponse = restTemplate.exchange(url, HttpMethod.GET,entity,String.class); System.out.println(response.getBody());
当你看到这段测试代码,你觉得会输出什么呢?相信你很可能觉得是
helloorld:1#2
实际上,事与愿违,结果是
helloorld:1
即服务器并不认为 #2 是 para1 的内容。如何理解这个现象呢?接下来我们可以具体解析下。
类似案例 1 解析的套路,在具体解析之前,我们可以先直观感受下问题出在什么地方。
我们使用调试方式去查看解析后的 URL,截图如下
可以看出,para1 丢掉的 #2 实际是以 Fragment 的方式被记录下来了。
这里顺便科普下什么是 Fragment,这得追溯到 URL 的格式定义
protocol://hostname[:port]/path/[?query]#fragment
本案例中涉及到的两个关键元素解释如下
http://example./data.csv#ro=4 – Selects the 4th ro.
http://example./data.csv#col=2 – Selects 2nd column.
参考上述调用栈,解析 URL 的关键点在于 UriComponentsBuilder#fromUriString 实现
private static final Pattern URI_PATTERN = Pattern.pile( "^(" + SCHEME_PATTERN + ")?" + "(//(" + USERINFO_PATTERN + "@)?" + HOST_PATTERN + "(:" + PORT_PATTERN + ")?" + ")?" + PATH_PATTERN + "(\?" + QUERY_PATTERN + ")?" + "(#" + LAST_PATTERN + ")?"); public static UriComponentsBuilder fromUriString(String uri) { Matcher matcher = URI_PATTERN.matcher(uri); if (matcher.matches()) { UriComponentsBuilder builder = ne UriComponentsBuilder(); String scheme = matcher.group(2); String userInfo = matcher.group(5); String host = matcher.group(6); String port = matcher.group(8); String path = matcher.group(9); String query = matcher.group(11); String fragment = matcher.group(13); //省略非关键代码 else { builder.userInfo(userInfo); builder.host(host); if (StringUtils.hasLength(port)) { builder.port(port); } builder.path(path); builder.query(query); } if (StringUtils.hasText(fragment)) { builder.fragment(fragment); } return builder; } else { thro ne IllegalArgumentException("[" + uri + "] is not a valid URI"); } }
从上述代码实现中,我们可以看到关键的几句,这里我摘取了出来
String query = matcher.group(11); String fragment = matcher.group(13);
很明显,Query 和 Fragment 都有所处理。
最终它们根据 URI_PATTERN 各自找到了相应的值 (1 和 2),虽然这并不符合我们的原始预期。
那么怎么解决这个问题呢? 如果你不了解 RestTemplate 提供的各种 URL 组装方法,那你肯定是有点绝望的。
这里我给出了代码修正方法,你可以先看看
String url = "http://localhost:8080/hi?para1=1#2"; UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url); URI uri = builder.build().encode().toUri(); HttpEntity> entity = ne HttpEntity<>(null); RestTemplate restTemplate = ne RestTemplate(); HttpEntityresponse = restTemplate.exchange(uri, HttpMethod.GET,entity,String.class); System.out.println(response.getBody());
最终测试结果符合预期
helloorld:1#2
与之前的案例代码进行比较,你会发现 URL 的组装方式发生了改变。但最终可以获取到我们预期的效果,调试视图参考如下
可以看出,参数 para1 对应的值变成了我们期待的"1#2"。
如果你想了解更多的话,还可以参考 UriComponentsBuilder#fromHttpUrl,并与之前使用的 UriComponentsBuilder#fromUriString 进行比较
private static final Pattern HTTP_URL_PATTERN = Pattern.pile( "^" + HTTP_PATTERN + "(//(" + USERINFO_PATTERN + "@)?" + HOST_PATTERN + "(:" + PORT_PATTERN + ")?" + ")?" + PATH_PATTERN + "(\?" + LAST_PATTERN + ")?") public static UriComponentsBuilder fromHttpUrl(String httpUrl) { Assert.notNull(httpUrl, "HTTP URL must not be null"); Matcher matcher = HTTP_URL_PATTERN.matcher(httpUrl); if (matcher.matches()) { UriComponentsBuilder builder = ne UriComponentsBuilder(); String scheme = matcher.group(1); builder.scheme(scheme != null ? scheme.toLoerCase() : null); builder.userInfo(matcher.group(4)); String host = matcher.group(5); if (StringUtils.hasLength(scheme) && !StringUtils.hasLength(host)) { thro ne IllegalArgumentException("[" + httpUrl + "] is not a valid HTTP URL"); } builder.host(host); String port = matcher.group(7); if (StringUtils.hasLength(port)) { builder.port(port); } builder.path(matcher.group(8)); builder.query(matcher.group(10)); return builder; } else { thro ne IllegalArgumentException("[" + httpUrl + "] is not a valid HTTP URL"); } }
可以看出,这里只解析了 Query 并没有去尝试解析 Fragment,所以最终获取到的结果符合预期。通过这个例子我们可以知道,当 URL 中含有特殊字符时,一定要注意 URL 的组装方式,尤其是要区别下面这两种方式
UriComponentsBuilder#fromHttp
UrlUriComponentsBuilder#fromUriString
三、小心多次 URL Encoder
@RestController public class HelloWorldController { @RequestMapping(path = "hi", method = RequestMethod.GET) public String hi(@RequestParam("para1") String para1){ return "helloorld:" + para1; }; }
然后我们可以换一种使用方式来访问这个接口,示例如下
RestTemplate restTemplate = ne RestTemplate(); UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl("http://localhost:8080/hi"); builder.queryParam("para1", "开发测试 001"); String url = builder.toUriString(); ResponseEntityforEntity = restTemplate.getForEntity(url, String.class); System.out.println(forEntity.getBody());
我们期待的结果是"helloorld: 开发测试 001",运行上述代码后,你会发现结果却是下面这样
helloorld:%E5%BC%80%E5%8F%91%E6%B5%8B%E8%AF%95001
那为什么呢???
要了解这个案例,我们就需要对上述代码中关于 URL 的处理有个简单的了解。我们看下案例中的代码调用
String url = builder.toUriString();
它执行的方式是 UriComponentsBuilder#toUriString
public String toUriString() { return this.uriVariables.isEmpty() ? build().encode().toUriString() : buildInternal(EncodingHint.ENCODE_TEMPLATE).toUriString(); }
可以看出,它最终执行了 URL Encode
public final UriComponents encode() { return encode(StandardCharsets.UTF_8); }
查询调用栈,结果如下
而当我们把 URL 转化成 String,再通过下面的语句来发送请求时
//url 是一个 string
restTemplate.getForEntity(url, String.class);
我们会发现,它会再进行一次编码
看到这里,你或许已经明白问题出在哪了,即我们按照案例的代码会执行 2 次编码(Encode),所以最终我们反而获取不到想要的结果了。
,我们还可以分别查看下两次编码后的结果,示例如下
1 次编码后
2 次编码后
解决方案
RestTemplate restTemplate = ne RestTemplate(); UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl("http://localhost:8080/hi"); builder.queryParam("para1", "开发测试 001"); URI url = builder.encode().build().toUri(); ResponseEntityforEntity = restTemplate.getForEntity(url, String.class); System.out.println(forEntity.getBody());
其实说白了,这种修正方式就是避免多次转化而发生多次编码。
四、
- 当使用 RestTemplate 组装表单数据时,我们应该注意要使用 MultiValueMap 而非普通的 HashMap。否则会以 JSON 请求体的形式发送请求而非表单,正确示例如下
MultiValueMap
paramMap = ne LinkedMultiValueMap (); paramMap.add("para1", "001"); paramMap.add("para2", "002"); String url = "http://localhost:8080/hi"; String result = template.postForObject(url, paramMap, String.class); System.out.println(result) - 当使用 RestTemplate 发送请求时,如果带有查询(Query)参数,我们一定要注意是否含有一些特殊字符(#)。如果有的话,可以使用下面的 URL 组装方式进行规避
String url = "http://localhost:8080/hi?para1=1#2"; UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url); URI uri = builder.build().encode().toUri();
- 在 RestTemplate 中使用 URL,我们一定要避免多次转化而导致的多次编码问题。
空调维修
- 海信电视维修站 海信电视维修站点
- 格兰仕空调售后电话 格兰仕空调维修售后服务电
- 家电售后服务 家电售后服务流程
- 华扬太阳能维修 华扬太阳能维修收费标准表
- 三菱电机空调维修 三菱电机空调维修费用高吗
- 美的燃气灶维修 美的燃气灶维修收费标准明细
- 科龙空调售后服务 科龙空调售后服务网点
- 华帝热水器维修 华帝热水器维修常见故障
- 康泉热水器维修 康泉热水器维修故障
- 华凌冰箱维修电话 华凌冰箱维修点电话
- 海尔维修站 海尔维修站点地址在哪里
- 北京海信空调维修 北京海信空调售后服务
- 科龙空调维修 科龙空调维修故障
- 皇明太阳能售后 皇明太阳能售后维修点
- 海信冰箱售后服务 海信冰箱售后服务热线电话
- 海尔热水器服务热线