Java开发经验——SpringRestTemplate常见错误

摘要

本文分析了在使用Spring框架的RestTemplate发送表单请求时遇到的常见错误。主要问题在于将表单参数错误地以JSON格式提交,导致服务器无法正确解析参数。文章提供了错误案例的分析,并提出了修正方法。

1. 表单参数类型是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 表单参数。为什么会出现这个错误呢?我们提交的表单最后又成了什么?

1.1. 案例解析

在具体解析这个问题之前,我们先来直观地了解下,当我们使用上述的 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 的方式发送出去的。

1.2. 问题修正

相信你肯定知道怎么去解决这个问题了,其实很简单,把案例中的 HashMap 换成一个 MultiValueMap 类型来存储表单数据即可。修正代码示例如下:

//错误:
//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");

最终你会发现,当完成上述修改后,表单数据最终使用下面的代码进行了编码,参考 FormHttpMessageConverter#write:

public void write(MultiValueMap<String, ?> map, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
      throws IOException, HttpMessageNotWritableException {

   if (isMultipart(map, contentType)) {
      writeMultipart((MultiValueMap<String, Object>) map, contentType, outputMessage);
   }
   else {
      writeForm((MultiValueMap<String, Object>) map, contentType, outputMessage);
   }
}

2. 当 URL 中含有特殊字符

@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 的内容。如何理解这个现象呢?接下来我们可以具体解析下。

2.1. 案例解析

在具体解析之前,我们可以先直观感受下问题出在什么地方。我们使用调试方式去查看解析后的 URL,截图如下:

可以看出,para1 丢掉的 #2 实际是以 Fragment 的方式被记录下来了。这里顺便科普下什么是 Fragment,这得追溯到 URL 的格式定义:

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

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

  1. Query(查询参数)

页面加载请求数据时需要的参数,用 & 符号隔开,每个参数的名和值用 = 符号隔开。

  1. Fragment(锚点)

#开始,字符串,用于指定网络资源中的片断。例如一个网页中有多个名词解释,可使用 Fragment 直接定位到某一名词的解释。例如定位网页滚动的位置,可以参考下面一些使用示例:

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

了解了这些补充知识后,我们其实就能知道问题出在哪了。不过本着严谨的态度,我们还是翻阅下源码。首先,我们先看下 URL 解析的调用栈,示例如下:

参考上述调用栈,解析 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),虽然这并不符合我们的原始预期。

2.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#fromHttpUrl- UriComponentsBuilder#fromUriString

3. 防止URL Encoder

@RestController
public class HelloWorldController {
    @RequestMapping(path = "hi", method = RequestMethod.GET)
    public String hi(@RequestParam("para1") String para1){
        return "helloworld:" + para1;
    };

}

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

estTemplate 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

如何理解这个问题呢?

3.1. 案例解析

要了解这个案例,我们就需要对上述代码中关于 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 次编码后:

3.2. 问题修正

如何修正? 直接上代码:

RestTemplate restTemplate = new RestTemplate();
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl("http://localhost:8080/hi");
builder.queryParam("para1", "开发测试 001");
URI url = builder.encode().build().toUri();
ResponseEntity<String> forEntity = restTemplate.getForEntity(url, String.class);
System.out.println(forEntity.getBody());

其实说白了,这种修正方式就是避免多次转化而发生多次编码。这里不再赘述其内部实现,因为正确的方式并非这次解析的重点,你能意识到这个问题出在哪,我们的目的就达到了。

重新运行测试,结果符合预期:

helloworld:开发测试 001

博文参考

  • 《spring常见错误》

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/922628.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

《生成式 AI》课程 作业6 大语言模型(LLM)的训练微调 Fine Tuning -- part1

资料来自李宏毅老师《生成式 AI》课程&#xff0c;如有侵权请通知下线 Introduction to Generative AI 2024 Spring 该文档主要介绍了国立台湾大学&#xff08;NTU&#xff09;2024 年春季 “生成式人工智能&#xff08;GenAI&#xff09;” 课程的作业 5&#xff08;GenAI HW…

tcpdump抓包 wireShark

TCPdump抓包工具介绍 TCPdump&#xff0c;全称dump the traffic on anetwork&#xff0c;是一个运行在linux平台可以根据使用者需求对网络上传输的数据包进行捕获的抓包工具。 tcpdump可以支持的功能: 1、在Linux平台将网络中传输的数据包全部捕获过来进行分析 2、支持网络层…

利用Hooka开源的多种功能shellcode加载器实现快速免杀火绒,静态360+360杀毒,微步查杀1,vt查杀7(教程)

免责声明: 本文旨在提供有关特定漏洞的深入信息&#xff0c;帮助用户充分了解潜在的安全风险。发布此信息的目的在于提升网络安全意识和推动技术进步&#xff0c;未经授权访问系统、网络或应用程序&#xff0c;可能会导致法律责任或严重后果。因此&#xff0c;作者不对读者基于…

MyBatis中特殊SQL的执行

目录 1.模糊查询 2.批量删除 3.动态设置表名 4.添加功能获取自增的主键 1.模糊查询 List<User> getUserByLike(Param("username") String username); <select id"getUserByLike" resultType"com.atguigu.mybatis.pojo.User">&…

九、FOC原理详解

1、FOC简介 FOC&#xff08;field-oriented control&#xff09;为磁场定向控制&#xff0c;又称为矢量控制&#xff08;vectorcontrol&#xff09;&#xff0c;是目前无刷直流电机&#xff08;BLDC&#xff09;和永磁同步电机&#xff08;PMSM&#xff09;高效控制的最佳选择…

selinux及防火墙

selinux说明 SELinux 是 Security-Enhanced Linux 的缩写&#xff0c;意思是安全强化的 linux 。 SELinux 主要由美国国家安全局&#xff08; NSA &#xff09;开发&#xff0c;当初开发的目的是为了避免资源的误用。 httpd进程标签&#xff08;/usr/share/nginx/html &#…

Flink学习连载第二篇-使用flink编写WordCount(多种情况演示)

使用Flink编写代码&#xff0c;步骤非常固定&#xff0c;大概分为以下几步&#xff0c;只要牢牢抓住步骤&#xff0c;基本轻松拿下&#xff1a; 1. env-准备环境 2. source-加载数据 3. transformation-数据处理转换 4. sink-数据输出 5. execute-执行 DataStream API开发 //n…

解锁PPTist的全新体验:Windows系统环境下本地部署与远程访问

文章目录 前言1. 本地安装PPTist2. PPTist 使用介绍3. 安装Cpolar内网穿透4. 配置公网地址5. 配置固定公网地址 前言 在Windows系统环境中&#xff0c;如何本地部署开源在线演示文稿应用PPTist&#xff0c;并实现远程访问&#xff1f;本文将为您提供详细的部署和配置指南。 P…

《第十部分》1.STM32之通信接口《精讲》之IIC通信---介绍

经过近一周的USART学习&#xff0c;我深刻体会到通信对单片机的重要性。它就像人类的手脚和大脑&#xff0c;只有掌握了通信技术&#xff0c;单片机才能与外界交互&#xff0c;展现出丰富多彩的功能&#xff0c;变得更加强大和实用。 单片机最基础的“语言”是二进制。可惜&am…

URL在线编码解码- 加菲工具

URL在线编码解码 打开网站 加菲工具 选择“URL编码解码” 输入需要编码/解码的内容&#xff0c;点击“编码”/“解码”按钮 编码&#xff1a; 解码&#xff1a; 复制已经编码/解码后的内容。

【TEST】Apache JMeter + Influxdb + Grafana

介绍 使用Jmeter发起测试&#xff0c;测试结果存入Influxdb&#xff0c;Grafana展示你的测试结果。 环境 windows 10docker desktopJDK17 安装 Apache JMeter 访问官网&#xff08;Apache JMeter - Apache JMeter™&#xff09;下载JMeter&#xff08;目前最新版本5.6.3&a…

Linux笔记---进程:进程切换与O(1)调度算法

1. 补充概念 1.1 并行与并发 竞争性&#xff1a;系统进程数目众多&#xff0c;而CPU资源只有少量&#xff0c;甚至只有1个&#xff0c;所以进程之间是具有竞争属性的。为了高效完成任务&#xff0c;更合理竞争相关资源&#xff0c;便具有了优先级。独立性&#xff1a;多进程运…

C语言:深入理解指针

一.内存和地址 我们知道计算机上CPU&#xff08;中央处理器&#xff09;在处理数据的时候&#xff0c;需要的数据是在内存中读取的&#xff0c;处理后的数据也会放回内存中&#xff0c;那我们买电脑的时候&#xff0c;电脑上内存是 8GB/16GB/32GB 等&#xff0c;那这些内存空间…

mybatis学习(一)

声明&#xff1a;该内容来源于动力节点&#xff0c;本人在学习mybatis过程中参考该内容&#xff0c;并自己做了部分笔记&#xff0c;但个人觉得本人做的笔记不如动力节点做的好&#xff0c;故使用动力节点的笔记作为后续mybatis的复习。 一、MyBatis概述 1.1 框架 在文献中看…

【C++】list模拟实现(详解)

本篇来详细说一下list的模拟实现&#xff0c;list的大体框架实现会比较简单&#xff0c;难的是list的iterator的实现。我们模拟实现的是带哨兵位头结点的list。 1.准备工作 为了不和C库里面的list冲突&#xff0c;我们在实现的时候用命名空间隔开。 //list.h #pragma once #…

IT服务团队建设与管理

在 IT 服务团队中&#xff0c;需要明确各种角色。例如系统管理员负责服务器和网络设备的维护与管理&#xff1b;软件工程师专注于软件的开发、测试和维护&#xff1b;运维工程师则保障系统的稳定运行&#xff0c;包括监控、故障排除等。通过清晰地定义每个角色的职责&#xff0…

初学 flutter 问题记录

windows搭建flutter运行环境 一、运行 flutter doctor遇到的问题 Xcmdline-tools component is missingRun path/to/sdkmanager --install "cmdline-tools;latest"See https://developer.android.com/studio/command-line for more details.1&#xff09;cmdline-to…

神经网络(系统性学习二):单层神经网络(感知机)

此前篇章&#xff1a; 神经网络中常用的激活函数 神经网络&#xff08;系统性学习一&#xff09;&#xff1a;入门篇 单层神经网络&#xff08;又叫感知机&#xff09; 单层网络是最简单的全连接神经网络&#xff0c;它仅有输入层和输出层&#xff0c;没有隐藏层。即&#x…

H.265流媒体播放器EasyPlayer.js播放器提示MSE不支持H.265解码可能的原因

随着人工智能和机器学习技术的应用&#xff0c;流媒体播放器将变得更加智能&#xff0c;能够根据用户行为和偏好提供个性化的内容推荐。总体而言&#xff0c;流媒体播放器的未来发展将更加注重技术创新和用户互动&#xff0c;以适应不断变化的市场需求和技术进步。 提示MSE不支…

MySQL原理简介—6.简单的生产优化案例

大纲 1.MySQL日志的顺序写和数据文件的随机读指标 2.Linux存储系统软件层原理及IO调度优化原理 3.数据库服务器使用的RAID存储架构介绍 4.数据库Too many connections故障定位 1.MySQL日志的顺序写和数据文件的随机读指标 (1)磁盘随机读操作 (2)磁盘顺序写操作 (1)磁盘随…