shop-starter-http-client 集成指南:从零到第一个 @HttpExchange 客户端
目录
这是
shop-starter-http-client的集成指南,按这套步骤通常可以跑起第一个出站客户端。
- 想了解 starter 内部如何设计:shop-starter-http-client 设计
- 想深入连接池/超时/HTTP2 的取舍:BFF 连接池与 HTTP/2 实战
前置条件#
| 项 | 要求 |
|---|---|
| JDK | Java 21+(建议 Java 25) |
| Spring Boot | 3.5.x |
| 依赖 | 已通过 shop-common-bom / shop-contracts-bom 管理共享版本 |
| 可观测性(推荐) | micrometer-tracing-bridge-otel、micrometer-registry-prometheus |
5 步集成#
下面以 buyer-bff 调用 marketplace-service 列商品接口为例,从 0 走完。
加依赖] --> B[Step 2
配 application.yml] B --> C[Step 3
声明 @HttpExchange 接口] C --> D[Step 4
注入 Support 建代理 Bean] D --> E[Step 5
业务代码调用]
Step 1:加依赖#
services/buyer-bff/pom.xml:
<dependency>
<groupId>dev.meirong.shop</groupId>
<artifactId>shop-starter-http-client</artifactId>
</dependency>
<!-- 下游服务对应的 contracts 模块(path 常量 + DTO) -->
<dependency>
<groupId>dev.meirong.shop</groupId>
<artifactId>shop-contracts-marketplace</artifactId>
</dependency>
启动时 ShopHttpClientAutoConfiguration 自动装配下面四个 bean,无需手动声明:
ShopHttpExchangeSupport(代理工厂)TracingHeaderInterceptor(baggage → header)SharedDownstreamErrorHandler(4xx/5xx 解析)DownstreamServiceObservationConvention(metric tag)
通过 /actuator/conditions 可以验证 auto-config 已经命中。
Step 2:配 application.yml#
shop:
http-client:
connect-timeout: 2s # 全局 connect-timeout(所有代理共享一个 HttpClient 实例)
read-timeout: 5s # 默认 read-timeout,可在 createClient() 重载里覆盖
buyer:
marketplace-service-url: http://marketplace-service:8080
# Baggage:必填,否则 X-Buyer-Id 等 header 不会传到下游
management:
tracing:
baggage:
remote-fields: # 入站 HTTP header → Baggage(关键)
- X-Buyer-Id
- X-Username
- X-Roles
- X-Order-Id
- X-Request-Id
correlation-fields: # Baggage → 日志 MDC(推荐)
- X-Buyer-Id
- X-Username
remote-fields 基本算是前提配置。TracingHeaderInterceptor 通过 tracer.getBaggage(name) 读 baggage;如果没配 remote-fields,入站 header 进不了 Baggage,下游就收不到。
Step 3:声明 @HttpExchange 接口#
放在调用方模块自己的 client/ 包下。我这里不建议放进 shared 模块——ArchUnit 规则 SPRING-03 也会拦你:
// services/buyer-bff/src/main/java/dev/meirong/shop/buyerbff/client/MarketplaceServiceClient.java
package dev.meirong.shop.buyerbff.client;
import dev.meirong.shop.common.api.ApiResponse;
import dev.meirong.shop.contracts.marketplace.MarketplaceApi;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;
@HttpExchange
public interface MarketplaceServiceClient {
@PostExchange(MarketplaceApi.LIST)
ApiResponse<MarketplaceApi.ProductsView> listProducts(
@RequestBody MarketplaceApi.ListProductsRequest request);
}
只列你自己用到的端点。每个调用方维护自己的 client 接口,避免共享 client 模块的"改一个接口牵连所有消费方"问题。
Step 4:注入 Support 建代理 Bean#
// services/buyer-bff/src/main/java/dev/meirong/shop/buyerbff/client/BuyerBffClientConfiguration.java
package dev.meirong.shop.buyerbff.client;
import dev.meirong.shop.httpclient.support.ShopHttpExchangeSupport;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
class BuyerBffClientConfiguration {
@Bean
MarketplaceServiceClient marketplaceClient(
ShopHttpExchangeSupport support,
@Value("${shop.buyer.marketplace-service-url}") String url) {
return support.createClient(url, MarketplaceServiceClient.class);
}
}
ShopHttpExchangeSupport 是 starter 自动装配的 bean,直接注入。createClient() 内部已经把 tracing 拦截器、错误处理、observation convention 都装好。
Step 5:业务代码里调用#
@Service
class BuyerHomeService {
private final MarketplaceServiceClient marketplaceClient;
BuyerHomeService(MarketplaceServiceClient marketplaceClient) {
this.marketplaceClient = marketplaceClient;
}
public List<Product> listFeaturedProducts() {
var request = MarketplaceApi.ListProductsRequest.featured(20);
ApiResponse<MarketplaceApi.ProductsView> response =
marketplaceClient.listProducts(request);
return response.data().products();
}
}
跑通之后,这一次调用通常会自动:
- 发 HTTP POST 到
marketplace-service - 注入
X-Buyer-Idheader(从当前请求的 baggage 取值) - 注入 W3C
traceparentheader - 4xx/5xx 自动解析为
DownstreamException(带原始 status code) - metric
http_client_requests_seconds_count{downstream_service="marketplace-service"}自增
单服务集成完成。
进阶 1:加更多下游服务#
复制 Step 3-4,每个下游一个 client interface + 一个 @Bean。所有代理共享同一个 HttpClient,通常不需要先关心连接池隔离:
@Configuration(proxyBeanMethods = false)
class BuyerBffClientConfiguration {
@Bean MarketplaceServiceClient marketplaceClient(
ShopHttpExchangeSupport support,
@Value("${shop.buyer.marketplace-service-url}") String url) {
return support.createClient(url, MarketplaceServiceClient.class);
}
@Bean LoyaltyServiceClient loyaltyClient(
ShopHttpExchangeSupport support,
@Value("${shop.buyer.loyalty-service-url}") String url) {
return support.createClient(url, LoyaltyServiceClient.class);
}
@Bean PromotionServiceClient promotionClient(
ShopHttpExchangeSupport support,
@Value("${shop.buyer.promotion-service-url}") String url) {
return support.createClient(url, PromotionServiceClient.class);
}
}
不同下游为什么不需要拆连接池?
HttpClient内部连接池天然 per-origin,不同 origin 不互相挤占。详见 BFF 连接池与 HTTP/2 实战 §2。
进阶 2:慢服务覆盖 read-timeout#
某些下游响应慢(搜索索引、报表服务),单独覆盖:
@Bean SearchServiceClient searchClient(
ShopHttpExchangeSupport support,
@Value("${shop.buyer.search-service-url}") String url) {
return support.createClient(url, SearchServiceClient.class, Duration.ofSeconds(15));
}
createClient(url, class, readTimeout) 重载——这是代理级配置,每次调用 new 一个 JdkClientHttpRequestFactory,不影响其他代理。
进阶 3:调外部 API(独立 connectTimeout / TLS)#
外部 API(支付、地图、第三方 webhook)和内部服务的 connectTimeout、SSLContext 完全不同,需要独立的 ShopHttpExchangeSupport bean:
@Bean("externalSupport")
ShopHttpExchangeSupport externalSupport(
ObjectProvider<RestClient.Builder> builderProvider,
SharedDownstreamErrorHandler errorHandler,
TracingHeaderInterceptor tracingInterceptor) {
return new ShopHttpExchangeSupport(
builderProvider::getObject,
errorHandler,
tracingInterceptor,
Duration.ofSeconds(10), // 外部 connect 长
Duration.ofSeconds(30)); // 外部 read 长
}
@Bean
PaymentGatewayClient paymentClient(
@Qualifier("externalSupport") ShopHttpExchangeSupport support,
@Value("${shop.payment.gateway-url}") String url) {
return support.createClient(url, PaymentGatewayClient.class);
}
经验法则:一个 ShopHttpExchangeSupport bean = 一组隔离边界。一个内部 + 一个外部,绝大多数 BFF 通常两个就够。
进阶 4:聚合接口里 fan-out#
聚合接口里并发调多个下游,需要用 ContextSnapshot.captureAll() 包装任务,否则虚拟线程读不到 Baggage:
import io.micrometer.context.ContextSnapshot;
import java.util.concurrent.Executors;
public BuyerApi.DashboardResponse loadDashboard(String buyerId) {
ContextSnapshot snapshot = ContextSnapshot.captureAll(); // 关键步骤
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var marketplace = executor.submit(snapshot.wrap(() -> listMarketplace()));
var loyalty = executor.submit(snapshot.wrap(() -> getLoyaltyAccount(buyerId)));
var promotions = executor.submit(snapshot.wrap(this::listPromotions));
return new BuyerApi.DashboardResponse(
marketplace.get(), loyalty.get(), promotions.get());
}
}
不做这一步会出现:
- 下游服务收不到
X-Buyer-Id - 链路在 fan-out 处断成两半
- 日志 MDC 里
buyerId丢失
具体原理见 设计文档 §5。这一步只解决了"上下文怎么传"——某个下游失败时聚合接口怎么办,是下面要回答的问题。
进阶 5:聚合接口的异常处理#
进阶 4 把 baggage 传到了 fan-out 出去的虚拟线程;本节解决另一半问题:一个下游失败时整个聚合接口怎么办?
聚合接口的异常处理比单次调用复杂得多——你得先决定哪个下游失败可以忍受、哪个下游更适合直接让请求失败。
5.1 先 unwrap ExecutionException#
Future.get() 抛的是 ExecutionException,原始异常在 getCause()。如果直接 catch 后随便包一层,会把下游的原始 status code 吞掉:
// ❌ 错——下游 404 被埋成 500,客户端拿不到原始语义
} catch (ExecutionException ex) {
throw new BusinessException(CommonErrorCode.DOWNSTREAM_ERROR, "aggregation failed", ex);
}
// ✅ 对——unwrap 让 DownstreamException 继续向上抛,GlobalExceptionHandler 能透传 status code
} catch (ExecutionException ex) {
if (ex.getCause() instanceof BusinessException be) throw be;
if (ex.getCause() instanceof RuntimeException re) throw re;
throw new BusinessException(CommonErrorCode.DOWNSTREAM_ERROR, "aggregation failed", ex);
}
5.2 关键路径 vs 非关键路径#
按业务影响程度区分(与 设计篇 §8.2 一致):
| 类型 | 服务示例 | 失败时聚合接口行为 |
|---|---|---|
| 关键路径 | marketplace、order | 整个聚合接口失败,让客户端重试 |
| 非关键路径 | promotion、loyalty | 降级返回空结果,核心流程继续 |
实现上把两类用两个 helper 分开:
public BuyerApi.DashboardResponse loadDashboard(String buyerId) {
ContextSnapshot snapshot = ContextSnapshot.captureAll();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var marketplace = executor.submit(snapshot.wrap(() -> listMarketplace()));
var loyalty = executor.submit(snapshot.wrap(() -> getLoyaltyAccount(buyerId)));
var promotions = executor.submit(snapshot.wrap(this::listPromotions));
return new BuyerApi.DashboardResponse(
getCritical(marketplace), // 关键:失败抛出
getOptional(loyalty, LoyaltyApi.AccountResponse.empty()), // 非关键:失败兜底
getOptional(promotions, List.of()));
}
}
private <T> T getCritical(Future<T> future) {
try {
return future.get();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new BusinessException(CommonErrorCode.INTERNAL_ERROR,
"aggregation interrupted", ex);
} catch (ExecutionException ex) {
// unwrap:保留下游 DownstreamException 的 status code 和 error code
if (ex.getCause() instanceof BusinessException be) throw be;
if (ex.getCause() instanceof RuntimeException re) throw re;
throw new BusinessException(CommonErrorCode.DOWNSTREAM_ERROR,
"aggregation failed", ex);
}
}
private <T> T getOptional(Future<T> future, T fallback) {
try {
return future.get();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return fallback;
} catch (ExecutionException ex) {
log.warn("optional downstream failed, returning fallback", ex.getCause());
return fallback;
}
}
5.3 InterruptedException 应该先恢复中断标志#
虚拟线程被中断时(调用方设了整体 deadline、线程池关闭),子任务会拿到 InterruptedException:
- 不管走
getCritical还是getOptional,第一行应该Thread.currentThread().interrupt() - 否则中断标志被吞,外层线程池清理 /
try-with-resources关闭时的行为可能出现问题
5.4 我不建议把 @CircuitBreaker 直接加到聚合方法上#
// ❌ 错——一个下游失败就把整个聚合方法熔断,所有用户都受影响
@CircuitBreaker(name = "loadDashboard")
public DashboardResponse loadDashboard(String buyerId) { ... }
熔断器应该挂在单个下游调用上:
@CircuitBreaker(name = "loyalty-service", fallbackMethod = "getLoyaltyAccountFallback")
LoyaltyApi.AccountResponse getLoyaltyAccount(String buyerId) {
return loyaltyClient.getAccount(buyerId).data();
}
熔断器决定"单个下游何时短路",不该决定"整个聚合方法怎么响应"——后者由 getCritical / getOptional 在聚合层显式表达。
5.5 整体 deadline(可选)#
如果想给聚合接口加一个逻辑级 deadline(不只是单调用 read-timeout),可以用 Future.get(timeout):
try {
return future.get(3, TimeUnit.SECONDS);
} catch (TimeoutException ex) {
future.cancel(true);
throw new BusinessException(CommonErrorCode.DOWNSTREAM_ERROR, "aggregation timeout", ex);
}
绝大多数场景用不到——HttpClient 的 read-timeout 已经为每个调用单独限了时。整体 deadline 只在 “fan-out 范围已知 + 整体 SLA 严格” 时才需要。
进阶 6:单次调用的错误处理#
非聚合场景下调用一个下游,4xx/5xx 会抛 DownstreamException:
public Product getProduct(String productId) {
try {
return marketplaceClient.getProduct(productId).data();
} catch (DownstreamException ex) {
if (ex.getDownstreamHttpStatus() == 404) {
throw new ProductNotFoundException(productId); // 翻译为业务异常
}
throw ex; // 5xx 等向上抛,GlobalExceptionHandler 统一处理
}
}
DownstreamException 字段:
| 字段 | 含义 |
|---|---|
downstreamHttpStatus |
下游原始 HTTP 状态码(404 / 422 / 503 …) |
downstreamCode |
下游原始错误码(SC_NOT_FOUND / validation-failed …) |
message |
下游原始错误消息 |
何时加 @CircuitBreaker#
我不建议默认全开。 只在出过事故 + 服务非关键 + 业务上能定义合理 fallback 时才加。详见 设计文档 §8.2。
验证集成#
跑起应用后,三个验证点:
✅ 1. metric 已经暴露#
$ curl http://localhost:8081/actuator/prometheus | grep http_client_requests_seconds_count | head -2
http_client_requests_seconds_count{downstream_service="marketplace-service",method="POST",outcome="SUCCESS",status="200",...} 5.0
看到 downstream_service= label = ✅ DownstreamServiceObservationConvention 装上了。
✅ 2. baggage 透传到下游#
发一个请求,记录返回的 traceId(开 write-trace-header 后从响应 header 直接取)。在下游服务的 Loki 日志里查这条 trace:
{app="marketplace-service"} | logfmt | traceId = `<your-trace-id>`
应该看到 buyerId=... 出现在下游的 MDC 字段里——证明 X-Buyer-Id header 透传成功。
✅ 3. 错误语义保留#
故意调一个返回 404 的下游接口,业务代码 catch 到的应该是:
DownstreamException
downstreamHttpStatus = 404
downstreamCode = "SC_NOT_FOUND"
message = "..."
而不是 Spring 的通用 RestClientResponseException。
排查清单#
| 现象 | 可能原因 | 解法 |
|---|---|---|
下游收不到 X-Buyer-Id |
management.tracing.baggage.remote-fields 没配 |
Step 2 补上 |
日志 MDC 没有 buyerId |
correlation-fields 没配 |
Step 2 补上 |
metric 里 downstream_service=unknown |
URL 没有 host(用了相对路径) | 检查 @Value 注入的 URL |
| 聚合接口里 trace 断链 | fan-out 时没 ContextSnapshot.wrap() |
进阶 4 |
RestClient.Builder 不能注入 |
Spring Boot < 3.4 | 升 SB 3.5 |
4xx/5xx 没抛 DownstreamException,抛了 RestClientResponseException |
自定义 defaultStatusHandler 覆盖了 starter |
移除自定义 handler 或链式调用 starter 的 |
启动找不到 ShopHttpExchangeSupport bean |
starter 依赖没引入 / 自动装配被 exclude | 检查 pom.xml、spring.autoconfigure.exclude |
相关阅读#
- shop-starter-http-client 设计 — starter 内部如何工作
- BFF HTTP 客户端连接池与 HTTP/2 实战 — 连接配置取舍
- Spring Boot 3.5 微服务 tracing 为什么会断链 — tracing 配套