如何在Hibernate Validator中实现动态校验组的规则触发

在实际项目开发中,我们通常使用 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();
        }

        @Override
        public 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> {

    @Override
    public 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") {
        @Override
        public boolean check(Object val, String ruleVal) {
            if (val instanceof Number) {
                return ((Number) val).doubleValue() >= Double.parseDouble(ruleVal);
            }
            return true;
        }

    },

    MAX("max") {
        @Override
        public 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 email not_null 邮箱不能为空 null true
2 com.example.User age min 18 年龄必须 ≥ 18 null true
3 com.example.User email expression 管理员必须填写邮箱 `obj.userType != 'admin' (obj.email != null && obj.email.length > 0)`

你可以通过 ORM 工具(如 MyBatis / JPA)将其读取为 ValidationRule 列表,并存入缓存中。

  • 启动时从数据库加载所有规则
  • 也可以做定时刷新 / 动态更新等机制

4. 表达式支持:MVEL

部分校验逻辑无法静态表示(如跨字段约束),我们引入 MVEL 表达式引擎进行动态校验。

优势包括:

  • 与 Java 语法兼容性好,支持对象引用、复杂逻辑判断
  • 轻量级、无须额外脚本引擎依赖,适用于 Java 11+

某些字段的校验规则依赖上下文(如:另一个字段的值),这属于动态规则校验中的高级用法,可以通过表达式 + 动态引擎来实现。

✅ 示例场景

假设有以下业务规则:

userType == "admin" 时,email 必填,否则可为空。

这个规则显然不能只靠单字段判断,而需要跨字段 + 动态表达式

✅ 解决方案:引入表达式引擎(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)进行动态校验组的规则触发。

三、使用方式

  1. DTO 类上添加 @DynamicValidate
  2. 控制器中使用标准 @Valid 注解触发动态校验,或使用 ValidationUtils.validateMessage(obj, group) 显式调用
  3. 校验器根据上下文 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 8Nashorn 是默认自带的 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();
        }

        @Override
        public 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 的增强:

  • 保留了原生注解式校验的使用方式
  • 引入可配置、热更新的动态规则机制
  • 兼容原有分组校验体系
  • 表达式引擎支持跨字段/复杂逻辑判断

大幅提升了系统参数校验的灵活性、安全性与可维护性。

posted @ 2025-09-01 21:46  Violet-EV  阅读(28)  评论(0)    收藏  举报
Language: HTML