☰
RestTemplate 发送表单 POST 请求:避开 415 与编码坑
2026/10/1 5:49:33 网站建设 项目流程

上周帮同事排一个线上对接的坑,对方开放平台的文档写得清清楚楚:POST 请求,请求体格式 application/x-www-form-urlencoded,参数按 key=value 拼在 body 里。我们这边的代码用 RestTemplate 发出去,对方服务器一直回 415 Unsupported Media Type。抓包看了一眼请求体,是{"appId":"xxx","sign":"yyy"}这样的 JSON 串,Content-Type 写着 application/json。问题就出在这一步——RestTemplate 的默认重心是 JSON,想让它老老实实发一个标准表单,请求体和请求头都得你亲手摆正,它不会替你做这个判断。

这篇东西我想把这个过程完整拆一遍,核心就三个词:RestTemplate、application/x-www-form-urlencoded、post请求。我会讲清楚表单编码和 JSON 到底差在哪儿、RestTemplate 有哪几种写法能把表单发出去、中文和特殊字符怎么处理才不翻车、以及怎么用 curl 和 Postman 反过来验证你的代码没写错。适合刚接触 HTTP 客户端封装的同学,也适合被 415、400 折磨过但没搞明白根因的老手再回头对一遍。全文的代码都是可以直接复制到项目里跑起来的,参数为什么这么给,我也会一条条说清楚。

1. 先弄明白表单编码和 JSON 差在哪,不然改了也是瞎改

1.1 同一个接口,请求体的两种长相

把 HTTP 请求想成寄快递。URL 是收件地址,Header 是面单上写的备注,Body 才是包裹本身。application/x-www-form-urlencoded 这种编码格式,本质上是把一组键值对拍扁成一行字符串,用&连接、用=连接键和值,长得像这样:username=test&password=123456&age=18。它没有层级结构,没有类型信息,所有值都是字符串,嵌套对象要靠items[0].name=book这种方括号命名约定硬凑。

JSON 就完全是另一回事了,{"username":"test","age":18}保留了类型和层级,可读性也好得多。两者最大的差异在于:表单是"查询字符串的变体",它的所有内容都必须能做百分号编码;而 JSON 是一段文本,里面出现&、=、+都是合法的字面量,不需要转义。这就导致一个很实际的问题——如果你的参数值里本身带&,表单格式必须把它编码成%26,否则服务端会把它当成参数分隔符,一个值就被切成两个参数了。JSON 不会有这个烦恼。

1.2 服务端为什么有的只认表单

这不是老古董思维,背后是有原因的。Spring MVC 里@RequestParam这个注解,默认就是从请求参数集合里取值,而请求参数集合是 query string 和表单 body 合并后的结果。也就是说,服务端写@RequestParam String username,它天然期待的就是表单格式,你发 JSON 过来,它压根不看 body,取到的就是 null,然后回你一个 400。反过来,如果你在服务端写的是@RequestBody LoginDTO dto,那它走的是消息转换器,只认 JSON。

还有一些历史原因。像一些开放平台、支付回调、老版本的认证接口,它们的签名算法是把所有参数按 key 排序拼成一个待签串,再算摘要。这套逻辑天生就是围绕键值对设计的,用表单格式最自然。你硬要用 JSON 提交,对方不但要解析 JSON,还得处理"数字要不要加引号"这种破事,签名对不上又要来回扯皮。所以遇到这类接口,老老实实按表单发,别自作聪明改成 JSON。

1.3 什么情况下你该主动选表单

我的判断标准很简单,分三种情况。第一种是对方接口文档明确写了application/x-www-form-urlencoded,没得商量,照做。第二种是参数少、全是扁平字符串、没有嵌套,比如登录、验证码校验、简单的状态上报,用表单比 JSON 更省事,服务端也不用额外定义 DTO 类。第三种是需要容易被浏览器表单直接提交的场景,比如某些管理后台的跳转接口。

反过来,参数有嵌套结构、有数组、有明确类型(布尔、数字、时间),或者接口是你自己设计的,那就用 JSON。我见过有人为了"统一风格",把带三层嵌套的对象硬压成a.b.c=1这种 key,结果两边解析全乱套,维护成本高得离谱。格式选择不是审美问题,是匹配问题。

2. RestTemplate 发表单请求的几种写法,以及各自的坑

2.1 最小可用版本:MultiValueMap 加 HttpHeaders

这是最正统、也是最推荐的一种写法,核心就三行。先准备一个MultiValueMap装参数,注意要用LinkedMultiValueMap而不是普通 HashMap;再准备一个HttpHeaders把 Content-Type 显式设成MediaType.APPLICATION_FORM_URLENCODED;最后把两者包成一个HttpEntity丢给 RestTemplate。

MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); params.add("appId", "10086"); params.add("sign", "a1b2c3d4"); params.add("timestamp", "1717000000"); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers); ResponseEntity<String> response = restTemplate.postForEntity("https://open.example.com/api/token", request, String.class);

为什么必须是MultiValueMap而不是Map?因为表单里同一个 key 可以出现多次,比如ids=1&ids=2&ids=3,这是表单格式的特性。MultiValueMap的 value 类型是List<String>,天生支持这一点。为什么推荐LinkedMultiValueMap?因为它按插入顺序遍历,而签名接口几乎都要求参数顺序和待签串一致,用 HashMap 的话顺序随机,签名必然对不上,这类问题查起来非常折磨。

2.2 postForObject、postForEntity、exchange 挑哪个

RestTemplate 提供了好几个提交方法,新手容易一脸懵。我按实际使用频率给个结论。

方法返回内容适用场景
postForObject直接把响应体反序列化成对象只关心业务数据,不关心状态码和响应头
postForEntityResponseEntity,含状态码、响应头、响应体需要判断状态码、读 Set-Cookie 或某些响应头
exchangeResponseEntity,但可以自定义 HttpMethod 和 HttpEntity需要 PUT/DELETE、需要完全控制请求头、需要泛型响应类型
execute最底层,需要自己写 RequestCallback 和 ResponseExtractor特殊流式场景,日常基本用不上

我个人的习惯是:表单请求一律走exchange。原因是它把 URL、HttpMethod、HttpEntity、响应类型四件事都摆在明面上,出问题的时候一眼能看出是哪里给错了。postForEntity虽然也能用,但它的参数顺序容易记混,尤其是第三个参数uriVariables,很多人会把 HttpEntity 塞到那个位置,结果参数根本没进 body,服务端收到一个空请求,白白排查半天。

ResponseEntity<String> resp = restTemplate.exchange( url, HttpMethod.POST, request, String.class);

注意exchange的第三个参数是 HttpEntity,如果你想传 URL 路径变量,用的是第四个参数之后的可变参数,两者不要搞混。

2.3 不设 Content-Type 会发生什么

这是最容易踩、也最隐蔽的一个坑。如果你只写restTemplate.postForObject(url, params, String.class),把MultiValueMap当成请求体直接传进去,默认情况下请求体会被序列化成 JSON,Content-Type 变成 application/json,服务端直接 415。原因在于 RestTemplate 在挑选消息转换器时,是按顺序往下找第一个"能写"的转换器,而 Jackson 的转换器排在表单转换器前面,MultiValueMap本质上是个Map,Jackson 认为它能写,就接手了。

所以结论很直接:发表单请求,Content-Type 必须显式设置,一个字符都不能省。这不是"建议",是硬性要求。我见过有人用headers.set("Content-Type", "application/x-www-form-urlencoded")这种字符串写法,效果一样,但拼错了不会有编译期报错,只会在运行时莫名其妙地失败,所以我还是推荐用MediaType常量。

2.4 消息转换器在背后做了什么

RestTemplate 处理请求体靠的是HttpMessageConverter列表,表单相关的那个叫FormHttpMessageConverter(Spring 里默认注册的是它的子类AllEncompassingFormHttpMessageConverter)。它的write方法会判断:如果 Content-Type 是 multipart/form-data,走 multipart 分支;如果是 application/x-www-form-urlencoded,走表单分支,遍历MultiValueMap,把每个 key 和 value 做一次百分号编码,再用&和=拼起来,最后按你声明的 charset(没声明就默认 UTF-8)转成字节写进 body。

这里有一个细节值得记住:编码是转换器做的,你不要自己动手。有些同学怕中文乱码,先URLEncoder.encode(name, "UTF-8")再塞进 map,结果被转换器又编了一次,+变成%2B、%变成%25,服务端解出来是一串乱码。同样的问题也会出现在你手工拼 query string 塞进 URL 的场景里,RestTemplate 默认会对 URI 模板做一次编码,你提前编好的内容会被二次编码。记住一句话:交给框架的,永远是原始值。

3. 从零搭一个能跑起来的表单请求

3.1 依赖和 RestTemplate 实例怎么创建

Spring Boot 3.x 环境下,spring-web 是随 spring-boot-starter-web 一起进来的,不用单独引。但 RestTemplate 本身不是自动装配的 Bean(Spring Boot 只提供了RestTemplateBuilder),需要你自己声明。

@Configuration public class HttpConfig { @Bean public RestTemplate restTemplate(RestTemplateBuilder builder) { return builder .setConnectTimeout(Duration.ofSeconds(3)) .setReadTimeout(Duration.ofSeconds(8)) .build(); } }

为什么强烈建议走RestTemplateBuilder而不是new RestTemplate()?因为new出来的实例用的是SimpleClientHttpRequestFactory,底层是 JDK 的HttpURLConnection,默认没有超时时间。这意味着如果对方服务器网络抖动或者卡住不响应,你的业务线程会一直挂在那里,最后把线程池占满,整个服务跟着雪崩。这类故障在线上是真会出现的,而且现象很迷惑——日志里什么都没打,接口就是不动了。

如果你的项目里已经有 Apache HttpClient 5 的依赖,可以换成HttpComponentsClientHttpRequestFactory,它能配连接池、按路由限制并发、复用连接,高并发场景下比 JDK 的实现稳得多。

3.2 一个完整可运行的例子

下面这段是我平时写对接代码的模板,包含参数准备、请求发送、异常处理和响应判断。

@Service public class OpenApiClient { private static final Logger log = LoggerFactory.getLogger(OpenApiClient.class); private final RestTemplate restTemplate; public OpenApiClient(RestTemplate restTemplate) { this.restTemplate = restTemplate; } public String fetchToken(String appId, String appSecret) { String url = "https://open.example.com/oauth/token"; MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); form.add("grant_type", "client_credentials"); form.add("app_id", appId); form.add("app_secret", appSecret); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(form, headers); try { ResponseEntity<String> resp = restTemplate.exchange( url, HttpMethod.POST, entity, String.class); if (!resp.getStatusCode().is2xxSuccessful()) { throw new IllegalStateException("获取令牌失败,状态码=" + resp.getStatusCode()); } return resp.getBody(); } catch (RestClientResponseException e) { log.error("请求被拒绝,status={}, body={}", e.getRawStatusCode(), e.getResponseBodyAsString(), e); throw e; } catch (ResourceAccessException e) { log.error("网络或超时异常,url={}", url, e); throw e; } } }

两个异常要分开捕。RestClientResponseException表示请求发出去了、服务端也响应了,只是状态码是 4xx 或 5xx,这时候getResponseBodyAsString()往往带着对方的错误说明,是最有价值的排查信息。ResourceAccessException表示连接都没建起来或者读超时了,属于网络层问题,处理策略完全不同,一般是重试。把这两个混在一起 catch,日志里就永远看不出到底是哪一类。

3.3 中文、空格、加号的处理规则

这是一个几乎人人都会碰到的问题。假设你要提交name=张三,转换器会用 UTF-8 把"张三"编码成%E5%BC%A0%E4%B8%89,看起来没问题。但服务端如果没配编码过滤器,Spring MVC 默认可能按 ISO-8859-1 去解表单,解出来就是"å¼ ä¸‰"这种鬼东西。所以我的做法是 Content-Type 里把 charset 一起写上,双保险:

headers.setContentType(new MediaType(MediaType.APPLICATION_FORM_URLENCODED, StandardCharsets.UTF_8));

这样请求头会变成application/x-www-form-urlencoded;charset=UTF-8,服务端大多数实现看到 charset 就会用 UTF-8 解码。如果对方服务端确实按 ISO-8859-1 解了,你还能在本地做一次new String(name.getBytes("ISO-8859-1"), "UTF-8")的补救,但这是治标不治本的土办法,能推动对方改还是让对方改。

再说空格和加号。URLEncoder.encode会把空格编成+,而标准的百分号编码规范里空格应该编成%20。大部分服务端对+和%20都能正确还原成空格,但确实有一小部分框架只认%20,收到+就当字面加号处理。如果你自己手工拼串,碰上这种服务端,就会出现"值里明明没加号,对方说签名里多了个 +"的怪事。我踩过一次,最后是靠对比双方的待签原始串才定位到的,从那以后凡是签名接口,我都在本地把编码后的 body 完整打印出来,和对方的日志对齐。

还有一个高频问题:值里本身带+。比如password=p@ss+word。这时候转换器会把+编码成%2B,服务端解回来还是+,这是正确行为。但如果服务端用的是URLDecoder且在解码前又把%2B二次处理了,就会出错。遇到这种,别急着改代码,先确认对方的解码实现。

3.4 数组型参数和嵌套 key 怎么拼

多值参数用add往同一个 key 上追加就行:

form.add("ids", "1001"); form.add("ids", "1002"); form.add("ids", "1003"); // 编码后:ids=1001&ids=1002&ids=1003

有些服务端不接受重复 key,而是要求ids=1001,1002,1003这种逗号分隔形式,那就自己拼字符串,别用add。这两种风格没有谁对谁错,纯粹看对方约定,我一般会先去翻对方文档或者直接问对接人,省得来回试。

嵌套结构靠方括号命名:

form.add("items[0].sku", "A001"); form.add("items[0].num", "2"); form.add("items[1].sku", "B002"); form.add("items[1].num", "1");

这套写法来自 PHP 和 Rails 的传统,很多老系统兼容。但要注意方括号里的内容也会被百分号编码,[会变成%5B、]变成%5D,某些服务端解析时是先把整体解码再按方括号切割,这没问题;但也有服务端是直接对原始串做字符串匹配,就会匹配不上。所以用之前一定跟对方确认一句。

4. 几个真实场景的落地写法

4.1 对接开放平台的签名接口

签名接口是表单格式的重灾区。典型流程是:把所有业务参数按字典序排序,拼接成k1=v1&k2=v2的待签串,末尾追加&key=应用密钥,做 MD5 或 HMAC-SHA256,把结果作为 sign 参数一起提交。这里面有三个细节必须一致。

第一是参数顺序。用LinkedMultiValueMap并按字典序插入,或者直接用TreeMap保证顺序,别用 HashMap。第二是空值参数要不要参与签名。有的平台说"空值不参与",有的说"空值也要拼成 key=",差一个字符签名就全错。第三是编码时机,待签串用的是原始值,不是 URL 编码后的值,这一点最容易搞错——很多人拿编码后的串去算签名,结果怎么都对不上。

public String sign(Map<String, String> params, String secret) { TreeMap<String, String> sorted = new TreeMap<>(params); StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> e : sorted.entrySet()) { if (e.getValue() == null || e.getValue().isEmpty()) { continue; } sb.append(e.getKey()).append('=').append(e.getValue()).append('&'); } sb.append("key=").append(secret); return DigestUtils.md5Hex(sb.toString()).toUpperCase(); }

我建议把这段逻辑单独抽一个方法,并且在里面打一行 DEBUG 日志输出待签串。上线初期这行日志能救命,后期确认稳定了再关掉。

4.2 值里有 &、=、% 这些特殊字符怎么办

标准答案是:什么都不用做,交给转换器编码就行。&会被编成%26、=编成%3D、%编成%25,服务端解码后拿到的还是原始值。真正会出问题的场景只有一个——你手工拼了 body 字符串然后当参数传进去,比如:

// 错误示范 String body = "name=" + name + "&age=" + age;

如果name里带&,body 直接就被污染了。有些老代码为了避免这个问题,会在拼串前先URLEncoder.encode一次,但这时候又要注意别把这个编码后的字符串再当 URI 传进 RestTemplate,否则会二次编码。我的规矩是:只在"手工拼串"这一条路径上做显式编码,用 HttpEntity 的路径一律不编。两条路径不要混着走。

4.3 multipart 和 urlencoded 别搞混

这两个格式都是表单,但用途完全不同。application/x-www-form-urlencoded 只能传文本,所有内容都在一行字符串里;multipart/form-data 是分块传输,每块可以有自己的 Content-Type,用来传文件。有人问"我要上传一个文件顺便带几个参数,能不能用 urlencoded",答案是不能,文件是二进制,urlencoded 会把二进制内容当字符串编码,等于把文件毁了。

multipart 的写法是另一个分支,FormHttpMessageConverter会根据你声明的 Content-Type 走不同逻辑:

MultiValueMap<String, Object> body = new LinkedMultiValueMap<>(); body.add("file", new FileSystemResource("/data/upload.png")); body.add("bizType", "avatar"); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.MULTIPART_FORM_DATA); HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers); restTemplate.postForEntity(uploadUrl, entity, String.class);

注意这里的MultiValueMap泛型是<String, Object>,因为 value 可以是资源对象。如果你照着 urlencoded 的写法写成<String, String>,编译能过(自动装箱成字符串路径),但行为就完全不是你想要的了。

4.4 响应体怎么接

对方返回 JSON 的话,最稳的方式是定义 DTO 让 Jackson 反序列化:

ResponseEntity<LoginResult> resp = restTemplate.exchange(url, HttpMethod.POST, entity, LoginResult.class); LoginResult result = resp.getBody();

但对接第三方的时候,我一般会用String.class先接住,再用 Jackson 手动解析,或者干脆只做字符串匹配。原因有两个:一是第三方接口的字段类型经常变,今天code是数字,明天变成字符串,用 DTO 接会直接抛反序列化异常;二是出问题的时候,String形态的原始响应体可以直接打日志,一眼能看清对方到底回了什么,而 DTO 反序列化失败后你连原始内容都拿不到。等接口稳定了再考虑改成 DTO,前期不要给自己找麻烦。

如果对方返回的是text/plain但内容是 JSON,RestTemplate 可能会因为 Content-Type 不匹配报转换异常,这时候可以强制指定转换器,或者在StringHttpMessageConverter上加setSupportedMediaTypes。

5. 自测和验证:curl 和 Postman 怎么用

5.1 用 curl 发一个标准表单请求

curl 是验证表单请求最快的工具,因为它不掺和任何框架逻辑,你给它什么它就发什么。

curl -v -X POST 'http://127.0.0.1:8080/api/login' \ -H 'Content-Type: application/x-www-form-urlencoded;charset=UTF-8' \ --data-urlencode 'username=张三' \ --data-urlencode 'password=p@ss word'

几个参数值得说清楚。-v会打印完整的请求头和响应头,你能直接看到 Content-Type 到底写成什么样了,排查 415 的时候特别有用。--data-urlencode会自动帮你做百分号编码,值和 key 都支持key=value形式;而-d或--data是原样发送,不做编码。很多人用-d传中文,结果服务端解出乱码,还以为是自己代码的问题,其实是他 curl 命令写错了。另外,用-d时 curl 会自动补上Content-Type: application/x-www-form-urlencoded,这也是为什么-d是发表单的默认手段。

如果想构造一个"错误请求"来复现对方的报错,可以用-d直接发 JSON:

curl -X POST 'http://127.0.0.1:8080/api/login' \ -H 'Content-Type: application/json' \ -d '{"username":"test","password":"123456"}'

这时候服务端如果只收表单,就会返回 415,正好复现了前面说的那个场景。这种"故意发错"的手段在定位问题时很有用,能帮你确认到底是格式问题还是其他问题。

5.2 Postman 里怎么配这个格式

Postman 的操作路径是 Body 标签页里选x-www-form-urlencoded,然后像填表格一样一行行填 key 和 value。这里有个细节:选了之后 Postman 会自动帮你加 Content-Type 头,你不要再手动在 Headers 里加一条,否则会出现两个 Content-Type,某些服务端会取第一条或者直接报错。如果你确实需要带上 charset,正确的做法是在 Headers 里手动指定一条完整的application/x-www-form-urlencoded;charset=UTF-8,同时把 Body 里自动生成的头部关掉。

Postman 的另一个用处是看它自动生成的代码。点右侧的 Code 按钮,可以生成 curl、Java、Python 等各种语言的请求代码,和你自己写的 RestTemplate 代码对比一下,一眼就能看出差异在哪。我经常用这招帮同事找"为什么我的代码发出去的请求和 Postman 不一样"这类问题,比来回猜快得多。

5.3 服务端打日志反推

最直接的验证方式还是服务端留痕。如果你有条件改服务端代码,写一个最小的测试 Controller:

@PostMapping(value = "/api/login", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) public Map<String, Object> login(@RequestParam String username, @RequestParam String password) { log.info("收到表单请求 username={}, password={}", username, password); Map<String, Object> result = new HashMap<>(); result.put("ok", true); return result; }

注意consumes属性,它会让这个接口只接受表单格式,发 JSON 过来直接 415,非常适合验证。@RequestParam会从 query string 和表单 body 里取值,两者都能命中。如果你的接口需要接收任意键值对,可以用@RequestParam Map<String, String> allParams,把所有参数一次性接住,排查阶段特别方便。

如果连服务端都改不了,那就抓包。开发机上常用的是 Wireshark 或者 tcpdump,最简单的是在本地起一个反向代理把请求转存下来。不过说实话,绝大多数问题在客户端打印一行"编码后的 body"就能解决,我会在发送前加这么一段:

if (log.isDebugEnabled()) { log.debug("form body = {}", UriComponentsBuilder.newInstance() .query(form.toSingleValueMap()).build().getQuery()); }

虽然这行打印的内容和实际发送的字节可能有细微差别,但用来确认参数有没有进去、值长什么样,足够了。

6. 常见故障速查与排查思路

6.1 一张表对完所有高频报错

现象大概率原因怎么快速确认处理方式
415 Unsupported Media Type请求体被序列化成 JSON,Content-Type 是 application/jsoncurl -v 或抓包看 Content-Type 和 body显式 setContentType(APPLICATION_FORM_URLENCODED)
400 参数全部为 nullHttpEntity 没放进 exchange 的第三个参数服务端打印 request.getParameterMap()检查方法参数位置,别把 entity 塞到 uriVariables
中文乱码Content-Type 缺 charset,服务端按 ISO-8859-1 解对比 curl 的编码字节显式带 charset=UTF-8,或推动服务端加编码过滤器
签名校验失败参数顺序、空值处理或编码时机不一致双方打印待签原始串对比LinkedMultiValueMap 或 TreeMap 保序,签名用原始值
值里多出+或%手工编码后又交给框架编了一次打印最终 body只在一处编码,框架路径传原始值
302 跳转到登录页网关或鉴权层重定向,POST 被降级成 GET打印 response.getStatusCode() 和 Location 头修正鉴权参数,或手动处理重定向
偶发卡死不返回没设超时,线程被挂住jstack 看线程栈停在 socketRead配 connectTimeout 和 readTimeout
日志里 body 是一串乱码值里有不可见字符或编码不匹配输出 byte 数组的十六进制统一 UTF-8,检查数据来源

6.2 超时、连接池和重试

超时这件事我已经强调过一次,这里再说细一点。连接超时(connectTimeout)管的是"TCP 握手多久没成功就放弃",一般给 1 到 3 秒;读取超时(readTimeout)管的是"连上了但多久没收到数据就放弃",这个要按对方的响应速度来给,一般 3 到 10 秒。别给 30 秒这种大数,一旦对方变慢,你的线程全堵在那里,等于把自己的服务也拖垮了。

用 JDK 自带的HttpURLConnection实现时,每个请求基本是独立的连接,高并发下开销不小。如果 QPS 上来了,换成HttpComponentsClientHttpRequestFactory并配连接池:

PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); cm.setMaxTotal(200); cm.setDefaultMaxPerRoute(50); RequestConfig config = RequestConfig.custom() .setConnectTimeout(3000) .setConnectionRequestTimeout(1000) .setSocketTimeout(8000) .build(); CloseableHttpClient httpClient = HttpClients.custom() .setConnectionManager(cm) .setDefaultRequestConfig(config) .build(); HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient);

maxPerRoute这个参数很关键。默认值是 2,意味着对同一个域名最多只有 2 个并发连接,你开 50 个线程去请求,48 个在排队,表现出来就是"响应时间莫名其妙变长了"。这个坑我踩过一次,压测的时候 QPS 怎么都上不去,最后发现就是 maxPerRoute 卡住了。

重试要谨慎。表单提交大多数是有副作用的写操作,比如下单、扣款,无脑重试会造成重复提交。如果确实要重试,只对连接阶段的异常重试(ResourceAccessException里的连接超时),而且必须保证对方接口有幂等设计,比如带上唯一的 requestId。读超时的重试尤其危险,因为请求可能已经到达对方并执行了,只是响应回不来。

6.3 几个容易忽略的细节

第一个细节,HttpHeaders不是线程安全的,别把它做成静态常量然后多个请求共用、每次往里 set 东西,并发下会出诡异问题。每次请求都new HttpHeaders(),开销可以忽略,安全第一。

第二个细节,RestTemplate 的exchange传 URL 时,如果 URL 里带了 query string,且值里有特殊字符,RestTemplate 会按照UriTemplateHandler的编码模式处理。默认是TEMPLATE_AND_VALUES,也就是模板变量和 URI 都会编码。如果你的 URL 里已经有%开头的编码串,会被编成%25。遇到这种情况,要么把参数从 URL 里挪到表单 body,要么把编码模式改成NONE。

第三个细节,注意FormHttpMessageConverter从请求体读取时的行为。如果你用@RequestBody MultiValueMap<String, String> form接表单,它会把内容按&切分成键值对。但如果值里本身有未编码的&,就会切错。所以服务端和客户端一定要约定好编码规则,不能一边编一边不编。

第四个细节,判断响应是否成功不要只看HttpStatus.OK。有些接口返回 200 但 body 里code是失败,有些返回 201 也算成功。用resp.getStatusCode().is2xxSuccessful()比硬编码 200 稳。

7. 生产环境里我更推荐的写法

7.1 把表单请求封装成工具方法

项目里如果有多处表单调用,重复写 MultiValueMap、HttpHeaders、HttpEntity 这套必然出错。我的做法是抽一个方法:

public <T> ResponseEntity<T> postForm(String url, Map<String, ?> params, Class<T> respType) { MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); params.forEach((k, v) -> { if (v == null) { return; } if (v instanceof Collection<?> c) { c.forEach(item -> form.add(k, String.valueOf(item))); } else { form.add(k, String.valueOf(v)); } }); HttpHeaders headers = new HttpHeaders(); headers.setContentType(new MediaType(MediaType.APPLICATION_FORM_URLENCODED, StandardCharsets.UTF_8)); HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(form, headers); return restTemplate.exchange(url, HttpMethod.POST, entity, respType); }

这个方法里有两个设计点值得说。一是 Collection 类型自动展开成多个同名参数,这样上层传List<String>也不用关心底层格式。二是 null 值直接跳过,因为绝大多数签名接口都不接受key=null这种串。如果你的接口确实需要传空值,加个参数开关控制。

7.2 参数和日志怎么留

对接外部接口,我习惯把三个东西写进日志:请求 URL、编码后的完整 body、响应状态码和响应体。而且日志级别分开,请求参数用 DEBUG(里面有敏感信息,不能长期打 INFO),响应状态码用 INFO,异常用 ERROR 并把响应体带上。这样线上出问题的时候,日志里能看到对方到底回了什么,不用反复加日志重新发版。

敏感字段要脱敏。appSecret、密码、令牌这些不能进日志,我一般会在拼接前把值替换成***,或者只打印长度和前几位。这个习惯最好一开始就养成,后面补很麻烦。

7.3 什么时候该换掉 RestTemplate

RestTemplate 从 Spring 5 开始就已经处于维护模式了,官方推荐新项目用 WebClient(响应式,也能阻塞式使用)或者 Spring 6.1 引入的 RestClient。如果你的项目技术栈允许,新写的代码我真心建议直接用 RestClient,它的 API 更接近现代风格:

String result = restClient.post() .uri(url) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .body(form) .retrieve() .body(String.class);

但如果是维护老项目,或者团队里大家都熟悉 RestTemplate,没必要为了"技术新"去做迁移,风险大于收益。RestTemplate 的坑我们都摸清楚了,稳定可控。真正需要警惕的是那些还在用HttpURLConnection手工拼串的代码,那才是维护灾难。

最后分享一个我在实际对接里总结出来的习惯:每次对接一个新的表单接口,先用 curl 把请求跑通,再把 curl 命令翻译成 Java 代码。这样做的好处是,你一开始就确认了"服务端本身是好的、格式是对的",后面 Java 代码报错的时候,问题范围就缩小到"我的代码和 curl 有什么差异",排查效率能提高好几倍。反过来先写 Java 再调,一旦失败你连是网络、格式还是参数的问题都分不清,只能在黑箱里瞎试。这个顺序看着不起眼,但省下来的时间是真的多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询