在实际项目开发中,我们通常使用 Hibernate Validator 对参数进行校验,利用注解方式简洁直观。然而,这种静态注解配置也带来了一些弊端:
一旦项目上线,校验规则便固定下来,如果业务需要调整(如支持新的手机号号段),就必须修改代码并重新部署。这在生产环境中可能导致较大风险和效率损耗。
为解决这一问题,我们引入了一种支持“动态校验规则”的机制,支持显示触发与规则热更新,核心方案如下:
一、校验工具类封装
我们封装了 ValidationUtils 工具类,简化调用并增强灵活性。它支持:
- 基于分组的参数校验(支持标准
@Group) - 字段级别值校验
- 支持获取详细的错误信息(不抛异常)
- 提供线程上下文保存校验组信息,便于扩展到动态校验逻辑
package org.example;import jakarta.validation.*;
import jakarta.validation.groups.Default;import java.util.Arrays;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;public final class ValidationUtils {private ValidationUtils() {throw new IllegalStateException("Utility class");}private static final Validator validator;static {ValidatorFactory factory = Validation.buildDefaultValidatorFactory();validator = factory.getValidator();}/*** 校验整个对象(默认组),失败时抛出 ConstraintViolationException*/public static <T> void validate(T object) {validate(object, Default.class);}/*** 支持指定分组的校验,失败时抛出 ConstraintViolationException*/public static <T> void validate(T object, Class<?>... groups) {try {ValidationGroupContext.setGroups(groups);Set<ConstraintViolation<T>> violations = validator.validate(object, groups);if (!violations.isEmpty()) {throw new ConstraintViolationException("参数校验失败", violations);}} finally {ValidationGroupContext.clear();}}/*** 校验信息(默认组)*/public static <T> void validateMessage(T object) {validateMessage(object, Default.class);}/*** 获取指定分组的校验信息,不抛异常*/public static <T> void validateMessage(T object, Class<?>... groups) {try {ValidationGroupContext.setGroups(groups);Set<ConstraintViolation<T>> violations = validator.validate(object, groups);if (!violations.isEmpty()) {String errorMsg = violations.stream().map(v -> v.getPropertyPath() + ": " + v.getMessage()).collect(Collectors.joining("; "));throw new IllegalArgumentException(errorMsg);}} finally {ValidationGroupContext.clear();}}/*** 校验某个字段值是否符合指定组的约束*/public static <T> void validatePropertyValue(Class<T> clazz, String propertyName, Object value, Class<?>... groups) {try {ValidationGroupContext.setGroups(groups);Set<ConstraintViolation<T>> violations = validator.validateValue(clazz, propertyName, value, groups);if (!violations.isEmpty()) {throw new ConstraintViolationException("字段值校验失败", violations);}} finally {ValidationGroupContext.clear();}}/*** 设置当前线程的校验组,留给动态校验组的拓展*/public static class ValidationGroupContext implements AutoCloseable {private static final ThreadLocal<Set<String>> GROUPS = new ThreadLocal<>();public static void setGroups(Class<?>... groups) {if (groups == null || groups.length == 0) {return;}Set<String> groupNames = Arrays.stream(groups).filter(Objects::nonNull) // 过滤掉 null 的 group.map(Class::getName).collect(Collectors.toSet());if (!groupNames.isEmpty()) {GROUPS.set(groupNames);}}public static Set<String> getCurrentGroups() {return GROUPS.get() != null ? GROUPS.get() : Set.of();}public static void clear() {GROUPS.remove();}@Overridepublic void close() {clear();}}
}
✅ 使用示例(含 Group)
1. 定义分组接口:
public interface AddGroup {}
public interface UpdateGroup {}
2. 实体类中使用分组注解:
public class User {@NotBlank(message = "用户名不能为空", groups = {AddGroup.class})private String name;@Min(value = 18, message = "年龄必须大于等于18", groups = {AddGroup.class, UpdateGroup.class})private int age;// getter / setter / constructor ...
}
3. 使用分组进行校验
User user = new User(null, 15);// 校验 AddGroup 分组规则
ValidationUtils.validate(user, AddGroup.class);// 或获取错误信息
ValidationUtils.validateMessage(user, AddGroup.class);
二、✳️ 动态校验的实现机制
1. 自定义注解 @DynamicValidate
为支持动态规则,我们定义了一个类级别注解 @DynamicValidate,该注解通过 Hibernate Validator 标准接口 @Constraint(validatedBy = ...) 与我们自定义的 ConstraintValidator 实现绑定。
注解必须显式包含 groups 参数,否则会因不符合 Hibernate 约定而报错。
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DynamicBeanValidator.class)
@Documented
public @interface DynamicValidate {String message() default "动态参数校验失败";Class<?>[] groups() default {};Class<? extends Payload>[] payload() default {};
}
Hibernate Validator 框架要求约束注解必须包含 groups 参数
否则会报错:HV000074: contains Constraint annotation, but does not contain a groups parameter.
2. 自定义校验器 DynamicBeanValidator
该类实现了 ConstraintValidator<DynamicValidate, Object> 接口,核心逻辑如下:
- 从缓存中读取目标类对应的校验规则集合
List<ValidationRule> - 遍历每条规则,根据当前校验组信息进行条件匹配(仅执行匹配的规则)
- 支持两种校验方式:
- 普通规则(如 min/max/null 检查)
- 表达式校验(如跨字段依赖,使用 MVEL 动态引擎执行)
- 对失败项构造具体的字段级错误提示
通过 ValidationUtils.ValidationGroupContext 静态上下文对象,我们在 validate 方法入口处传入的 group 信息可以被 DynamicBeanValidator 获取,实现了“从外部传入 -> 动态解析规则 -> 分组匹配执行”这一完整闭环。
package org.example;import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;import java.lang.reflect.Field;
import java.util.List;
import java.util.Set;public class DynamicBeanValidator implements ConstraintValidator<DynamicValidate, Object> {@Overridepublic boolean isValid(Object obj, ConstraintValidatorContext context) {List<ValidationRule> rules = RuleCacheManager.getRules(obj.getClass().getName());if (rules == null || rules.isEmpty()) {return true;}boolean valid = true;context.disableDefaultConstraintViolation();// 从上下文获取当前执行的 groups(由外部 validate 调用时设置)Set<String> currentGroups = ValidationUtils.ValidationGroupContext.getCurrentGroups();for (ValidationRule rule : rules) {try {Field field = obj.getClass().getDeclaredField(rule.getFieldName());field.setAccessible(true);Object value = field.get(obj);// 若该规则未指定 group,默认校验所有. 当前 group 与 ruleGroups 不匹配,跳过Set<String> ruleGroups = rule.getGroups();if (ruleGroups != null && !ruleGroups.isEmpty() && ruleGroups.stream().noneMatch(currentGroups::contains)) {continue;}// 获取对应规则策略RuleCheckerEnum checker = RuleCheckerEnum.getRuleChecker(rule.getRuleType());if (checker == null) {continue;}// 如果规则是Mvel表达式,则直接执行表达式,优先级更高if (checker == RuleCheckerEnum.EXPRESSION && MvelExpressionValidator.evalExpression(rule.getExpression(), obj)) {continue;}// 普通规则校验方式if (checker != RuleCheckerEnum.EXPRESSION && checker.check(value, rule.getRuleValue())) {continue;}context.buildConstraintViolationWithTemplate(rule.getMessage()).addPropertyNode(rule.getFieldName()).addConstraintViolation();valid = false;} catch (Exception e) {context.buildConstraintViolationWithTemplate("字段解析失败: " + e.getMessage()).addConstraintViolation();return false;}}return valid;}
}
package org.example;import lombok.AllArgsConstructor;
import lombok.Getter;@AllArgsConstructor
@Getter
public enum RuleCheckerEnum {MIN("min") {@Overridepublic boolean check(Object val, String ruleVal) {if (val instanceof Number) {return ((Number) val).doubleValue() >= Double.parseDouble(ruleVal);}return true;}},MAX("max") {@Overridepublic boolean check(Object val, String ruleVal) {if (val instanceof Number) {return ((Number) val).doubleValue() >= Double.parseDouble(ruleVal);}return true;}},EXPRESSION("expression");/// --------------------------------------------------------------------------------private final String ruleName;public boolean check(Object value, String param) {return true;}public static RuleCheckerEnum getRuleChecker(String ruleName) {for (RuleCheckerEnum ruleChecker : values()) {if (ruleChecker.getRuleName().equals(ruleName)) {return ruleChecker;}}return null;}
}
✅ 实体类中使用自定义注解
@DynamicValidate
public class UserDTO {private String name;private Integer age;// getter/setter...
}
✅ 控制器中使用标准的 @Valid 校验或者手动调用
@PostMapping("/save")
public String save(@RequestBody @Valid UserDTO userDTO) {return "success";
}
// 校验 AddGroup 分组规则
ValidationUtils.validate(user, AddGroup.class);// 或获取错误信息
ValidationUtils.validateMessage(user, AddGroup.class);
3. 校验规则缓存与动态加载
通过 RuleCacheManager 管理类名与规则列表的映射关系,可实现:
- 启动时加载数据库中的校验规则
- 支持后续通过管理界面或接口实现规则动态更新与热加载
规则结构(ValidationRule)包含字段名、校验类型、参数、错误提示、可选表达式及所属 group。
package org.example;import java.util.HashMap;
import java.util.List;
import java.util.Map;public class RuleCacheManager {private RuleCacheManager() {throw new IllegalStateException("Utility class");}private static final Map<String, List<ValidationRule>> ruleCache = new HashMap<>();public static List<ValidationRule> getRules(String className) {return ruleCache.get(className);}public static void putRules(String className, List<ValidationRule> rules) {ruleCache.put(className, rules);}
}
✅ 示例数据库表结构(映射上面的类)
| id | class_name | field_name | rule_type | rule_value | message | expression | group | enabled |
|---|---|---|---|---|---|---|---|---|
| 1 | com.example.User | not_null | 邮箱不能为空 | null | true | |||
| 2 | com.example.User | age | min | 18 | 年龄必须 ≥ 18 | null | true | |
| 3 | com.example.User | expression | 管理员必须填写邮箱 | `obj.userType != 'admin' | (obj.email != null && obj.email.length > 0)` |
你可以通过 ORM 工具(如 MyBatis / JPA)将其读取为 ValidationRule 列表,并存入缓存中。
- 启动时从数据库加载所有规则
- 也可以做定时刷新 / 动态更新等机制
4. 表达式支持:MVEL
部分校验逻辑无法静态表示(如跨字段约束),我们引入 MVEL 表达式引擎进行动态校验。
优势包括:
- 与 Java 语法兼容性好,支持对象引用、复杂逻辑判断
- 轻量级、无须额外脚本引擎依赖,适用于 Java 11+
某些字段的校验规则依赖上下文(如:另一个字段的值),这属于动态规则校验中的高级用法,可以通过表达式 + 动态引擎来实现。
✅ 示例场景
假设有以下业务规则:
当
userType == "admin"时,
这个规则显然不能只靠单字段判断,而需要跨字段 + 动态表达式。
✅ 解决方案:引入表达式引擎(SpEL / MVEL / JavaScript)
我们推荐用 JSR-223 标准支持的脚本引擎,例如 JavaScript 引擎 来动态执行校验表达式,灵活强大且跨字段无压力。
✅ 实现步骤
1. 规则格式升级(以 JSON 或数据库存储为例)
{"field": "email","expression": "obj.userType != 'admin' || (obj.email != null && obj.email.length > 0)","message": "管理员必须填写邮箱"
}
说明:表达式中可以引用对象属性,表达式最终返回
true/false
2. 在校验器中使用 JavaScript 引擎执行表达式
import javax.script.*;
import java.util.*;public class JsExpressionValidator {private static final ScriptEngine engine = new ScriptEngineManager().getEngineByName("JavaScript");public static boolean evalExpression(String expression, Object obj) {Bindings bindings = engine.createBindings();bindings.put("obj", obj);try {Object result = engine.eval(expression, bindings);return result instanceof Boolean && (Boolean) result;} catch (Exception e) {// 建议记录日志}}
}
支持动态校验的组校验
想要通过校验入口类 ValidationUtils.validateMessage(obj, groupClass)进行动态校验组的规则触发。
三、使用方式
- DTO 类上添加
@DynamicValidate - 控制器中使用标准
@Valid注解触发动态校验,或使用ValidationUtils.validateMessage(obj, group)显式调用 - 校验器根据上下文 group 与缓存规则完成动态执行
public interface TestUpdateGroup extends Default {
}public interface TestAddGroup extends Default {
}
package org.example.test;import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import org.example.DynamicValidate;import java.io.Serializable;@DynamicValidate
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
@Data
public class TestDTO implements Serializable {@NotNull(message = "id不能为空", groups = TestUpdateGroup.class)private Long id;@NotNull(message = "姓名不能为空", groups = TestAddGroup.class)private String name;@NotNull(message = "年龄不能为空")@Max(100)@Min(0)private Integer age;@NotNull(message = "性别不能为空")@Max(1)@Min(0)private Integer sex;private String address;private String phone;private String email;private String idCard;}
package org.example.test;import org.example.RuleCacheManager;
import org.example.ValidationRule;
import org.example.ValidationUtils;import java.util.Collections;
import java.util.Set;public class TestMain {public static void main(String[] args) {TestDTO testDTO = new TestDTO().setName("测试的名字").setAge(20).setSex(1);ValidationRule validationRule = new ValidationRule();validationRule.setClassName(TestDTO.class.getName());validationRule.setFieldName("address");validationRule.setRuleType("expression");validationRule.setMessage("地址不合法");validationRule.setExpression("obj.age > 19");validationRule.setEnabled(true);validationRule.setGroups(Set.of(TestAddGroup.class.getName()));RuleCacheManager.putRules(TestDTO.class.getName(), Collections.singletonList(validationRule));ValidationUtils.validateMessage(testDTO);}
}
四、常见问题与说明
Q1: ScriptEngineManager().getEngineByName("JavaScript") 返回 null?
✅ 问题根因
JavaScript 引擎依赖 Nashorn,而:
- 在 Java 8:
Nashorn是默认自带的 JavaScript 引擎 ✅ - 在 Java 11 起:Nashorn 被标记为废弃 ❌
- 在 Java 15 起:Nashorn 被完全移除 ❌
因此你用的是 Java 11+,就会导致:
new ScriptEngineManager().getEngineByName("JavaScript") // 返回 null
✅ 解决方案
使用 MVEL 表达式替代 JavaScript
你原来是想动态校验表达式,例如 obj.age > 20,可以使用 MVEL 表达式引擎,轻量高效,兼容 Java 语法:
<dependency><groupId>org.mvel</groupId><artifactId>mvel2</artifactId><version>2.4.12.Final</version></dependency>
package org.example;import org.mvel2.MVEL;import java.io.Serializable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;public class MvelExpressionValidator {private MvelExpressionValidator() {throw new IllegalStateException("Utility class");}// 缓存表达式对应的编译对象private static final Map<String, Serializable> expressionCache = new ConcurrentHashMap<>();/*** 执行表达式*/public static boolean evalExpression(String expression, Object obj) {try {Serializable compiled = expressionCache.computeIfAbsent(expression, MVEL::compileExpression);Map<String, Object> context = Map.of("obj", obj);Object result = MVEL.executeExpression(compiled, context);return result instanceof Boolean && (Boolean) result;} catch (Exception e) {// 建议记录日志return false;}}
}
Q2: 为什么不能在 @Constraint 中将 groups 默认值设为 {Default.class}?
- Hibernate Validator 会反射校验该注解的结构;
- 一旦你自定义注解上标注了
@Constraint,它就要求groups()的默认值必须是空数组; Default.class是框架默认逻辑的一部分,不允许你直接设置为默认值,会干扰Hibernate 的 group 合并逻辑。
Q3:DynamicBeanValidator 有办法知道当前是需要校验哪些Group吗?
这是个非常好的问题,涉及 Hibernate Validator 在运行时传递 group 信息的机制。
简短回答是:
✅ 标准的
ConstraintValidator接口中无法直接获取当前正在使用的校验 groups。
❓ 为什么无法直接拿到 group?
Hibernate Validator 的 ConstraintValidator#isValid() 方法只接收:
boolean isValid(Object value, ConstraintValidatorContext context);
它没有传递当前 groups 的参数。这是 JSR 规范的限制 —— groups 是 Validator 引擎在内部处理的一部分,在运行时不会自动传给 ConstraintValidator。
✅ 解决方案: 使用自定义的 ThreadLocal 上下文(推荐)
public class ValidationUtils {public static void validateMessage(Object obj, Class<?>... groups) {try {// 设置当前 group 到上下文中ValidationGroupContext.setGroups(groups);Validator validator = Validation.buildDefaultValidatorFactory().getValidator();Set<ConstraintViolation<Object>> violations = validator.validate(obj, groups);for (ConstraintViolation<Object> violation : violations) {System.out.println("字段:" + violation.getPropertyPath() + " 错误:" + violation.getMessage());}} finally {// 清理上下文ValidationGroupContext.clear();}}/*** 设置当前线程的校验组,留给动态校验组的拓展*/public static class ValidationGroupContext implements AutoCloseable {private static final ThreadLocal<Set<String>> GROUPS = new ThreadLocal<>();public static void setGroups(Class<?>... groups) {if (groups == null || groups.length == 0) {return;}Set<String> groupNames = Arrays.stream(groups).filter(Objects::nonNull) // 过滤掉 null 的 group.map(Class::getName).collect(Collectors.toSet());if (!groupNames.isEmpty()) {GROUPS.set(groupNames);}}public static Set<String> getCurrentGroups() {return GROUPS.get() != null ? GROUPS.get() : Set.of();}public static void clear() {GROUPS.remove();}@Overridepublic void close() {clear();}}
}
动态校验的逻辑调整为
// 从上下文获取当前执行的 groups(由外部 validate 调用时设置)Set<String> currentGroups = ValidationUtils.ValidationGroupContext.getCurrentGroups();for (ValidationRule rule : rules) {try {Field field = obj.getClass().getDeclaredField(rule.getFieldName());field.setAccessible(true);Object value = field.get(obj);// 若该规则未指定 group,默认校验所有. 当前 group 与 ruleGroups 不匹配,跳过Set<String> ruleGroups = rule.getGroups();if (ruleGroups != null && !ruleGroups.isEmpty() && ruleGroups.stream().noneMatch(currentGroups::contains)) {continue;}.......}}
五、总结
通过上述方式,我们实现了对 Hibernate Validator 的增强:
- 保留了原生注解式校验的使用方式
- 引入可配置、热更新的动态规则机制
- 兼容原有分组校验体系
- 表达式引擎支持跨字段/复杂逻辑判断
大幅提升了系统参数校验的灵活性、安全性与可维护性。
