Day620.SpringRestTemplate常见错误 -Spring编程常见错误

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day620.SpringRestTemplate常见错误 -Spring编程常见错误相关的知识,希望对你有一定的参考价值。

SpringRestTemplate常见错误

微服务之间的通信大多都是使用 HTTP 方式进行的,这自然少不了使用 HttpClient。

在不使用 Spring 之前,我们一般都是直接使用 Apache HttpClientOk 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 "helloworld:" + para1 + "," + para2;
    ;

这里我们想完成的功能是接受一个 Form 表单请求,读取表单定义的两个参数 para1 和 para2,然后作为响应返回给客户端。

定义完这个接口后,我们使用 RestTemplate 来发送一个这样的表单请求,代码示例如下:

RestTemplate template = new RestTemplate();
Map<String, Object> paramMap = new HashMap<String, Object>();
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) throws 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<Object> genericConverter =
                  (GenericHttpMessageConverter<Object>) messageConverter;
            if (genericConverter.canWrite(requestBodyType, requestBodyClass, requestContentType)) 
               if (!requestHeaders.isEmpty()) 
                  requestHeaders.forEach((key, values) -> httpHeaders.put(key, new LinkedList<>(values)));
               
               logBody(requestBody, requestContentType, genericConverter);
               genericConverter.write(requestBody, requestBodyType, requestContentType, httpRequest);
               return;
            
         
         else if (messageConverter.canWrite(requestBodyClass, requestContentType)) 
            if (!requestHeaders.isEmpty()) 
               requestHeaders.forEach((key, values) -> httpHeaders.put(key, new LinkedList<>(values)));
            
            logBody(requestBody, requestContentType, messageConverter);
            ((HttpMessageConverter<Object>) messageConverter).write(
                  requestBody, requestContentType, httpRequest);
            return;
         
      
      String message = "No HttpMessageConverter for " + requestBodyClass.getName();
      if (requestContentType != null) 
         message += " and content type \\"" + requestContentType + "\\"";
      
      throw new RestClientException(message);
   

上述代码看起来比较复杂,实际上功能很简单:

根据当前要提交的 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 的方式发送出去的。


解决方案

//错误:
//Map<String, Object> paramMap = new HashMap<String, Object>();
//paramMap.put("para1", "001");
//paramMap.put("para2", "002");

//修正代码:
MultiValueMap<String, Object> paramMap = new LinkedMultiValueMap<String, Object>();
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 "helloworld:" + para1;
    ;


不需要我多介绍,你大体应该知道我们想实现的功能是什么了吧,无非就是提供一个带“参数”的 HTTP 接口而已。

然后我们使用下面的 RestTemplate 相关代码来测试一下:

String url = "http://localhost:8080/hi?para1=1#2";
HttpEntity<?> entity = new HttpEntity<>(null);

RestTemplate restTemplate = new RestTemplate();
HttpEntity<String> response = restTemplate.exchange(url, HttpMethod.GET,entity,String.class);

System.out.println(response.getBody());

当你看到这段测试代码,你觉得会输出什么呢?相信你很可能觉得是:

helloworld:1#2

但是实际上,事与愿违,结果是:

helloworld:1

即服务器并不认为 #2 是 para1 的内容。如何理解这个现象呢?接下来我们可以具体解析下。


类似案例 1 解析的套路,在具体解析之前,我们可以先直观感受下问题出在什么地方。

我们使用调试方式去查看解析后的 URL,截图如下:

可以看出,para1 丢掉的 #2 实际是以 Fragment 的方式被记录下来了。

这里顺便科普下什么是 Fragment,这得追溯到 URL 的格式定义:

protocol://hostname[:port]/path/[?query]#fragment

本案例中涉及到的两个关键元素解释如下:

http://example.com/data.csv#row=4 – Selects the 4th row.
http://example.com/data.csv#col=2 – Selects 2nd column.

参考上述调用栈,解析 URL 的关键点在于 UriComponentsBuilder#fromUriString 实现:

private static final Pattern URI_PATTERN = Pattern.compile(
      "^(" + 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 = new 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 
      throw new 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 = new HttpEntity<>(null);

RestTemplate restTemplate = new RestTemplate();
HttpEntity<String> response = restTemplate.exchange(uri, HttpMethod.GET,entity,String.class);

System.out.println(response.getBody());

最终测试结果符合预期:

helloworld:1#2

与之前的案例代码进行比较,你会发现 URL 的组装方式发生了改变。但最终可以获取到我们预期的效果,调试视图参考如下:

可以看出,参数 para1 对应的值变成了我们期待的"1#2"。

如果你想了解更多的话,还可以参考 UriComponentsBuilder#fromHttpUrl,并与之前使用的 UriComponentsBuilder#fromUriString 进行比较:

private static final Pattern HTTP_URL_PATTERN = Pattern.compile(
      "^" + 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 = new UriComponentsBuilder();
      String scheme = matcher.group(1);
      builder.scheme(scheme != null ? scheme.toLowerCase() : null);
      builder.userInfo(matcher.group(4));
      String host = matcher.group(5);
      if (StringUtils.hasLength(scheme) && !StringUtils.hasLength(host)) 
         throw new 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 
      throw new 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 "helloworld:" + para1;
    ;


然后我们可以换一种使用方式来访问这个接口,示例如下:

RestTemplate restTemplate = new RestTemplate();

UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl("http://localhost:8080/hi");
builder.queryParam("para1", "开发测试 001");
String url = builder.toUriString();

ResponseEntity<String> forEntity = restTemplate.getForEntity(url, String.class);
System.out.println(forEntity.getBody());

我们期待的结果是"helloworld: 开发测试 001",但是运行上述代码后,你会发现结果却是下面这样:

helloworld:%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);

我们会发现,它会再进行一次编码:
以上是关于Day620.SpringRestTemplate常见错误 -Spring编程常见错误的主要内容,如果未能解决你的问题,请参考以下文章

自学it18大数据笔记-第一阶段Java-day16-day17-day18-day19--day20-day21-day22——会持续更新

伸手党福利文,Python入门大全

目录大纲

自学it18大数据笔记-第一阶段Java-day05-day06-day07-day08

day56(2023.4.25)

Alpha阶段 - 博客链接合集