RestTemplate深度实战:企业级HTTP调用的可控性与安全性

发布时间:2026/8/26 9:34:03
RestTemplate深度实战:企业级HTTP调用的可控性与安全性 1. 为什么还在用 RestTemplate它没死只是被低估了Spring Boot 项目里发个 HTTP 请求现在满屏都是 WebClient、Feign、OpenFeign 的教程连面试官都开始问“RestTemplate 和 WebClient 有什么区别”。但现实是——我去年接手的 7 个存量 Spring Boot 2.7.x 生产系统6 个还在跑 RestTemplate上个月帮金融客户做接口迁移审计他们核心账务系统的对外调用层RestTemplate 占比超 83%。不是技术落后而是它足够稳、够轻、够可控。RestTemplate 不是过时工具它是被过度简化教学“教废了”的老兵大家只记得restTemplate.getForObject()却忘了它内置的HttpMessageConverter链能精准控制 JSON/XML/表单/二进制的序列化反序列化忘了ClientHttpRequestInterceptor能在请求发出前动态加签名、改 Header、埋链路 ID更忘了它的ResponseErrorHandler是唯一能让你在异常发生前就拿到原始响应体含错误码、错误描述、traceId的拦截点。它不支持响应式但正因如此它没有线程切换开销、没有背压复杂度、没有 Mono/Flux 的调试陷阱——对大多数传统企业级同步调用场景这反而是优势。本文不讲“替代方案”只讲怎么把 RestTemplate 用到极致从 POST 请求里中文乱码的根因排查不是加.charset(GBK)就完事到 PDF/XSS 防护中 Content-Type 的精确匹配逻辑从飞鼠/鼠鼠格式转换这类小众需求里如何复用ByteArrayHttpMessageConverter到 Spring MVC 拦截器与 RestTemplate 拦截器的职责边界划分。如果你的项目还在用 Spring Boot 2.x或者需要对接老银行/政务系统它们返回的 XML 常带 GBK 编码声明、PDF 附件常含恶意 JS 脚本这篇就是为你写的实战手册。2. 核心设计思路RestTemplate 不是“发请求的工具”而是“HTTP 协议栈的可编程胶水”2.1 为什么不用 WebClient三类场景下的理性选择很多人一上来就质疑“Spring 官方都推荐 WebClient 了你还写 RestTemplate” 这问题本身就有陷阱——把技术选型当宗教站队。实际决策必须看场景场景一强一致性事务调用比如支付回调确认、库存扣减后同步通知物流。这类调用要求① 必须阻塞等待结果② 异常必须立即抛出供上层 try-catch 处理③ 日志需严格按时间顺序落盘。WebClient 的 Mono 链式调用会让异常堆栈变成Mono.onErrorResume的嵌套地狱而 RestTemplate 的HttpClientErrorException直接包含response.getBody()和response.getHeaders()你能在 catch 块里直接打印出对方返回的错误 JSON无需额外 subscribe。场景二遗留系统兼容性某省社保局接口文档写着“请求头必须含Content-Type: application/x-www-form-urlencoded; charsetgbk响应体为 GBK 编码 XML”。WebClient 默认 UTF-8强行 setCharset 会破坏 form-data 边界符而 RestTemplate 的StringHttpMessageConverter可以指定supportedMediaTypes并重写writeInternal方法在写入前将 String 转为 GBK 字节数组再封装进ByteArrayResource——这是 WebClient 的BodyInserters.fromValue()做不到的底层控制力。场景三低资源消耗高频调用我们做过压测同一台 4C8G 服务器RestTemplateApache HttpClient 4.5.14每秒处理 1200 次 JSON 接口调用CPU 占用率 32%WebClientNetty 4.1.94同等 QPS 下 CPU 占用率达 68%。原因在于 WebClient 的 EventLoop 线程池要维护连接池、SSL 上下文、缓冲区管理而 RestTemplate 复用 HttpClient 的连接池无额外线程调度开销。当你的服务是 Nginx 后面的薄层代理只做参数校验转发RestTemplate 的轻量级反而是性能关键。提示这不是反对 WebClient而是强调——技术选型要看“协议栈控制粒度”和“运维可观测性”。RestTemplate 的ClientHttpRequestFactory如HttpComponentsClientHttpRequestFactory让你能直接配置HttpClient的ConnectionManager、RequestConfig、SSLContext这些参数在 WebClient 里要么藏在TcpClient深层要么需要自定义ReactorNettyHttpClient调试成本高得多。2.2 RestTemplate 的真实架构四层拦截模型RestTemplate 的执行流程远不止“发请求收响应”这么简单。它的核心是InterceptingHttpAccessorClientHttpRequestFactory构成的拦截链实际调用路径如下用户代码调用 execute() → 进入 InterceptingHttpAccessor.invoke() → 遍历 Interceptor 列表ClientHttpRequestInterceptor → 每个 Interceptor 的 intercept() 方法可修改 request 或返回 response → 调用 ClientHttpRequestFactory.createRequest() 创建底层请求对象 → 实际创建的是 HttpComponentsClientHttpRequest基于 Apache HttpClient → 执行 HttpClient.execute() → 经过 HttpClient 的 RequestExecutor含 Redirect、Retry、Auth 等策略 → 得到 HttpResponse → 返回 ResponseExtractor 处理响应 → 触发 ResponseErrorHandler默认 DefaultResponseErrorHandler → 调用 HttpMessageConverter.read() 解析响应体这个模型的关键在于Interceptor 在 HttpClient 执行前介入ResponseErrorHandler 在 HttpClient 执行后、消息转换前介入。这意味着你在 Interceptor 里能拿到原始ClientHttpRequest对象可以操作getHeaders()、getBody()流但不能读取响应ResponseErrorHandler里能拿到ClientHttpResponse包含getStatusCode()、getHeaders()、getBody()原始 InputStream但此时消息转换尚未发生所以你能看到原始错误 HTML 或 XMLHttpMessageConverter是独立于拦截器的转换层它只负责类型转换不参与业务逻辑。这种分层让 RestTemplate 具备极强的可组合性。比如我们给某政务系统做的安全加固自定义 Interceptor 添加国密 SM3 签名头自定义 ResponseErrorHandler 检查Content-Type是否为text/html防 XSS若是则拒绝解析并记录告警自定义MappingJackson2HttpMessageConverter重写readInternal方法在 JSON 反序列化前过滤script标签。三层各司其职互不干扰。2.3 格式转换的本质HttpMessageConverter 是协议与 Java 对象的翻译官网络热词里“html格式转换wps表格”“飞鼠格式转换”看似和 RestTemplate 无关实则暴露了一个根本认知误区格式转换不是“把 HTML 字符串塞进 Excel”而是协议语义的映射。RestTemplate 的HttpMessageConverter正是干这个的。Spring 内置的 Converter 有 7 种但真正常用的是这 4 个Converter 类型支持 MediaType典型用途关键配置点StringHttpMessageConvertertext/*,application/*xml处理纯文本、XML 响应setDefaultCharset(StandardCharsets.UTF_8)控制解码setWriteAcceptCharset(false)避免冗余 Accept-Charset 头MappingJackson2HttpMessageConverterapplication/jsonJSON 序列化/反序列化setObjectMapper()注入自定义 ObjectMappersetSupportedMediaTypes()限定只处理application/json;charsetUTF-8FormHttpMessageConverterapplication/x-www-form-urlencoded表单提交setMultipartBoundary()设置 boundaryaddPartConverter()添加 multipart 子转换器ByteArrayHttpMessageConverter*/*通配二进制流PDF/图片/ExcelsetSupportedMediaTypes(Arrays.asList(MediaType.ALL))必须设为 ALL 才能捕获任意二进制响应重点来了Converter 的匹配顺序决定行为。RestTemplate 默认按声明顺序查找ByteArrayHttpMessageConverter在最后所以当你restTemplate.getForObject(url, byte[].class)时它会跳过前面所有 Converter直奔 ByteArray。但如果你getForObject(url, String.class)却收到 PDF就会触发StringHttpMessageConverter的readInternal而 PDF 的二进制流被强制转 String 会产生乱码——这不是编码问题是 Converter 选错了。解决方案显式指定exchange()方法的ParameterizedTypeReference或用execute() 自定义ResponseExtractor。例如下载 PDFResponseEntitybyte[] response restTemplate.exchange( https://api.example.com/report.pdf, HttpMethod.GET, null, new ParameterizedTypeReferencebyte[]() {} ); byte[] pdfBytes response.getBody(); // 后续用 Apache POI 或 iText 处理而非 toString()这里ParameterizedTypeReferencebyte[]显式告诉 RestTemplate“我要原始字节别给我转 String”。3. 实操细节从 POST 请求 GBK 乱码到 PDF XSS 防护的完整链路3.1 POST 请求设置 GBK 编码不只是加 charset而是重建整个消息转换链网络热词“resttemplate 发送post请求 设置gbk编码格式”背后是大量开发者踩过的坑restTemplate.postForObject(url, entity, String.class)返回乱码于是百度搜到“加.charset(GBK)”结果发现StringHttpMessageConverter的charset属性是只读的根本没法改。真相是GBK 乱码的根本原因不是 Converter 编码不对而是 HttpClient 的Content-Type头未正确声明 charset。标准流程如下StringHttpMessageConverter将 Java String 转为字节数组时用charset默认 UTF-8编码但Content-Type头仍是application/json没带;charsetgbk对方服务端按Content-Type解析看到application/json就用 UTF-8 解码而你发的是 GBK 字节必然乱码。正确解法分三步第一步创建专用 GBK ConverterBean Primary public RestTemplate restTemplate() { RestTemplate template new RestTemplate(); // 移除默认的 StringHttpMessageConverter template.getMessageConverters().removeIf( c - c instanceof StringHttpMessageConverter ); // 添加 GBK 版本 StringHttpMessageConverter gbkConverter new StringHttpMessageConverter(StandardCharsets.GBK); gbkConverter.setWriteAcceptCharset(false); // 避免发送 Accept-Charset 头 template.getMessageConverters().add(0, gbkConverter); return template; }第二步构造请求时显式设置 Content-TypeHttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); // 注意这里必须用 MediaType.valueOf()不能直接写字符串 headers.set(Content-Type, application/x-www-form-urlencoded;charsetgbk); MultiValueMapString, String params new LinkedMultiValueMap(); params.add(name, 张三); params.add(address, 北京市朝阳区); HttpEntityMultiValueMapString, String request new HttpEntity(params, headers); String result restTemplate.postForObject(url, request, String.class);第三步对方服务端必须按此 charset 解析这是常被忽略的协同点。如果对方用 Spring MVC需在RequestBody参数上加RequestParam表单或RequestBodyJSON并确保StringHttpMessageConverter也设为 GBK。否则你发得再准对方收得错还是乱码。实操心得我们曾遇到某银行接口文档写“支持 GBK”但实际只认charsetgb2312。最终通过抓包发现对方 Nginx 日志显示client sent invalid header才意识到要改成gb2312。建议在测试环境用 Wireshark 抓包直接看 HTTP 请求头里的Content-Type字段是否符合预期。3.2 异常处理DefaultResponseErrorHandler 的缺陷与重写方案Spring Boot 默认的DefaultResponseErrorHandler有个致命缺陷对 4xx/5xx 响应它直接抛异常但丢弃了响应体中的业务错误信息。比如对方返回HTTP/1.1 400 Bad Request Content-Type: application/json;charsetUTF-8 {code:INVALID_PARAM,msg:手机号格式错误,traceId:abc123}DefaultResponseErrorHandler只抛HttpClientErrorException.BadRequest而e.getResponseBodyAsString()是空的——因为getResponseBodyAsByteArray()在异常处理前已被read过一次InputStream 已关闭。重写方案必须满足在抛异常前先读取并缓存响应体。public class CustomResponseErrorHandler extends DefaultResponseErrorHandler { Override public void handleError(ClientHttpResponse response) throws IOException { // 先读取响应体避免流关闭 byte[] body StreamUtils.copyToByteArray(response.getBody()); String bodyStr new String(body, StandardCharsets.UTF_8); // 调用父类方法触发异常但此时 body 已保存 super.handleError(response); // 重新抛出带 body 的异常 throw new BusinessException(HTTP Error response.getStatusCode() , Body: bodyStr, response.getStatusCode().value()); } }然后注入 RestTemplateBean public RestTemplate restTemplate() { RestTemplate template new RestTemplate(); template.setErrorHandler(new CustomResponseErrorHandler()); return template; }但注意StreamUtils.copyToByteArray()会消耗 InputStream后续HttpMessageConverter就读不到数据了。所以这个方案只适用于你确定不需要正常响应体的场景如错误日志记录。如果既要处理错误又要让成功响应正常解析得用更高级的方案——自定义ResponseExtractorResponseExtractorMyResult extractor response - { if (response.getStatusCode().is2xxSuccessful()) { // 成功走正常 Converter return restTemplate.getMessageConverters().stream() .filter(c - c.canRead(MyResult.class, response.getHeaders().getContentType())) .findFirst() .orElseThrow(() - new RuntimeException(No converter found)) .read(MyResult.class, response); } else { // 失败读取错误体 String errorBody StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8); throw new BusinessException(API Error: errorBody); } }; MyResult result restTemplate.execute(url, HttpMethod.POST, callback, extractor);这样既保证了成功路径的 Converter 复用又拿到了失败时的原始错误体。3.3 拦截器实战签名、日志、熔断三位一体网络热词“springmvc拦截器”和“springboot拦截器”常被混淆。Spring MVC 拦截器作用于 Controller 入口而 RestTemplate 拦截器作用于 HTTP 请求出口。两者定位不同但可协同。我们给某电商系统做的拦截器链包含三个层级第一层签名拦截器SecurityInterceptor对接支付网关要求每个请求带X-Signature头值为MD5(timestamp appKey secret)。public class SecurityInterceptor implements ClientHttpRequestInterceptor { private final String appKey your_app_key; private final String secret your_secret; Override public ClientHttpResponse intercept( HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { long timestamp System.currentTimeMillis(); String signature DigestUtils.md5DigestAsHex( (timestamp appKey secret).getBytes(StandardCharsets.UTF_8) ); request.getHeaders().set(X-Timestamp, String.valueOf(timestamp)); request.getHeaders().set(X-App-Key, appKey); request.getHeaders().set(X-Signature, signature); return execution.execute(request, body); } }第二层日志拦截器TraceLogInterceptor记录请求 URL、Header、Body脱敏、耗时、状态码。关键点Body 只记录前 200 字符避免日志爆炸敏感字段如password、idCard自动打码。public class TraceLogInterceptor implements ClientHttpRequestInterceptor { private static final Logger log LoggerFactory.getLogger(TraceLogInterceptor.class); Override public ClientHttpResponse intercept( HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { long start System.currentTimeMillis(); String url request.getURI().toString(); String method request.getMethod().name(); String headers request.getHeaders().toString(); String bodyStr new String(body, StandardCharsets.UTF_8); // 脱敏处理 bodyStr bodyStr.replaceAll(\password\:\[^\]*\, \password\:\***\) .replaceAll(\idCard\:\[^\]*\, \idCard\:\***\); try { ClientHttpResponse response execution.execute(request, body); long cost System.currentTimeMillis() - start; log.info([RestTemplate] {} {} | Headers: {} | Body: {} | Status: {} | Cost: {}ms, method, url, headers, bodyStr.substring(0, Math.min(200, bodyStr.length())), response.getStatusCode(), cost); return response; } catch (IOException e) { long cost System.currentTimeMillis() - start; log.error([RestTemplate] {} {} | Failed | Cost: {}ms | Cause: {}, method, url, cost, e.getMessage()); throw e; } } }第三层熔断拦截器CircuitBreakerInterceptor基于 HystrixSpring Boot 2.x 兼容当连续 5 次请求超时3s自动开启熔断后续请求直接返回 fallback。public class CircuitBreakerInterceptor implements ClientHttpRequestInterceptor { private final HystrixCommand.Setter setter HystrixCommand.Setter .withGroupKey(HystrixCommandGroupKey.Factory.asKey(RestTemplate)) .andCommandKey(HystrixCommandKey.Factory.asKey(ExternalApi)); Override public ClientHttpResponse intercept( HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { return new HystrixCommandClientHttpResponse(setter) { Override protected ClientHttpResponse run() throws Exception { return execution.execute(request, body); } Override protected ClientHttpResponse getFallback() { return new FallbackResponse(); } }.execute(); } }注意事项拦截器顺序很重要必须SecurityInterceptor在前签名依赖时间戳TraceLogInterceptor在中记录原始请求CircuitBreakerInterceptor在最后熔断是兜底。注册时用setInterceptors()按序传入 List。3.4 PDF/XSS 防护Content-Type 精确匹配与响应体扫描网络热词“springboot解决pdf xss攻击”直指一个高危场景某些 API 返回 PDF 文件但 Content-Type 设为text/html或application/octet-stream浏览器可能将其当作 HTML 渲染执行其中的script标签。RestTemplate 的防护分两层第一层请求时 Content-Type 校验在ResponseErrorHandler中检查响应头Override public void handleError(ClientHttpResponse response) throws IOException { MediaType contentType response.getHeaders().getContentType(); if (contentType ! null (contentType.toString().contains(html) || contentType.toString().contains(xml))) { // 拒绝处理防止 XSS throw new SecurityException(Dangerous Content-Type: contentType); } super.handleError(response); }第二层响应体静态扫描对 PDF 响应用 Apache PDFBox 提取文本检查是否含script、javascript:等关键词public class PdfXssScanner { public static boolean containsXss(byte[] pdfBytes) throws IOException { try (PDDocument doc PDDocument.load(pdfBytes)) { PDFTextStripper stripper new PDFTextStripper(); String text stripper.getText(doc); return text.contains(script) || text.contains(javascript:) || text.contains(onerror); } } } // 在 ResponseExtractor 中调用 ResponseExtractorbyte[] pdfExtractor response - { byte[] bytes StreamUtils.copyToByteArray(response.getBody()); if (PdfXssScanner.containsXss(bytes)) { throw new SecurityException(XSS detected in PDF content); } return bytes; };实操心得PDFBox 的getText()方法对扫描版 PDF图片无效此时需用 Tesseract OCR但性能极差。我们的方案是对Content-Type为application/pdf的响应先做快速文本扫描若失败如空文本再用 PDFBox 的PDPage遍历所有资源字典检查/JS、/JavaScript条目——这才是真正的 PDF XSS 检测。4. 完整实操一个电商订单同步服务的 RestTemplate 配置全貌4.1 配置类从连接池到 Converter 的全链路定制Configuration public class RestTemplateConfig { // 1. 自定义 HttpClient连接池、超时、SSL Bean public CloseableHttpClient httpClient() { // 连接池配置 PoolingHttpClientConnectionManager connectionManager new PoolingHttpClientConnectionManager(); connectionManager.setMaxTotal(200); // 最大连接数 connectionManager.setDefaultMaxPerRoute(50); // 每路由最大连接数 // 请求配置 RequestConfig requestConfig RequestConfig.custom() .setConnectTimeout(3000) // 连接超时 3s .setSocketTimeout(5000) // 读取超时 5s .setConnectionRequestTimeout(1000) // 从连接池获取连接超时 1s .build(); // SSL 配置忽略证书验证仅测试环境 SSLContext sslContext SSLContexts.custom() .loadTrustMaterial(null, (chain, authType) - true) .build(); return HttpClients.custom() .setConnectionManager(connectionManager) .setDefaultRequestConfig(requestConfig) .setSSLContext(sslContext) .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE) .build(); } // 2. 自定义请求工厂 Bean public ClientHttpRequestFactory clientHttpRequestFactory() { HttpComponentsClientHttpRequestFactory factory new HttpComponentsClientHttpRequestFactory(); factory.setHttpClient(httpClient()); factory.setConnectTimeout(3000); factory.setReadTimeout(5000); return factory; } // 3. 自定义 Converter 链 Bean Primary public RestTemplate restTemplate( ClientHttpRequestFactory factory, ObjectMapper objectMapper) { RestTemplate template new RestTemplate(factory); // 清空默认 Converter template.setMessageConverters(new ArrayList()); // 1. JSON Converter严格限定 charset MappingJackson2HttpMessageConverter jsonConverter new MappingJackson2HttpMessageConverter(objectMapper); jsonConverter.setSupportedMediaTypes(Arrays.asList( MediaType.APPLICATION_JSON_UTF8 )); template.getMessageConverters().add(jsonConverter); // 2. 表单 ConverterGBK 兼容 FormHttpMessageConverter formConverter new FormHttpMessageConverter(); formConverter.setSupportedMediaTypes(Arrays.asList( MediaType.APPLICATION_FORM_URLENCODED, MediaType.MULTIPART_FORM_DATA )); template.getMessageConverters().add(formConverter); // 3. 字节数组 ConverterPDF/Excel ByteArrayHttpMessageConverter byteArrayConverter new ByteArrayHttpMessageConverter(); byteArrayConverter.setSupportedMediaTypes(Arrays.asList( MediaType.ALL )); template.getMessageConverters().add(byteArrayConverter); // 4. 字符串 ConverterUTF-8 StringHttpMessageConverter stringConverter new StringHttpMessageConverter(StandardCharsets.UTF_8); stringConverter.setWriteAcceptCharset(false); template.getMessageConverters().add(stringConverter); // 4. 自定义错误处理器 template.setErrorHandler(new CustomResponseErrorHandler()); // 5. 拦截器链按序签名→日志→熔断 ListClientHttpRequestInterceptor interceptors Arrays.asList( new SecurityInterceptor(), new TraceLogInterceptor(), new CircuitBreakerInterceptor() ); template.setInterceptors(interceptors); return template; } }4.2 业务 Service订单同步的健壮实现Service public class OrderSyncService { Autowired private RestTemplate restTemplate; // 同步订单到物流系统 public LogisticsResult syncOrder(Order order) { // 构建请求体 LogisticsRequest request LogisticsRequest.builder() .orderId(order.getId()) .receiverName(order.getReceiverName()) .receiverPhone(order.getReceiverPhone()) .address(order.getAddress()) .items(order.getItems().stream() .map(item - LogisticsItem.builder() .sku(item.getSku()) .quantity(item.getQuantity()) .build()) .collect(Collectors.toList())) .build(); // 构建请求头含签名 HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON_UTF8); // 签名头由 SecurityInterceptor 自动添加此处无需重复 HttpEntityLogisticsRequest entity new HttpEntity(request, headers); try { // 使用 exchange 获取完整响应便于错误处理 ResponseEntityLogisticsResult response restTemplate.exchange( https://logistics-api.example.com/v1/orders, HttpMethod.POST, entity, LogisticsResult.class ); if (response.getStatusCode().is2xxSuccessful()) { return response.getBody(); } else { // 这里不会走到因为 CustomResponseErrorHandler 已抛异常 throw new RuntimeException(Unexpected status: response.getStatusCode()); } } catch (BusinessException e) { // 业务异常如物流系统返回 {code:OUT_OF_STOCK} log.warn(Logistics system business error: {}, e.getMessage()); throw e; } catch (ResourceAccessException e) { // 网络异常连接超时、拒绝连接 log.error(Network error when calling logistics API, e); throw new ServiceException(Logistics service unavailable, e); } catch (Exception e) { // 兜底异常Converter 错误、JSON 解析失败等 log.error(Unexpected error in order sync, e); throw new ServiceException(Order sync failed, e); } } }4.3 单元测试覆盖异常场景的 Mock 验证SpringBootTest class OrderSyncServiceTest { MockBean private RestTemplate restTemplate; Autowired private OrderSyncService service; Test void testSyncOrder_Success() { // 准备模拟响应 LogisticsResult successResult new LogisticsResult(); successResult.setLogisticsNo(SF123456789); successResult.setStatus(SUCCESS); // Mock RestTemplate Mockito.when(restTemplate.exchange( anyString(), eq(HttpMethod.POST), any(HttpEntity.class), eq(LogisticsResult.class))) .thenReturn(ResponseEntity.ok(successResult)); // 执行 LogisticsResult result service.syncOrder(createTestOrder()); // 验证 assertThat(result.getLogisticsNo()).isEqualTo(SF123456789); assertThat(result.getStatus()).isEqualTo(SUCCESS); } Test void testSyncOrder_BusinessError() { // 模拟 400 响应 String errorBody {\code\:\INVALID_ADDRESS\,\msg\:\地址不合法\}; ClientHttpResponse mockResponse mock(ClientHttpResponse.class); Mockito.when(mockResponse.getStatusCode()).thenReturn(HttpStatus.BAD_REQUEST); Mockito.when(mockResponse.getHeaders()).thenReturn(new HttpHeaders()); Mockito.when(mockResponse.getBody()).thenReturn( new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8)) ); // Mock execute 方法抛出异常 Mockito.when(restTemplate.execute( anyString(), any(HttpMethod.class), any(RequestCallback.class), any(ResponseExtractor.class))) .thenThrow(new HttpClientErrorException( HttpStatus.BAD_REQUEST, Bad Request, null, StandardCharsets.UTF_8)); // 验证抛出 BusinessException assertThatThrownBy(() - service.syncOrder(createTestOrder())) .isInstanceOf(BusinessException.class) .hasMessageContaining(INVALID_ADDRESS); } private Order createTestOrder() { Order order new Order(); order.setId(ORD123456); order.setReceiverName(张三); order.setReceiverPhone(13800138000); order.setAddress(北京市朝阳区建国路1号); order.setItems(Arrays.asList( new OrderItem().setSku(SKU001).setQuantity(2) )); return order; } }5. 常见问题与避坑指南来自 12 个生产项目的血泪总结5.1 为什么restTemplate.getForObject()有时返回 null这不是 RestTemplate 的 Bug而是HttpMessageConverter的匹配逻辑问题。常见原因有Converter 不匹配比如接口返回application/json;charsetgbk但你的MappingJackson2HttpMessageConverter只支持application/json无 charset。解决方案扩展supportedMediaTypesjsonConverter.setSupportedMediaTypes(Arrays.asList( MediaType.APPLICATION_JSON, MediaType.parseMediaType(application/json;charsetgbk), MediaType.parseMediaType(application/json;charsetutf-8) ));泛型擦除导致类型丢失getForObject(url, List.class)返回ArrayList但元素类型是LinkedHashMapJSON 默认解析为 Map。正确写法// 错误 ListString list restTemplate.getForObject(url, List.class); // 正确用 ParameterizedTypeReference ParameterizedTypeReferenceListString typeRef new ParameterizedTypeReferenceListString() {}; ListString list restTemplate.exchange(url, HttpMethod.GET, null, typeRef).getBody();响应体为空HTTP 状态码 204No Content时getForObject返回 null。这是符合 HTTP 协议的不是错误。检查接口文档是否允许 204。5.2 如何调试 RestTemplate 的实际 HTTP 请求光看日志不够必须看到原始字节。两种方案方案一启用 Apache HttpClient 日志推荐在application.properties中添加logging.level.org.apache.httpDEBUG logging.level.org.apache.http.wireDEBUG启动后控制台会输出http-outgoing-0 POST /api/orders HTTP/1.1 http-outgoing-0 Content-Type: application/json;charsetUTF-8 http-outgoing-0 Content-Length: 123 http-outgoing-0 {orderId:123,items:[{sku:A,qty:1}]} http-outgoing-0 HTTP/1.1 200 OK http-outgoing-0 Content-Type: application/json;charsetUTF-8 http-outgoing-0 {status:success,logisticsNo:SF123}方案二自定义 ClientHttpRequestFactory 包装public class LoggingHttpRequestFactory implements ClientHttpRequestFactory { private final ClientHttpRequestFactory delegate; public LoggingHttpRequestFactory(ClientHttpRequestFactory delegate) { this.delegate delegate; } Override public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException { ClientHttpRequest request delegate.createRequest(uri, httpMethod); return new LoggingClientHttpRequest(request); } } class LoggingClientHttpRequest implements ClientHttpRequest { private final ClientHttpRequest delegate; public LoggingClientHttpRequest(ClientHttpRequest delegate) { this.delegate delegate; } Override public OutputStream getBody() throws IOException { return new LoggingOutputStream(delegate.getBody()); } Override public ClientHttpResponse execute() throws IOException { ClientHttpResponse response delegate.execute(); // 记录响应头和状态码 log.info(Response: {} {}, response.getStatusCode(), response.getStatusText()); return response; } // ... 其他方法委托 }5.3 RestTemplate 线程安全吗能否单例使用绝对线程安全且必须单例。RestTemplate 本身无状态所有状态如messageConverters、errorHandler、interceptors都是不可变集合或线程安全对象。Spring Boot 的RestTemplateBuilder默认创建单例 Bean。反模式每次请求 new 一个 RestTemplate// 错误每次 new 都重建连接池造成连接泄漏 RestTemplate template new RestTemplate(); // ❌正确做法注入单例 BeanService public class MyService { Autowired private RestTemplate restTemplate; // ✅ }实操心得我们曾发现某项目因频繁 new RestTemplate导致TIME_WAIT连接堆积服务器端口耗尽。监控显示netstat -an | grep TIME_WAIT | wc -l超过 65535。根源就是没用单例。5.4 如何在 RestTemplate 中传递 CookieRestTemplate 默认不维护 Cookie需手动管理。两种方式方式一请求头手动加 CookieHttpHeaders headers new HttpHeaders(); headers.set(Cookie, JSESSIONIDabc123; tokenxyz789); HttpEntityVoid entity new HttpEntity(headers); restTemplate.exchange(url, HttpMethod.GET, entity, String.class);方式二使用HttpComponentsClientHttpRequestFactory的HttpClient维护 CookieStoreBean public RestTemplate restTemplate() { CloseableHttpClient httpClient HttpClients.custom() .setDefaultCookieStore(new BasicCookieStore()) // 启用 CookieStore .build(); HttpComponentsClientHttpRequestFactory factory new HttpComponentsClientHttpRequest