《SpringBoot4进阶实战》第12招 远程HTTP怎么调用
# Petclinic 宠物保险报价:RestClient 超时、异常映射与故障传播的工程实战 ```text 单体自足 → 首个远程依赖 → 线程阻塞与故障蔓延 ``` Petclinic 一直是个自给自足的单体应用,自己读写数据库,自己渲染页面,所有故障都局限在进程内部。 我是老李,负责把官方教学项目演进成生产级系统。今天要给 Petclinic 增加宠物保险报价服务,第一次引入远程 HTTP 调用。 为什么系统一旦拥有第一个远程依赖,就开始需要认真考虑 Timeout、线程占用和故障传播? 选择同步 RestClient 还是响应式 WebClient,依据是什么? 选错超时策略或选错调用模型,会带来哪些线上事故级别的后果? # 01、故事 ```text 周四 14:20 → 深圳科兴科学园 3 栋 11 层 → 任务是接保险报价 → 产品经理站在身后催 ``` 周四下午两点二十分,深圳科兴科学园 3 栋 11 层,窗外是灰蒙蒙的天。 任务是给 Petclinic 的宠物详情页接一个保险报价卡片,数据来自外部保险公司的 HTTP 接口。 产品经理老周站在我身后,手指敲着工位隔板:下午四点要和保险公司联调,你这边到底能不能通? 我打开 Petclinic 的代码仓库,发现项目里连一个 RestTemplate 都没用过,更别说 RestClient。 搜索框里敲下 RestClient,官方文档说它是 RestTemplate 的现代替代品,但我连它的 Builder 长什么样都没见过。 老周又催了一句:别查了,先跑通一个 Hello World 行不行? > 远程调用初登场, > 超时线程两茫茫。 > 故障传播如潮水, > 一腔孤勇对苍黄。 # 02、问题 ```text 需求变化:从本地数据到远程报价 → 冲突:外部服务不可控 → 后果:线程耗尽与雪崩 ``` Petclinic 原本所有数据都来自本地数据库,事务边界清晰,故障范围可控。 引入保险报价后,一次页面请求变成两次网络往返,Petclinic 的线程池开始被外部服务的响应时间绑架。 如果保险公司接口响应慢,Tomcat 的工作线程就会一直占着等响应,积累到一定程度,整个 Petclinic 的线程池被耗尽,连本地数据库查询都进不来。 更隐蔽的是,如果保险公司接口返回 500 或一段无法解析的 JSON,RestClient 默认抛出的异常会让 Petclinic 的全局异常处理器措手不及。 用户看到的不是友好的降级提示,而是 Whitelabel Error Page,甚至连接被重置。 可验证的验收标准是:保险公司接口 5 秒不响应,Petclinic 页面必须在 2 秒内返回降级内容,且 Tomcat 活跃线程数不持续增长。 > 远程一呼百应难, > 线程池中暗波澜。 > 超时未设终成患, > 雪崩来时悔已晚。 # 03、原理 ```text 同步 HTTP 调用的核心机制:线程占用、超时控制、异常映射 ``` RestClient 是同步阻塞模型,发请求的线程会一直等到响应或超时,期间该线程不能做任何其他事。 Tomcat 默认工作线程池是 200,每个线程同时只能处理一个请求,这是理解故障传播的起点。 Connection Timeout 是建立 TCP 连接的最大等待时间,Read Timeout 是连接建立后等待响应体的最大时间,两者独立配置,缺一不可。 反直觉判断一:Read Timeout 不是越大越好,它决定了单个线程被占用的最长时间,也决定了系统在故障时的恢复速度。 反直觉判断二:RestClient 抛出的 ResourceAccessException 和 HttpClientErrorException 需要分别处理,前者是网络层故障,后者是业务层故障,混为 一谈会让降级策略失效。 异常映射的本质是把远程调用的三类失败——连接失败、超时、非 2xx 响应——翻译成 Petclinic 领域内可理解的业务异常。 > 线程阻塞似枷锁, > 超时设置定死活。 > 异常映射分层次, > 降级兜底不背锅。 # 04、架构 ```text Petclinic Controller → InsuranceQuoteService → RestClient → 保险公司 HTTP API ``` 这一层架构的核心是引入 InsuranceQuoteClient 作为防腐层,隔离外部系统的变化。 Controller 只依赖 InsuranceQuoteService 接口,Service 内部持有 InsuranceQuoteClient,Client 封装 RestClient 的所有细节 。 边界在于:Petclinic 内部永远不出现 RestClient 的异常类型,Client 层负责把远程异常翻译成 InsuranceQuoteUnavailableException。 收益是 Petclinic 的 Controller 和 Service 层完全不知道远程调用的存在,替换保险公司或切换调用模型都不影响上层。 代价是多了一层类和一个 DTO 映射,对于只有两三个接口的集成场景,这个成本可以接受。 适用条件是远程依赖数量少但不可控,需要统一降级策略的场景。 ```text +----------------+ +---------------------+ +------------------+ +------------------+ | Petclinic | --> | InsuranceQuote | --> | InsuranceQuote | --> | 保险公司 | | Controller | | Service | | Client | | HTTP API | +----------------+ +---------------------+ +------------------+ +------------------+ | 超时/异常映射 v +------------------+ | 降级策略 | | 返回友好提示 | +------------------+ ``` > 防腐层中隔内外, > 异常翻译不越界。 > 降级策略藏深处, > 架构清晰福自来。 # 05、第一版实战 ```text 环境:Spring Boot 3.2 + Java 17 → 配置:Base URL 与超时 → 代码:Client 与 Service → 启动 → 验证 → V1 ``` 当前 Petclinic 基于 Spring Boot 3.2,Java 17,需按当前官方文档核验 RestClient 的包路径。 第一步,在 pom.xml 增加依赖,Spring Web 已包含 RestClient,无需额外引入。 ```xml org.springframework.boot spring-boot-starter-web ``` 第二步,在 application.properties 配置保险报价服务的 Base URL 和超时时间。 ```properties insurance.quote.base-url=http://localhost:9090 insurance.quote.connection-timeout=1000 insurance.quote.read-timeout=2000 ``` 第三步,创建 InsuranceQuoteClient,使用 RestClient.Builder 构建。 ```java @Component public class InsuranceQuoteClient { private final RestClient restClient; public InsuranceQuoteClient(RestClient.Builder builder, @Value("${insurance.quote.base-url}") String baseUrl, @Value("${insurance.quote.connection-timeout}") int connectTimeout, @Value("${insurance.quote.read-timeout}") int readTimeout) { this.restClient = builder .baseUrl(baseUrl) .requestFactory(new ClientHttpRequestFactorySettings() .withConnectTimeout(Duration.ofMillis(connectTimeout)) .withReadTimeout(Duration.ofMillis(readTimeout))) .build(); } public InsuranceQuote fetchQuote(Long petId) { return restClient.get() .uri("/quotes/{petId}", petId) .retrieve() .body(InsuranceQuote.class); } } ``` 第四步,创建 InsuranceQuoteService,处理降级逻辑。 ```java @Service public class InsuranceQuoteService { private final InsuranceQuoteClient client; public InsuranceQuoteService(InsuranceQuoteClient client) { this.client = client; } public InsuranceQuote getQuote(Long petId) { try { return client.fetchQuote(petId); } catch (ResourceAccessException e) { return InsuranceQuote.unavailable("报价服务暂时不可用"); } catch (HttpClientErrorException | HttpServerErrorException e) { return InsuranceQuote.unavailable("报价服务返回错误"); } } } ``` 第五步,启动 Petclinic,访问宠物详情页,观察报价卡片是否显示。 ```bash mvn spring-boot:run curl http://localhost:8080/owners/1/pets/1 ``` 未在当前环境实测,以下为预期结果:保险公司接口正常时,页面显示报价金额;接口不可达时,页面显示降级文案。 示例输出: ```text HTTP/1.1 200 OK 页面包含:"报价服务暂时不可用" ``` V1 成功标准:本地启动无报错,正常路径能拿到报价,接口关闭时页面不白屏。 > 第一版中代码忙, > 超时配置两行藏。 > 降级逻辑初成形, > 页面不再白茫茫。 # 06、排查 ```text 诊断树:慢响应、HTTP 500、错误 JSON、网络断开 ``` 问题一:保险公司接口模拟慢响应 5 秒,Petclinic 页面卡住 5 秒才返回。 现象:页面转圈 5 秒,用户流失。 怀疑:Read Timeout 配置没生效。 检查:查看启动日志中 RestClient 的配置。 ```bash curl -X POST http://localhost:9090/slow-quote -d '{"delay":5000}' ``` 证据:请求发出后,Tomcat 线程被占用 5 秒,Read Timeout 设为 2000ms 却未触发。 根因:ClientHttpRequestFactorySettings 的配置方式在 Spring Boot 3.2 中需要配合 JdkClientHttpRequestFactory 使用,默认的 SimpleClientHttpRequestFactory 不读取该设置。 修复:显式指定 RequestFactory。 ```java this.restClient = builder .baseUrl(baseUrl) .requestFactory(new JdkClientHttpRequestFactory( new ClientHttpRequestFactorySettings() .withConnectTimeout(Duration.ofMillis(connectTimeout)) .withReadTimeout(Duration.ofMillis(readTimeout)))) .build(); ``` 问题二:保险公司接口返回 HTTP 500,Petclinic 抛出 HttpServerErrorException,页面显示 500 错误。 现象:用户看到 Whitelabel Error Page。 怀疑:Service 层没有捕获 HttpServerErrorException。 检查:查看 Petclinic 日志中的异常堆栈。 ```bash tail -f logs/petclinic.log | grep HttpServerErrorException ``` 证据:日志显示 HttpServerErrorException 未被捕获,直接抛到全局异常处理器。 根因:Service 层只捕获了 ResourceAccessException,遗漏了 HttpServerErrorException。 修复:在 Service 层增加对 HttpServerErrorException 的捕获。 **错误尝试:** 最初尝试在 Controller 层用 @ExceptionHandler 捕获所有 RestClient 异常,结果导致 Controller 层与远程调用细节耦合,后续替换保险 公司时改动面过大,回滚重写。 > 慢响应来线程占, > 超时未生效心寒。 > 五百错误漏捕获, > 层层排查见真章。 # 07、优化 ```text V1 证据:超时未生效、500 未捕获 → V2:显式 RequestFactory + 统一异常映射 + 调用日志 ``` V1 暴露了两个问题:超时配置未生效,异常捕获不完整。 V2 的改动集中在 InsuranceQuoteClient 内部,对外接口不变。 第一,显式指定 JdkClientHttpRequestFactory,确保超时生效。 第二,引入 RestClient 的异常映射器,把非 2xx 响应统一转换为自定义异常。 ```java this.restClient = builder .baseUrl(baseUrl) .requestFactory(new JdkClientHttpRequestFactory( new ClientHttpRequestFactorySettings() .withConnectTimeout(Duration.ofMillis(connectTimeout)) .withReadTimeout(Duration.ofMillis(readTimeout)))) .defaultStatusHandler( HttpStatusCode::isError, (request, response) -> { throw new InsuranceQuoteRemoteException( "保险公司接口返回错误: " + response.getStatusCode()); }) .build(); ``` 第三,在 Client 层增加调用日志,记录请求 URL、耗时和状态。 ```java public InsuranceQuote fetchQuote(Long petId) { long start = System.currentTimeMillis(); try { InsuranceQuote quote = restClient.get() .uri("/quotes/{petId}", petId) .retrieve() .body(InsuranceQuote.class); log.info("Fetch quote for pet {} succeeded in {} ms", petId, System.currentTimeMillis() - start); return quote; } catch (Exception e) { log.warn("Fetch quote for pet {} failed in {} ms: {}", petId, System.currentTimeMillis() - start, e.getMessage()); throw e; } } ``` Service 层只需捕获 InsuranceQuoteRemoteException 和 ResourceAccessException 两种类型。 > 超时配置重调整, > 异常映射一网擒。 > 调用日志留痕迹, > 故障排查有迹寻。 # 08、演进 ```text V2 能力:超时可控、异常可映射、日志可观测 → 遗留:错误 JSON 解析与线程池隔离 ``` V2 之后,用 V1 同一组请求验证效果。 慢响应请求:保险公司接口延迟 5 秒,Petclinic 在 2 秒时触发 Read Timeout,返回降级文案。 ```text HTTP/1.1 200 OK 页面包含:"报价服务暂时不可用" 耗时:2003ms ``` HTTP 500 请求:保险公司接口返回 500,Petclinic 捕获 InsuranceQuoteRemoteException,返回降级文案。 ```text HTTP/1.1 200 OK 页面包含:"报价服务暂时不可用" 耗时:45ms ``` 网络断开请求:关闭保险公司服务,Petclinic 在 1 秒时触发 Connection Timeout,返回降级文案。 ```text HTTP/1.1 200 OK 页面包含:"报价服务暂时不可用" 耗时:1002ms ``` 收益是 Petclinic 的页面不再被远程故障拖垮,降级策略统一且可观测。 代价是引入了自定义异常类型和日志埋点,代码量略有增加。 遗留问题是错误 JSON 的解析:如果保险公司返回 200 但 JSON 结构不符合预期,Jackson 抛出的异常尚未被捕获。 另一个遗留是线程池隔离:目前所有远程调用共享 Tomcat 工作线程,极端情况下仍可能互相影响。 > 同一请求再验证, > 超时降级两相宜。 > 五百断网皆可挡, > 遗留问题待后续。 # 09、洞见 ```text 从现象到判断:远程依赖改变了故障模型 ``` ## 超时是系统恢复能力的上限 ```text 故障恢复时间 = 超时时间 + 降级处理时间 ``` 超时时间决定了系统在故障状态下能多快恢复,它不是越大越好,而是要与业务容忍度匹配。 ## 反直觉判断:同步阻塞不是原罪,失控的同步阻塞才是 ```text 同步调用 + 合理超时 = 可控的阻塞 同步调用 + 无超时 = 线程池耗尽 ``` Tomcat 200 个线程,每个线程等 30 秒,系统在 6 秒内就失去响应能力。 ## 反直觉判断:异常类型是故障分类的钥匙 ```text ResourceAccessException = 网络层故障 HttpServerErrorException = 服务端故障 HttpClientErrorException = 客户端故障 ``` 把三类异常混在一起捕获,等于放弃了精确的降级策略。 ## 故障传播的根源是共享资源 ```text 远程调用占用线程 → 线程耗尽 → 本地请求被拒 ``` 第一个远程依赖出现时,Petclinic 的故障模型从单点变成了链式传播。 > 远程依赖改模型, > 超时恢复定上限。 > 异常分类是钥匙, > 共享资源酿祸端。 # 10、系统落地 ```text 入口:宠物详情页 → 服务:InsuranceQuoteService → 本模块:RestClient 调用层 → 存储:无 → 未实现:错误 JSON 处理 ``` Petclinic 原本只有本地数据库访问,所有查询都在进程内完成,故障范围清晰。 本篇增加了保险报价调用层,包括 Base URL 配置、超时控制、异常映射和调用日志。 现在 Petclinic 能优雅地处理远程服务的慢响应、HTTP 500 和网络断开,页面始终返回友好内容。 下一篇缺的是错误 JSON 的解析容错,以及更细粒度的线程池隔离方案。 官方文档首页:https://docs.spring.io/spring-boot/index.html > 本地数据本无忧, > 远程调用添新愁。 > 超时异常皆可挡, > 错误 JSON 待回头。 # 11、小结 ```text 三问三答:超时、选型、后果 ``` 第一个问题:为什么第一个远程依赖就要考虑超时、线程和故障传播? 因为远程调用把 Petclinic 的线程从本地计算资源变成了外部服务的等待队列,外部服务的不可控性直接转化为内部线程的失控占用。 第二个问题:同步 RestClient 还是响应式 WebClient? Petclinic 是典型的 MVC 应用,请求量可控,同步模型配合合理超时足够应对,引入 WebClient 意味着全链路响应式改造,收益远小于成本。 第三个问题:选错超时或选错模型的后果是什么? 选错超时导致线程池耗尽,系统雪崩;选错模型导致架构复杂度飙升,团队维护成本翻倍。 当前状态:Petclinic 已具备基本的远程调用容错能力,降级策略统一,日志可观测。 > 远程依赖需敬畏, > 超时线程两相随。 > 同步够用不盲从, > 故障传播有壁垒。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:Connection Timeout 和 Read Timeout 的区别是什么? Connection Timeout 是建立 TCP 连接的最大等待时间,Read Timeout 是连接建立后等待响应数据的最大时间。前者在网络不可达时触发,后者在服务端接受连接但不返回数据时触发。 ## 实战题:给 InsuranceQuoteClient 增加错误 JSON 的容错处理 在 fetchQuote 方法中捕获 RestClientResponseException 和 ResourceAccessException,对 JSON 解析错误返回默认报价对象。 ```java public InsuranceQuote fetchQuote(Long petId) { try { return restClient.get() .uri("/quotes/{petId}", petId) .retrieve() .body(InsuranceQuote.class); } catch (RestClientResponseException e) { log.warn("Remote error for pet {}: {}", petId, e.getStatusCode()); return InsuranceQuote.unavailable("报价服务返回错误"); } catch (ResourceAccessException e) { log.warn("Network error for pet {}: {}", petId, e.getMessage()); return InsuranceQuote.unavailable("报价服务暂时不可用"); } } ``` ## 排障题:保险公司接口返回 200 但 JSON 字段缺失,页面报错,如何排查? 先看 Petclinic 日志中是否有 HttpMessageNotReadableException,确认是 Jackson 反序列化失败。 再检查 InsuranceQuote 类的字段注解,确认是否有 @JsonProperty 映射缺失。 最后在 Client 层增加对 HttpMessageNotReadableException 的捕获,返回降级对象。 ## 架构判断题:什么场景下 Petclinic 才需要从 RestClient 切换到 WebClient? 当 Petclinic 需要同时发起多个远程调用且要求并发聚合时,或者需要流式处理响应时,才考虑 WebClient。 如果只是单个同步调用,WebClient 的响应式编程模型会让代码复杂度显著上升,收益不明显。 > 作业四题层层深, > 理解实战两不分。 > 排障架构皆历练, > 功夫到家自然成。 # 13、升华 ```text 工程结论:第一个远程依赖是架构分水岭 ``` Petclinic 从单体自足到引入第一个远程依赖,表面上是加了一个 HTTP 调用,实际上是故障模型的根本转变。 超时不是配置项,而是系统恢复能力的上限;异常映射不是代码技巧,而是故障分类的领域语言;线程占用不是性能指标,而是故障传播的物理通道。 同步 RestClient 配合合理超时,是 MVC 应用应对远程依赖的默认选择,WebClient 是特定场景的升级选项,不是技术时髦的装饰品。 下一篇将深入错误 JSON 的容错处理和线程池隔离,让 Petclinic 的远程调用层更加健壮。 > 首个依赖是分水, > 故障模型从此变。 > 超时异常线程占, > 三管齐下保平安。 **系列项目:** 以 Spring 官方 Petclinic 为唯一贯穿项目,24 篇持续演进同一个代码仓库,覆盖 Spring Boot 4 生产级应用全生命周期。 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 层与安全机制、数据访问与事务管理。 **本篇:** 宠物保险报价服务的 RestClient 调用层,含 Base URL、超时控制、异常映射与调用日志。 **下一篇:** 错误 JSON 容错处理与远程调用线程池隔离方案。