在上一篇中,我们看了 API Gateway 如何作为统一入口处理 JWT 校验和路由分发。请求经过 Gateway 后,到达的就是本文的主角——BFF(Backend for Frontend)聚合层

📦 本文基于的完整项目源码:https://github.com/meirongdev/shop

上一篇:(二)API Gateway 架构

2026-04 实践更新 当前主线代码里,buyer-bff / seller-bff 都已经完成了 typed @HttpExchange 客户端改造;BuyerAggregationService / SellerAggregationService 不再直接持有一堆 raw RestClient 调用链,而是通过 HttpServiceProxyFactory 注入声明式客户端。本文下面与客户端装配相关的表述已按当前实现校正。


BFF 模式:为什么需要聚合层#

在微服务架构中,前端页面往往需要从多个领域服务获取数据。如果没有 BFF,前端直接调用每个领域服务会面临:

前端 ←→ profile-service      (获取用户档案)
     ←→ wallet-service       (获取钱包余额)
     ←→ promotion-service    (获取可用优惠)
     ←→ marketplace-service  (获取推荐商品)
     ←→ loyalty-service      (获取积分账户)

问题显而易见:前端需要知道每个服务的地址、认证方式、数据格式;任何服务变动都会影响前端代码;更重要的是,前端暴露了内部服务拓扑,安全和治理成本都会明显上升。

graph LR FE1["前端"] P["profile-service"] W["wallet-service"] PM["promotion-service"] M["marketplace-service"] L["loyalty-service"] FE1 -.-> P FE1 -.-> W FE1 -.-> PM FE1 -.-> M FE1 -.-> L style FE1 stroke:#f66,stroke-width:2px

BFF 在这里提供了一个位于前端和领域服务之间的聚合层

graph LR FE["前端"] BFF["BFF 聚合层"] P2["profile-service"] W2["wallet-service"] PM2["promotion-service"] M2["marketplace-service"] L2["loyalty-service"] FE --> BFF BFF --> P2 BFF --> W2 BFF --> PM2 BFF --> M2 BFF --> L2 style BFF stroke:#4a4,stroke-width:2px
前端 ←→ BFF ←→ profile-service
             ←→ wallet-service
             ←→ promotion-service
             ←→ marketplace-service
             ←→ loyalty-service

前端只需要和一个 BFF 打交道,BFF 负责编排、聚合、容错、数据转换。


整体架构#

Shop Platform 有两个 BFF:

BFF 服务对象 核心职责
buyer-bff 买家/游客 Dashboard、购物车、结账、订单追踪、忠诚度中心
seller-bff 卖家 商品管理、订单管理、促销配置

两者结构相似,以 buyer-bff 为例深入分析。

包结构#

buyer-bff/
├── config/
│   ├── BuyerBffConfig.java           # @HttpExchange proxy + HttpClient 装配
│   ├── BuyerClientProperties.java    # @ConfigurationProperties(目标服务地址、超时)
│   └── OpenApiConfig.java
├── controller/
│   ├── BuyerController.java          # 已登录买家端点
│   └── GuestBuyerController.java     # 游客端点(结账、订单追踪)
└── service/
    ├── BuyerAggregationService.java  # 核心聚合逻辑(typed clients + 编排 / 降级 / 补偿)
    ├── GuestCartStore.java           # Redis 游客购物车
    └── PageResponse.java             # 分页响应包装

Virtual Threads 并发编排#

BFF 最常见的场景是并发调用多个下游服务,然后组合结果返回。在 WebFlux 时代这是 Mono.zip(...),在 Virtual Threads 时代变成了最朴素的 Future.get()

public BuyerApi.DashboardResponse loadDashboard(String buyerId) {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var profileFuture = executor.submit(() ->
                call("profileService", true,
                        () -> profileClient.getProfile(buyerId),
                        "Profile service unavailable"));
        var walletFuture = executor.submit(() ->
                call("walletService", true,
                        () -> walletClient.getAccount(buyerId),
                        "Wallet service unavailable"));
        var promotionsFuture = executor.submit(() ->
                call("promotionService", true,
                        () -> promotionClient.listOffers(),
                        "Promotions unavailable"));
        var marketplaceFuture = executor.submit(() ->
                call("marketplaceService", true,
                        () -> marketplaceClient.listProducts(),
                        "Marketplace unavailable"));
        var loyaltyFuture = executor.submit(() ->
                call("loyaltyService", true,
                        () -> loyaltyClient.getAccount(buyerId),
                        "Loyalty service unavailable"));

        return new BuyerApi.DashboardResponse(
                profileFuture.get(), walletFuture.get(), promotionsFuture.get(),
                marketplaceFuture.get(), loyaltyFuture.get());
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new BusinessException(CommonErrorCode.INTERNAL_ERROR,
                "Buyer dashboard interrupted", e);
    } catch (ExecutionException e) {
        throw new BusinessException(CommonErrorCode.DOWNSTREAM_ERROR,
                "Buyer dashboard aggregation failed", e);
    }
}

关键点:

  • try-with-resources:每次请求创建新的 VirtualThreadPerTaskExecutor,方法结束时自动关闭
  • 5 个并发调用:profile、wallet、promotions、marketplace、loyalty,各自运行在独立虚拟线程上
  • 同步等待.get() 阻塞的是虚拟线程而非操作系统线程,开销极低
  • 异常处理InterruptedException 恢复中断标记,ExecutionException 包装为统一业务异常

这种写法的代码可读性和传统同步代码比较接近,但运行时仍然是并发的。对习惯同步调用栈的团队来说,断点调试和异常定位通常也会更直观一些。


Resilience4j:四层防护#

上面的 call() 方法实际上封装了 Resilience4j 的四层防护:

private <T> T call(String instanceName, boolean retryEnabled,
                   Supplier<T> supplier, String unavailableMessage) {
    if (retryEnabled) {
        return resilienceHelper.read(instanceName, supplier,
                throwable -> failDownstream(unavailableMessage, throwable));
    }
    return resilienceHelper.write(instanceName, supplier,
            throwable -> failDownstream(unavailableMessage, throwable));
}

ResilienceHelper(来自 shop-common)将四个 Resilience4j 组件串联成一个装饰器链:

// 装饰器链顺序:Retry → CircuitBreaker → Bulkhead → TimeLimiter
Supplier<T> retryDecorated = retryEnabled ?
        Retry.decorateSupplier(retry, supplier) : supplier;
Supplier<T> circuitDecorated =
        CircuitBreaker.decorateSupplier(circuitBreaker, retryDecorated);
Supplier<T> bulkheadDecorated =
        Bulkhead.decorateSupplier(bulkhead, circuitDecorated);
return timeLimiter.executeFutureSupplier(
        () -> executorService.submit(bulkheadDecorated::get));

各服务防护参数矩阵#

CircuitBreaker(熔断器)

下游服务 滑动窗口大小 失败率阈值 开启等待时间 半开允许请求数
searchService 10 50% 30s 3
promotionService 20 50% 20s 3
loyaltyService 20 50% 20s 3
marketplaceService 20 50% 10s 5
orderService 10 40% 15s 2
profileService 20 60% 30s 5
walletService 20 50% 20s 3

Retry(重试,仅读操作)

下游服务 最大尝试次数 初始等待时间 指数退避
searchService 3 200ms 是 (x2)
promotionService 2 300ms
marketplaceService 2 200ms
profileService 2 200ms

重试只针对 ResourceAccessException / ConnectExceptionBusinessException 不重试。

Bulkhead(信号量隔离)

下游服务 最大并发 最大等待
searchService 20 50ms
marketplaceService 15 0ms (快速失败)
profileService 15 0ms
promotionService 10 0ms
loyaltyService 10 0ms
orderService 8 0ms
walletService 8 0ms

TimeLimiter(超时)

下游服务 超时时间 取消运行中任务
searchService 5s
promotionService 4s
profileService 4s
marketplaceService 3s
orderService 3s
walletService 3s
loyaltyService 4s

为什么读操作开启重试,写操作不开#

  • 读操作:重试不会导致数据副作用(获取商品列表、查询用户档案)
  • 写操作:创建订单、支付、扣库存等写操作如果重试,可能造成重复下单、重复扣款,因此写操作只执行一次,失败直接返回

RestClient 声明式 HTTP 客户端#

BFF 调用下游服务使用的是 Spring 6.1 引入的 RestClient + @HttpExchange

// 声明式客户端接口
@HttpExchange
public interface SearchServiceClient {
    @GetExchange("/api/search/products")
    SearchApi.SearchResponse searchProducts(@RequestParam("q") String query,
                                             @RequestParam("page") int page);
}

// 配置类中装配
@Bean
SearchServiceClient searchServiceClient(BuyerClientProperties properties,
                                        JdkClientHttpRequestFactory factory) {
    RestClient searchRestClient = RestClient.builder()
            .requestFactory(factory)
            .baseUrl(properties.searchServiceUrl())
            .build();
    HttpServiceProxyFactory httpFactory = HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(searchRestClient))
            .build();
    return httpFactory.createClient(SearchServiceClient.class);
}

对比 RestTemplate@HttpExchange 在这个项目里带来的几个变化:

  • 接口定义即文档,参数和返回类型一目了然
  • 编译期类型检查,不需要在运行时拼字符串
  • 天然支持虚拟线程(运行在 RestClient 之上)

另外,这套 RestClient 会自动吃到 shop-common 里的 HeaderPropagationAutoConfiguration,把 Gateway 注入的 X-Request-IdX-Buyer-IdX-UsernameX-RolesX-Portal 等可信 Header 继续往下游透传。

为什么选择 @HttpExchange 而不是 OpenFeign?#

在微服务架构中,声明式客户端(declarative HTTP client)是 BFF 调领域服务的标准方式。早期的 shop 项目尝试过 Spring Cloud OpenFeign,后来整体迁移到了 Spring Framework 原生的 @HttpExchange + RestClient。选择后者主要基于几点考量:

  • Spring Cloud OpenFeign 已进入维护模式。Spring Cloud 2022.0.0 之后,OpenFeign 不再作为 Spring Cloud 默认集成的 HTTP 客户端,Spring 官方转向了基于 Spring Framework 6.1+ 原生 HTTP Interfaces 的路线。继续使用 OpenFeign 意味着承担生态滞后的风险。
  • @HttpExchange 是 Spring Framework 内置能力,直接存在于 spring-web 模块中,不需要引入额外的 Spring Cloud 组件。对于部署在 Kubernetes 上的服务,K8s 自身的 Service 机制已经处理了服务发现和负载均衡,引入 Spring Cloud 全套治理组件反而成了不必要的重量依赖。
  • 与 Java 21 虚拟线程原生兼容RestClient 为虚拟线程做了原生优化,配合 @HttpExchange 可以在不写响应式代码的前提下,以极低的硬件成本获得高并发吞吐。OpenFeign 的同步阻塞模型在高并发下更容易耗尽线程池。
  • 底层 HTTP 引擎统一。无论是直接使用 RestClient 编写编程式调用,还是用 @HttpExchange 编写声明式接口,它们共享同一套拦截器、HttpMessageConverter、连接池配置和错误处理机制。

注解层面的迁移很简单,基本上是一一对应:

功能 OpenFeign @HttpExchange
声明客户端 @FeignClient(name = "user-service") @HttpExchange("/users") 或通过工厂配置
GET 请求 @GetMapping @GetExchange
POST 请求 @PostMapping @PostExchange
路径参数 @PathVariable @PathVariable(无变化)
请求体 @RequestBody @RequestBody(无变化)

以用户服务为例,迁移前后的接口声明对比:

// 迁移前:OpenFeign
@FeignClient(name = "user-service", url = "https://api.example.com/users")
public interface UserFeignClient {
    @GetMapping("/{id}")
    UserResponse getUserById(@PathVariable("id") Long id);

    @PostMapping
    UserResponse createUser(@RequestBody CreateUserRequest request);
}

// 迁移后:@HttpExchange
@HttpExchange("/users")
public interface UserHttpClient {
    @GetExchange("/{id}")
    UserResponse getUserById(@PathVariable("id") Long id);

    @PostExchange
    UserResponse createUser(@RequestBody CreateUserRequest request);
}

配置层的变化也类似:OpenFeign 靠 @EnableFeignClients 自动扫描并注册 Bean,而 @HttpExchange 需要显式构造 HttpServiceProxyFactory。不过这个样板代码可以被 ShopHttpExchangeSupport 这类统一工厂封装掉——每个 caller 的 @Bean 方法通常只有 3-5 行。

两层超时机制#

BFF 有两个维度的超时设置,它们是不同的防护层:

层级 配置 当前默认值 说明
HTTP 客户端层 connectTimeout / readTimeout 2s / 5s JDK HttpClient 级别
Resilience4j TimeLimiter 各服务独立配置 3-5s 业务编排级别

HTTP 客户端超时是底层网络超时(TCP 连接 + 读取响应),TimeLimiter 是整个 call() 方法的执行超时(包含重试、熔断判断等逻辑)。两层组合提供了更细粒度的控制。

buyer-bff vs seller-bff 的 HttpClient 差异#

一个有趣的细节:buyer-bff 默认使用 HTTP/1.1,而 seller-bff 硬编码 HTTP/2

// buyer-bff: HTTP/1.1(默认)
HttpClient.newBuilder()
    .version(properties.httpVersion())  // HTTP_1_1
    .connectTimeout(properties.connectTimeout())
    .build();

// seller-bff: HTTP/2
HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_2)  // 强制 HTTP/2
    .connectTimeout(properties.connectTimeout())
    .build();

这里更适合把它理解为当前实现里的差异化取舍:seller-bff 目前固定使用 HTTP/2,buyer-bff 则保留可配置、默认 HTTP/1.1。它可能和卖家侧更偏后台操作、接口调用模式更集中有关,但如果没有结合压测数据,不宜把这点直接上升成通用最佳实践。


结账流程:Saga 编排 + 优雅降级#

buyer-bff 中最复杂的业务逻辑是结账(checkout)。它需要在多个领域服务之间编排一个同步 Saga

① 获取购物车
② 按卖家分组商品
③ 预扣库存(marketplace-service)
④ 为每个卖家创建订单(order-service)
⑤ 处理支付(wallet-service / Stripe)
⑥ 扣减积分(loyalty-service,可选)
⑦ 清空购物车
⑧ 返回支付结果

任何一步失败都需要逆向补偿

flowchart LR subgraph 正向 direction LR S1["① 购物车"] --> S2["② 按卖家分组"] S2 --> S3["③ 预扣库存"] S3 --> S4["④ 创建订单"] S4 --> S5["⑤ 扣款"] S5 --> S6["⑥ 扣积分"] S6 --> S7["⑦ 清空购物车"] end subgraph 逆向补偿 direction LR F1["⑤' 退款"] --> F2["④' 取消订单"] F2 --> F3["③' 恢复库存"] end Fail{"失败"} -.-> F1 ```java try { // ①-⑧ 结账流程 } catch (RuntimeException exception) { // 补偿:恢复库存 for (MarketplaceApi.DeductInventoryRequest deducted : deductedInventory) { try { postVoid(properties.marketplaceServiceUrl() + MarketplaceApi.INVENTORY_RESTORE, new MarketplaceApi.RestoreInventoryRequest( deducted.productId(), deducted.quantity())); } catch (BusinessException | RestClientException compensationException) { log.error("Failed to restore inventory for product {}: {}", deducted.productId(), compensationException.getMessage()); } } // 补偿:退款 for (OrderApi.OrderResponse order : createdOrders) { try { post(properties.walletServiceUrl() + WalletApi.PAYMENT_REFUND, new WalletApi.CreateRefundRequest(...)); } catch (...) { log.error("Failed to refund for order {}: {}", order.id(), ...); } } // 补偿:退回积分 if (pointsUsed > 0) { try { post(properties.loyaltyServiceUrl() + LoyaltyApi.INTERNAL_EARN, new LoyaltyApi.EarnPointsRequest(buyerId, "CHECKOUT_REFUND", pointsUsed, ...)); } catch (...) { ... } } }

补偿策略:

  • 逆向顺序:先恢复库存、再退款、最后退回积分(与正向操作相反)
  • Fire-and-forget:每个补偿操作独立 try-catch,失败只记录日志不抛异常(避免补偿本身失败把原始异常完全淹没)
  • 持久化补偿任务:对于关键补偿(如库存恢复),marketplace-service 有 CompensationTaskEntity 持久化重试机制(指数退避,最大 5 次)

这里也要说清楚边界:当前 buyer-bff 里的补偿仍然偏应用层编排,适合说明思路,但还不是一个“所有补偿都可追踪、可审计、可人工介入”的完整工作流系统。

优雅降级:优惠不可用 ≠ 结账不可用#

结账流程中,某些依赖服务失败不应阻止用户完成购买:

优惠券验证降级

PromotionApi.CouponValidationResponse couponValidation = null;
if (request.couponCode() != null && !request.couponCode().isBlank()) {
    couponValidation = validateCouponForCheckout(request.couponCode(), cart.subtotal());
    // 如果 promotion-service 不可用,couponValidation 为 null
    // 结账继续进行,只是不使用优惠券折扣
}

public PromotionApi.CouponValidationResponse validateCouponForCheckoutFallback(
        String couponCode, BigDecimal orderAmount, Throwable throwable) {
    if (throwable instanceof BusinessException businessException
            && shouldPropagateCouponFailure(businessException)) {
        throw businessException;  // 业务错误(券无效/已过期)→ 硬失败
    }
    log.warn("promotion-service unavailable, skipping coupon {}: {}",
            couponCode, throwable.getMessage());
    return null;  // 系统错误 → 优雅降级:不使用优惠继续结账
}

积分抵扣降级

public boolean deductLoyaltyPointsForCheckoutFallback(...) {
    if (throwable instanceof BusinessException businessException
            && shouldPropagateLoyaltyFailure(businessException)) {
        throw businessException;  // 余额不足 → 硬失败
    }
    log.warn("loyalty-service unavailable, skipping points deduction for buyer {}: {}",
            buyerId, throwable.getMessage());
    return false;  // 系统错误 → 不使用积分继续结账
}

判断逻辑:业务逻辑错误(COUPON_INVALIDINSUFFICIENT_BALANCEVALIDATION_ERROR)在当前实现里会直接中断流程;系统错误(DOWNSTREAM_ERRORTIMEOUT)则触发优雅降级,跳过该步骤继续执行。

库存扣减不降级

public void deductInventoryForCheckoutFallback(...) {
    throw new BusinessException(CommonErrorCode.DOWNSTREAM_ERROR,
            "Marketplace inventory is temporarily unavailable", throwable);
}

库存是核心依赖,如果库存服务不可用,结账直接失败——因为没有库存确认的订单无法发货。


游客购物流:无需注册也能下单#

Shop Platform 的买家策略是 Guest-First——用户不需要注册就能完成浏览、加购物车、结账的完整流程。

游客身份识别#

游客买家 ID 以 "guest-buyer-" 为前缀;这个身份来自 auth-server 签发的 guest JWT,而不是 BFF 自己生成的会话标识:

public boolean isGuestBuyer(String buyerId) {
    return buyerId != null && buyerId.startsWith("guest-buyer-");
}

Redis 购物车#

游客购物车存储在 Redis 中,不使用数据库:

@Component
public class GuestCartStore {
    private static final String CART_KEY_PREFIX = "buyer:guest:cart:";
    private static final Duration DEFAULT_TTL = Duration.ofHours(48);

    private final RedissonClient redissonClient;

    public List<OrderApi.CartItemResponse> listCart(String buyerId) {
        RBucket<String> bucket = redissonClient.getBucket(CART_KEY_PREFIX + buyerId);
        String json = bucket.get();
        if (json == null) return List.of();
        return objectMapper.readValue(json, new TypeReference<>() {});
    }

    public void addToCart(String buyerId, OrderApi.CartItemRequest item) {
        List<OrderApi.CartItemResponse> items = listCart(buyerId);
        // 合并同类项逻辑
        redissonClient.getBucket(CART_KEY_PREFIX + buyerId)
                .set(objectMapper.writeValueAsString(items), DEFAULT_TTL.toMillis(), TimeUnit.MILLISECONDS);
    }
}

BFF 中的购物车操作根据买家身份自动路由:

public OrderApi.CartView listCart(String buyerId) {
    if (guestCartStore.isGuestBuyer(buyerId)) {
        return guestCartStore.listCart(buyerId);  // 走 Redis
    }
    return call("orderService", false,
            () -> orderClient.listCart(buyerId),  // 走 order-service DB
            "Cart service unavailable");
}

游客结账#

游客结账走 order-serviceGUEST_CHECKOUT 端点,创建的是带 order_token 的 GUEST 订单:

// GuestBuyerController
@PostMapping("/checkout")
public ApiResponse<OrderApi.OrderResponse> guestCheckout(
        @Valid @RequestBody OrderApi.GuestCheckoutRequest request) {
    return ApiResponse.success(service.guestCheckout(request));
}

// 游客订单追踪:通过 order_token 而非 buyerId
@GetMapping("/order/track")
public ApiResponse<OrderApi.OrderResponse> trackOrder(
        @RequestParam String token) {
    return ApiResponse.success(service.trackOrder(token));
}

游客凭 order_token 追踪订单,不需要登录。这保证了用户即使不注册也能知道自己的订单状态。

登录后购物车合并#

当游客注册/登录时,BFF 提供购物车合并端点:

@PostMapping("/cart/merge")
public ApiResponse<OrderApi.CartView> mergeCart(
        @RequestHeader("X-Buyer-Id") String headerBuyerId,
        @RequestHeader("X-Roles") String headerRoles,
        @Valid @RequestBody BuyerApi.MergeGuestCartRequest request) {
    requireSignedInBuyer(headerRoles, "Buyer cart merge");
    return ApiResponse.success(service.mergeGuestCart(
            requireAuthenticatedBuyerId(headerBuyerId, "Buyer cart merge"),
            request.guestBuyerId()));
}

合并逻辑:

  1. 读取 Redis 中游客购物车的所有商品
  2. 逐个调用 order-serviceaddToCart 加入登录用户的正式购物车
  3. 删除 Redis 中的游客购物车

这样游客在登录前已经加入的购物车内容通常可以继续保留。


DTO 组合与contract管理#

BFF 主要复用 shop-contracts 共享模块中的 DTO;自身只保留像 PageResponse 这样的聚合/分页包装对象:

// shop-contracts-buyer 中的 DashboardResponse
public record DashboardResponse(
        ProfileApi.ProfileResponse profile,       // 来自 profile-service
        WalletApi.WalletAccountResponse wallet,   // 来自 wallet-service
        List<PromotionApi.OfferResponse> promotions, // 来自 promotion-service
        List<MarketplaceApi.ProductResponse> marketplace, // 来自 marketplace-service
        LoyaltyApi.AccountResponse loyalty) {}    // 来自 loyalty-service

BFF 的职责是:

  1. 解包:从 ApiResponse<T> 中提取 data()
  2. 组合:将多个下游 DTO 组合成前端需要的结构
  3. 包装:用 ApiResponse.success(T) 返回

当搜索服务不可用时,BFF 还能将 marketplace 的商品列表映射为搜索响应格式,保证前端不会崩溃:

List<SearchApi.ProductHit> hits = fallback.products().stream()
        .map(product -> new SearchApi.ProductHit(
                product.id().toString(), product.sellerId(), product.name(),
                product.description(), product.price(),
                product.inventory() == null ? 0 : product.inventory(),
                product.categoryId(), product.categoryName(),
                product.imageUrl(), product.status()))
        .toList();

测试策略#

BFF 的测试分为三层:

1. 单元测试(Mockito)#

BuyerAggregationServiceTest 验证优雅降级逻辑:

@Test
void validateCouponFallback_rethrowsDomainCouponErrors() {
    // 业务错误(券无效)→ 在当前实现里继续抛出
    when(service.validateCoupon(...))
            .thenThrow(new BusinessException(CommonErrorCode.COUPON_INVALID, "..."));
    assertThrows(BusinessException.class, () -> ...);
}

@Test
void validateCouponFallback_skipsOnSystemFailures() {
    // 系统错误(服务不可用)→ 返回 null,继续结账
    when(service.validateCoupon(...))
            .thenThrow(new BusinessException(CommonErrorCode.DOWNSTREAM_ERROR, "..."));
    var result = service.validateCouponForCheckout(...);
    assertNull(result);
}

2. 控制器测试(@WebMvcTest)#

BuyerControllerTest 验证权限控制和指标采集:

@Test
void guestBuyerCannotMergeCart() {
    mockMvc.perform(post("/buyer/v1/cart/merge")
                    .header("X-Roles", "ROLE_BUYER_GUEST"))
            .andExpect(status().isForbidden());
}

@Test
void checkoutRecordsDurationMetrics() {
    mockMvc.perform(post("/buyer/v1/checkout/create"))
            .andExpect(status().isOk());
    assertThat(registry.find("shop_order_checkout_duration_seconds")
            .timer()).isNotNull();
}

3. WireMock contract testing(无 Spring 上下文)#

MarketplaceContractTestOrderContractTestLoyaltyContractTest 等使用 standalone WireMock 验证消费者侧的 JSON 反序列化和字段兼容性。这些测试不启动 Spring 上下文,不依赖 Redis,通过 mock ResilienceHelperGuestCartStore 实现快速执行;它们更接近"消费者兼容性校验",而不是完整的 provider contract 平台。


参考与实现位置#

  • Spring Framework REST Clients:https://docs.spring.io/spring-framework/reference/integration/rest-clients.html
  • Spring Blog: New in Spring 6.1 RestClient:https://spring.io/blog/2023/07/13/new-in-spring-6-1-restclient
  • Resilience4j Docs:https://resilience4j.readme.io/docs
  • 仓库实现入口:services/buyer-bff/src/main/java/dev/meirong/shop/buyerbff/service/BuyerAggregationService.javaservices/buyer-bff/src/main/java/dev/meirong/shop/buyerbff/service/GuestCartStore.javaservices/buyer-bff/src/main/java/dev/meirong/shop/buyerbff/config/BuyerBffConfig.javaservices/seller-bff/src/main/java/dev/meirong/shop/sellerbff/config/SellerBffConfig.javashared/shop-common/shop-common-core/src/main/java/dev/meirong/shop/common/resilience/ResilienceHelper.javashared/shop-common/shop-common-core/src/main/java/dev/meirong/shop/common/http/HeaderPropagationAutoConfiguration.java

小结#

BFF 聚合层在这个项目里的主要实现特点:

  • Virtual Threads 并发编排Executors.newVirtualThreadPerTaskExecutor() + Future.get(),同步代码享受异步并发
  • Resilience4j 四层防护:CircuitBreaker + Retry + Bulkhead + TimeLimiter,每个下游服务独立配置
  • Saga 结账补偿:逆向顺序补偿(库存→支付→积分),fire-and-forget 策略
  • 优雅降级:优惠/积分不可用时跳过而非阻断,库存不可用时硬失败
  • 游客购物车:Redis 存储、TTL 48 小时、登录后自动合并
  • contract驱动:核心 DTO 尽量来自 shop-contracts,聚合层只补少量包装对象

下一篇将深入 领域服务设计:每服务独立数据库模式、Outbox Pattern 实现、JPA 组织方式、以及补偿任务的持久化重试机制。

项目仓库:github.com/meirongdev/shop(公开,可结合源码一起看)