以订单提交校验为例,使用可组合规则对象拆分复杂条件,讨论快速失败、错误聚合、执行顺序、外部查询隔离及测试策略。
问题背景
订单提交、退款申请、优惠券领取这类业务,通常都存在一组不断增长的前置条件。最初只有两三个判断时,直接写 if 很自然:
public void submit(Order order, Customer customer) {
if (order.getStatus() != OrderStatus.CREATED) {
throw new IllegalStateException("订单状态不允许提交");
}
if (order.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("订单金额必须大于零");
}
if (customer.isBlocked()) {
throw new IllegalStateException("客户已被限制交易");
}
if (!stockService.hasEnoughStock(order)) {
throw new IllegalStateException("库存不足");
}
order.submit();
}真正的问题并不是 if 本身,而是规则会持续变化:不同入口需要不同组合,页面希望一次展示全部错误,后台任务却只关心能否执行;部分判断依赖数据库,部分判断只依赖订单字段。继续把逻辑堆在一个方法里,会逐渐出现重复代码、隐式执行顺序和难以覆盖的分支。
这时可以借鉴 Specification 思想,将每条业务规则表示为对象,并通过组合形成完整校验。重点不是消灭条件判断,而是给规则明确的边界和名字。
设计一个可解释的规则接口
只返回 boolean 虽然简单,却无法告诉调用方失败原因。下面定义一个适用于 Java 8 及以上版本的规则接口:
@FunctionalInterface
public interface Rule<T> {
CheckResult check(T target);
default Rule<T> and(Rule<T> next) {
return target -> {
CheckResult current = check(target);
if (!current.isPassed()) {
return current;
}
return next.check(target);
};
}
}and 采用短路语义:前一条规则失败后,不再执行下一条。这适合后台任务和命令处理,因为可以减少无意义的计算,也能避免后续规则建立在错误前提上。
校验结果同时保存状态和错误信息:
public final class CheckResult {
private final boolean passed;
private final List<String> errors;
private CheckResult(boolean passed, List<String> errors) {
this.passed = passed;
this.errors = Collections.unmodifiableList(
new ArrayList<>(errors));
}
public static CheckResult pass() {
return new CheckResult(true, Collections.emptyList());
}
public static CheckResult fail(String error) {
return new CheckResult(false,
Collections.singletonList(error));
}
public static CheckResult fail(List<String> errors) {
return new CheckResult(false, errors);
}
public boolean isPassed() {
return passed;
}
public List<String> getErrors() {
return errors;
}
}这里让结果保持不可变,避免多个规则共享并修改同一个错误列表。规则对象如果注册为 Spring 单例,也不会因此产生并发问题。
先准备上下文,再执行规则
规则经常需要订单之外的信息,例如客户是否被限制、库存是否充足。一个常见错误是让每条规则自行查询数据库,最终一次校验触发多次重复查询,而且调用方无法看出成本。
更稳妥的做法是由应用服务统一准备上下文:
public final class ReviewContext {
private final Order order;
private final boolean customerBlocked;
private final boolean stockEnough;
public ReviewContext(Order order,
boolean customerBlocked,
boolean stockEnough) {
this.order = order;
this.customerBlocked = customerBlocked;
this.stockEnough = stockEnough;
}
public Order getOrder() {
return order;
}
public boolean isCustomerBlocked() {
return customerBlocked;
}
public boolean isStockEnough() {
return stockEnough;
}
}这样,规则只负责判断,不隐藏网络或数据库访问。应用服务还可以批量加载数据,并明确设置超时、事务和缓存边界。
完整的规则组合示例
下面给出几条独立规则:
Rule<ReviewContext> statusRule = context ->
context.getOrder().getStatus() == OrderStatus.CREATED
? CheckResult.pass()
: CheckResult.fail("只有待创建订单可以提交");
Rule<ReviewContext> amountRule = context ->
context.getOrder().getAmount().compareTo(BigDecimal.ZERO) > 0
? CheckResult.pass()
: CheckResult.fail("订单金额必须大于零");
Rule<ReviewContext> customerRule = context ->
context.isCustomerBlocked()
? CheckResult.fail("客户已被限制交易")
: CheckResult.pass();
Rule<ReviewContext> stockRule = context ->
context.isStockEnough()
? CheckResult.pass()
: CheckResult.fail("商品库存不足");后台提交可以采用快速失败组合:
Rule<ReviewContext> submitRule = statusRule
.and(amountRule)
.and(customerRule)
.and(stockRule);
CheckResult result = submitRule.check(context);
if (!result.isPassed()) {
throw new OrderRejectedException(result.getErrors());
}
order.submit();规则名称表达了业务含义,组合处则直接展示本场景的校验清单。以后如果“人工补单”不需要库存校验,可以组合另一套规则,而不是在规则内部增加 if (source == MANUAL)。
页面场景如何收集全部错误
快速失败并不适合所有入口。用户填写表单后,通常希望一次看到所有可以独立判断的问题。可以提供另一种显式组合方式:
public final class Rules {
private Rules() {
}
@SafeVarargs
public static <T> Rule<T> allOf(Rule<T>... rules) {
return target -> {
List<String> errors = new ArrayList<>();
for (Rule<T> rule : rules) {
CheckResult result = rule.check(target);
if (!result.isPassed()) {
errors.addAll(result.getErrors());
}
}
return errors.isEmpty()
? CheckResult.pass()
: CheckResult.fail(errors);
};
}
}使用时应当明确表达意图:
Rule<ReviewContext> pageValidation = Rules.allOf(
statusRule,
amountRule,
customerRule,
stockRule
);不要机械地收集所有错误。例如订单不存在时,继续校验金额和库存没有意义。更合理的方式是分阶段:先验证订单存在和数据完整性,再聚合彼此独立的业务规则。
测试应围绕规则,而不是覆盖组合数量
拆分后的直接收益是测试范围变小。金额规则只需要验证边界值,不必构造客户和库存服务:
@Test
void amountMustBeGreaterThanZero() {
Order order = new Order(OrderStatus.CREATED, BigDecimal.ZERO);
ReviewContext context = new ReviewContext(order, false, true);
CheckResult result = amountRule.check(context);
assertFalse(result.isPassed());
assertEquals("订单金额必须大于零", result.getErrors().get(0));
}除了单条规则,还应为每种业务场景保留少量组合测试,确认规则没有遗漏、顺序符合预期。没有必要穷举所有规则的排列组合。
常见的重构陷阱
1. 把规则类拆得过碎
如果每个规则只有一行代码,却需要接口、实现类、工厂和配置类才能运行,可读性反而下降。稳定且简单的规则可以使用 Lambda;逻辑复杂、需要独立测试或拥有明确业务名称时,再提取为类。
2. 在规则中修改订单
校验规则最好保持只读。如果某条规则顺便锁库存、改状态,执行顺序就会变成隐藏契约,重复校验还可能产生副作用。状态变更应在全部必要规则通过后执行。
3. 混淆业务拒绝与系统异常
“库存不足”是可预期的业务结果,应返回失败原因;数据库不可用、响应解析失败则是系统异常,不应该伪装成普通校验失败,否则监控和重试策略都会失真。
4. 用配置动态编排所有规则
规则组合写在代码中并不落后。代码能够接受编译检查、重构和测试。只有规则确实需要由运营人员频繁调整时,才值得引入动态配置或规则引擎,同时还要承担版本管理、灰度和审计成本。
实践建议
重构时不要一次改写整个校验模块。可以先选取重复最多、变化最频繁的一组条件,提取为无副作用规则;随后把外部查询上移到应用服务,形成明确的上下文;最后根据入口决定快速失败还是错误聚合。
可组合规则对象并不是为了展示设计模式,而是让业务约束拥有名字、边界和可测试性。当新增规则只需要增加一个判断并修改清晰的组合列表,而不必穿过多层嵌套条件时,这次重构才真正改善了可维护性。