Bootstrap

Spring Validator 学习指南:从零掌握对象校验

Spring Validator 学习指南:从零掌握对象校验


一、Validator 接口的作用:你的数据“守门员”

想象你开发了一个用户注册功能,用户提交的数据可能有各种问题:名字没填、年龄写成了负数……这些错误如果直接保存到数据库,会导致后续流程出错。Validator 就像一位严格的守门员,在数据进入系统前,检查每个字段是否符合规则。

核心任务

  • 检查对象属性是否合法(如非空、数值范围)。
  • 收集错误信息,方便后续提示用户。

二、Validator 接口的两大方法:如何工作?
1. supports(Class clazz):我能处理这个对象吗?
  • 作用:判断当前 Validator 是否支持校验某个类的对象。
  • 关键选择
    • 精确匹配return Person.class.equals(clazz); → 只校验 Person 类。
    • 灵活匹配return Person.class.isAssignableFrom(clazz); → 支持 Person 及其子类。

示例场景

  • 如果你有一个 Student extends Person,使用 equals 时,Student 对象不会被校验;使用 isAssignableFrom 则会校验。
2. validate(Object target, Errors errors):执行校验!
  • 作用:编写具体的校验规则,发现错误时记录到 Errors 对象。
  • 常用工具ValidationUtils 简化非空检查。

示例代码

public void validate(Object target, Errors errors) {
    // 检查 name 是否为空
    ValidationUtils.rejectIfEmpty(errors, "name", "name.empty");
    
    Person person = (Person) target;
    // 检查年龄是否合法
    if (person.getAge() < 0) {
        errors.rejectValue("age", "negative.age", "年龄不能为负数!");
    }
}

三、处理嵌套对象:如何避免重复代码?

假设你有一个 Customer 类,包含 Address 对象:

public class Customer {
    private String firstName;
    private String surname;
    private Address address; // 嵌套对象
}
问题:直接在一个 Validator 中校验所有字段
  • 缺点
    • 若其他类(如 Order)也包含 Address,需重复编写地址校验代码。
    • 维护困难:修改地址规则时,需改动多处代码。
正确做法:拆分 Validator,组合使用!
步骤 1:为每个类创建独立的 Validator
  • AddressValidator(校验地址):
public class AddressValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return Address.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        ValidationUtils.rejectIfEmpty(errors, "street", "street.empty");
        ValidationUtils.rejectIfEmpty(errors, "city", "city.empty");
    }
}
  • CustomerValidator(校验客户,并复用 AddressValidator):
public class CustomerValidator implements Validator {
    private final Validator addressValidator;

    // 通过构造函数注入 AddressValidator
    public CustomerValidator(Validator addressValidator) {
        this.addressValidator = addressValidator;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return Customer.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        // 1. 校验客户的直属字段(firstName, surname)
        ValidationUtils.rejectIfEmpty(errors, "firstName", "firstName.empty");
        ValidationUtils.rejectIfEmpty(errors, "surname", "surname.empty");

        Customer customer = (Customer) target;
        Address address = customer.getAddress();

        // 2. 校验嵌套的 Address 对象
        if (address == null) {
            errors.rejectValue("address", "address.null");
            return;
        }

        // 3. 关键:切换错误路径到 "address",防止字段名冲突
        errors.pushNestedPath("address");
        try {
            ValidationUtils.invokeValidator(addressValidator, address, errors);
        } finally {
            errors.popNestedPath(); // 恢复原始路径
        }
    }
}
步骤 2:实际使用
// 创建 Validator
AddressValidator addressValidator = new AddressValidator();
CustomerValidator customerValidator = new CustomerValidator(addressValidator);

// 准备测试数据
Customer customer = new Customer();
customer.setFirstName(""); // 空名字
customer.setAddress(new Address()); // 空地址

// 执行校验
Errors errors = new BeanPropertyBindingResult(customer, "customer");
customerValidator.validate(customer, errors);

// 输出错误
if (errors.hasErrors()) {
    errors.getAllErrors().forEach(error -> {
        System.out.println("字段:" + error.getObjectName() + "." + error.getCode());
    });
}
// 输出结果:
// 字段:customer.firstName.empty
// 字段:customer.address.street.empty

四、关键技巧与常见问题
1. 错误路径管理
  • pushNestedPathpopNestedPath
    确保嵌套对象的错误字段带上前缀(如 address.street),避免与主对象的字段名冲突。
2. 防御性编程
  • 在组合 Validator 时,检查注入的 Validator 是否支持目标类型:
public CustomerValidator(Validator addressValidator) {
    if (!addressValidator.supports(Address.class)) {
        throw new IllegalArgumentException("必须支持 Address 类型!");
    }
    this.addressValidator = addressValidator;
}
3. 国际化支持
  • 错误代码(如 firstName.empty)可对应语言资源文件(如 messages_zh.properties),实现多语言提示:
# messages_zh.properties
firstName.empty=名字不能为空
address.street.empty=街道地址不能为空

五、总结:为什么这样设计?
  1. 代码复用AddressValidator 可被其他需要校验地址的类(如 OrderCompany)直接使用。
  2. 单一职责:每个 Validator 只负责一个类的校验,逻辑清晰,易于维护。
  3. 灵活扩展:新增嵌套对象(如 PaymentInfo)时,只需创建新的 Validator 并注入,无需修改已有代码。

3.2. 将错误代码解析为错误信息:深入解析与实例演示


一、核心概念:错误代码的多层次解析

当你在 Spring 中调用 rejectValue 方法注册错误时(例如校验用户年龄不合法),Spring 不会只记录你指定的单一错误代码,而是自动生成一组层级化的错误代码。这种设计允许开发者通过不同层级的错误代码,灵活定义错误消息,实现“从具体到通用”的覆盖策略。


二、错误代码生成规则

假设在 PersonValidator 中触发以下校验逻辑:

errors.rejectValue("age", "too.darn.old");

生成的错误代码(按优先级从高到低):

  1. too.darn.old.age.int字段名 + 错误代码 + 字段类型
  2. too.darn.old.age字段名 + 错误代码
  3. too.darn.old原始错误代码

三、消息资源文件的匹配策略

Spring 的 MessageSource 会按照错误代码的优先级顺序,在消息资源文件(如 messages.properties)中查找对应的消息。一旦找到匹配项,立即停止搜索

示例消息资源文件

# messages.properties
too.darn.old.age.int=年龄必须是整数且不超过 120 岁
too.darn.old.age=年龄不能超过 120 岁
too.darn.old=输入的值不合理

匹配过程

  1. 优先查找 too.darn.old.age.int → 若存在则使用。
  2. 若未找到,查找 too.darn.old.age → 若存在则使用。
  3. 最后查找 too.darn.old → 作为兜底消息。

四、实战演示:从代码到错误消息
步骤 1:创建实体类与校验器
// Person.java
public class Person {
    private String name;
    private int age;
    // getters/setters
}

// PersonValidator.java
public class PersonValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return Person.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        Person person = (Person) target;
        if (person.getAge() > 120) {
            errors.rejectValue("age", "too.darn.old");
        }
    }
}
步骤 2:配置消息资源文件

src/main/resources/messages.properties 中定义:

# 具体到字段类型
too.darn.old.age.int=年龄必须是整数且不能超过 120 岁
# 具体到字段
too.darn.old.age=年龄不能超过 120 岁
# 通用错误
too.darn.old=输入的值无效
步骤 3:编写测试代码
@SpringBootTest
public class ValidationTest {

    @Autowired
    private MessageSource messageSource;

    @Test
    public void testAgeValidation() {
        Person person = new Person();
        person.setAge(150); // 触发错误

        Errors errors = new BeanPropertyBindingResult(person, "person");
        PersonValidator validator = new PersonValidator();
        validator.validate(person, errors);

        // 提取错误消息
        errors.getFieldErrors("age").forEach(error -> {
            String message = messageSource.getMessage(error.getCode(), null, Locale.getDefault());
            System.out.println("错误消息:" + message);
        });
    }
}

输出结果

错误消息:年龄必须是整数且不能超过 120 岁

解析
因为 too.darn.old.age.int 在消息文件中存在,优先使用该消息。若删除这行,则会匹配 too.darn.old.age,以此类推。


五、自定义错误代码生成策略

默认的 DefaultMessageCodesResolver 生成的代码格式为:
错误代码 + 字段名 + 字段类型
若需修改规则,可自定义 MessageCodesResolver

示例:简化错误代码
@Configuration
public class ValidationConfig {

    @Bean
    public MessageCodesResolver messageCodesResolver() {
        DefaultMessageCodesResolver resolver = new DefaultMessageCodesResolver();
        resolver.setMessageCodeFormatter(DefaultMessageCodesResolver.Format.POSTFIX_ERROR_CODE);
        return resolver;
    }
}

效果
调用 rejectValue("age", "too.darn.old") 生成的代码变为:

  1. age.too.darn.old
  2. too.darn.old

六、常见问题与解决方案
问题 1:如何查看实际生成的错误代码?

在测试代码中打印错误对象:

errors.getFieldErrors("age").forEach(error -> {
    System.out.println("错误代码列表:" + Arrays.toString(error.getCodes()));
});

输出

错误代码列表:[too.darn.old.age.int, too.darn.old.age, too.darn.old]
问题 2:字段类型在代码中如何表示?

Spring 使用字段的简单类名(如 intString)。对于自定义类型(如 Address),代码中会使用 address(类名小写)。


七、总结:为何需要层级化错误代码?
  1. 灵活覆盖:允许针对特定字段或类型定制消息,同时提供通用兜底。
  2. 国际化友好:不同语言可定义不同层级的消息,无需修改代码。
  3. 代码解耦:校验逻辑与具体错误消息分离,提高可维护性。

学习建议

  • 通过调试观察 errors.getCodes() 的输出,深入理解代码生成规则。
  • 在项目中优先使用字段级错误代码(如 too.darn.old.age),提高错误消息的精准度。
;