命令的定义边界
13.1 节搭好了领域模型,这一节把它包成命令。命令和事件的区别在 1.1 节已经讲清:事件是已发生的事实,命令是可以被拒绝的请求。借书、还书、续借三者的共同点是都需要校验前置条件,都有可能因为资源不足、版本冲突或者重复提交而失败。
命令设计的目标是三条:构造时拒绝明显非法输入;处理时按业务规则拒绝非法请求;每条被接受的命令恰好生成一个领域事件。
三种命令的类型设计
命令也是不可变值对象,因为同一条命令被投递两次时,两次的内容必须完全相同。下面三个命令分别对应借书、还书、续借:
final class BorrowBookCommand {
private final String commandId;
private final String memberId;
private final String bookIsbn;
private final int loanDays;
BorrowBookCommand(String commandId, String memberId, String bookIsbn, int loanDays) {
this.commandId = Objects.requireNonNull(commandId, "commandId");
this.memberId = Objects.requireNonNull(memberId, "memberId");
this.bookIsbn = Objects.requireNonNull(bookIsbn, "bookIsbn");
this.loanDays = loanDays;
validate();
}
private void validate() {
if (commandId.isEmpty()) throw new IllegalArgumentException("commandId empty");
if (!bookIsbn.matches("\\d{10}|\\d{13}")) throw new IllegalArgumentException("invalid ISBN");
if (loanDays < 1 || loanDays > 365) throw new IllegalArgumentException("loanDays out of range");
}
// getters...
}
BorrowBookCommand 的校验在构造函数里完成。这不是「先构造再校验」的两步式,而是一步否决:如果 ISBN 格式不对,对象根本不存在。Objects.requireNonNull 在参数为 null 时立即抛 NullPointerException,比 IllegalArgumentException 更明确地指出问题来源。
ReturnBookCommand 额外携带 expectedVersion,对应聚合根里的乐观并发控制。还书前先检查版本,防止两人同时操作同一笔借阅:
final class ReturnBookCommand {
private final String commandId;
private final String loanId;
private final String memberId;
private final long expectedVersion;
// constructor + getters
}
RenewLoanCommand 的结构与还书类似,区别是需要 extensionDays 来重新计算到期日:
final class RenewLoanCommand {
private final String commandId;
private final String loanId;
private final String memberId;
private final int extensionDays;
// constructor + getters
}
三个命令没有共享基类。命令之间共享字段(如 commandId、memberId)是偶然的,不是继承的理由。如果加了 Command 基类,编译器就无法阻止把事件对象误传给命令处理器。
命令结果:接受或拒绝
命令处理器返回的不是布尔值,是一个显式的结果对象:
enum CommandStatus { ACCEPTED, REJECTED }
class CommandResult {
final CommandStatus status;
final String reason;
final List<Object> events;
}
ACCEPTED 时 events 非空(通常只有一个),REJECTED 时 reason 非空且 events 为空。空列表不是 null,这样调用方不需要判 null 就能 for-each。
命令处理器:校验顺序决定错误信息
命令处理器是应用层,不直接修改领域对象的状态,而是先校验、再委派聚合根。校验顺序决定了用户看到的错误信息质量:
- 幂等检查:同一
commandId已处理过,直接拒绝重复提交。 - 身份校验:会员存在吗?借阅存在吗?
- 权限校验:这个会员能借这本书吗?借阅属于这个会员吗?
- 前置条件:版本号匹配吗?状态允许操作吗?
- 执行并生成事件。
CommandResult handle(BorrowBookCommand cmd) {
// 1. 幂等
if (processedCommands.contains(cmd.commandId())) {
return CommandResult.rejected("Duplicate command: " + cmd.commandId());
}
// 2. 存在性
MemberAgg member = members.get(cmd.memberId());
if (member == null) {
return CommandResult.rejected("Member not found: " + cmd.memberId());
}
// 3. 可用性
if (!member.canBorrow()) {
return CommandResult.rejected("Member loan limit reached");
}
// ... 同上检查图书
// 4. 执行
String loanId = generateLoanId();
Instant due = Instant.now().plus(cmd.loanDays(), ChronoUnit.DAYS);
book.available = false;
member.activeLoanCount++;
// 5. 生成事件
LoanCreatedEvent event = new LoanCreatedEvent(
loanId, cmd.bookIsbn(), cmd.memberId(),
Instant.now(), due, cmd.commandId());
processedCommands.add(cmd.commandId());
return CommandResult.accepted(Collections.singletonList(event));
}
注意幂等检查在第 1 步。如果把它放在第 5 步之后,那么重复提交刚好赶上版本冲突时,用户会先看到「版本不匹配」,而不是更准确的「这条命令已经处理过了」。
对于 ReturnBookCommand,检查顺序类似,但增加了版本比对和归属校验:
if (loan.version != cmd.expectedVersion()) {
return CommandResult.rejected(
"Version mismatch: expected " + cmd.expectedVersion() + ", got " + loan.version);
}
if (!loan.memberId.equals(cmd.memberId())) {
return CommandResult.rejected("Loan belongs to different member");
}
幂等键的存储位置
processedCommands 是内存 HashSet,生产环境需要换成持久化存储(Redis Set、数据库唯一索引或事件日志自身)。最可靠的做法是:命令处理器把 commandId 写入事件存储的去重索引,事件消费者在读流时先查该索引。第 9 章的事件存储设计里,commandId -> eventId 的映射是幂等的最终保障。
无论底层存储是什么,去重索引对外只有一个规则:同一 commandId 的第二次处理必须返回与第一次完全相同的返回值。如果第一次是 ACCEPTED + Event,第二次也是 ACCEPTED + Event;如果第一次是 REJECTED(“Member loan limit reached”),第二次也必须是同样的拒绝。这样才能保证命令重试是安全的。
编译运行
本节代码位于 LibrarydCommandDesign.java,JDK 8 验证通过:
javac LibrarydCommandDesign.java
java LibrarydCommandDesign
输出:
=== libraryd 命令设计与验证 ===
1. 借书命令验证
BorrowBook[cmd=CMD-001, member=M-1001, isbn=9780134685991] => ACCEPTED
event: LoanCreated[loan=L-1, book=9780134685991, due=2026-11-13T06:21:29.055Z, cmd=CMD-001]
重复提交 => REJECTED: Duplicate command: CMD-001
非法ISBN => 构造拒绝: invalid ISBN
2. 还书命令验证
ReturnBook(loan=L-1, v=1) => ACCEPTED
event: LoanReturned[loan=L-1, returnedAt=2026-10-30T06:21:29.106Z]
ReturnBook(loan=L-1, v=1, 再次) => REJECTED: Version mismatch: expected 1, got 2
会员不匹配 => REJECTED: Loan belongs to different member
3. 续借命令验证
第一次续借 => ACCEPTED
event: LoanRenewed[loan=L-2, due=2026-11-20T06:21:29.107Z, count=1]
第4次续借 => REJECTED: Max renewals (3) reached
4. 命令转事件映射
BorrowBook -> LoanCreated (1:1)
ReturnBook -> LoanReturned (1:1)
RenewLoan -> LoanRenewed (1:1)
每条命令最多生成一个领域事件
5. 当前状态
借阅笔数: 2
L-2 续借次数: 3, 版本: 4
=== 全部验证通过 ===
输出中的时间戳随运行时间变化,但五种校验场景全部覆盖:成功借书并生成事件、幂等拒绝、构造期 ISBN 格式拒绝、版本冲突拒绝、会员归属拒绝、续借上限拒绝。每个拒绝场景都有明确的 REJECTED + reason。
命令设计常见错误
| 错误 | 症状 | 修正 |
|---|---|---|
| 命令(payload) 和事件共用一个类型 | 消费者把请求当事实,无法拒绝 | 分类型、分包、分主题 |
| 命令处理器返回 void | 调用方不知道成功还是失败 | 返回显式结果对象 |
| 幂等检查放在「执行后」 | 重复执行导致副作用 | 幂等检查必须是第 1 步 |
| 命令生成多个事件 | 补发命令时事件数量不一致 | 1 条命令 -> 1 个聚合变更 -> 1 个事件 |
用 commandId 做日志但不入库 | 进程重启后幂等失效 | commandId 必须写入持久化索引 |
命令校验的层次模型
命令从 HTTP 请求到聚合根执行,要经过三层校验,每一层的目的和容错策略都不同:
| 层次 | 负责内容 | 失败响应 | 是否可恢复 |
|---|---|---|---|
| API 层 (DTO 绑定) | JSON 字段类型、必填项、格式正则 | 400 Bad Request | 修复参数后重试 |
| 命令对象层 (构造函数) | ISBN 长度、天数范围、null 检查 | IllegalArgumentException | 修复参数后重试 |
| 应用层 / 聚合根 | 会员是否存在、图书是否可借、版本号 | CommandResult.REJECTED | 部分可恢复(如版本冲突需刷新后重试) |
API 层的校验如果没过,命令对象根本不会被构造,聚合根也永远不会被访问。这叫做「快速失败」——越早拒绝的请求,对系统的冲击越小。BorrowBookCommand 的构造函数放在第 2 层,作用是防止「ISBN 格式错误」这种明显垃圾数据一路闯到数据库。
第 3 层的校验分两阶段:存在性校验(会员/图书在不在)和业务不变量校验(续借次数还有没有)。前者属于「读状态」,后者属于「写约束」。在 CQRS 分离的架构里,存在性校验可能查只读副本,业务不变量校验必须查写端聚合根——两者可能在不同的数据源上执行。
如果一个校验放在错误的层,后果很严重。比如把 ISBN 正则放在聚合根里,那每次借书都要先构造聚合根再检查,聚合根的创建成本白白浪费。反过来,如果把「会员借阅限额」放在 API 层(只查缓存),就会遗漏实时已借数量,导致超借。正确的分层是:API 层只验格式,命令层验范围,聚合根验状态一致性。
命令处理器的并发模型
本节示例的命令处理器没有显式处理并发,因为所有状态都在一个 HashMap 里,单线程执行就够了。但真实系统里,同一会员可能同时发起「借书」和「还书」两个命令,如果两个命令处理器并行执行,就会破坏状态一致性。
最直接的方案是对聚合根加锁:命令处理器拿到 loanId 后,先获取该 Loan 的分布式锁(Redis RedLock、ZooKeeper 或数据库行锁),再执行校验和状态变更。锁的粒度必须是单个聚合根,而不是全局锁——全局锁会让所有会员的借还操作串行执行,吞吐量暴跌。
锁的持有时间要尽可能短:只覆盖「读当前状态 + 校验 + 写新状态 + 生成事件」这四步。事件发送到 Kafka 的操作应该在释放锁之后进行,因为 Kafka Producer 的发送是异步的,持有锁等待网络 IO 没有意义。这意味着一个可能的失败模式是:状态已改、锁已放、但事件发送失败。第 4.2 节的外箱模式和第 9.3 节的事件存储设计会解决这个问题。
另一种方案是乐观并发放弃锁,只依赖 expectedVersion:命令处理器读状态、校验版本、生成事件,写聚合根时用 WHERE version = expectedVersion 的条件更新。如果版本已被别的命令改了,更新失败,命令被拒绝。这种方案适合冲突率低的场景(比如个人借还),不适合高频冲突场景(比如秒杀热门图书)。
本节边界与延伸
这一节的命令处理器用的还是内存存储,13.3 节的事件流会把这些事件写进可追踪的分区,用投影重建状态。两个节之间没有行为层断裂:命令处理器决定了事件的内容和顺序,事件流只负责保序投递。
命令到事件的映射是 1:1,但不是永远如此。批量命令(如「为会员批量续借所有即将过期的书」)可能生成多个事件,但这类命令在真正的 CQRS 系统里通常被拆成单独命令或用 Saga 编排。4.3 节的一致性边界和 5.2 节的幂等边界会深入讨论批量场景的折中。
小结
- 命令是值对象,构造时校验 ISBN 格式、天数范围,非法参数直接构造失败。
- 命令处理器分五步:幂等检查 → 存在性校验 → 权限校验 → 前置条件 → 执行并生成事件。
commandId是去重键,幂等索引必须持久化,重试必须返回与首次相同的结果。- 乐观并发用
expectedVersion,不匹配时拒绝命令并返回版本冲突信息。 - 一条命令对应一个聚合变更,对应一个领域事件;这是命令与事件之间最简单的映射。
命令在消息系统中的投递
本节所有命令都通过内存调用直接传递,但在真实的事件驱动架构中,命令通常跨越网络到达命令处理器。跨进程传递命令时有三个额外约束:
- 序列化:命令对象必须能被序列化为 JSON 或 Avro,且不能包含 Java 特有的类型(如
java.time.Instant在某些框架中需要自定义序列化器)。 - 超时:命令处理有业务超时和用户感知超时之分。业务超时是聚合根修改的截止时间,用户感知超时是 API 响应的截止时间。后者通常更短——比如命令在 2 秒内已经处理成功,但返回 HTTP 响应花了 3 秒,用户看到的是超时错误,而命令实际上已经生效。
- 最大重试次数:幂等只保证重试安全,不保证无限重试合理。恶意或异常客户端可能通过持续重试压垮系统。命令网关应设置每小时/每天的
commandId重试上限,超限后直接拒绝而不转发到命令处理器。
这些约束不影响本节的核心叙事(命令设计与校验),但会在第 5 章的幂等边界和第 8 章的流表对偶性中分别深入。
阅读导航:上一节 13.1 libraryd 领域模型 | 下一节 13.3 libraryd 事件流
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。