# 05. 集成层设计(F6 / Mini 域) ## 决策 供应商(F6)和历史 Mini 域的调用统一收口在 `integration/f6-adapter` / `integration/mini-clients` 模块(模块位置见 [01-project-structure.md](./01-project-structure.md)),业务 domain 不直接持有 HTTP 客户端;用 Resilience4j 统一管理超时、重试、熔断、并发隔离。 HTTP 客户端用 **`RestClient`(同步)**,底层是 Apache HttpClient 5 连接池,不用 `WebClient`/`Mono`。 ### 为什么是同步 `RestClient` 而不是 `WebClient` `WebClient` 是 Spring 官方推荐的现代 HTTP 客户端,但它的返回值是 `Mono`/`Flux`,把响应式编程模型带进了整个调用链。在我们这套以 Spring MVC(同步 Servlet 栈)为主的应用里,这会造成几个实际问题: - **上下文会丢**。`StoreContextHolder` 是 `@RequestScope` bean、日志的 `traceId` 在 MDC 里,这两样都绑在请求线程上。一旦回调跑在 Reactor 的 event-loop 线程上,两者都拿不到——日志断链、上下文取不到值,而且这类 bug 只在特定时序下出现,很难查。 - **收益用不上**。响应式的价值是用少量线程扛住大量并发连接,前提是**整条链路都是非阻塞的**。我们的链路里有 JPA(阻塞 JDBC),只要还在用 JPA,中间夹一段响应式并不会减少线程占用,只是把复杂度带进来。 - **心智成本**。`Mono` 一旦进入业务代码,测试、异常处理、事务边界都要按另一套规则写,团队要同时掌握两套模型。 `RestClient`(Spring 6.1+)提供的是和 `WebClient` 一样的流式 API,但返回值是普通对象、执行是同步的。等到将来整条链路真的要转非阻塞(比如换掉 JPA),再统一切换到 `WebClient` 不迟——那时是一次有明确收益的整体迁移,而不是现在这样局部引入。 ## 结构约定 ``` platform-integration/ RestClientConfig # 按下游系统构建 RestClient(连接池、超时、拦截器) IntegrationClientProperties # integration.clients.* 配置绑定 TracePropagationInterceptor # 统一给出站请求加 X-Trace-Id IntegrationException # 集成层异常基类(继承 platform-web 的 BusinessException) integration/f6-adapter/ 负责:换票、供应商访问上下文准备、超时/重试/熔断/舱壁策略、异常转换为内部标准错误码 integration/mini-clients/ 对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装,供 workbench / bff-orchestration 调用 ``` `platform-integration` 依赖 `platform-web`(为了复用 `BusinessException` 和 `ErrorCode`)是本次允许的少数几个 platform 间依赖之一。 ## `RestClient` 配置 ```groovy // platform-integration/build.gradle dependencies { implementation project(':platform:platform-web') implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.apache.httpcomponents.client5:httpclient5' // 版本由 Boot BOM 管 implementation 'io.github.resilience4j:resilience4j-spring-boot4:2.4.0' } ``` ```kotlin // platform-integration/.../RestClientConfig.kt @Configuration @EnableConfigurationProperties(IntegrationClientProperties::class) class RestClientConfig(private val props: IntegrationClientProperties) { @Bean fun f6RestClient(builder: RestClient.Builder): RestClient = build(builder, props.f6) { req, resp -> // 5xx / 连接类问题 → 可重试;4xx → 请求本身有问题,重试没有意义 if (resp.statusCode.is5xxServerError) { throw F6ServerException("F6 返回 ${resp.statusCode}") } throw F6ClientException("F6 拒绝了请求: ${resp.statusCode}") } @Bean fun miniRestClient(builder: RestClient.Builder): RestClient = build(builder, props.mini) { _, resp -> throw MiniIntegrationException("Mini 域返回 ${resp.statusCode}") } private fun build( builder: RestClient.Builder, // 注入 Boot 提供的 Builder,而不是 RestClient.create(): config: ClientConfig, // 这样 Micrometer 的 observation/W3C traceparent 会自动挂上(见 08) statusHandler: RestClient.ResponseSpec.ErrorHandler, ): RestClient = builder .baseUrl(config.baseUrl) .requestFactory(requestFactory(config)) .requestInterceptor(TracePropagationInterceptor()) .defaultStatusHandler(HttpStatusCode::isError, statusHandler) .build() private fun requestFactory(config: ClientConfig): ClientHttpRequestFactory { val connectionManager = PoolingHttpClientConnectionManagerBuilder.create() .setMaxConnTotal(config.maxConnections) .setMaxConnPerRoute(config.maxConnectionsPerRoute) // 必须 ≥ 对应的舱壁并发上限,理由见下 .setDefaultConnectionConfig( ConnectionConfig.custom() .setConnectTimeout(Timeout.ofMilliseconds(config.connectTimeoutMs)) .setValidateAfterInactivity(TimeValue.ofSeconds(5)) // 复用前探活,避开对端已断的空闲连接 .build(), ) .build() val httpClient = HttpClients.custom() .setConnectionManager(connectionManager) .setDefaultRequestConfig( RequestConfig.custom() // 从连接池拿连接的等待上限。漏配它是最隐蔽的一个坑: // 池子被占满时线程会无限期地等在这里,前面所有超时配置全部失效。 .setConnectionRequestTimeout(Timeout.ofMilliseconds(config.connectionRequestTimeoutMs)) .setResponseTimeout(Timeout.ofMilliseconds(config.readTimeoutMs)) .build(), ) .evictIdleConnections(TimeValue.ofSeconds(30)) .build() return HttpComponentsClientHttpRequestFactory(httpClient) } } ``` ```yaml integration: clients: f6: base-url: ${F6_BASE_URL} connect-timeout-ms: 1000 read-timeout-ms: 2000 connection-request-timeout-ms: 500 max-connections: 60 max-connections-per-route: 30 mini: base-url: ${MINI_BASE_URL} connect-timeout-ms: 500 read-timeout-ms: 1000 connection-request-timeout-ms: 300 max-connections: 60 max-connections-per-route: 30 ``` **超时由 HTTP 客户端承担,不用 `@TimeLimiter`。** Resilience4j 的 `@TimeLimiter` 只能作用于 `CompletableFuture` 或响应式返回值——同步方法上加了不会报错,但**完全不生效**,是个非常容易误配的注解。同步栈下真正管用的是上面三个:`connectTimeout`(建连)、`responseTimeout`(等响应)、`connectionRequestTimeout`(等连接池)。 ## Resilience4j 配置 ```yaml resilience4j: # 叠加顺序由这几个 *-aspect-order 属性决定,跟注解写在方法上的先后顺序无关(详见文末附录)。 # 这里显式写死,避免依赖框架默认值——默认值会随版本变,而顺序变了语义就变了。 retry: retry-aspect-order: 3 # 最外层 instances: f6-api: max-attempts: 2 wait-duration: 200ms exponential-backoff-multiplier: 2 retry-exceptions: # 同步栈下真实会抛出来的类型: # - ResourceAccessException 包住了 SocketTimeoutException/ConnectException 等所有 IO 异常, # 业务代码永远不会直接见到 SocketTimeoutException,写它是无效配置 # - F6ServerException 是我们在 statusHandler 里对 5xx 的转换 - org.springframework.web.client.ResourceAccessException - com.continental.retailapp.integration.f6.F6ServerException ignore-exceptions: # 熔断已打开时抛的异常,重试它毫无意义,只会白白多等一轮 wait-duration - io.github.resilience4j.circuitbreaker.CallNotPermittedException - com.continental.retailapp.integration.f6.F6ClientException circuitbreaker: circuit-breaker-aspect-order: 2 instances: f6-api: sliding-window-type: COUNT_BASED sliding-window-size: 20 minimum-number-of-calls: 10 # 样本太少时不做判断,避免启动后头几个请求就把熔断打开 failure-rate-threshold: 50 slow-call-duration-threshold: 1500ms slow-call-rate-threshold: 80 # 慢调用也算故障:只统计失败率的话,"每次都卡满 2 秒但最终成功"永远不会熔断 wait-duration-in-open-state: 10s permitted-number-of-calls-in-half-open-state: 5 record-exceptions: - org.springframework.web.client.ResourceAccessException - com.continental.retailapp.integration.f6.F6ServerException bulkhead: bulkhead-aspect-order: 1 # 最内层,贴着真实调用 instances: f6-api: max-concurrent-calls: 20 max-wait-duration: 0 # 拿不到名额立刻失败走降级,不排队 —— 排队等于把阻塞换个地方而已 mini-o2o: max-concurrent-calls: 20 max-wait-duration: 0 ``` ## F6 客户端示例 ```kotlin // integration/f6-adapter/.../F6ApiClient.kt @Component class F6ApiClient( private val f6RestClient: RestClient, // 来自 platform-integration 的统一封装 ) { @Bulkhead(name = "f6-api") // 默认 SEMAPHORE 类型,不额外起线程 @CircuitBreaker(name = "f6-api", fallbackMethod = "fallbackProcurementList") @Retry(name = "f6-api") fun fetchProcurementList(storeId: Long): ProcurementListResponse = f6RestClient.get() .uri("/f6/procurement/list?storeId={storeId}", storeId) .retrieve() .body(ProcurementListResponse::class.java)!! // Resilience4j 约定:fallback 方法签名 = 原方法参数 + Throwable,返回类型与原方法一致(同步下就是 T) fun fallbackProcurementList(storeId: Long, ex: Throwable): ProcurementListResponse { log.warn("F6 采购列表降级返回,storeId={}, cause={}", storeId, ex.toString()) return ProcurementListResponse.degraded() } } ``` ```kotlin // platform-integration/.../IntegrationException.kt // 继承 BusinessException(见 06-api-design.md),复用 GlobalExceptionHandler, // 不再单独写一个 @RestControllerAdvice —— 少一个会和全局处理器抢优先级的地方。 open class IntegrationException( code: Int, message: String, httpStatus: HttpStatus = HttpStatus.BAD_GATEWAY, ) : BusinessException(code, message, httpStatus) // integration/f6-adapter/.../F6Exceptions.kt class F6ServerException(message: String) : IntegrationException(ErrorCode.F6_UNAVAILABLE, "供应商服务暂不可用,请稍后重试") class F6ClientException(message: String) : IntegrationException(ErrorCode.F6_BUSINESS_ERROR, "供应商请求被拒绝") ``` 错误码是 `Int`,落在 `3xxxx` 集成段(`ErrorCode.F6_UNAVAILABLE = 30001`),完整分段表见 [06-api-design.md](./06-api-design.md)——客户端 [../12-error-and-api-contract.md](../../conti-retail-app/docs/12-error-and-api-contract.md) 的 `ApiCode` 也是数字,两边必须一致。异常里带的原始 `message`(含 F6 的状态码和响应体)只进日志,**不进返回给 APP 的 `message`**,避免把供应商的协议细节泄漏出去。 ## Mini 域客户端示例(内部系统,策略更宽松) ```kotlin // integration/mini-clients/.../O2OClient.kt @Component class O2OClient(private val miniRestClient: RestClient) { @Bulkhead(name = "mini-o2o") // 只做超时 + 并发隔离,不加熔断(内部系统,稳定性相对可控) fun fetchOrderSummary(storeId: Long): OrderSummary = try { miniRestClient.get() .uri("/o2o/orders/summary?storeId={storeId}", storeId) .retrieve() .body(OrderSummary::class.java)!! } catch (ex: Exception) { log.warn("O2O 订单摘要降级返回,storeId={}", storeId, ex) OrderSummary.empty() // 局部降级:首页某个 tile 空着,好过整个首页 500 } } ``` ## traceId 透传 ```kotlin // platform-integration/.../TracePropagationInterceptor.kt class TracePropagationInterceptor : ClientHttpRequestInterceptor { override fun intercept( request: HttpRequest, body: ByteArray, execution: ClientHttpRequestExecution, ): ClientHttpResponse { MDC.get("traceId")?.let { request.headers.set("X-Trace-Id", it) } return execution.execute(request, body) } } ``` 同步栈下拦截器和业务代码跑在同一个线程上,MDC 一定取得到,业务代码零感知。W3C 标准的 `traceparent` 头由 Micrometer Tracing 自动加(前提是用注入的 `RestClient.Builder` 构建,见上面的注释);这里额外发的 `X-Trace-Id` 是给 F6 这类只认自定义头的外部系统用的,两者并存,规则见 [08-observability.md](./08-observability.md)。 ## 关键规则 - **F6 是外部供应商域**,稳定性不可控,必须配齐超时 + 重试 + 熔断 + 舱壁,且熔断后要有降级返回(`fallbackXxx` 方法),不能让异常直接穿透到 APP。 - **异常统一转换**:F6 / Mini 域的异常和非标准错误,在集成模块内部转换成内部标准错误码,业务 domain 和最终 API 响应都不暴露供应商侧的原始协议细节。 - **Mini 域调用相对可控**(内部系统),熔断可以不加,但超时和舱壁不能省,避免慢查询拖垮 `workbench` 聚合——对应架构图 Flow 3 "首页失败按 tile 降级"。 - **只对幂等调用配 `@Retry`**。GET 查询可以放心重试;有副作用的调用(下单、扣减)**默认不重试**,确实需要时接口必须带幂等 key,规则见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。 - **不在数据库事务里调外部 HTTP**,理由见 [03-persistence.md](./03-persistence.md)。 - 业务 domain(如 `workbench`)只依赖 `integration/*` 暴露的接口,不自己构造 `RestClient` 发请求。 - `workbench` 首页把多个下游并行拉起来的写法(线程池、整体超时预算、按 tile 降级)见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)——那不属于单个客户端的职责。 ## 附录一:超时、重试、熔断、舱壁分别解决什么问题 四者经常被一起提,但作用点不同,配置的时候容易搞混: - **超时(Timeout)**:解决"对方一直不回应,我方线程/连接被一直占着"的问题。这是最基础、必须有的一道防线。同步栈下它由 HTTP 客户端提供,不是 Resilience4j 提供的。 - **重试(Retry)**:解决"这次失败大概率是偶发的(网络抖动、瞬时过载)"的问题。前提是**幂等**。 - **熔断(Circuit Breaker)**:解决"对方已经持续故障,继续请求只是在浪费资源、拖慢自己"的问题。统计滑动窗口内的失败率(和慢调用率),超阈值后直接短路走 fallback(`OPEN`),过一段时间放几个探测请求(`HALF_OPEN`)判断是否恢复。 - **舱壁(Bulkhead)**:解决"一个慢下游把我方所有工作线程吃光"的问题——**这一道在同步栈下尤其重要**。算笔账:Tomcat 默认 200 个工作线程,F6 读超时 2 秒,如果 F6 全面卡住且没有并发限制,200 个线程会在 2 秒内全部堵在 F6 上,此时这个应用连登录、连查本地数据库的接口都不可用了,一个外部依赖直接把整个服务拖死。配上 `max-concurrent-calls: 20` 之后,最多 20 个线程能进去,剩下 180 个照常干活,超出的请求立刻走降级——**用一个功能的降级换整个服务的存活**。 响应式栈下这道防线的必要性没这么强(event-loop 天生不会被阻塞占死),这也是从 `WebClient` 换到 `RestClient` 后必须补上它的原因。 舱壁上限和连接池要对齐:`max-connections-per-route` 必须 ≥ `max-concurrent-calls`,否则真正的瓶颈会变成"抢连接",请求会堵在 `connectionRequestTimeout` 上,而不是被舱壁干脆利落地挡掉。 ## 附录二:叠加顺序由配置决定,不是由注解顺序决定 **一个常见误解是"注解写在上面的就在外层"——不是的。** Resilience4j 的各个切面都是独立的 Spring AOP Aspect,它们的嵌套顺序由 `resilience4j..-aspect-order` 属性(也就是 Spring 的 `@Order` 语义)决定,跟注解在方法上的书写顺序完全无关。框架的默认顺序是 **Retry 在最外层**: ``` Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( 真实调用 ) ) ) ) ) ``` 顺序不同,语义差别很实在: - **Retry 在外(默认,我们采用)**:每一次重试都会各自经过熔断器,所以一次失败的调用会往熔断器的统计窗口里记 2 笔(`max-attempts: 2`)。好处是下游真出问题时熔断器打开得更快;代价是窗口里的样本数会被重试放大,`sliding-window-size` 要按放大后的量来估。这也是为什么必须把 `CallNotPermittedException` 加进 `ignore-exceptions`——熔断打开后抛的就是它,不排除掉的话每次请求还要白白多等一轮重试。 - **CircuitBreaker 在外**:整组重试合起来只在熔断器上记 1 笔,统计更"干净",但下游故障时熔断打开会慢一些。 **建议**:像上面 yaml 那样把三个 `*-aspect-order` 显式写出来,不要依赖默认值——默认值可能随版本变化,而这个顺序一变,熔断的触发速度就跟着变了,还很难从现象上察觉。另外,各版本对"order 数值大是更外层还是更内层"的约定容易记反,配完之后**用一个必定失败的集成测试实际验证一次**叠加顺序(比如断言下游被调用了几次、熔断器记录了几笔),比查文档可靠,写法见 [10-testing.md](./10-testing.md)。 ## 待补充 - 具体超时/重试/舱壁参数需要结合 F6 实际 SLA 压测后调整,示例里的数值是起点,不是最终值。 - 熔断后降级返回的数据结构约定(`degraded()` 的具体字段,以及 APP 侧如何展示"这块数据是降级的")。 - F6 换票的具体协议细节(对接 [04-security-auth.md](./04-security-auth.md) 的切店联动失效)。 ## 参考链接 - [Resilience4j 官方文档](https://resilience4j.readme.io/docs) - [Spring Framework: RestClient](https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-restclient) - [Apache HttpClient 5 连接管理](https://hc.apache.org/httpcomponents-client-5.4.x/current/tutorial/html/connmgmt.html) - [Martin Fowler: CircuitBreaker](https://martinfowler.com/bliki/CircuitBreaker.html) - [Release It! 中的 Bulkhead 模式](https://learn.microsoft.com/azure/architecture/patterns/bulkhead)