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. 错误路径管理
pushNestedPath
和popNestedPath
:
确保嵌套对象的错误字段带上前缀(如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=街道地址不能为空
五、总结:为什么这样设计?
- 代码复用:
AddressValidator
可被其他需要校验地址的类(如Order
、Company
)直接使用。 - 单一职责:每个 Validator 只负责一个类的校验,逻辑清晰,易于维护。
- 灵活扩展:新增嵌套对象(如
PaymentInfo
)时,只需创建新的 Validator 并注入,无需修改已有代码。
3.2. 将错误代码解析为错误信息:深入解析与实例演示
一、核心概念:错误代码的多层次解析
当你在 Spring 中调用 rejectValue
方法注册错误时(例如校验用户年龄不合法),Spring 不会只记录你指定的单一错误代码,而是自动生成一组层级化的错误代码。这种设计允许开发者通过不同层级的错误代码,灵活定义错误消息,实现“从具体到通用”的覆盖策略。
二、错误代码生成规则
假设在 PersonValidator
中触发以下校验逻辑:
errors.rejectValue("age", "too.darn.old");
生成的错误代码(按优先级从高到低):
too.darn.old.age.int
→ 字段名 + 错误代码 + 字段类型too.darn.old.age
→ 字段名 + 错误代码too.darn.old
→ 原始错误代码
三、消息资源文件的匹配策略
Spring 的 MessageSource
会按照错误代码的优先级顺序,在消息资源文件(如 messages.properties
)中查找对应的消息。一旦找到匹配项,立即停止搜索。
示例消息资源文件:
# messages.properties
too.darn.old.age.int=年龄必须是整数且不超过 120 岁
too.darn.old.age=年龄不能超过 120 岁
too.darn.old=输入的值不合理
匹配过程:
- 优先查找
too.darn.old.age.int
→ 若存在则使用。 - 若未找到,查找
too.darn.old.age
→ 若存在则使用。 - 最后查找
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")
生成的代码变为:
age.too.darn.old
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 使用字段的简单类名(如 int
、String
)。对于自定义类型(如 Address
),代码中会使用 address
(类名小写)。
七、总结:为何需要层级化错误代码?
- 灵活覆盖:允许针对特定字段或类型定制消息,同时提供通用兜底。
- 国际化友好:不同语言可定义不同层级的消息,无需修改代码。
- 代码解耦:校验逻辑与具体错误消息分离,提高可维护性。
学习建议:
- 通过调试观察
errors.getCodes()
的输出,深入理解代码生成规则。 - 在项目中优先使用字段级错误代码(如
too.darn.old.age
),提高错误消息的精准度。