这是 shop-starter-http-client集成指南,按这套步骤通常可以跑起第一个出站客户端。

前置条件#

要求
JDK Java 21+(建议 Java 25)
Spring Boot 3.5.x
依赖 已通过 shop-common-bom / shop-contracts-bom 管理共享版本
可观测性(推荐) micrometer-tracing-bridge-otelmicrometer-registry-prometheus

5 步集成#

下面以 buyer-bff 调用 marketplace-service 列商品接口为例,从 0 走完。

graph LR A[Step 1
加依赖] --> 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-Id header(从当前请求的 baggage 取值)
  • 注入 W3C traceparent header
  • 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.xmlspring.autoconfigure.exclude

相关阅读#