校验逻辑几乎是每个业务系统都绕不开的部分。一开始可能只是判断一个字符串是否为空、一个数字是否越界,写几个if语句就够了。但随着业务复杂度上升,校验规则会变得层层嵌套:字段A在字段B为某状态时必填、金额上限根据用户等级动态计算、多个字段之间存在联合约束。这时候if-else的堆叠会让代码变得难以阅读,也难以测试。Lambda表达式提供了一条出路:把每条校验规则抽象成一个函数对象,让规则本身成为可以传递、组合、复用的一等公民。

校验契约的核心设计思路
所谓校验契约,本质上是把“校验规则”从业务代码中抽离出来,形成一个独立的描述层。一条规则可以抽象为一个谓词函数:输入是被校验的对象,输出是布尔值或者携带错误信息的校验结果。在Java中,这个抽象可以直接映射为Function<T, ValidationResult>或者Predicate<T>。
与直接写if语句相比,函数化的规则有三个显著优势。第一,规则是声明式的,读代码的人一眼能看出意图,而不需要在嵌套的条件分支里迂回。第二,规则是可组合的,可以通过与、或、非等逻辑运算把细粒度规则拼装成复杂规则。第三,规则是可复用的,同一个规则对象可以在不同的校验场景中重复引用,也可以集中注册到规则库中统一管理。
先定义最基础的数据结构,一个校验结果和一个规则接口:
// 校验结果:合法时不含消息,非法时携带错误描述
public class ValidationResult {
private final boolean valid;
private final String message;
private ValidationResult(boolean valid, String message) {
this.valid = valid;
this.validMsg = message;
}
public static ValidationResult ok() {
return new ValidationResult(true, null);
}
public static ValidationResult fail(String message) {
return new ValidationResult(false, message);
}
public boolean isValid() { return valid; }
public String getMessage() { return message; }
}
// 校验规则:输入待校验对象,输出校验结果
@FunctionalInterface
public interface Rule<T> {
ValidationResult apply(T target);
}
注意这里把Rule定义成了函数式接口,这样任何符合签名的Lambda表达式都可以直接作为规则传入,不需要额外的适配层。这是整个方案灵活性的根基。
规则的组合与链式调用
单条规则的价值有限,真正的威力来自组合。可以在接口中提供默认方法,实现与、或、非三种逻辑组合,让规则之间像积木一样拼接:
@FunctionalInterface
public interface Rule<T> {
ValidationResult apply(T target);
// 与:两条规则都通过才通过
default Rule<T> and(Rule<T> other) {
return t -> {
ValidationResult first = this.apply(t);
if (!first.isValid()) {
return first;
}
return other.apply(t);
};
}
// 或:任意一条通过即通过
default Rule<T> or(Rule<T> other) {
return t -> {
ValidationResult first = this.apply(t);
if (first.isValid()) {
return first;
}
return other.apply(t);
};
}
// 非:反转校验结果,并替换错误消息
default Rule<T> not(String errorMessage) {
return t -> this.apply(t).isValid()
? ValidationResult.fail(errorMessage)
: ValidationResult.ok();
}
}
有了这些组合子,针对一个订单对象的校验就可以写成下面这种声明式风格:
Rule<Order> amountRule = o -> o.getAmount().compareTo(BigDecimal.ZERO) > 0
? ValidationResult.ok()
: ValidationResult.fail("订单金额必须大于0");
Rule<Order> vipRule = o -> o.getAmount().compareTo(new BigDecimal("100000")) <= 0
? ValidationResult.ok()
: ValidationResult.fail("普通订单金额不能超过10万");
Rule<Order> orderRule = amountRule.and(vipRule);
ValidationResult result = orderRule.apply(order);
这种写法的一个隐藏好处是短路语义可以自己控制。上面and的实现是短路的,第一条失败就立即返回;如果业务上需要一次性收集所有错误(比如表单提交时把所有问题一次性展示给用户),可以另外实现一个collect方法,把所有规则执行一遍并汇总错误列表。两种语义并存,按场景选用。
另外一个实用技巧是静态工厂方法。比如把“非空判断”封装成工具方法,接收一个取值函数和错误消息,返回一个规则:
public static <T, R> Rule<T> notNull(
Function<T, R> getter, String message) {
return t -> getter.apply(t) != null
? ValidationResult.ok()
: ValidationResult.fail(message);
}
// 使用方式:规则复用变得非常自然
Rule<Order> rule = notNull(Order::getBuyerId, "买家ID不能为空");
方法引用Order::getBuyerId和Lambda在这里是等价的,方法引用在只调用单个方法时可读性更好。
应对复杂场景:条件校验与跨字段约束
现实业务中最棘手的往往不是单字段规则,而是条件性规则:只有当某个条件成立时,才需要执行另一组校验。这可以通过一个when组合子优雅地解决:
// 仅当条件谓词成立时才执行规则,否则直接放行
public static <T> Rule<T> when(Predicate<T> condition, Rule<T> rule) {
return t -> condition.test(t) ? rule.apply(t) : ValidationResult.ok();
}
// 组合示例:电子发票时必须填写税号
Rule<Order> taxRule = when(
o -> "ELECTRONIC".equals(o.getInvoiceType()),
notNull(Order::getTaxNumber, "电子发票必须填写税号")
);
跨字段约束也可以直接在Lambda中引用整个对象,因为Lambda捕获的是对象引用,任意字段之间的联合判断都不受限制。比如“优惠后金额不能高于原金额”、“退款数量不能超过购买数量”,这类规则本质上就是两个字段比较的谓词,写成Lambda后与其他规则无差别组合。
还有一类场景是动态阈值。阈值不写死在代码里,而是通过外部配置或上游服务获取。由于规则本身就是一个函数对象,可以在构造规则时把动态值闭包进去,甚至在每次执行时实时查询。需要注意的是,如果规则内部访问了外部资源(比如数据库或远程服务),建议把这类规则单独标记出来,避免在纯内存校验的快速失败路径中混入高延迟操作,影响整体响应时间。
方案落地时的取舍与建议
这套方案在可读性和可维护性上收益明显,但也要正视它的代价。首先是调试难度:当一条规则由十几条子规则组合而成时,出错后定位具体是哪一层失败,需要依赖错误消息设计得足够精确,建议在组合时为关键层级附加上下文前缀,例如“订单金额校验:普通订单金额不能超过10万”。其次是性能:Lambda和函数式接口在JVM上经过内联优化后开销极小,但每层组合都会增加一次方法调用,对性能极度敏感的热路径(比如每秒百万次的校验)需要做基准测试再决定。
落地建议有三条。第一,把规则按业务域组织成规则常量类或配置,避免规则散落在各个Service方法里,否则灵活性强了但治理弱了。第二,为校验框架补充统一的异常转换,让校验失败可以自然地映射到业务异常体系,上层代码不需要感知ValidationResult的细节。第三,与Bean Validation这类注解式框架并不冲突,简单字段校验继续用注解,复杂业务规则用Lambda契约,两者分层共存往往是工程上最务实的选择。
总的来说,Lambda表达式把校验规则从过程式代码提升为可组合的函数对象,这种抽象带来的声明式风格和组合能力,恰好契合了复杂业务场景下校验逻辑不断变化的现实。掌握这套思路后,你甚至可以把它推广到过滤、授权、路由等一切“谓词驱动”的场景中。