本节目标:把 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 上线、观测与回滚 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。