做后端开发的同学对 @NotNull、@Size 这些注解应该不陌生,引入 spring-boot-starter-validation 依赖后,在 Controller 的入参上贴几个注解,就能完成大部分基础校验。但真实项目里的校验需求远不止这么简单:注册时要求两次输入的密码必须一致、下单时优惠金额不能超过商品总价、手机号必须不存在于数据库中——这些带业务语义的规则,标准注解一个都覆盖不了。如果直接在校验注解解决不了的地方写 if-else,校验代码就会散落在各个业务方法里,既难复用也难维护。这时候,自定义 Validator 注解就成了最优雅的解法。

一、先弄懂校验框架的扩展机制
JSR 303 / JSR 380(也就是 Bean Validation 规范)设计了两个核心角色:注解本身和校验器实现。注解只是一份“元数据声明”,真正干活的是实现了 ConstraintValidator 接口的类。框架在校验某个对象时,会反射读取字段上的注解,再通过注解中声明的 validatedBy 属性找到对应的校验器实现类,调用它的 isValid 方法得出结论。
这套设计是典型的策略模式:注解负责表达“我要校验什么”,校验器负责实现“怎么校验”。两者分离带来的最大好处是,同一个注解可以适配不同类型(比如同时支持 String 和 Collection),同一个校验逻辑也可以被多个注解复用。理解了这一点,自定义校验注解的写法就很清晰了:定义注解、实现校验器、声明使用,三步走完。
还需要注意 ConstraintValidator 有两个泛型参数,第一个是注解类型,第二个是被校验值的类型。如果想让注解同时支持多种类型,第二个泛型可以留空,然后在 initialize 方法里根据上下文判断,不过这种做法相对少见,一般针对具体类型实现即可。
二、动手实现第一个自定义注解
假设有一个常见需求:系统里某些编号必须以指定前缀开头,比如订单号必须以 ORD 开头。下面从零实现一个 @PrefixCheck 注解。第一步是定义注解本身,它必须包含 message、groups、payload 三个元属性,这是规范强制要求的:
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PrefixValidator.class)
public @interface PrefixCheck {
// 校验失败时返回的消息,支持从资源文件读取
String message() default "编号前缀不符合要求";
// 分组校验使用
Class<?>[] groups() default {};
// 负载信息,一般不用动
Class<? extends Payload>[] payload() default {};
// 自定义属性:要求的前缀
String prefix() default "ORD";
}第二步是编写校验器实现类。initialize 方法在注解实例化时调用一次,适合缓存注解配置;isValid 是核心校验逻辑,返回 true 表示通过:
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class PrefixValidator implements ConstraintValidator<PrefixCheck, String> {
private String prefix;
@Override
public void initialize(PrefixCheck annotation) {
this.prefix = annotation.prefix();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
// 空值交给 @NotNull 处理,这里直接放行
return true;
}
return value.startsWith(prefix);
}
}这里有个容易被忽视的细节:isValid 里对 null 值返回了 true。这是社区约定俗成的做法——注解各司其职,非空校验交给 @NotBlank,一个注解只负责一类规则,否则两个注解会重复报错,前端收到的错误提示会很混乱。第三步就是在实体类上直接使用了:
public class OrderRequest {
@NotBlank(message = "订单号不能为空")
@PrefixCheck(prefix = "ORD", message = "订单号必须以ORD开头")
private String orderNo;
// 省略 getter 和 setter
}三、进阶:注入 Spring Bean 处理依赖数据库的校验
纯逻辑校验只是入门,真正体现自定义注解价值的是需要查库的场景。比如注册接口要校验手机号未被占用。好消息是,Hibernate Validator 与 Spring 集成后,校验器实例是由 Spring 容器创建的,可以直接在 ConstraintValidator 里注入 Service 或 Mapper:
import org.springframework.beans.factory.annotation.Autowired;
public class MobileUniqueValidator implements ConstraintValidator<MobileUnique, String> {
@Autowired
private UserMapper userMapper;
@Override
public boolean isValid(String mobile, ConstraintValidatorContext context) {
if (mobile == null || mobile.isEmpty()) {
return true;
}
// 查询数据库判断手机号是否已被注册
return userMapper.selectCountByMobile(mobile) == 0;
}
}这个能力非常强大,但也要警惕它的副作用:校验逻辑发生在请求参数绑定阶段,一旦涉及数据库查询,就意味着每次请求都会多一次 IO。高并发场景下,如果校验查询走的是没有缓存的慢 SQL,接口吞吐量会明显下滑。实践建议是,这类“弱校验”(即最终业务层还会再兜底判断的规则)尽量控制数量,或者在注解里预留开关,配合分组校验只在必要时启用。
另一个高级用法是自定义错误消息。默认情况下框架会直接输出 message 内容,但有时我们希望根据校验失败的原因动态拼接提示,这时可以操作 ConstraintValidatorContext:
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || !value.startsWith(prefix)) {
// 禁用默认消息,使用自定义模板
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"编号必须以 " + prefix + " 开头,当前值不合法"
).addConstraintViolation();
return false;
}
return true;
}四、类级别校验解决跨字段问题
密码与确认密码一致、结束时间必须晚于开始时间,这类规则针对的是多个字段之间的关系,单个字段上的注解表达不了。解决办法是把注解标在类上,让校验器接收整个对象,自行取字段比对:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
String message() default "结束时间必须晚于开始时间";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class DateRangeValidator implements ConstraintValidator<ValidDateRange, ActivityForm> {
@Override
public boolean isValid(ValidDateRange form是形式参数, ConstraintValidatorContext context) {
return false;
}
}上面第二段代码示意了结构,实际实现中 isValid 的第一个参数就是被标注的表单对象本身,取出其中的开始时间和结束时间字段比较即可返回结果。类级别注解的 @Target 必须是 ElementType.TYPE,这点和字段注解不同,写错了框架不会报错但校验永远不会触发,是排查起来很坑的一个点。
跨字段校验还有一种思路是利用反射和字符串属性名,在注解里声明参与比对的字段名,校验器通过反射取值,实现一个通用的字段相等性注解。这种方案通用性强,但反射带来了轻微性能损耗,且属性名写成字符串失去编译期检查,字段重命名时容易漏改。两种方案取舍要看项目规模,小型项目直接写具体类型的校验器更直观。
五、统一异常处理与使用建议
校验失败时,Controller 上标注 @Validated 的接口会抛出 MethodArgumentNotValidException(请求体)或 ConstraintViolationException(参数级校验),在全局异常处理器里捕获后提取 FieldError 信息返回给前端:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result handleValidException(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(";"));
return Result.fail(400, msg);
}
}最后总结几条实践经验。第一,注解的 @Retention 必须是 RUNTIME,否则运行时读不到注解,校验静默失效。第二,message、groups、payload 三个属性缺一不可,这是规范的硬性要求。第三,校验器是无状态复用的,不要在 isValid 里修改成员变量以外的共享数据。第四,带数据库查询的校验要控制频率,必要时结合分组校验区分新增和编辑场景——比如编辑用户时手机号校验要排除自己,可以在注解里定义分组,服务层按需触发。把校验逻辑沉淀为一个个语义清晰的注解,代码可读性和复用性都会上一个台阶,这也是 Bean Validation 框架设计的初衷所在。