《Spring Boot 实战》18.2 实现与联调

把 18.1 的架构决策落成代码:分层骨架与关键接口、跨模块契约与统一错误码、借阅上限的乐观锁加唯一约束兜底、借书事件的可靠投递,并把单元、切片、Testcontainers 集成与契约测试串成四层测试,最后给出本地起依赖到联调前端的可复现步骤。

本节目标:把 18.1 的架构决策落成可运行代码——分层骨架、跨模块契约、并发与异步的关键写法,并给出一套「本地起全套依赖 → 跑集成测试 → 联调前端」的可复现流程。
适用版本:Spring Boot 4.1.x(Java 21)

18.2 实现与联调

18.1 定下了「账务同步、投影异步」这条线。本节要把它写出来。真实项目里,架构决策落不了地,多半卡在三处:跨模块调用的契约没定(于是 DTO 到处复制)、并发兜底只写了一半(乐观锁有、唯一约束没加,压测才爆)、测试只覆盖了理想路径(集成环境一联调就崩)。

这三处正是本节的重点。代码不求写满,重点是每个关键位置的取舍说明。

18.2.1 分层骨架与职责

按 1.3 分层与包结构约定 的约定,circulation 模块内部仍分四层,但每层职责要收紧:

包职责禁止
web协议转换、校验、鉴权入口写业务判断、直接调 repository
application用例编排、事务边界出现 SQL、返回实体
domain实体、值对象、领域规则依赖 Spring、依赖 web
infrastructure持久化、消息、外部客户端承载业务规则

关键接口骨架如下。application 层只暴露用例方法,参数与返回值都是 DTO,不泄漏实体:

public interface LoanUseCase {

    LoanView borrow(BorrowCommand command);

    LoanView returnLoan(Long loanId, String idempotencyKey);

    LoanView renew(Long loanId, String tenantId);
}

domain 层放不依赖框架的规则,例如「能否续借」的判断,用纯 Java 表达,方便单测:

public record Loan(Long id, Long copyId, Long memberId, LoanStatus status,
                   LocalDate dueDate, int renewCount, int maxRenew) {

    public boolean canRenew(LocalDate today, boolean reserved) {
        return status == LoanStatus.ACTIVE
            && !today.isAfter(dueDate)
            && renewCount < maxRenew
            && !reserved;
    }
}

把规则放在 domain 而不是 application,是因为它不依赖事务与 IO,可以毫秒级跑成千上万次单测——这是四层测试里最便宜的一层。

18.2.2 跨模块契约与统一错误码

circulation 调用 catalog 判断某册书是否可借,调用 member 判断会员是否有效。跨模块调用不共享实体,只共享一组稳定的接口与 DTO:

public interface CatalogPort {
    CopyStatus checkBorrowable(Long tenantId, Long copyId);
}

public interface MemberPort {
    boolean isActive(Long tenantId, Long memberId);
}

circulation 只依赖这两个接口,实现由 catalog/member 提供。这样 circulation 的单测可以注入桩实现,不必拉起整个数据库。

错误必须收敛到一处。用 5.3 幂等与并发控制 里的 ProblemDetail,配一个错误码枚举:

public enum LoanErrorCode {
    BOOK_ALREADY_LOANED(HttpStatus.CONFLICT, false),
    LOAN_LIMIT_EXCEEDED(HttpStatus.CONFLICT, false),
    LOAN_NOT_FOUND(HttpStatus.NOT_FOUND, false),
    STALE_VERSION(HttpStatus.PRECONDITION_FAILED, true),
    TENANT_MISMATCH(HttpStatus.FORBIDDEN, false);

    private final HttpStatus status;
    private final boolean retryable;
    // 构造与 getter 省略
}

统一的异常处理器把领域异常转成 ProblemDetail,并把 retryable 写进响应体:

@RestControllerAdvice
class LoanExceptionHandler {

    @ExceptionHandler(LoanDomainException.class)
    ProblemDetail handle(LoanDomainException ex) {
        LoanErrorCode code = ex.code();
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(code.status(), ex.getMessage());
        pd.setProperty("code", code.name());
        pd.setProperty("retryable", code.retryable());
        return pd;
    }
}

这样客户端拿到的错误既有机器可读的 code,也有「能不能重试」的 retryable,不必靠猜状态码。

18.2.3 并发:乐观锁加唯一约束兜底

借书要同时满足两个约束:同一册书只能被一人持有,同一会员在借数不超上限。两者机制不同:

  • 单册唯一:用数据库唯一约束兜底。在 book_copy 上加「状态为 LOANED 时唯一」的部分索引,或让 loan 表上 (copy_id, status) 对活跃记录唯一。并发下两个请求都通过应用层校验时,数据库会拒掉第二个。
  • 会员上限:用乐观锁处理。读取会员当前在借数,更新时带版本号,冲突则重试。
@Transactional
public LoanView borrow(BorrowCommand command) {
    MemberLoanCounter counter = counterRepository
        .findByTenantAndMember(command.tenantId(), command.memberId())
        .orElseThrow(() -> new LoanDomainException(LoanErrorCode.LOAN_NOT_FOUND));

    if (counter.getActiveCount() >= counter.getMaxLimit()) {
        throw new LoanDomainException(LoanErrorCode.LOAN_LIMIT_EXCEEDED);
    }
    counter.increment();                 // @Version 字段,提交时带 WHERE version=?
    // ... 写 loan、更新 copy 状态、写 outbox(同一事务)
}

@Version 与唯一约束是两层不同的防线,不能互相替代,详见 12.2 乐观锁与悲观锁 。乐观锁防的是「基于旧值的更新」,唯一约束防的是「两个请求都以为自己是第一个」。少了任何一层,压测到高并发就会漏。

对于秒杀式的热门书抢借,乐观锁会大量失败重试,此时改用 SELECT ... FOR UPDATE 悲观锁更稳——见 12.2 的选型表。

18.2.4 异步:借书成功事件的可靠投递

按 18.1 的 ADR-03,事件走事务性发件箱。核心是业务写入与事件写入在同一事务:

@Entity
@Table(name = "outbox_event")
public class OutboxEvent {

    @Id
    @GeneratedValue
    private Long id;

    @TenantId
    private String tenantId;

    private String eventType;      // LoanCreated
    private String payload;        // JSON
    private Instant createdAt;
    private Instant publishedAt;   // null 表示未投递
    // getter / setter 省略
}

在借书事务里顺手写一条 outbox,提交后由独立投递器扫描未投递记录:

@Scheduled(fixedDelayString = "${app.outbox.poll-interval:2000}")
public void publishPending() {
    List<OutboxEvent> batch = outboxRepository.findTop100ByPublishedAtIsNullOrderByIdAsc();
    for (OutboxEvent event : batch) {
        messageSender.send(event.getEventType(), event.getPayload());
        event.markPublished(Instant.now());
    }
}

要点:投递是至少一次,所以订阅方必须幂等(用事件 id 去重)。发送成功后才标记 publishedAt,中途崩溃会重发,不会丢。这条链路详见 10.3 可靠投递 。

这里不引入分布式事务:账务与事件同库同事务,投递靠扫描重试,用最终一致换取可用性,符合 18.1 的 ADR-03。

18.2.5 四层测试各覆盖什么

测试体系见 4.1 测试策略 。四层的分工必须清晰,否则会出现「单测里拉数据库、集成测试只测了个 200」。

层工具覆盖不覆盖
单元JUnit + 断言领域规则(续借条件、罚金计算)事务、HTTP
切片@WebMvcTest / @DataJpaTest参数校验、序列化、查询跨模块编排
集成Testcontainers 起真实库事务、唯一约束、租户过滤外部 MQ(用桩)
契约契约测试工具接口形状与错误码业务逻辑

集成测试用 Testcontainers 起真实数据库,验证并发与约束——这类问题在 H2 上测不出来,见 4.2 Testcontainers :

@Testcontainers
@SpringBootTest
class BorrowConcurrencyIT {

    @Container
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", db::getJdbcUrl);
    }

    @Test
    void duplicateBorrowOnSameCopyFails() {
        // 两个线程抢同一册书,断言只有一个成功
    }
}

契约测试覆盖 4.3 契约与数据隔离 讲过的接口形状,确保 catalog 与 circulation 对 CopyStatus 的理解一致。契约测试的价值在联调前就能发现字段语义分歧,而不是等前端联调时才发现。

多租户的越权用例属于集成层必测项:北区分馆的请求必须查不到南区的数据,见 9.3 方法级授权与多租户 。

18.2.6 本地起全套依赖与联调

联调前先让本地能一键起依赖。用 Compose 描述(示例配置,非实测输出):

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: library
      POSTGRES_PASSWORD: local
    ports:
      - "5432:5432"
  redis:
    image: redis:7
    ports:
      - "6379:6379"

可复现流程固定为五步:

# 1. 起依赖
docker compose up -d

# 2. 迁移到最新(Flyway 随应用启动执行,也可单独跑)
mvn -q spring-boot:run -Dspring-boot.run.profiles=local

# 3. 跑集成测试(含 Testcontainers)
mvn -q verify

# 4. 起应用(另一终端)
mvn -q spring-boot:run -Dspring-boot.run.profiles=local

# 5. 冒烟:借一本书
curl -s -X POST localhost:8080/api/loans \
  -H 'Content-Type: application/json' \
  -H 'X-Tenant-Id: branch-north' \
  -d '{"copyId":42,"memberId":7}'

前端联调时最容易出问题的是租户头与错误码。约定 X-Tenant-Id 由网关注入、前端不伪造;错误统一读 ProblemDetail.code。联调环境的数据用种子脚本准备,避免「本地库是空的,前端一点就 404」。

spring-boot:run 的启动日志形态可参考本卷实测:4.1.1 主线启动约 1 秒,Tomcat 相关日志包名为 o.s.boot.tomcat.*(4.x 模块化后的新包名,不再是 3.x 的 o.s.b.w.embedded.tomcat.*)。

18.2.7 常见坑

  • 跨模块共享实体。 一旦 circulation 直接引用 catalog 的 @Entity,两个模块的变更就绑死了,拆模块的意义归零。
  • 并发兜底只做一层。 只有乐观锁没有唯一约束,或反之,高并发下都会漏。
  • outbox 与业务不在同一事务。 分成两次提交,崩溃窗口就丢事件,对账对不上。
  • 集成测试用 H2。 H2 不支持部分唯一索引、@TenantId 行为也可能不同,测不出真实约束。
  • 本地依赖靠人肉记忆。 不写 Compose 与步骤,换台机器就要重新摸索半天。

小结

  • 分层职责收紧:web 只做协议、application 管事务、domain 放规则、infrastructure 管 IO。
  • 跨模块只共享接口与 DTO,错误统一走 ProblemDetail + 错误码枚举,带上 retryable。
  • 借阅上限用乐观锁,单册唯一用数据库唯一约束,两层缺一不可。
  • 事件用事务性发件箱,至少一次投递,订阅方靠事件 id 幂等。
  • 四层测试分工明确:单测管规则、切片管协议、集成管约束、契约管形状。
  • 本地联调要有 Compose 与固定步骤,租户头与错误码是联调高频雷区。

代码联调通过只是「能跑」。18.3 讲「敢上线」:上线检查清单、灰度策略、上线后看什么指标、告警阈值怎么定,以及出问题时怎么回滚。

阅读导航:上一节:18.1 需求与架构 · 下一节:18.3 上线、观测与回滚 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计