前言
写 Java 的你,是不是也经常被重复的校验代码搞得头大?
做了这些年的开发,你会发现在项目里,这些场景几乎天天碰:
- 用户注册:手机号/邮箱是否唯一,账号状态是否可用
- 添加单据:单据号是否已存在、客户ID是否有效、制单日期是否小于当前日期。
- 编辑单据:当前的单据状态是否允许修改,删除单据时是否允许删除
- 时间窗口:预约/发货时间不早于创建时间,截止时间不晚于开始时间
就这些场景,在项目的 Service 里面经常能看到一大堆这种校验代码,看着都不复杂,无非就是按某个字段查数据,判个空,或者判断状态,然后决定是否抛个异常:
// 判断用户 ID 是否有效
public void checkUserExistence(String userId) {
User user = userDao.findById(userId);
if (user == null) {
throw new RuntimeException("用户ID无效");
}
}
// 判断部门 ID 是否有效(复制、粘贴、改个名...又一个方法诞生了)
public void checkDeptExistence(String deptId) {
Dept dept = deptDao.findById(deptId);
if (dept == null) {
throw new RuntimeException("部门ID无效");
}
}
刚开始实体少还能忍,等业务一上量,这种代码像长草一样蔓延。每加一个实体校验就得复制一遍,哪天想统一一下异常类型或者返回信息,还得把所有地方挨个改一轮,心累。
不过说实话,能把校验抽成上面这种独立方法,已经算是有代码洁癖了。 我见过的更多项目,是把这些逻辑直接嵌在一大堆
if...else里,层层嵌套,那才叫真正的“屎山”。
所以,今天我想分享一个我的解决方案。 它不依赖任何复杂的设计模式,而是巧妙地利用了 Java 8 的函数式编程特性和 MyBatis-Plus。我们将大量使用方法引用(由 SFunction 接口承载)来保证类型安全,并借助 Predicate 等函数式接口传递灵活的校验逻辑,最终打造一个优雅的通用断言工具。
在动手前,我们先分析一下问题的本质
好的代码源于好的设计,而好的设计源于对问题的深刻分析。
在写下第一行通用代码之前,我们先回过头来,仔细审视一下那些重复的校验逻辑,看看能否从中抽象出统一的模式。
我们来看几个典型的校验场景及其背后的逻辑:
-
场景一:【唯一性】 校验新增用户的
username是否已存在。- SQL:
SELECT count(*) FROM t_user WHERE username = ?; - 逻辑: 若
count > 0,则抛异常。
- SQL:
-
场景二:【存在性】 校验创建订单时,
userId是否有效。- SQL:
SELECT count(*) FROM t_user WHERE id = ?; - 逻辑: 若
count == 0,则抛异常。
- SQL:
-
场景三:【批量存在性】 批量导入商品时,校验所有
categoryId是否都有效。- SQL:
SELECT count(*) FROM t_category WHERE id IN (?, ?, ...); - 逻辑: 若
count != idList.size(),则抛异常。
- SQL:
-
场景四:【单体属性】 校验订单状态是否可以编辑(例如,必须是"草稿"状态)。
- SQL:
SELECT status FROM t_order WHERE id = ?; - 逻辑: 获取
status值,若不等于"DRAFT",则抛异常。
- SQL:
-
场景五:【批量属性】 批量删除订单时,校验所有订单是否都可被删除(例如,状态必须是"已完成"或"已取消")。
- SQL:
SELECT id, status FROM t_order WHERE id IN (?, ?, ...); - 逻辑: 获取所有订单的
status,遍历检查,任一不满足则抛异常。
- SQL:
总结
- 唯一性校验:判断值是否已存在
- 存在性校验:判断值是否真实存在
- 批量存在性校验:集合中所有值是否都存在
- 单体属性校验:某字段值是否符合业务要求
- 批量属性校验:集合中所有记录的某字段是否符合要求
提炼“公式”:发现重复的“套路”
通过之前的分析,现在我们来提炼一下,这些看似不同的校验,背后有没有一个统一的“结构”,在这些固定结构里面,哪些是可以替换的?
我们把上面 SQL 里的可变部分用【】括起来:
SELECT count(*) FROM 【t_user】 WHERE 【username】 = 【'zhangsan'】;SELECT count(*) FROM 【t_user】 WHERE 【username】 = 【'lisi'】 AND 【id】 != 【101】;SELECT count(*) FROM 【t_dept】 WHERE 【id】 = 【'D007'】;SELECT 【status】 FROM 【t_order】 WHERE 【id】 = 【1】;SELECT 【status】 FROM 【t_order】 WHERE 【id】 in 【1,2,3】;
发现了什么?万变不离其宗!一个校验动作,底层逻辑是固定的,只是下面几个元素是可变的:
- 【操作的表】 :
t_user,t_dept,t_order... - 【查询的字段】 :
count(*),status... - 【过滤的字段】 :
username,id... - 【过滤的条件】 :
=,!=,in... - 【过滤的字段值】 :
= 'zhangsan',!= 101,= 'D007',in (1,2,3)...
抽象出通用模式
好了,案例已经足够丰富。现在我们来归纳一下,这些校验可以被清晰地分为两大类,每一类又包含“单体”和“批量”两种模式:
模式A:存在性校验 (Existence Check)
-
核心:判断满足条件的记录是否存在。
-
关注点:数据库中的
count数量。 -
子模式:
- 单体存在性 (场景一、二):
WHERE column = ? - 批量存在性 (场景三):
WHERE column IN (...)
- 单体存在性 (场景一、二):
模式B:属性校验 (Property Check)
-
核心:获取记录的一个或多个属性,在代码中进行业务逻辑判断。
-
关注点:从数据库取回的具体字段值。
-
子模式:
- 单体属性 (场景四):
SELECT column2 FROM ... WHERE id = ? - 批量属性 (场景五):
SELECT column1, column2 FROM ... WHERE id IN (...)
- 单体属性 (场景四):
选择我们的“武器”:MyBatis-Plus + Lambda
要实现这个封装,我们需要解决如何灵活地传递 “变化的参数”:
-
如何传递“查询的表”?
MyBatis-Plus 的IService<T>是不二之选。IService<T>天然就绑定了User实体和其对应的表,我们可以直接将IService<T>实例作为参数。 -
如何传递“查询的字段/条件字段”?
这部分是关键。我们希望调用者能以一种类型安全、无硬编码字符串的方式来指定查询。-
指定字段:手写
"username"、"id"这样的字符串是万恶之源。Java 8 的方法引用(如User::getUsername)是完美的替代品,它在编译期就能保证字段的正确性。MyBatis-Plus 提供的SFunction接口就是为此而生的。 -
指定值:对于单体校验,可以直接传值;对于批量校验,可以传一个
Collection(如List,Set)。 -
组合更复杂的条件:对于更复杂的
AND/OR查询,MyBatis-Plus 的LambdaQueryWrapper是标准解决方案。
-
-
如何传递“条件”? 由专门的方法实现或者传递 Predicate 进去
-
如何传递“条件字段值”? 直接作为参数传进去就行了
总结:
- 控制表:用
IService<T>绑定实体和表 - 字段选择:用
SFunction<T, ?>方法引用避免硬编码字符串 - 条件构造:用 LambdaQueryWrapper 构建灵活查询
- 自定义断言: 用 Java
Predicate实现复杂业务判断
思路已经非常清晰了:
我们要构建一个或一系列的静态方法,它们能够处理单值和集合两种输入,内部利用这些输入动态构建 LambdaQueryWrapper(使用 .eq() 或 .in()),执行查询,并完成最终的断言逻辑。
好了,理论分析已经为我们铺平了道路。接下来,让我们看看用代码如何实现这个构想。
工具类 Asserts 设计与实现
理论的推演最终要回归代码的实践。
现在,我们就来打造这个通用的断言工具类,我将其命名为 Asserts。
它将包含一系列静态方法,专门用于处理我们前面分析的各种校验场景。
存在性校验 (Existence Check)
这类校验的核心是判断记录的“有”或“无”,对应我们分析的 模式A。它非常适合用 count 查询来实现。
1. 断言存在:exists
这是最基础的校验,确保某条记录必须存在。
代码实现 (Asserts.java):
public final class Asserts {
private Asserts() {} // 工具类,私有化构造函数
/**
* 断言记录存在,若不存在则抛出异常
* (对应场景三:校验 userId 是否有效)
*
* @param service IService 实例
* @param value 要校验的值
* @param column 要查询的列(方法引用)
* @param errorMsg 错误信息
* @param <T> 实体类型
* @param <V> 值的类型
*/
public static <T, V> void exists(IService<T> service, V value, SFunction<T, ?> column, String errorMsg) {
long count = service.lambdaQuery().eq(column, value).count();
if (count == 0) {
throw new RuntimeException(errorMsg); // 建议使用自定义业务异常
}
}
}
如何使用:
告别旧的 checkUserExistence 方法,现在在 Service 中只需要一行代码:
// 在 OrderService 中校验用户 ID 是否有效
public void createOrder(OrderDTO orderDTO) {
// 校验用户是否存在
Asserts.exists(userService, orderDTO.getUserId(), User::getId, "下单用户不存在");
// ... 其他业务逻辑
}
2. 断言唯一(或不存在):unique
与 exists 相反,它确保记录不存在,常用于“唯一性”校验。
代码实现 (Asserts.java):
// 在 Asserts 类中添加新方法
/**
* 断言记录不存在(唯一性),若存在则抛出异常
* (对应场景一:校验 username 是否已存在)
*
* @param service IService 实例
* @param value 要校验的值
* @param column 要查询的列(方法引用)
* @param errorMsg 错误信息
*/
public static <T, V> void unique(IService<T> service, V value, SFunction<T, ?> column, String errorMsg) {
long count = service.lambdaQuery().eq(column, value).count();
if (count > 0) {
throw new RuntimeException(errorMsg);
}
}
如何使用:
// 在 UserService 中校验用户名是否唯一
public void register(UserDTO userDTO) {
Asserts.unique(this, userDTO.getUsername(), User::getUsername, "用户名已存在");
// ... 其他业务逻辑
}
编辑场景下的唯一校验
/**
* 唯一性校验(排除某列等于指定值的记录)
* 使用场景:更新时检查某字段唯一,但要排除当前记录
*
* @param service IService 实例
* @param value 要校验唯一性的字段值
* @param column 要查询的字段列(方法引用)
* @param excludeColumn 排除条件的列(方法引用)
* @param excludeValue 排除条件的值
* @param errorMsg 错误信息
*/
public static <T, V, EV> void unique(IService<T> service,
V value,
SFunction<T, V> column,
SFunction<T, EV> excludeColumn,
EV excludeValue,
String errorMsg) {
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(column, value)
.ne(excludeColumn, excludeValue);
long count = service.count(wrapper);
if (count > 0) {
throw new RuntimeException(errorMsg);
}
}
使用示例:
public void updateUser(UserDTO userDTO) {
// 排除当前用户 id 相同的记录
Asserts.unique(this, userDTO.getUsername(), User::getUsername, User::getId, userDTO.getId(), "用户名已存在");
}
思考:场景二那种编辑时带
id != ?的复杂条件怎么处理?我们可以重载一个unique方法,额外接收一个Consumer<LambdaQueryWrapper<T>>参数,让调用者可以自由地拼接更复杂的查询条件。这部分作为扩展思考,我们先聚焦核心功能。
3. 断言批量存在:allExists
处理批量场景,确保集合中所有的值在数据库中都有对应记录。
代码实现 (Asserts.java):
// 在 Asserts 类中添加新方法
/**
* 断言集合中所有值对应的记录都存在
*
* @param service IService 实例
* @param values 要校验的值的集合
* @param column 要查询的列(方法引用)
* @param errorMsg 错误信息
*/
public static <T, V> void allExists(IService<T> service, Collection<V> values, SFunction<T, ?> column, String errorMsg) {
if (values == null || values.isEmpty()) {
return; // 空集合不校验
}
long count = service.lambdaQuery().in(column, values).count();
if (count != values.size()) {
// 注意:这里为了简化,没有去重。如果传入的 values 可能有重复,
// 可以先用 Set 去重再比较大小。
throw new RuntimeException(errorMsg);
}
}
如何使用:
// 批量操作前,校验所有传入的部门 ID 是否都有效
public void assignUsersToDepts(List<String> userIds, List<String> deptIds) {
Asserts.allExists(deptService, deptIds, Dept::getId, "包含无效的部门ID");
// ... 其他业务逻辑
}
属性校验 (Property Check)
这类校验需要先查出数据,再对数据的具体字段值进行判断,对应我们分析的 模式B。
1. 精准断言:assertPropertyEquals 和 assertPropertyNotEquals
这类校验需要先查出数据,再对具体字段值进行判断。为了性能,我们优先选择只查询必要的列。
代码实现 (Asserts.java):
import java.util.Objects;
// ... 其他 import
/**
* [精准] 断言指定记录的单个属性值等于期望值
* (对应场景四:校验订单状态)
* SQL优化:SELECT property_column FROM table WHERE id_column = ?
*
* @param service IService 实例
* @param idValue 用于定位记录的ID值
* @param idColumn 定位记录的列(如 Order::getId)
* @param propertyColumn 要校验的属性列(如 Order::getStatus)
* @param expectedValue 期望的属性值
* @param errorMsg 校验失败时的错误信息
*/
public static <T, V, P> void assertPropertyEquals(IService<T> service, V idValue, SFunction<T, ?> idColumn, SFunction<T, P> propertyColumn, P expectedValue, String errorMsg) {
// 只查询需要校验的属性列,而不是整个实体
T entity = service.lambdaQuery()
.select(propertyColumn) // 核心优化点
.eq(idColumn, idValue)
.one();
if (entity == null) {
throw new RuntimeException("记录不存在");
}
P actualValue = propertyColumn.apply(entity);
if (!Objects.equals(expectedValue, actualValue)) {
throw new RuntimeException(errorMsg);
}
}
/**
* [精准] 断言指定记录的单个属性值不等于期望值
*/
public static <T, V, P> void assertPropertyNotEquals(IService<T> service, V idValue, SFunction<T, ?> idColumn, SFunction<T, P> propertyColumn, P unexpectedValue, String errorMsg) {
T entity = service.lambdaQuery()
.select(propertyColumn)
.eq(idColumn, idValue)
.one();
if (entity == null) {
throw new RuntimeException("记录不存在");
}
P actualValue = propertyColumn.apply(entity);
if (Objects.equals(unexpectedValue, actualValue)) {
throw new RuntimeException(errorMsg);
}
}
如何使用:
// 编辑订单前,校验订单状态必须为 "DRAFT"
public void editOrder(OrderDTO orderDTO) {
Asserts.assertPropertyEquals(
this, // 当前 OrderService
orderDTO.getId(), // 订单ID
Order::getId, // 按 ID 查询
Order::getStatus, // 要校验的属性是 status
OrderStatusEnum.DRAFT, // 期望 status 的值是 "DRAFT"
"只有草稿状态的订单才能编辑"
);
// ... 其他业务逻辑
}
// 支付订单前,校验订单状态不能是 "CANCELLED"
public void payOrder(String orderId) {
Asserts.assertPropertyNotEquals(
this,
orderId,
Order::getId,
Order::getStatus,
OrderStatusEnum.CANCELLED, // 不期望 status 的值是 "CANCELLED"
"已取消的订单无法支付"
);
// ... 其他业务逻辑
}
通过 select(propertyColumn),我们生成的 SQL 从 SELECT * 变成了 SELECT status ...,性能提升立竿见影,代码意图也更加清晰。
2. 灵活断言:assertEntity
当然,有时我们的校验逻辑会更复杂,比如“订单金额必须大于0,并且收货人信息不能为空”。这种场景需要获取多个字段,用简单的“等于/不等于”无法满足。
为此,我们保留一个更灵活的、可以自定义断言逻辑的方法,并将其重命名为 assertEntity,以区别于前面的精准属性断言。并且我们现在允许调用者指定需要查询的列,从而将 SELECT * 优化为 SELECT column1, column2...,避免查询不必要的列。
我们使用 Java 的可变参数(varargs)特性,让一个方法同时支持两种调用方式:
- 如果不传入
selectColumns,它就执行查询所有列。 - 如果传入了
selectColumns,它就只查询指定的列。
代码实现 (Asserts.java):
// 在 Asserts 类中,用这个新方法替换掉旧的 assertEntity
/**
* [灵活] 断言查询到的单个实体满足复杂的业务条件。
* 支持指定查询部分列以优化性能。
*
* @param service IService 实例
* @param value 用于定位实体的值 (通常是ID)
* @param column 定位实体的列
* @param predicate 对实体进行校验的断言逻辑
* @param errorMsg 校验失败时的错误信息
* @param selectColumns 可选参数,指定需要查询的列,若不传则查询所有列 (SELECT *)
*/
public static <T, V> void assertEntity(IService<T> service, V value, SFunction<T, ?> column, Predicate<T> predicate, String errorMsg, SFunction<T, ?>... selectColumns) {
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
// 核心优化:如果调用者指定了要查询的列,则应用它们
if (selectColumns != null && selectColumns.length > 0) {
wrapper.select(selectColumns);
}
wrapper.eq(column, value);
T entity = service.getOne(wrapper);
if (entity == null) {
throw new RuntimeException("记录不存在");
}
if (!predicate.test(entity)) {
throw new RuntimeException(errorMsg);
}
}
如何使用:
现在,assertEntity 的使用变得既强大又高效。
用法一:指定列进行性能优化
我们的发货校验只需要 status 和 shippingAddress 两个字段。现在可以这么写:
// 订单发货前,进行复杂校验 (只查询必要的2个字段)
public void shipOrder(String orderId) {
Asserts.assertEntity(
this,
orderId,
Order::getId,
order -> "PAID".equals(order.getStatus()) && order.getShippingAddress() != null,
"订单状态非“已支付”或收货地址为空,无法发货",
// --- 这就是优化的关键 ---
Order::getStatus, Order::getShippingAddress()
);
// ... 发货逻辑
}
生成的SQL将是 SELECT status, shipping_address FROM t_order WHERE id = ?,而不是 SELECT * ...,完美!
用法二:不指定列,兼容旧用法(查询所有字段)
如果校验逻辑确实很复杂,或者你懒得去指定具体列,那么像以前一样调用即可。
// 假设有个非常复杂的校验,需要用到实体的大部分字段
public void complexCheck(String orderId) {
Asserts.assertEntity(
this,
orderId,
Order::getId,
order -> {
// ... 一段依赖很多字段的复杂校验逻辑 ...
return true;
},
"订单不满足复杂的校验条件"
// 不传入第6个参数,则自动查询所有字段
);
// ...
}
这样一来,我们的工具箱就非常完备了:
- 日常简单场景:使用
assertPropertyEquals/assertPropertyNotEquals,获得最佳性能和可读性。 - 复杂业务场景:使用
assertEntity,牺牲一点查询性能,换取无与伦比的灵活性。
3. 断言批量实体的属性:allEntities
与单体验证同理,批量场景更需要精细化的方法来平衡性能和灵活性。
精准断言:assertAllPropertiesEqual
确保一批记录的某个属性全部都等于期望值。
// Asserts.java
public static <T, V, P> void assertAllPropertiesEqual(IService<T> service, Collection<V> idValues, SFunction<T, V> idColumn, SFunction<T, P> propertyColumn, P expectedValue, String errorMsg) {
if (idValues == null || idValues.isEmpty()) return;
Set<V> distinctIds = new HashSet<>(idValues);
// 只查询 id 和要校验的属性列
List<T> entities = service.lambdaQuery()
.select(idColumn, propertyColumn)
.in(idColumn, distinctIds)
.list();
if (entities.size() != distinctIds.size()) throw new RuntimeException("部分记录不存在");
for (T entity : entities) {
if (!Objects.equals(expectedValue, propertyColumn.apply(entity))) {
V failedId = idColumn.apply(entity);
throw new RuntimeException(String.format(errorMsg, failedId)); // 精准报错
}
}
}
使用:
// 批量审核前,确保所有单据都是 "待审核" 状态
Asserts.assertAllPropertiesEqual(
this, orderIds, Order::getId,
Order::getStatus, OrderStatusEnum.PENDING_REVIEW,
"订单[%s]不是待审核状态,操作失败"
);
灵活断言:assertAllEntities
批量场景的终极解决方案,支持自定义查询列和自定义校验逻辑。
// Asserts.java
public static <T, V> void assertAllEntities(IService<T> service, Collection<V> idValues, SFunction<T, V> idColumn, Predicate<T> predicate, String errorMsgFormat, SFunction<T, ?>... selectColumns) {
if (idValues == null || idValues.isEmpty()) return;
Set<V> distinctIds = new HashSet<>(idValues);
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();```
wrapper.in(idColumn, distinctIds);
// 动态添加 select 列,必须包含 idColumn 以便后续获取 failedId
if (selectColumns != null && selectColumns.length > 0) {
List<SFunction<T, ?>> columns = new ArrayList<>(Arrays.asList(selectColumns));
columns.add(idColumn); // 确保id列被查询
wrapper.select(columns.toArray(new SFunction[columns.size()]));
}
List<T> entities = service.list(wrapper);
if (entities.size() != distinctIds.size()) throw new RuntimeException("部分记录不存在");
for (T entity : entities) {
if (!predicate.test(entity)) {
V failedId = idColumn.apply(entity);
throw new RuntimeException(String.format(errorMsgFormat, failedId));
}
}
}
使用:
// 批量删除前,确保订单状态为已完成或已取消
Asserts.assertAllEntities(
this, orderIds, Order::getId,
order -> OrderStatusEnum.COMPLETED.equals(order.getStatus()) || OrderStatusEnum.CANCELLED.equals(order.getStatus()),
"订单[%s]状态不正确,无法删除",
Order::getStatus // 只查 status 就够了
);
为什么我选择将“校验”与“取值”分开?
有朋友可能会问,为什么这个方法是 void 类型?校验通过后我还得再查一次数据库才能拿到实体,这不是多此一举吗?
这是一个非常好的问题,它涉及到职责单一原则和过早优化的权衡。
首先,我追求的是职责单一。 exists 这个方法的名字就告诉我们,它的唯一职责就是“确保值存在/有效”,像一个门卫,只负责拦下不该进的人,不负责接待客人。将“校验”和“取值”分开,让每个方法只做一件事,代码的意图会非常清晰。
其次,我们应该警惕“想当然的性能问题”。 在绝大多数业务场景中,一个简单的、带索引的 findById 查询对数据库的压力微乎其微。为了节省这“可能”存在的性能,而去破坏代码的简洁性和职责单一性,往往得不偿失。
我始终认为,代码首先要追求的是好读、好改、好维护。 大多数项目的代码连“好读”都做不到,却在过度担心那些未经测试的性能瓶颈。在一个清晰、可维护的结构之上,如果未来真的出现了性能问题,定位和优化也会变得非常简单。
注意
⚠️ 重要:这个工具的适用场景
必须强调,这个通用方法是一把“手术刀”,而不是“开山斧”。它最适合的场景是:在一个业务操作中,需要校验多个、来源不同(分属不同表)的某个/某些属性的有效性。
比如在创建一个订单时,你需要同时校验 userId、customerId、productId 是否都真实存在。这些ID分属不同实体,本来就需要独立的查询。用我们的方法,可以把这些独立的校验写得非常统一和优雅。
总结
通过上面几步,我们构建了一个小而强大的 Asserts 工具类。它不仅消灭了大量重复的模板代码,还带来了诸多好处:
- 可读性:
Asserts.exists(...)比一堆if-else更能清晰地表达“业务意图”。 - 可维护性:统一了校验逻辑和异常抛出方式,未来需要修改时,只需改动
Asserts类即可。 - 类型安全:借助方法引用,彻底告别了手写字段名的低级错误。
- 高扩展性:基于函数式接口,可以轻松应对未来更复杂的校验需求。
现在,你可以把这个Asserts类放到你项目的 common 包里,开始享受写清爽业务代码的乐趣了!
如果这篇文章对你有帮助,点个赞就是对我最大的鼓励!如果你觉得它未来会派上用场,别忘了点个收藏,方便随时查阅哦!欢迎在评论区留下你的看法。