参数检查
✨ 基于接口的参数校验机制,提供比 Bean Validation 更灵活的自定义校验能力。
- 🎯 接口驱动:通过实现
CheckParamProvider接口启用参数校验 - 🔧 灵活自定义:在
check()方法中编写任意复杂的校验逻辑 - 🚀 自动执行:切面自动拦截并执行校验,无需手动调用
- 🛠️ 工具方法:提供常用的集合、Pair 等参数检查工具方法
- 🔗 无缝集成:与 Bean Validation 和全局异常处理无缝配合
快速开始
自动启用
引入 krismile-boot3-autoconfigure 依赖后,参数检查切面默认自动启用,无需任何配置。
xml
<dependency>
<groupId>host.springboot</groupId>
<artifactId>krismile-boot3-autoconfigure</artifactId>
</dependency>基础示例
java
import host.springboot.framework3.core.model.CheckParamProvider;
// 1. 请求参数实现 CheckParamProvider 接口
public class UserQueryRequest implements CheckParamProvider {
private Long userId;
private List<Long> userIds;
private String startDate;
private String endDate;
@Override
public void check() {
// 自定义校验逻辑:至少提供一个查询条件
if (checkAssembleCollectionNull(userId, userIds)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"用户ID和用户ID列表不能同时为空"
);
}
// 日期范围校验
if (startDate != null && endDate != null) {
LocalDate start = LocalDate.parse(startDate);
LocalDate end = LocalDate.parse(endDate);
if (start.isAfter(end)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"开始日期不能大于结束日期"
);
}
}
}
}
// 2. Controller 方法正常使用,切面自动执行校验
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/query")
public MultiVO<UserDTO> queryUsers(UserQueryRequest request) {
// check() 方法会在进入此方法前自动执行
// 校验失败会自动抛出异常,由全局异常处理器处理
return R.okMulti(userService.queryUsers(request));
}
}核心架构
工作流程
HTTP 请求
↓
CheckParamAspect 拦截
↓
遍历方法参数
↓
是否实现 CheckParamProvider?
├─ 是 → 执行 check() 方法
│ ↓
│ 校验通过?
│ ├─ 是 → 继续执行
│ └─ 否 → 抛出异常(全局异常处理器捕获)
│
└─ 否 → 跳过
↓
执行 Controller 方法核心类说明
| 类名 | 职责 | 说明 |
|---|---|---|
CheckParamAspect | 参数检查切面 | 拦截 Controller 方法,自动执行参数校验 |
CheckParamProvider | 参数检查接口 | 定义 check() 方法和工具方法 |
ExecuteOrder.Aop.CHECK_PARAM | 执行顺序常量 | 值为 200,在 RequestLog (100) 之后执行 |
执行顺序
AOP 执行顺序(Order 数字越小越先执行):
1. RequestLogAop (Order = 100) - 记录请求信息
↓
2. CheckParamAspect (Order = 200) - 参数校验
↓
3. Controller 方法执行CheckParamProvider 接口
核心方法
java
public interface CheckParamProvider {
/**
* 参数校验方法(必须重写)
* 校验失败时抛出异常即可
*/
default void check() {
}
/**
* 检查联合集合是否都为空
* @return true-都为空, false-至少有一个不为空
*/
default <T, C extends Collection<T>> boolean checkAssembleCollectionNull(
T source, C sources) {
return ObjectUtils.isEmpty(source) && CollectionUtils.isEmpty(sources);
}
/**
* 检查 Pair 自身和内部值是否为 null
* @return true-Pair为null或内部值都不为null, false-其他情况
*/
default boolean checkPairNull(Pair<?, ?> source) {
return Objects.isNull(source) ||
Objects.nonNull(source.left()) && Objects.nonNull(source.right());
}
}方法说明
1. check() - 参数校验入口
所有自定义校验逻辑都在此方法中实现。
示例:
java
@Override
public void check() {
// 必填字段校验
if (StringUtils.isBlank(username)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"用户名不能为空"
);
}
// 业务规则校验
if (age != null && age < 18) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"年龄必须大于18岁"
);
}
}2. checkAssembleCollectionNull() - 联合集合空值检查
用于检查单个值和集合是否都为空,常用于「至少提供一个查询条件」的场景。
示例:
java
public class UserQueryRequest implements CheckParamProvider {
private Long userId; // 单个用户ID
private List<Long> userIds; // 批量用户ID
@Override
public void check() {
// 检查 userId 和 userIds 是否都为空
if (checkAssembleCollectionNull(userId, userIds)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"至少提供一个用户ID"
);
}
}
}3. checkPairNull() - Pair 空值检查
用于检查 Pair<L, R> 对象及其内部值是否为 null。
示例:
java
public class RangeQueryRequest implements CheckParamProvider {
private Pair<Integer, Integer> ageRange; // 年龄范围 (最小值, 最大值)
@Override
public void check() {
// 检查 Pair 是否为 null 或内部值是否都有值
if (!checkPairNull(ageRange)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"年龄范围必须同时提供最小值和最大值"
);
}
// 范围合理性校验
if (ageRange != null && ageRange.left() > ageRange.right()) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"年龄范围最小值不能大于最大值"
);
}
}
}常见场景
场景1:联合查询参数校验
至少提供一个查询条件的场景:
java
public class OrderQueryRequest implements CheckParamProvider {
private Long orderId; // 订单ID
private List<Long> orderIds; // 批量订单ID
private String orderNo; // 订单号
private Long userId; // 用户ID
@Override
public void check() {
// 方式1:使用工具方法检查
boolean noOrderId = checkAssembleCollectionNull(orderId, orderIds);
boolean noOrderNo = StringUtils.isBlank(orderNo);
boolean noUserId = userId == null;
if (noOrderId && noOrderNo && noUserId) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"至少提供订单ID、订单号或用户ID之一"
);
}
// 方式2:手动检查
if (orderId == null &&
CollectionUtils.isEmpty(orderIds) &&
StringUtils.isBlank(orderNo) &&
userId == null) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"查询条件不能全部为空"
);
}
}
}场景2:日期范围校验
java
public class ReportQueryRequest implements CheckParamProvider {
private String startDate; // 格式: yyyy-MM-dd
private String endDate;
@Override
public void check() {
// 1. 必填校验
if (StringUtils.isAnyBlank(startDate, endDate)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"开始日期和结束日期不能为空"
);
}
// 2. 格式校验
LocalDate start, end;
try {
start = LocalDate.parse(startDate);
end = LocalDate.parse(endDate);
} catch (DateTimeParseException e) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"日期格式错误,请使用 yyyy-MM-dd 格式"
);
}
// 3. 范围合理性校验
if (start.isAfter(end)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"开始日期不能大于结束日期"
);
}
// 4. 时间跨度校验
long days = ChronoUnit.DAYS.between(start, end);
if (days > 90) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"查询时间跨度不能超过90天"
);
}
}
}场景3:分页参数校验
java
public class PageQueryRequest implements CheckParamProvider {
private Integer pageNo = 1; // 当前页
private Integer pageSize = 10; // 每页大小
private String sortField; // 排序字段
private String sortOrder; // 排序方向: ASC/DESC
// 允许的排序字段白名单
private static final Set<String> ALLOWED_SORT_FIELDS =
Set.of("createTime", "updateTime", "id", "name");
@Override
public void check() {
// 1. 分页参数范围校验
if (pageNo != null && pageNo < 1) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"页码必须大于0"
);
}
if (pageSize != null && (pageSize < 1 || pageSize > 100)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"每页大小必须在1-100之间"
);
}
// 2. 排序字段白名单校验(防止SQL注入)
if (StringUtils.isNotBlank(sortField)) {
if (!ALLOWED_SORT_FIELDS.contains(sortField)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"不支持的排序字段: " + sortField
);
}
}
// 3. 排序方向校验
if (StringUtils.isNotBlank(sortOrder)) {
if (!"ASC".equalsIgnoreCase(sortOrder) &&
!"DESC".equalsIgnoreCase(sortOrder)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"排序方向只能是 ASC 或 DESC"
);
}
}
}
}场景4:复杂业务规则校验
java
public class TransferRequest implements CheckParamProvider {
private Long fromAccountId; // 转出账户
private Long toAccountId; // 转入账户
private BigDecimal amount; // 转账金额
private String password; // 交易密码
@Override
public void check() {
// 1. 必填校验
if (fromAccountId == null || toAccountId == null ||
amount == null || StringUtils.isBlank(password)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"转账信息不完整"
);
}
// 2. 业务规则:不能转给自己
if (fromAccountId.equals(toAccountId)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"不能向自己的账户转账"
);
}
// 3. 金额范围校验
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"转账金额必须大于0"
);
}
if (amount.compareTo(new BigDecimal("50000")) > 0) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"单笔转账金额不能超过5万元"
);
}
// 4. 密码格式校验
if (password.length() != 6 || !password.matches("\\d{6}")) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"交易密码必须是6位数字"
);
}
}
}场景5:Pair 参数校验
java
public class PriceRangeQueryRequest implements CheckParamProvider {
private Pair<BigDecimal, BigDecimal> priceRange; // 价格范围
private Pair<Integer, Integer> stockRange; // 库存范围
@Override
public void check() {
// 1. 检查 Pair 是否有值
boolean hasPriceRange = !checkPairNull(priceRange);
boolean hasStockRange = !checkPairNull(stockRange);
// 至少提供一个查询条件
if (!hasPriceRange && !hasStockRange) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"至少提供价格范围或库存范围之一"
);
}
// 2. 价格范围合理性校验
if (priceRange != null) {
BigDecimal min = priceRange.left();
BigDecimal max = priceRange.right();
if (min == null || max == null) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"价格范围必须同时提供最小值和最大值"
);
}
if (min.compareTo(max) > 0) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"价格范围最小值不能大于最大值"
);
}
if (min.compareTo(BigDecimal.ZERO) < 0) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"价格不能为负数"
);
}
}
// 3. 库存范围合理性校验
if (stockRange != null) {
Integer min = stockRange.left();
Integer max = stockRange.right();
if (min == null || max == null) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"库存范围必须同时提供最小值和最大值"
);
}
if (min > max) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"库存范围最小值不能大于最大值"
);
}
}
}
}与 Bean Validation 对比
Bean Validation
优点:
- 声明式注解,简洁直观
- 标准规范,通用性强
- 内置常用校验器
局限:
- 难以实现复杂业务规则
- 跨字段校验不够灵活
- 错误信息定制能力有限
java
public class UserRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@Min(value = 18, message = "年龄必须大于18岁")
private Integer age;
// ❌ 难以实现:用户名和邮箱至少提供一个
// ❌ 难以实现:开始日期必须小于结束日期
}CheckParamProvider
优点:
- 灵活强大,支持任意复杂逻辑
- 跨字段校验轻松实现
- 可调用 Service 进行数据库校验
- 错误信息完全自定义
适用场景:
- 复杂业务规则校验
- 跨字段关联校验
- 需要查询数据库的校验
- 动态校验规则
java
public class UserRequest implements CheckParamProvider {
private String username;
private String email;
private Integer age;
@Override
public void check() {
// ✅ 灵活实现:用户名和邮箱至少提供一个
if (StringUtils.isAllBlank(username, email)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"用户名和邮箱至少提供一个"
);
}
// ✅ 灵活实现:年龄校验
if (age != null && age < 18) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"年龄必须大于18岁"
);
}
}
}最佳实践:组合使用
java
public class UserRegisterRequest implements CheckParamProvider {
// 简单校验使用 Bean Validation
@NotBlank(message = "用户名不能为空")
@Length(min = 3, max = 20, message = "用户名长度3-20位")
private String username;
@NotBlank(message = "密码不能为空")
@Length(min = 6, max = 20, message = "密码长度6-20位")
private String password;
@Email(message = "邮箱格式错误")
private String email;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式错误")
private String phone;
// 复杂校验使用 CheckParamProvider
@Override
public void check() {
// 业务规则:邮箱和手机号至少提供一个
if (StringUtils.isAllBlank(email, phone)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"邮箱和手机号至少提供一个"
);
}
// 密码强度校验
if (!isStrongPassword(password)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"密码必须包含字母、数字和特殊字符"
);
}
}
private boolean isStrongPassword(String password) {
return password.matches("^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@#$%]).+$");
}
}最佳实践
✅ 推荐做法
java
// 1. 简单校验用 Bean Validation,复杂校验用 CheckParamProvider
public class OrderRequest implements CheckParamProvider {
@NotNull(message = "商品ID不能为空")
private Long productId;
private Long userId;
private List<Long> userIds;
@Override
public void check() {
// 复杂的联合校验
if (checkAssembleCollectionNull(userId, userIds)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"至少提供一个用户ID"
);
}
}
}
// 2. 使用工具方法简化校验逻辑
@Override
public void check() {
if (checkAssembleCollectionNull(id, ids)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"ID参数不能为空"
);
}
}
// 3. 提取复杂校验逻辑为私有方法
@Override
public void check() {
validateDateRange();
validatePriceRange();
validateStock();
}
private void validateDateRange() {
// 日期范围校验逻辑
}
// 4. 使用有意义的错误提示
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"查询时间跨度不能超过90天,当前跨度: " + days + "天"
);
// 5. 在需要数据库校验时注入 Service
public class UpdateUserRequest implements CheckParamProvider {
@Autowired
private transient UserService userService;
private Long userId;
private String newEmail;
@Override
public void check() {
// 检查邮箱是否已被使用
if (userService.existsByEmail(newEmail, userId)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"该邮箱已被其他用户使用"
);
}
}
}❌ 不推荐做法
java
// ❌ 不要在 check() 中返回 boolean 或错误信息
@Override
public void check() {
if (invalid) {
return false; // 错误:应该抛出异常
}
}
// ❌ 不要在 check() 中捕获并吞掉异常
@Override
public void check() {
try {
validateSomething();
} catch (Exception e) {
// 错误:不要吞掉异常
}
}
// ❌ 不要过度使用,简单校验应该用 Bean Validation
public class SimpleRequest implements CheckParamProvider {
private String username;
@Override
public void check() {
// 错误:这种简单校验应该用 @NotBlank
if (StringUtils.isBlank(username)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"用户名不能为空"
);
}
}
}
// 正确做法:
public class SimpleRequest {
@NotBlank(message = "用户名不能为空")
private String username;
}
// ❌ 不要在 check() 中执行业务逻辑
@Override
public void check() {
// 错误:不要在校验中执行业务逻辑
userService.updateUser(this.userId, this.username);
}常见问题
Q: CheckParamProvider 和 Bean Validation 如何选择?
A: 根据校验复杂度选择:
- Bean Validation:简单的单字段校验(非空、长度、格式等)
- CheckParamProvider:复杂的跨字段校验、业务规则校验
- 组合使用:简单校验用注解,复杂校验实现
check()方法
java
public class Request implements CheckParamProvider {
@NotBlank // 简单校验用注解
private String name;
private String startDate;
private String endDate;
@Override
public void check() { // 复杂校验用 check()
if (startDate != null && endDate != null) {
// 复杂的日期范围校验逻辑
}
}
}Q: check() 方法什么时候执行?
A: 在 Controller 方法执行之前自动执行:
1. 请求到达 Controller
2. RequestLogAop 记录请求信息 (Order=100)
3. CheckParamAspect 执行 check() (Order=200) ← 这里执行
4. Controller 方法执行如果 check() 抛出异常,Controller 方法不会执行,异常会被全局异常处理器捕获。
Q: 为什么我的 check() 方法没有执行?
A: 检查以下几点:
- 确认实现了接口:
java
// ✅ 正确
public class Request implements CheckParamProvider {
@Override
public void check() { ... }
}
// ❌ 错误:忘记实现接口
public class Request {
public void check() { ... }
}- 确认参数是请求参数:
java
// ✅ 正确
@GetMapping("/query")
public BaseVO query(QueryRequest request) { ... }
// ❌ 错误:不是请求参数
@GetMapping("/query")
public BaseVO query() {
QueryRequest request = new QueryRequest();
// 手动创建的对象不会触发切面
}- 确认引入了自动配置:
xml
<dependency>
<groupId>host.springboot</groupId>
<artifactId>krismile-boot3-autoconfigure</artifactId>
</dependency>Q: 如何在 check() 方法中访问数据库?
A: 通过注入 Service 实现:
java
public class UpdateEmailRequest implements CheckParamProvider {
// 使用 @Autowired 注入 Service
@Autowired
private transient UserService userService; // 加 transient 避免序列化
private Long userId;
private String newEmail;
@Override
public void check() {
// 调用 Service 查询数据库
if (userService.existsByEmail(newEmail, userId)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"该邮箱已被使用"
);
}
}
}💡 注意:
- 加
transient关键字避免序列化 Service - 确保请求对象被 Spring 管理(通常框架会自动处理)
Q: 如何禁用参数检查切面?
A: 目前参数检查切面没有单独的开关,但可以通过以下方式控制:
- 方法级别:不实现
CheckParamProvider接口即可 - 全局禁用:排除自动配置类
java
@SpringBootApplication(exclude = {KsAopAutoConfiguration.class})
public class Application {
// ...
}Q: checkAssembleCollectionNull() 的逻辑是什么?
A: 检查单个值和集合是否都为空:
java
// 返回 true 表示都为空
checkAssembleCollectionNull(null, null) // true
checkAssembleCollectionNull(null, List.of()) // true
checkAssembleCollectionNull(null, Collections.emptyList()) // true
// 返回 false 表示至少有一个不为空
checkAssembleCollectionNull(1L, null) // false
checkAssembleCollectionNull(null, List.of(1L)) // false
checkAssembleCollectionNull(1L, List.of(2L)) // false典型用法:
java
// 至少提供一个查询条件
if (checkAssembleCollectionNull(userId, userIds)) {
throw new ApplicationException(
ErrorCodeEnum.INVALID_USER_INPUT,
"至少提供一个用户ID"
);
}💡 提示:切面的执行顺序为 RequestLogAop (100) → CheckParamAspect (200),确保请求信息已记录后再进行参数校验。
