HTTP 客户端:RestClient、HTTP 接口、连接池与重试边界
RestClient 在 6.1 是官方的同步推荐入口,RestTemplate 进入维护模式但不是立刻删除;HTTP 接口(@HttpExchange)把客户端调用变成声明式。固定基线 Framework 6.1.12 / JDK 17。OS 层连接复用、TCP/TLS 语义由HTTP/TLS 连接池与重试边界权威讲授(owner http),本页讲 Spring 客户端层的配置、失败与恢复;WebFlux WebClient 与流式组合归WebFlux票(U27)讲授,不在本页展开。
三代客户端的定位
| 客户端 | 模型 | 状态 | 适用 |
|---|---|---|---|
RestTemplate | 模板方法,同步 | 维护模式(不新增功能) | 存量代码;新代码迁移 |
RestClient | 流式 API,同步 | 6.1 起推荐 | 绝大多数同步场景 |
WebClient | 响应式 | Reactor 栈 | WebFlux/流式/需要组合异步时(U27) |
迁移不是等价替换:RestTemplate 的默认错误处理(非 2xx 抛 RestTemplateResponseException 家族)与 RestClient 的 onStatus 语义、拦截器与 requestInterceptor 的位置、消息转换器默认集都有差异,迁移要按行为清单核对,不能只改调用形态。
RestClient:最小用法与默认工厂
RestClient client = RestClient.builder()
.baseUrl("http://127.0.0.1:8080")
.build();
Result body = client.get().uri("/api/items/{id}", 42)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(Result.class); // 非 2xx 默认抛 RestClientResponseException
// 错误处理:onStatus 按状态码短路,返回 Throwable 时抛出
Result r = client.get().uri("/api/items/{id}", 42)
.retrieve()
.onStatus(status -> status.value() == 404, (req, res) -> new ItemNotFoundException(42))
.body(Result.class);不显式配工厂时按类路径检测选择:
private ClientHttpRequestFactory initRequestFactory() {
if (this.requestFactory != null) {
return this.requestFactory;
}
else if (httpComponentsClientPresent) {
return new HttpComponentsClientHttpRequestFactory();
}
else if (jettyClientPresent) {
return new JettyClientHttpRequestFactory();
}
else if (jdkClientPresent) {
// java.net.http module might not be loaded, so we can't default to the JDK HttpClient
return new JdkClientHttpRequestFactory();
}
else {
return new SimpleClientHttpRequestFactory();
}偏好顺序 Apache HttpClient → Jetty → JDK HttpClient → Simple(HttpURLConnection)。依赖变化会静默换实现:今天用 Simple(无连接池、每请求模型受限),引入 Apache 依赖后自动变成池化实现——连接复用、超时语义、TLS 配置方式全都随之改变。生产代码应显式 requestFactory(...) 固定实现与参数,不把网络行为交给类路径偶然性。
超时、取消与重试的分层
三类"超时"各属一层,混配会得到不可解释的尾延迟:
| 层 | 参数 | 语义 |
|---|---|---|
| 连接 | connectTimeout | TCP/TLS 建立的上限 |
| 读取 | readTimeout(或按实现的 responseTimeout) | 单次读阻塞上限 |
| 调用 | 总截止时间 deadline | 整个业务调用的预算(含重试) |
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory();
factory.setReadTimeout(Duration.ofMillis(500)); // 落在 HttpRequest.timeout():请求级超时;JDK 对外可能是 HttpTimeoutException 或 IOException("Request timed out")
// Apache HttpClient 工厂对应的是 responseTimeout/socketTimeout,语义与参数单位不同,换工厂要重核
RestClient client = RestClient.builder().requestFactory(factory).build();取消:同步 RestClient 调用在调用线程上阻塞,取消调用线程的 interrupt 并不保证底层 HTTP 请求终止(取决于工厂实现);需要可取消的调用要么用 JDK HttpClient 的 CompletableFuture 形态,要么把调用放进可丢弃的执行器并声明放弃的语义(放弃等待 ≠ 服务端未执行)。
重试的三个问题在客户端层就要回答:谁重试(客户端自动 vs 调用方语义决定)、什么可重试(连接失败/读超时后结果未知 vs 4xx 明确失败)、叠加了几层(网关 2 次 × 服务 3 次 × 客户端 2 次 = 最坏 12 次下游尝试,见 PRD EX2 算例,不重复推导)。实现上用 requestInterceptor 计数并按谓词重发:
ClientHttpRequestInterceptor retryOnceOn5xx = (request, body, execution) -> {
ClientHttpResponse response = execution.execute(request, body);
if (response.getStatusCode().is5xxServerError() && attempt < 1) { /* 重发一次 */ }
return response;
};POST/非幂等操作的重试必须有幂等键或去重记录,否则重试放大重复副作用;这属于调用方协议,不是客户端内部细节。
HTTP 接口:声明式客户端
public interface ItemApi {
@GetExchange("/api/items/{id}")
Item get(@PathVariable long id);
@PostExchange("/api/items")
ResponseEntity<Void> create(@RequestBody Item item);
}
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(client))
.build();
ItemApi api = factory.createClient(ItemApi.class);要点与陷阱:注解只声明交换(exchange)语义——路径/方法/参数绑定;超时、重试、拦截器仍配在底层 RestClient 上,声明层不重复。参数解析按注解类型走(@PathVariable/@RequestParam/@RequestBody),缺注解的复杂参数会被拒绝或误解析——接口签名错误在首调时暴露,应加契约测试。与 OpenFeign 的关系:同为声明式,Feign 属 Cloud 生态(U33),替换不是本层内部的改动。
序列化与错误输入
客户端复用 HttpMessageConverter 集(默认含 Jackson;与 MVC 侧同族——转换器选择与 406/415 的方向语义见MVC 消息转换)。错误输入按发生层排障:
| 症状 | 层 | 处置 |
|---|---|---|
ResourceAccessException,cause 为 HttpTimeoutException 或 IOException("Request timed out")(JDK 工厂实测两种形态) | readTimeout 触发(请求级超时) | 调 readTimeout 或修慢端点;超时后结果未知 |
ResourceAccessException + ConnectException | 连接拒绝 | 核对地址/服务存活 |
RestClientResponseException | 非 2xx 且未被 onStatus 短路 | 按 status/body 分类;4xx 不重试 |
| 反序列化失败(Jackson 异常) | 转换器层 | 契约漂移;给 DTO 加严格性与契约测试 |
| 依赖升级后行为漂移 | 工厂检测换实现 | 显式固定 requestFactory |
条件变化与边界
| 条件变化 | 需要复核的点 |
|---|---|
| 引入/移除 HttpClient 类依赖 | 默认工厂切换(本文摘录);显式固定 |
| Boot 3.3.3 自动配置 | RestClient.Builder 由 Boot 提供,属性来源与默认值和裸 MVC 不同 |
| Framework 7 / Boot 4 | Jackson 3、null 安全与工厂集变化按新基线重核 |
| 网关重试/熔断叠加 | 各层次数与总 deadline 预算 |
| 服务端返回 5xx 但已执行 | 重试需幂等键;读超时同理 |
实验:输入、步骤、预期与边界
实验目录:labs/spring-senior(ClientLab.java,run.sh client 运行;http 模式起本地 com.sun.net.httpserver.HttpServer(127.0.0.1 随机端口,验证后关闭)做真实网络调用)。
| 场景 | 输入 | 预期 |
|---|---|---|
| 正常调用 | GET /ok | 200,DTO 反序列化正确(默认 JDK HttpClient 工厂) |
| 状态映射 | GET /missing(404)+ onStatus | 自定义异常抛出,替代默认 ResponseException |
| 读取超时 | GET /slow(3s)+ readTimeout 500ms | ResourceAccessException,cause 为 HttpTimeoutException(JDK 工厂 readTimeout 是请求级超时;Apache 工厂语义不同) |
| 重试一次 | GET /flaky(先 500 后 200)+ 计数拦截器 | 第 2 次成功;服务端计数 2 |
| HTTP 接口 | ItemApi.get(42) | 声明式调用返回 DTO |
| 连接拒绝 | 调未监听端口 | ResourceAccessException + ConnectException |
本机已运行(2026-10-25,JDK 17.0.19,Framework 6.1.12):全过。未覆盖:Apache HttpClient 工厂行为、TLS/证书链、代理环境、线程中断取消(正文按实现边界说明)。
追问链
第一层:为什么"迁移到 RestClient"不是机械替换?
两者在错误处理默认值、拦截器位置、转换器初始化上都有差异;等价迁移要按"状态码→异常、超时→行为、拦截器→顺序"三张对照表验证,否则生产表现漂移。评分关注行为对齐而非 API 形态。
第二层:读超时后重试安全吗?
不安全。读超时意味着请求已发出,服务端可能已执行——重试等价于重复提交;只有幂等操作(GET/带幂等键的写)可自动重试,否则需状态查询。连接失败(未发出请求)可重试。
第三层:各层重试叠加时如何设计总预算?
自顶向下分配 deadline:总预算减去网关/服务各层保留时间,剩余分配给客户端;每层重试次数×单次超时 ≤ 该层预算。最坏下游尝试数是各层次数乘积(12 次算例),超过下游承受力时应削减层数而非调大队列。
技术案例
- 内部服务调用:RestClient + 显式 JDK/Apache 工厂 + connect/read 双超时 + 幂等键写路径;4xx 不重试、5xx/读超时按谓词一次。
- 第三方 API 适配:HTTP 接口声明 + 契约测试锁签名与响应 shape;转换器对未知字段显式策略。
- 慢依赖隔离:调用放进独立线程池并设总 deadline;放弃等待时记录"结果未知"而非"失败"。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Spring Framework Reference: RestClient / HTTP Interface | 6.1 | 官方用法与迁移指引 |
| DefaultRestClientBuilder.java | v6.1.12 | 默认工厂检测顺序(L426–L442) |
| JDK HttpClient | Java SE 17 | JDK 工厂底层语义 |
| HTTP/TLS 连接池与重试边界 | 本仓库 | 连接复用/重试叠加/总预算(owner http) |
本地 ClientLab | Framework 6.1.12 / JDK 17.0.19 | 真实本地 HttpServer 调用实测 |
访问日期:2026-10-25。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。