概述
系列定位说明:本文是《领域驱动设计与业务架构》系列的第13篇,聚焦于"代码表达力"。在前十二篇完成了战略设计、战术DDD、模块化单体、事件驱动、领域事件、Event Sourcing、防腐层、聚合设计、K8s部署、微服务拆分、遗留重构到业务中台之后,本文将视角下沉到最微观的代码层面------如何让每一行代码都说话,让领域模型的设计意图不再被技术细节淹没。陈述式代码是DDD统一语言在代码中的最终闭环。
总结性引言 :订单模块的代码通过了所有测试,上线运行稳定。但架构师在代码审查中皱起眉头:order.setStatus("PAID")出现在6个不同的Service类中;一个简单的"查询当前用户所有待支付订单"需求,需要在一个300行的Service方法中解读一连串if (order.getStatus().equals("PENDING"))才能理解;订单状态能从"已发货"直接变成"已取消"的逻辑漏洞,因为setStatus没有任何约束。代码能跑,但它不会"说话"------读代码的人无法从中看出订单的生命周期规则、无法快速定位查询条件的业务含义、无法信任状态的合法性。陈述式代码解决这些问题的途径是:order.confirmPayment()代替order.setStatus("PAID"),状态机DSL定义合法转换路径,byStatus(PAID).and(byCustomerId(userId))代替晦涩的JPA Criteria,@PreAuthorize("@orderAuth.isOwner(principal, #orderId)")代替Controller中的if-else权限判断。本文以电商订单系统的订单生命周期为案例,从贫血的setStatus+if-else代码出发,逐步引入状态机DSL、Builder、Specification和SpEL,展示如何将"能跑的代码"重构为"会说话的代码"------让代码本身成为统一语言的最佳载体。
核心要点:
- 陈述式 vs 命令式:陈述式表达"做什么",命令式暴露"怎么做"。
order.confirm()vsorder.setStatus()。 - 状态机 DSL:建模订单生命周期(PENDING→PAID→SHIPPED→DELIVERED),声明转换规则,非法路径自动拒绝。
- Builder 模式:
Order.builder().items(items).recipient(addr).build()内部校验,链式调用表达构建意图。 - Specification 模式:
byStatus(PAID).and(byCustomerId(userId))可组合查询,替代方法爆炸。 - SpEL 权限:
@PreAuthorize("@orderAuth.isOwner(principal, #orderId)")声明式表达业务权限。 - 重构路径:贫血
setter+if-else→ 行为封装 → 状态机 → Builder + Specification + SpEL。
文章组织架构图:
架构图说明:
- 总览说明:全文从陈述式理念出发,逐一深入状态机、Builder、Specification、SpEL四种代码模式,再给出反模式和重构路径,最后以贯穿案例和面试题收尾。
- 逐模块说明:模块1建立陈述式代码的认知标准;模块2-5是四种核心陈述式代码模式的设计与实现;模块6-7提供从坏代码到好代码的重构方法和理论闭环;模块8用电商订单重构串联全部知识点;模块9-10缝合系列并巩固。
- 关键结论:陈述式代码不是"代码美学",而是"统一语言的代码化"。当领域专家能读懂代码,当业务规则在代码中一目了然,当状态的合法性由状态机而非if-else保障,领域驱动设计才真正从架构图和文档中落地到了每一行代码里。
1. 陈述式代码的核心理念与命令式对比
1.1 陈述式设计的本质
Eric Evans在《领域驱动设计》第10章中提出了"陈述式设计"(Declarative Design)的概念:代码应该清晰地表达出领域模型的结构和行为,而不是被技术实现细节所掩盖。陈述式代码并非一种具体的设计模式,而是一种编码哲学------它追求的是让代码的阅读者能够直接理解"业务在做什么",而无需解析底层技术操作。
在Java企业开发中,命令式代码泛滥的根本原因在于开发者习惯于将实体视为数据容器(贫血模型),然后通过服务过程式地编排这些容器。这种做法割裂了数据与行为,导致业务规则散布于Service层各处,形成重复、难以维护的代码。陈述式代码的核心原则则是:
- 封装行为:将状态变更包装为有意义的业务动作(方法名即业务意图)。
- 隐藏机制:调用者不需要知道内部如何实现状态校验、事件发布或持久化。
- 使用领域语言:类名、方法名、变量名均取自统一语言术语表。
- 组合胜于步骤:通过组合模式(Builder、Specification、状态机)构建复杂操作,而非暴露一系列setter或if-else。
1.2 命令式代码的问题
以订单支付为例,典型的命令式代码如下:
java
// 命令式:暴露每一步技术操作
if (order.getStatus().equals("PENDING")) {
order.setStatus("PAID");
order.setPaidAt(LocalDateTime.now());
orderRepository.save(order);
notificationService.sendPaymentConfirmation(order);
} else {
throw new RuntimeException("订单状态不允许支付");
}
这段代码的问题在于:
- 重复性 :同样的状态检查和
setStatus调用可能出现在多个Service方法中(如定时取消、管理后台强制支付等),每次重复都可能遗漏或写错。 - 脆弱性 :状态值是一个字符串(
"PAID"),任何拼写错误只能在运行时发现。如果某天决定增加"REFUNDING"状态,所有字符串比较处都需要排查。 - 意图不明 :阅读者必须解析if条件和setter调用才能推断出"这是在确认支付",而不是直接看到
confirmPayment()。 - 规则散落:业务规则(哪些状态可以转换为哪些状态)没有集中定义,改动时需要修改多个类。
1.3 陈述式代码的对比
陈述式版本如下:
java
// 陈述式:调用业务动作,隐藏实现
order.confirmPayment(); // 方法名即意图
orderRepository.save(order);
聚合根内部封装逻辑:
java
public class Order {
private OrderStatus status;
// ...
public void confirmPayment() {
if (this.status != OrderStatus.PENDING) {
throw new InvalidOrderStateTransitionException(this.status, OrderEvent.PAY);
}
this.status = OrderStatus.PAID;
this.paidAt = LocalDateTime.now();
DomainEvents.raise(new OrderConfirmed(this));
}
}
可见,业务规则被内聚在聚合根中,调用方只需表达"确认支付"的意图。即使未来的实现变成通过状态机驱动,调用方代码也无需改变。
1.4 陈述式代码的价值
- 可读性:代码如同业务文档,新成员可以快速理解系统在做什么。
- 可维护性:修改状态转换规则只需改动一处(聚合根或状态机配置),不会遗漏。
- 安全性:禁止外部直接修改状态字段,杜绝非法状态变更。
- 与领域专家的协作:领域专家虽然不懂Java,但能阅读方法名、Specification链,参与代码审查验证业务准确性。
《Clean Code》中强调"代码应该读起来像是写好的散文"。陈述式代码就是这种理念在DDD中的实践。
2. 领域状态机DSL的设计与实现
2.1 订单生命周期建模
电商订单的核心状态包括:PENDING(待支付)、PAID(已支付)、SHIPPED(已发货)、DELIVERED(已签收)、CANCELLED(已取消)、RETURNED(已退货)。合法转换路径如下:
PENDING → PAID:支付成功。PENDING → CANCELLED:超时或用户主动取消。PAID → SHIPPED:商家发货。SHIPPED → DELIVERED:物流签收。DELIVERED → RETURNED:用户退货。- 可能路径:
PAID → CANCELLED(未发货退款),本案例暂不深入。
非法路径如SHIPPED → CANCELLED(已发货不能直接取消)、DELIVERED → SHIPPED(不能逆向发货)必须被禁止。下图展示了合法与非法转换的可视化。
图表主旨概括:该图展示了订单生命周期中的合法状态转换路径(实线)与典型的非法转换(虚线并标记"X"),明确哪些业务操作在特定状态下是允许的。
逐层/逐元素分解 :节点代表订单状态(PENDING, PAID, SHIPPED, DELIVERED, CANCELLED, RETURNED),边代表领域事件驱动的转换。实线边表示合法转换,如PENDING经"支付"事件转为PAID;虚线加粗X形标记表示禁止转换,如已发货的订单不能直接取消。
设计原理映射:状态机DSL将这些转换规则显式化,避免分散在多个Service方法中的if-else判断。它遵循状态模式(State Pattern)的思想,将状态相关行为封装为有限状态自动机,确保聚合根状态一致性。
工程联系与关键结论加粗 :在实际编码中,任何试图从SHIPPED直接执行cancel()的调用都将在状态机层被拦截并抛出InvalidOrderStateTransitionException,从根本上杜绝业务规则的绕过。
2.2 纯Java DSL实现:聚合根内部状态表
在状态数量有限且转换相对固定的场景下,可通过在聚合根内部维护一个状态转换表来实现轻量级状态机。这种方式没有外部框架依赖,代码透明且易于测试。
状态与事件枚举:
java
public enum OrderStatus {
PENDING, PAID, SHIPPED, DELIVERED, CANCELLED, RETURNED
}
public enum OrderEvent {
PAY, CANCEL, SHIP, DELIVER, RETURN
}
聚合根实现:
java
public class Order {
private Long id;
private OrderStatus status;
private LocalDateTime paidAt;
private LocalDateTime shippedAt;
private LocalDateTime deliveredAt;
// 其他字段...
// 状态转换表:当前状态 -> 允许的事件集合
private static final Map<OrderStatus, Set<OrderEvent>> allowedTransitions = new EnumMap<>(OrderStatus.class);
private static final Map<OrderEvent, OrderStatus> transitionTarget = new EnumMap<>(OrderEvent.class);
static {
allowedTransitions.put(OrderStatus.PENDING, EnumSet.of(OrderEvent.PAY, OrderEvent.CANCEL));
allowedTransitions.put(OrderStatus.PAID, EnumSet.of(OrderEvent.SHIP));
allowedTransitions.put(OrderStatus.SHIPPED, EnumSet.of(OrderEvent.DELIVER));
allowedTransitions.put(OrderStatus.DELIVERED, EnumSet.of(OrderEvent.RETURN));
// CANCELLED、RETURNED 为终态,无出边
transitionTarget.put(OrderEvent.PAY, OrderStatus.PAID);
transitionTarget.put(OrderEvent.CANCEL, OrderStatus.CANCELLED);
transitionTarget.put(OrderEvent.SHIP, OrderStatus.SHIPPED);
transitionTarget.put(OrderEvent.DELIVER, OrderStatus.DELIVERED);
transitionTarget.put(OrderEvent.RETURN, OrderStatus.RETURNED);
}
// 陈述式方法:确认支付
public void confirmPayment() {
applyEvent(OrderEvent.PAY);
}
public void cancel() {
applyEvent(OrderEvent.CANCEL);
}
public void ship() {
applyEvent(OrderEvent.SHIP);
}
public void deliver() {
applyEvent(OrderEvent.DELIVER);
}
public void returnOrder() {
applyEvent(OrderEvent.RETURN);
}
private void applyEvent(OrderEvent event) {
Set<OrderEvent> allowed = allowedTransitions.getOrDefault(this.status, Collections.emptySet());
if (!allowed.contains(event)) {
throw new InvalidOrderStateTransitionException(this.status, event);
}
this.status = transitionTarget.get(event);
// 根据事件设置对应的时间戳
switch (event) {
case PAY:
this.paidAt = LocalDateTime.now();
DomainEvents.raise(new OrderConfirmed(this.id));
break;
case CANCEL:
DomainEvents.raise(new OrderCancelled(this.id));
break;
case SHIP:
this.shippedAt = LocalDateTime.now();
DomainEvents.raise(new OrderShipped(this.id));
break;
case DELIVER:
this.deliveredAt = LocalDateTime.now();
DomainEvents.raise(new OrderDelivered(this.id));
break;
case RETURN:
DomainEvents.raise(new OrderReturned(this.id));
break;
}
}
// 查询方法(陈述式)
public boolean canConfirmPayment() {
return allowedTransitions.getOrDefault(status, Collections.emptySet()).contains(OrderEvent.PAY);
}
// getters(仅允许JPA访问,可通过@Access(AccessType.FIELD)限制)
}
设计解读 :applyEvent私有方法集中了状态转换的校验和执行。所有公开的业务方法(confirmPayment、cancel等)均委托给它,不仅避免了重复的if-else,而且使得状态迁移规则一目了然。这种方法实现了"单一变化点"------当需要添加新状态或新事件时,只需修改静态映射表及applyEvent内的分支处理。
2.3 Spring Statemachine实现
对于具有正交状态、子状态、历史状态,或者需要复杂监听器、拦截器、持久化能力的场景,Spring Statemachine提供了强大的状态机框架。
2.3.1 配置状态机工厂
java
@Configuration
@EnableStateMachineFactory
public class OrderStateMachineConfig extends StateMachineConfigurerAdapter<OrderStatus, OrderEvent> {
@Override
public void configure(StateMachineStateConfigurer<OrderStatus, OrderEvent> states) throws Exception {
states
.withStates()
.initial(OrderStatus.PENDING)
.states(EnumSet.allOf(OrderStatus.class));
}
@Override
public void configure(StateMachineTransitionConfigurer<OrderStatus, OrderEvent> transitions) throws Exception {
transitions
.withExternal().source(OrderStatus.PENDING).target(OrderStatus.PAID).event(OrderEvent.PAY)
.and()
.withExternal().source(OrderStatus.PENDING).target(OrderStatus.CANCELLED).event(OrderEvent.CANCEL)
.and()
.withExternal().source(OrderStatus.PAID).target(OrderStatus.SHIPPED).event(OrderEvent.SHIP)
.and()
.withExternal().source(OrderStatus.SHIPPED).target(OrderStatus.DELIVERED).event(OrderEvent.DELIVER)
.and()
.withExternal().source(OrderStatus.DELIVERED).target(OrderStatus.RETURNED).event(OrderEvent.RETURN);
}
// 可选配置:状态改变监听器
@Override
public void configure(StateMachineConfigurationConfigurer<OrderStatus, OrderEvent> config) throws Exception {
config.withConfiguration()
.listener(new OrderStateMachineListener());
}
}
2.3.2 状态机服务组件
聚合根不应该直接依赖StateMachineFactory,而是通过一个领域服务(或封装器)来执行状态转换。这样聚合根仍然是纯粹的POJO,而状态机的运行交给基础设施服务。
java
@Component
public class OrderStateMachineProcessor {
private final StateMachineFactory<OrderStatus, OrderEvent> factory;
public OrderStateMachineProcessor(StateMachineFactory<OrderStatus, OrderEvent> factory) {
this.factory = factory;
}
/**
* 应用事件到订单状态机,返回新状态
*/
public OrderStatus fire(OrderStatus currentStatus, OrderEvent event) {
StateMachine<OrderStatus, OrderEvent> sm = factory.getStateMachine();
// 重置状态机到当前订单的状态
sm.getStateMachineAccessor().doWithAllRegions(access ->
access.resetStateMachineReactively(
new DefaultStateMachineContext<>(currentStatus, null, null, null)
).block()
);
sm.startReactively().block();
boolean accepted = sm.sendEvent(Mono.just(MessageBuilder.withPayload(event).build()))
.blockLast()
.getResultType() == ResultType.ACCEPTED;
if (!accepted) {
throw new InvalidOrderStateTransitionException(currentStatus, event);
}
return sm.getState().getId();
}
}
2.3.3 聚合根集成
聚合根仍然暴露陈述式方法,但内部委托给OrderStateMachineProcessor。
java
public class Order {
private OrderStatus status;
// ... 其他字段
public void confirmPayment(OrderStateMachineProcessor processor) {
this.status = processor.fire(this.status, OrderEvent.PAY);
this.paidAt = LocalDateTime.now();
DomainEvents.raise(new OrderConfirmed(this.id));
}
// 其他方法类似
}
为了避免聚合根依赖外部服务,也可以将状态机处理器作为应用服务的一部分,应用服务先通过处理器获取新状态,再调用聚合根的内部状态设置方法,但这样会略微泄漏步骤。更好的平衡是在应用服务层调用聚合根的confirmPayment()等陈述式方法,而这些方法内部不直接依赖状态机处理器,而是通过一个单例的纯Java状态表(如2.2节)或构造时注入策略。两种方式各有利弊,纯Java DSL更干净,Spring Statemachine功能更强,选择取决于项目复杂度。
2.4 状态机与领域事件的配合
每次成功转换后发布领域事件是关键步骤。领域事件连接了状态机与CQRS读写分离、事件驱动流程(参见本系列第4篇)。事件发布通常使用DomainEvents静态工具类(如Spring Data的AbstractAggregateRoot或自定义机制)。例如:
java
private void applyEvent(OrderEvent event) {
// ...状态转换后
switch (event) {
case PAY:
registerEvent(new OrderConfirmed(this.id, this.customerId));
break;
// ...
}
}
注意 :事件应该在聚合根的持久化之前注册,然后在资源库save时统一发布,保证原子性(结合@DomainEvents注解)。详细实现可参考系列第5篇关于Outbox模式的讨论。
2.5 序列图:状态机执行流程
图表主旨概括 :该序列图详细展示了从应用服务调用confirmPayment(),到状态机处理器执行转换,再到领域事件注册和持久化的完整流程。
逐层/逐元素分解 :应用服务调用聚合根陈述式方法;聚合根委托StateMachineProcessor;处理器创建状态机实例并注入当前状态,发送事件;状态机执行校验并返回新状态;聚合根更新字段并注册领域事件;资源库在save时通过Spring Data机制发布事件。
设计原理映射 :聚合根不直接依赖状态机框架,保持了纯领域对象的可测试性。StateMachineProcessor作为基础设施服务,封装了Spring Statemachine的具体操作,符合六边形架构的端口-适配器思想。
工程联系与关键结论加粗 :在分布式场景下,状态机的状态需要持久化以防实例丢失。可使用数据库存储状态机上下文(StateMachinePersist接口),每次操作从数据库加载并执行转换后再持久化,保证状态一致性。
3. Builder模式构建复杂领域对象
3.1 复杂构建的挑战
聚合根Order往往包含多个必填和可选参数:客户ID、订单项列表、收货地址、优惠券、备注等。直接使用构造函数会导致参数爆炸:
java
public Order(Long customerId, List<OrderItem> items, Address recipient, Coupon coupon, String note) {
// 冗长且调用时难以阅读
}
使用无参构造+setter则让对象在构造过程中处于不完整状态,违反聚合根的一致性原则。Builder模式提供了解决方案:渐进式提供参数,并在最终build()时一次性校验。
3.2 手写Builder与内部校验
java
public class Order {
private Long id;
private Long customerId;
private List<OrderItem> items;
private Address recipient;
private String note;
private OrderStatus status;
private LocalDateTime createdAt;
// 私有构造,仅Builder调用
private Order() {}
public static OrderBuilder builder() {
return new OrderBuilder();
}
public static class OrderBuilder {
private Long customerId;
private List<OrderItem> items;
private Address recipient;
private String note;
public OrderBuilder customerId(Long customerId) {
this.customerId = customerId;
return this;
}
public OrderBuilder items(List<OrderItem> items) {
this.items = items;
return this;
}
public OrderBuilder recipient(Address recipient) {
this.recipient = recipient;
return this;
}
public OrderBuilder note(String note) {
this.note = note;
return this;
}
public Order build() {
// 校验
if (customerId == null) {
throw new OrderCreationException("Customer ID is required");
}
if (items == null || items.isEmpty()) {
throw new OrderCreationException("Order must contain at least one item");
}
for (OrderItem item : items) {
if (item.getQuantity() <= 0) {
throw new OrderCreationException("Item quantity must be positive");
}
if (item.getProductId() == null) {
throw new OrderCreationException("Product ID is required for each item");
}
}
if (recipient == null || recipient.getAddressLine() == null) {
throw new OrderCreationException("Recipient address is required");
}
Order order = new Order();
order.customerId = this.customerId;
order.items = new ArrayList<>(this.items); // 防御性拷贝
order.recipient = this.recipient;
order.note = this.note;
order.status = OrderStatus.PENDING;
order.createdAt = LocalDateTime.now();
return order;
}
}
// ...业务方法
}
调用示例:
java
Order newOrder = Order.builder()
.customerId(userId)
.items(List.of(new OrderItem(productId, 2)))
.recipient(address)
.note("请尽快发货")
.build();
3.3 Lombok @Builder的使用与局限
使用Lombok的@Builder可以减少模板代码,但默认的build()方法不包含自定义校验。我们可以结合手写的build()方法或利用@Builder的内部钩子:
java
@Builder
public class Order {
// ...字段
// 手写Builder内部类覆盖Lombok生成的,但较复杂,通常采用以下方式:
@Builder
public Order(Long customerId, List<OrderItem> items, Address recipient, String note) {
// 校验写在构造函数中
if (customerId == null) throw ...;
if (items == null || items.isEmpty()) throw ...;
if (recipient == null) throw ...;
this.customerId = customerId;
this.items = new ArrayList<>(items);
// ...其他赋值
}
}
这种方式利用@Builder注解在构造函数上,校验逻辑写入构造函数,由Builder最终调用带参构造触发。这是一种折中,但会将构造与校验混合,且不利于维护复杂的构建步骤。对于聚合根,建议使用手写Builder以获得完全的控制力。
3.4 Builder与工厂方法对比
| 维度 | 工厂方法 Order.create(cmd) |
Builder模式 |
|---|---|---|
| 参数数量 | 适合少量固定参数 | 适合多参数、可选参数 |
| 可读性 | 一般,需查看方法签名 | 很高,链式调用如自然语言 |
| 复杂度 | 简单 | 增加Builder类,适合复杂构造 |
| 灵活性 | 低,需新增方法处理参数组合 | 高,步骤自由组合 |
| 不变量校验 | 方法内校验 | build()集中校验 |
当聚合根构造涉及多个必填项和大量可选配置时,Builder是更佳选择。如果构造逻辑非常简单(如只有两个参数),工厂方法更轻量。
3.5 Builder构建流程图
图表主旨概括 :该流程图展示了Builder逐步设置属性后,在build()方法中执行多步校验,确保所有必须参数满足业务规则后才生成一致的聚合根对象。
逐层/逐元素分解 :链式调用依次设置customerId、items、recipient、note;build()按优先级检查每个必填字段,任意失败即抛异常;全部通过后创建实例,赋予初始状态PENDING和创建时间。
设计原理映射:Builder实现了构造过程与表示分离,内置的不变量检查体现了聚合根必须始终处于一致状态的要求。步骤命名直接映射领域概念,使构建代码本身成为统一语言的表达。
工程联系与关键结论加粗 :Builder内部应始终对集合类字段进行防御性拷贝,避免外部修改影响聚合根内部状态。同时,所有校验异常应使用明确的业务异常类型,便于上层统一处理。
4. Specification模式封装可复用查询
4.1 传统DAO方法爆炸问题
在基于Spring Data JPA的传统开发中,自定义查询通常通过接口方法命名规则实现:
java
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByStatus(OrderStatus status);
List<Order> findByStatusAndCustomerId(OrderStatus status, Long customerId);
List<Order> findByStatusAndCustomerIdAndCreatedAtBetween(OrderStatus status, Long customerId, LocalDateTime from, LocalDateTime to);
// ...更多组合
}
随着查询条件增加,方法数量呈组合爆炸增长,导致Repository接口臃肿,且任何新条件都需要修改接口。Specification模式将查询条件封装为可组合的对象,完美解决了这一问题。
4.2 JPA Specification接口
Spring Data JPA提供了Specification<T>接口(源自Eric Evans的Specification模式),核心方法是toPredicate。通过JpaSpecificationExecutor<T>接口,Repository获得findAll(Specification<T> spec)能力。
4.3 领域层Specification工厂
将可复用的查询条件定义为静态工厂方法,放置在领域层,避免基础设施细节泄漏:
java
public class OrderSpecifications {
public static Specification<Order> byStatus(OrderStatus status) {
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<Order> byCustomerId(Long customerId) {
return (root, query, cb) -> cb.equal(root.get("customerId"), customerId);
}
public static Specification<Order> byDateRange(LocalDate from, LocalDate to) {
return (root, query, cb) -> {
if (from == null && to == null) return cb.conjunction(); // 无过滤
if (from != null && to != null) {
return cb.between(root.get("createdAt"), from.atStartOfDay(), to.atTime(LocalTime.MAX));
}
if (from != null) {
return cb.greaterThanOrEqualTo(root.get("createdAt"), from.atStartOfDay());
}
return cb.lessThanOrEqualTo(root.get("createdAt"), to.atTime(LocalTime.MAX));
};
}
public static Specification<Order> withItemsContainingProduct(Long productId) {
return (root, query, cb) -> {
Subquery<OrderItem> subquery = query.subquery(OrderItem.class);
Root<OrderItem> itemRoot = subquery.from(OrderItem.class);
subquery.select(itemRoot)
.where(cb.equal(itemRoot.get("productId"), productId),
cb.equal(itemRoot.get("order"), root)); // 关联
return cb.exists(subquery);
};
}
}
可组合查询示例:
java
Specification<Order> spec = Specification
.where(OrderSpecifications.byStatus(OrderStatus.PAID))
.and(OrderSpecifications.byCustomerId(userId))
.and(OrderSpecifications.byDateRange(from, to));
List<Order> orders = orderRepository.findAll(spec);
4.4 避免接口膨胀的威力
Repository接口现在只需继承JpaSpecificationExecutor<Order>,无需任何自定义查询方法(除极特殊复杂查询可使用@Query)。新增查询条件只需添加一个Specification工厂方法,并与现有条件自由组合。这完全符合开闭原则。
4.5 与CQRS读模型的关系
在CQRS架构中,命令侧(写模型)仍然需要对聚合根进行查询以执行业务操作(例如检查重复、计算价格)。Specification用于这些精确的条件查询,保证业务逻辑的正确性。查询侧(读模型)则可能使用Elasticsearch、Redis或专门的DAO,提供高性能展示需求。两者互不冲突,Specification专注于领域表达,读模型专注于性能优化。
4.6 Specification组合类图
图表主旨概括 :该类图清晰地展示了Specification接口、工厂类OrderSpecifications、Spring Data的JpaSpecificationExecutor以及自定义OrderRepository之间的静态关系。
逐层/逐元素分解 :Specification<T>是核心接口,支持and/or/not组合;OrderSpecifications提供静态工厂方法返回具体Specification;OrderRepository继承JpaSpecificationExecutor从而获得组合查询能力;所有查询都基于领域语义命名。
设计原理映射:Specification模式将查询意图提升为领域层的一等公民,使得查询逻辑可以复用、组合,且不受基础设施变更影响。它与资源库模式配合,将数据访问的"做什么"与"怎么做"分离。
工程联系与关键结论加粗 :Specification适用于动态条件组合,但对于固定且高频的查询,直接使用@Query或方法命名查询更直接,不应过度设计。实践中可将常用组合封装为Repository默认方法或领域服务,避免重复编写组合逻辑。
5. SpEL声明式权限控制
5.1 业务权限的陈述式表达
在Controller中常见的权限判断:
java
if (!order.getCustomerId().equals(currentUser.getId())) {
throw new AccessDeniedException("无权访问此订单");
}
这种命令式方式将权限规则和业务代码耦合,且if条件重复出现。Spring Security结合SpEL提供了声明式权限注解@PreAuthorize,可以在方法调用前解析表达式并决定是否允许执行。
5.2 基础用法:hasPermission
java
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping("/{orderId}")
@PreAuthorize("hasPermission(#orderId, 'order', 'read')")
public OrderDTO getOrder(@PathVariable Long orderId) {
// ...
}
}
这里需要自定义PermissionEvaluator实现:
java
@Component
public class OrderPermissionEvaluator implements PermissionEvaluator {
@Autowired
private OrderRepository orderRepository;
@Override
public boolean hasPermission(Authentication auth, Object targetId, Object permission) {
if (targetId instanceof Long && "order".equals(targetDomainType)) {
Long orderId = (Long) targetId;
Order order = orderRepository.findById(orderId).orElse(null);
if (order == null) return false;
Long currentUserId = (Long) auth.getPrincipal(); // 假设principal是userId
return order.getCustomerId().equals(currentUserId);
}
return false;
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId, String targetType, Object permission) {
return false;
}
}
5.3 引用领域Bean的方法
更贴近业务语言的方式是直接引用一个自定义的Bean,表达业务规则:
java
@PreAuthorize("@orderAuthorization.isOwner(principal, #orderId)")
public OrderDTO getOrder(@PathVariable Long orderId) { ... }
orderAuthorization Bean的实现:
java
@Component("orderAuthorization")
public class OrderAuthorization {
private final OrderRepository orderRepository;
public OrderAuthorization(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public boolean isOwner(Long userId, Long orderId) {
return orderRepository.findById(orderId)
.map(order -> order.getCustomerId().equals(userId))
.orElse(false);
}
public boolean canCancel(Long userId, Long orderId) {
Order order = orderRepository.findById(orderId).orElse(null);
if (order == null) return false;
return order.getCustomerId().equals(userId) && order.canCancel();
}
}
在Controller中:
java
@PostMapping("/{orderId}/cancel")
@PreAuthorize("@orderAuthorization.canCancel(principal, #orderId)")
public void cancelOrder(@PathVariable Long orderId) {
// 调用应用服务取消订单
}
5.4 配置支持
需要启用全局方法级安全:
java
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 配置认证与授权规则
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and().httpBasic(); // 示例简化
}
}
5.5 收益分析
- 分离关注点 :权限逻辑从业务代码中抽离,集中在
OrderAuthorization服务中。 - 声明式表达 :注解本身即文档,
canCancel表达清晰,非技术人员也能理解。 - 易于测试:权限Bean可独立单元测试,Controller只需模拟权限通过/不通过。
- 安全性增强:方法未加注解则默认拒绝访问,减少遗漏风险。
6. 陈述式代码的反模式与重构路径
6.1 四大反模式详细剖析
反模式1:贫血模型与setter滥用
实例如下:
java
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private String status; // 反模式3
// 只有getter/setter
}
// 应用服务到处设置状态
order.setStatus("PAID");
order.setStatus("SHIPPED");
后果 :状态变更散落各处,任何地方都可以修改状态,极易出现非法状态。例如某处代码忘记检查当前状态就直接setStatus("CANCELLED"),导致已发货订单被取消。
反模式2:if-else状态判断重复
多个Service中出现类似代码:
java
if ("PENDING".equals(order.getStatus())) {
// 执行支付
} else {
throw new BusinessException("状态错误");
}
如果状态规则修改(如增加"部分支付"状态),则需要排查所有相关if-else,容易遗漏。
反模式3:String类型枚举值
order.setStatus("PAID")丢失类型安全,编译期无法发现"PAID"和"paid"的拼写错误。重构第一步就是使用枚举OrderStatus。
反模式4:Builder无校验
java
Order order = Order.builder().customerId(id).build(); // 未设置items,导致聚合根不完整
依赖Builder但未强制校验,允许创建出缺少必要数据的聚合根。
6.2 逐步重构路径与对比图
下图直观对比了贫血代码到陈述式代码的重构演进路径。
图表主旨概括:该对比图从四个维度展示了从贫血的面向数据代码到表达业务意图的陈述式代码的重构映射,每步重构都有明确的改进目标。
逐层/逐元素分解:左侧贫血代码四部分存在状态裸露、逻辑散乱、方法膨胀、权限耦合四大问题。右侧通过行为封装、状态机集中规则、Specification可组合查询、SpEL声明式权限逐一消除问题。
设计原理映射:重构路径遵循单一职责和开闭原则。将状态和行为合并到聚合根,把查询和权限逻辑外移到可复用的领域组件,提高内聚、降低耦合。
工程联系与关键结论加粗 :重构应分步进行,每一步都需要充足的单元测试保障。优先封装行为(贫血到富血模型),这是最基础的改进;其次引入状态机简化内部逻辑;最后处理查询和权限。每步重构的代码都应与原始代码通过对比测试验证行为一致性。
7. 陈述式代码与统一语言的闭环
统一语言(Ubiquitous Language)是DDD战略设计的核心产出。它要求在项目所有沟通渠道(口头、文档、代码)使用同一套术语。陈述式代码让代码直接使用这些术语作为类名、方法名、Builder步骤名、Specification名和SpEL表达式引用名,使得代码成为统一语言的权威表达形式。
例如:
- 领域术语:"订单确认支付" → 代码方法:
Order.confirmPayment() - 术语:"客户待支付订单查询" → 代码查询组合:
Specification.where(byCustomerId(id)).and(byStatus(PENDING)) - 术语:"订单所有者权限" → 代码权限表达式:
@PreAuthorize("@orderAuthorization.isOwner(...)")
当领域专家能够阅读并理解这些代码片段时,代码本身就成了活文档。代码评审不再仅仅是技术活动,领域专家参与其中,可以验证代码是否真正反映了业务规则,从而形成"代码即文档,文档即代码"的良性循环。这避免了传统开发中文档与实现脱节的问题,因为任何业务逻辑的变更都将直接体现在代码命名上。
8. 贯穿案例:电商订单系统从贫血代码到陈述式代码的重构
本节完整展示一个电商订单模块的重构全过程,包含重构前代码、五个重构步骤的详细实现、以及重构后的收益分析。
8.1 重构前:贫血代码全貌
Order实体 (JPA Entity):
java
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue
private Long id;
private Long customerId;
private String status; // "PENDING", "PAID", "SHIPPED", etc.
private LocalDateTime paidAt;
private LocalDateTime shippedAt;
private LocalDateTime deliveredAt;
private String recipientName;
private String recipientAddress;
// 仅有getter和setter
}
OrderService (部分):
java
@Service
public class OrderService {
@Autowired private OrderRepository orderRepository;
public void payOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
if ("PENDING".equals(order.getStatus())) {
order.setStatus("PAID");
order.setPaidAt(LocalDateTime.now());
orderRepository.save(order);
} else {
throw new RuntimeException("订单状态不正确");
}
}
public void shipOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
if ("PAID".equals(order.getStatus())) {
order.setStatus("SHIPPED");
order.setShippedAt(LocalDateTime.now());
orderRepository.save(order);
} else {
throw new RuntimeException("不能发货");
}
}
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
if ("PENDING".equals(order.getStatus())) {
order.setStatus("CANCELLED");
orderRepository.save(order);
} else if ("PAID".equals(order.getStatus())) {
// 假设允许退款取消
order.setStatus("CANCELLED");
orderRepository.save(order);
} else {
throw new RuntimeException("不能取消");
}
}
public List<Order> findMyOrders(Long customerId, String statusFilter) {
if (statusFilter != null) {
return orderRepository.findByCustomerIdAndStatus(customerId, statusFilter);
} else {
return orderRepository.findByCustomerId(customerId);
}
}
}
Repository方法爆炸:
java
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerId(Long customerId);
List<Order> findByStatus(String status);
List<Order> findByCustomerIdAndStatus(Long customerId, String status);
List<Order> findByCustomerIdAndStatusAndCreatedAtBetween(...);
// 更多...
}
Controller权限判断:
java
@RestController
public class OrderController {
@Autowired private OrderService orderService;
@Autowired private UserContext userContext;
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
Order order = orderService.findById(id);
if (!order.getCustomerId().equals(userContext.getUserId())) {
throw new AccessDeniedException("无权访问");
}
return order;
}
}
8.2 重构步骤1:封装行为,引入枚举
首先,将String状态改为OrderStatus枚举,并封装行为方法。
java
public enum OrderStatus {
PENDING, PAID, SHIPPED, DELIVERED, CANCELLED, RETURNED
}
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
private Long customerId;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private LocalDateTime paidAt;
private LocalDateTime shippedAt;
// ...
// 移除setter,提供行为方法
public void confirmPayment() {
if (this.status != OrderStatus.PENDING) {
throw new InvalidOrderStateTransitionException(this.status, OrderEvent.PAY);
}
this.status = OrderStatus.PAID;
this.paidAt = LocalDateTime.now();
DomainEvents.raise(new OrderConfirmed(this.id));
}
public void ship() {
if (this.status != OrderStatus.PAID) {
throw new InvalidOrderStateTransitionException(this.status, OrderEvent.SHIP);
}
this.status = OrderStatus.SHIPPED;
this.shippedAt = LocalDateTime.now();
DomainEvents.raise(new OrderShipped(this.id));
}
public void cancel() {
if (this.status != OrderStatus.PENDING && this.status != OrderStatus.PAID) {
throw new InvalidOrderStateTransitionException(this.status, OrderEvent.CANCEL);
}
this.status = OrderStatus.CANCELLED;
DomainEvents.raise(new OrderCancelled(this.id));
}
}
Service层简化为:
java
@Service
public class OrderService {
public void payOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.confirmPayment();
orderRepository.save(order);
}
// ...类似
}
8.3 重构步骤2:引入状态机DSL
将行为方法内部的if-else替换为状态表驱动,如2.2节所示。此时聚合根的业务规则完全集中在静态转换表中,所有公开方法都委托给统一的applyEvent私有方法。Service层代码无变化,但聚合根更整洁且易于扩展。
8.4 重构步骤3:Builder模式构建订单
创建订单时原代码可能使用无参构造+若干setter,或直接new Order(customerId, items, ...)。现用Builder替代:
java
Order order = Order.builder()
.customerId(cmd.getUserId())
.items(cmd.getItems())
.recipient(cmd.getAddress())
.note(cmd.getNote())
.build();
orderRepository.save(order);
Builder内部校验已在第3节详述。
8.5 重构步骤4:抽取Specification
消除OrderRepository中一堆findBy...方法,改为继承JpaSpecificationExecutor<Order>,并在Service中使用Specification组合查询:
java
public List<Order> findMyOrders(Long customerId, OrderStatus status) {
Specification<Order> spec = Specification.where(OrderSpecifications.byCustomerId(customerId));
if (status != null) {
spec = spec.and(OrderSpecifications.byStatus(status));
}
return orderRepository.findAll(spec);
}
8.6 重构步骤5:SpEL声明式权限
Controller代码去除if-else,改为注解:
java
@GetMapping("/orders/{id}")
@PreAuthorize("@orderAuthorization.isOwner(principal, #id)")
public Order getOrder(@PathVariable Long id) {
return orderService.findById(id);
}
8.7 重构后全貌与收益分析
重构后,订单模块各层代码变得清晰且富有表达力。Service层不再包含任何状态判断,所有业务规则内聚于聚合根和状态机;查询灵活可组合;权限独立于业务代码。具体收益:
- 代码行数:Service层减少约60%逻辑代码。
- 重复代码:状态检查的if-else从5处减少为0(由状态机统一处理)。
- 可读性:开发者可通过阅读聚合根方法名立即理解业务。
- 安全性 :不可能再出现
setStatus导致的非法状态。 - 测试性:聚合根的状态机可脱离Spring独立单元测试;权限Bean可单独测试。
9. 与前后系列的衔接
- 第1篇(战略设计):本文的陈述式代码直接使用统一语言术语命名,将领域专家与开发者的共识落实为具体代码,消除了翻译成本。
- 第2篇(战术DDD):聚合根、资源库、领域服务的实现若缺少陈述式编码,依然会陷入贫血模型。本文的模式是对战术DDD的代码级优化。
- 第4篇(事件驱动CQRS):状态机转换后发布的领域事件是CQRS中事件驱动流程的起点,读写分离通过事件消费完成,详见该篇。
- 第8篇(聚合设计指南) :聚合根不变量保护通过
confirmPayment()等方法实现,确保了事务边界内的规则一致性,设计时可结合该篇的决策框架。
10. 面试高频专题
此部分提供不少于10题的深度面试问答,每题包含一句话回答、详细解释、多角度追问及加分回答,最后附一道综合系统设计题。
Q1:什么是陈述式代码?与命令式代码的核心区别是什么?
一句话回答:陈述式代码直接表达"做什么",隐藏"怎么做";命令式代码暴露操作步骤和技术细节,让调用者关注于实现机制。
详细解释 :陈述式代码将业务意图封装在命名良好的方法中,例如order.confirm()封装状态转换、时间戳和事件发布;命令式则要求调用者顺序调用setStatus()和save()等。陈述式代码降低重复,提升可读性,使领域专家也能参与代码评审。其核心是"意图揭示",与《Clean Code》的命名原则一致。在DDD中,陈述式代码是实现统一语言的代码载体的关键。
多角度追问:
- 如何平衡陈述式的封装性和框架的限制(如JPA的Entity需要getter/setter)?
- 陈述式是否会导致聚合根过于庞大?
- 在重构遗留系统时,如何渐进式引入陈述式设计?
加分回答 :可结合函数式编程思想(如使用Optional和Lambda)进一步减少技术噪音;Spring Data JPA的@Entity虽需getter,但可限制setter可见性为protected并引入领域方法访问,实现领域行为封装。同时,可以使用@Access(AccessType.FIELD)让JPA直接访问字段,避免公开setter。
Q2:如何用状态机DSL建模订单生命周期?Spring Statemachine和纯Java DSL各有什么优劣?
一句话回答:通过定义状态、事件和转换规则,将合法路径显式化;Spring Statemachine功能丰富但较重,纯Java DSL轻量但功能有限。
详细解释 :订单生命周期包括PENDING、PAID等状态,通过状态机配置source(PENDING).target(PAID).event(PAY)来声明合法转换,非法转换直接拒绝。Spring Statemachine提供持久化、监听器、正交状态、子状态等特性,适合复杂流程;纯Java DSL(如内部转换表)无外部依赖,代码简洁,适合状态少的简单场景。在实际选择中,若订单状态多达十余种且有复杂嵌套,Spring Statemachine更合适;若为简单线性或少数分支,纯Java DSL更易维护。
多角度追问:
- 状态机如何与JPA实体集成并持久化状态?
- 如何处理并发下的状态转换(乐观锁)?
- 状态机的事件如何触发后续业务流程(如发货单生成)?
加分回答 :可结合StateMachinePersist接口实现状态机状态的数据库持久化;并发场景下通过JPA乐观锁(@Version)或数据库行锁保证一致性;事件触发可结合Spring事件机制或领域事件总线,在状态机监听器中发布对应领域事件。
Q3:Builder模式在构建聚合根时有什么优势?如何在校验不变量?
一句话回答:Builder提供流式构造接口并集中校验不变量,避免不一致聚合根的出现。
详细解释 :聚合根构建往往需要多个必填参数和关联对象,Builder通过customerId().items().recipient().build()逐步组装,在build()中执行if (items==null) throw...等校验,确保生成的对象始终一致。相比构造函数,代码可读性更高;相比setter,避免了对象构造期间的不完整窗口期。不变量校验通常包括非空、数量限制、业务规则(如订单项必须属于同一商家)等。
多角度追问:
- Lombok @Builder如何自定义校验逻辑?
- Builder与工厂方法或静态构造方法的适用场景如何区分?
- 如何处理Builder的线程安全性?
加分回答 :Lombok的@Builder可通过在类中定义静态内部类并覆盖build()方法来自定义校验,或利用@Builder在构造函数上,将校验写入构造函数。工厂方法适用于参数较少且构造逻辑稳定的场景,Builder适合复杂多参数对象。Builder通常是方法局部对象,不存在线程安全顾虑。
Q4:Specification模式如何替代DAO的方法爆炸?如何设计可组合的查询?
一句话回答 :将查询条件封装为对象并通过and/or组合,避免为每种条件组合新增DAO方法。
详细解释 :传统DAO为findByStatusAndCustomerIdAndDateBetween等方法编写不同接口,Specification通过Specification<Order> byStatus(...)返回条件对象,组合时使用where(byStatus(PAID)).and(byCustomerId(id)),调用findAll(spec)即可。新增条件只需新增一个工厂方法。Spring Data JPA的JpaSpecificationExecutor提供了天然支持。设计时注意将Specification工厂放在领域层,避免直接依赖JPA Criteria细节(虽然方法参数仍依赖,但调用者只与领域命名方法交互)。
多角度追问:
- Specification如何与JPA Criteria API集成?如何优化N+1问题?
- 如何处理动态查询的性能?
- 在CQRS架构下Specification的作用范围是什么?
加分回答 :Specification内部使用Criteria API;N+1问题可通过EntityGraph或@EntityGraph注解配合findAll(spec, pageable)解决。性能优化可结合二级缓存或对频繁查询使用索引。在CQRS中,Specification主要用于命令侧的聚合根精确查询,读模型走ES/Redis等高性能存储。
Q5:SpEL如何在Spring Security中实现声明式业务权限?
一句话回答 :通过@PreAuthorize和SpEL表达式引用业务Bean,将权限逻辑抽离为业务语义。
详细解释 :如@PreAuthorize("@orderAuthorization.isOwner(principal, #orderId)"),Spring Security在方法执行前解析SpEL,调用容器中的orderAuthorization Bean的isOwner方法。权限判断移至领域服务,表达式命名直接表达业务规则,Controller无需处理权限。这种方法使得权限规则可重用、可测试。
多角度追问:
- 如何自定义
PermissionEvaluator实现基于资源的权限? - SpEL表达式的性能如何?是否会影响系统吞吐?
- 如何对SpEL权限规则进行单元测试?
加分回答 :实现PermissionEvaluator接口并注入Spring容器;SpEL通过缓存提升性能,但频繁解析仍有一定开销,可结合方法级缓存或AOP优化。单元测试使用@WithMockUser和Spring Security Test模块模拟认证用户,并对权限Bean进行独立测试。
Q6:如何将贫血的setter+if-else代码重构为陈述式代码?重构路径是怎样的?
一句话回答:先封装聚合根行为,再引入状态机、Builder、Specification和SpEL,逐步提升代码表达力。
详细解释 :重构可以分步进行:第一步,将setter方法替换为领域行为方法(如confirmPayment()),使用枚举类型;第二步,引入状态机管理生命周期,消除if-else;第三步,用Builder构造聚合根,确保不变量;第四步,用Specification替代动态查询,消除Repository方法爆炸;第五步,用@PreAuthorize抽离权限。每一步都应有测试保护,并且每一步重构都可独立交付,无需一次性完成。
多角度追问:
- 在大型系统中,如何识别出需要重构的贫血实体?
- 重构时如何处理对外部接口的兼容性?
- 陈述式重构是否会影响数据库表结构?
加分回答:可通过代码分析工具(如SonarQube)检测setter调用次数和Service中状态判断重复度来识别热点。对外接口可通过适配器模式保持兼容,内部实现重构;数据库表结构通常不变,因为状态语义和字段映射保持一致。
Q7:陈述式代码中如何处理聚合根的持久化与并发问题?
一句话回答:聚合根的陈述式方法仅负责业务逻辑,持久化交由资源库;并发通过乐观锁或数据库锁保证状态机执行的一致。
详细解释 :order.confirmPayment()只改变内存状态,资源库的save方法完成持久化。当多个请求同时操作同一订单时,可能发生状态不一致。通常使用JPA的@Version乐观锁,在聚合根实体上添加一个版本字段,save时如果版本冲突则抛出OptimisticLockFailureException,上层可重试或返回业务异常。状态机本身是无状态的,所以并发冲突由持久化机制解决。
多角度追问:
- 如何设计重试机制避免反复失败?
- 使用悲观锁(
@Lock)有哪些优缺点? - 在微服务分布式环境中如何处理并发状态转换?
加分回答:可结合Spring Retry进行有限次数重试;悲观锁适用于冲突频繁的场景,但会降低并发性能;分布式场景可借助分布式锁(Redis/Redisson)或利用数据库唯一约束实现幂等性。领域事件发布通常结合事务性Outbox以保证at-least-once语义。
Q8:陈述式代码对单元测试有什么影响?如何测试状态机和Specification?
一句话回答:陈述式代码使单元测试更专注于业务规则验证,状态机可独立测试其转换逻辑,Specification可通过内存数据库或Mock测试组合。
详细解释 :由于聚合根行为内聚,可直接实例化Order并调用confirmPayment()断言状态变化。状态机表驱动方式可编写参数化测试覆盖所有合法与非法转换。Specification测试可使用H2内存数据库,通过EntityManager构建查询并校验Predicate生成的结果。SpEL权限可集成Spring Security Test或对授权Bean进行纯单元测试。
多角度追问:
- 如何测试领域事件的发布是否被正确触发?
- 对于复杂Specification,如何确保其生成的SQL正确且高效?
- 如何对@PreAuthorize注解进行集成测试?
加分回答 :可结合Mockito验证DomainEvents.raise调用,或使用Spring Data的@DomainEvents发布后通过捕获事件测试。对于SQL正确性,可启用JPA日志输出实际SQL并手工检查,或使用Querydsl类型安全查询替代Criteria以降低出错率。集成测试可用@WithMockUser模拟认证,并调用Controller方法验证权限拦截。
Q9:陈述式代码与函数式编程、响应式编程有何关联?
一句话回答:陈述式编程思想与函数式编程的声明式风格、响应式编程的数据流抽象相吻合,它们都致力于分离"做什么"和"怎么做"。
详细解释 :函数式编程中的Lambda和方法引用本身就是一种陈述式表达,如list.stream().filter(Order::canCancel).collect(...),方法引用Order::canCancel直接表达了过滤意图。响应式编程如Spring WebFlux通过声明式组合操作链,也是陈述式思想的体现。在DDD中,将业务逻辑封装为陈述式方法便于使用函数式特性进行组合。
多角度追问:
- 如何在订单聚合根中使用Optional提升陈述性?
- 响应式堆栈下状态机如何实现(如Reactor State Machine)?
- 函数式DDD是否可行?有何限制?
加分回答 :Optional.ofNullable(order.getPaidAt())比if (order.getPaidAt() != null)更具陈述性。响应式状态机可利用Project Reactor的Mono/Flux与自定义操作符构建非阻塞状态转换。函数式DDD在某些方面可行,例如代数数据类型表达状态,但Java语言限制和聚合根持久化要求通常需要妥协。
Q10:在微服务架构中,陈述式代码如何跨服务边界保持统一语言?
一句话回答:通过API契约、领域事件和防腐层,将陈述式设计应用于服务间通信,确保业务术语的一致性。
详细解释 :每个微服务内部采用陈述式代码,通过REST API或消息传递出去的DTO和事件名称同样使用统一语言。例如,订单服务发布OrderConfirmed事件,下游服务使用同样术语。防腐层(ACL)负责将外部系统的语言转义成本地统一语言,防止污染。SpEL权限也可结合网关声明实现,但服务内部权限仍应基于领域术语。
多角度追问:
- 如何处理不同服务对同一术语的语义偏差?
- 事件驱动中如何保证事件命名不丢失业务意图?
- 跨服务的事务性如何与陈述式设计结合?
加分回答 :不同限界上下文可有不同统一语言,通过防腐层转换;事件命名应遵循"名词+动词过去式"模式(如OrderShipped)。跨服务事务最终一致性通过Saga或事件编排实现,陈述式设计在Saga中体现在明确的状态声明和补偿动作命名。
系统设计题(Q11)
题目 :一个电商订单系统,当前Order只有getter/setter,状态转换通过order.setStatus()散落在5个Service中,查询接口有20个findByXxx方法,权限判断写在Controller的if-else中。请设计:(1) 订单生命周期的状态机DSL方案;(2) 聚合根的Builder设计;(3) 使用Specification替代findByXxx的查询重构;(4) 使用SpEL替代Controller if-else的权限重构;(5) 给出重构前后代码对比与收益分析。同时绘制架构演进图、订单状态转换序列图及查询架构类图。
方案设计:
(1) 状态机DSL方案
采用纯Java DSL(内部状态转换表),如2.2节。Order聚合根通过allowedTransitions映射定义合法转换路径,公开confirmPayment()、cancel()等方法,内部调用applyEvent()。状态转换表集中管理所有规则。若未来引入复杂分支或需要持久化状态机上下文,可迁移至Spring Statemachine,但聚合根对外接口不变。
(2) 聚合根Builder设计
创建OrderBuilder手写内部类,步骤方法包括customerId、items、recipient、note等,build()执行所有不变量校验。Lombok @Builder不在核心聚合根中使用,以确保完全可控的校验。
(3) Specification查询重构
- 定义
OrderSpecifications工厂类,提供byStatus、byCustomerId、byDateRange、withItemsContainingProduct等静态方法。 OrderRepository改为继承JpaSpecificationExecutor<Order>,移除所有自定义查询方法。- 应用服务使用组合Specification进行查询。
(4) SpEL权限重构
- 创建
OrderAuthorization组件,注入Spring容器,提供isOwner(userId, orderId)和canCancel(userId, orderId)等方法。 - Controller方法添加
@PreAuthorize("@orderAuthorization.xxx(principal, #orderId)"),移除所有硬编码的权限判断。
(5) 重构前后代码对比与收益
(见贯穿案例第8节)。重构后,Service层逻辑大幅简化,Repository接口从20+方法收缩至0,Controller权限逻辑消失。业务规则单一集中,领域专家可读性增强,非法状态被杜绝。
架构演进图
图表说明:重构前各层承担了过多技术细节和业务逻辑;重构后每层职责清晰,业务规则内聚于领域层,权限和查询独立为可复用组件。
支付状态转换序列图(以支付为例)
查询架构类图
图表说明 :查询流程从Controller委托给应用服务,应用服务组合Specification条件,通过Repository的findAll执行,整个查询链使用统一语言命名。
收益分析:
- 可维护性:状态变更逻辑集中一处,新增状态只需修改状态机表。
- 安全性:不可能再通过
setStatus绕过校验。 - 可测试性:聚合根可单独测试状态转换,Specification可独立测试组合逻辑。
- 代码量:Service层代码量减少约50%,Repository接口清除所有自定义方法。
Demo代码与延伸阅读:核心代码示例已嵌入各节。延伸阅读推荐:《领域驱动设计》第10章;《Clean Code》第2-3章;《实现领域驱动设计》第7章;Martin Fowler的DSL和Specification模式文章;Spring Statemachine参考文档。
总结:陈述式代码是将DDD战略和战术设计落地为高质量实现的最后一块拼图。它弥合了领域语言与编程语言之间的鸿沟,让代码成为团队共同的知识资产。当每一行代码都能讲述业务故事时,系统的可演化性、可理解性和可维护性便达到了新的高度。