一、为什么需要自定义注解校验?
Spring Boot 已经自带了一堆好用的校验注解,比如:
@NotNull:不能为 null@Size(min=, max=):字符串 / 集合长度范围@Email:邮箱格式@Min/@Max:数值范围
但现实业务经常有"标准注解表达不了"的规则,比如:
- 手机号必须是
1开头、11位、第二位3~9 - 密码必须包含大小写字母 + 数字
- 身份证号要符合校验规则
- 某个字段的值必须在枚举范围内
这些"业务规则",就可以用 自定义注解 + 校验器 来优雅地解决,让校验逻辑集中、可复用,而且写起来就像用 @NotNull 一样简单:
typescript
@Phone
private String mobile;
二、核心三件套
自定义一个校验注解,本质上要准备三样东西:
| 角色 | 是什么 | 作用 |
|---|---|---|
| 注解 | 你自己定义的 @interface |
用在字段 / 参数上,声明"这里要校验" |
@Constraint |
加在注解上的元注解 | 把注解和"校验器类"绑在一起 |
ConstraintValidator |
一个实现类 | 写真正的校验逻辑(isValid) |
一句话记忆:注解负责"贴哪里、报什么错",校验器负责"怎么算合法"。
三、第一个例子:手机号校验
我们一步步来。
1. 加入依赖
Spring Boot 2.3 之后,校验被拆成了独立 starter,需要手动引入(如果是老版本可能已在 spring-boot-starter-web 里)。
xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
2. 创建自定义注解 @Phone
java
package com.example.demo.validation;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Target({ElementType.FIELD, ElementType.PARAMETER}) // 可以用在字段、方法参数上
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,才能被反射读取
@Constraint(validatedBy = PhoneValidator.class) // 关键:绑定校验器
public @interface Phone {
// 校验失败时的提示信息(必写)
String message() default "手机号格式不正确";
// 分组校验用(先按固定写法写,后面会讲)
Class<?>[] groups() default {};
// 附加信息载体(一般空着即可,固定写法)
Class<? extends Payload>[] payload() default {};
}
💡 初学者提示:
message()/groups()/payload()这三个方法是 Bean Validation 规范强制要求 的,少一个都会在启动时报错。所以新建注解时直接照抄这三行最省心。
3. 创建校验器 PhoneValidator
java
package com.example.demo.validation;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
public class PhoneValidator implements ConstraintValidator<Phone, String> {
// 中国大陆手机号正则:1 开头,第二位 3~9,共 11 位
private static final Pattern PATTERN = Pattern.compile("^1[3-9]\d{9}$");
/**
* 第一个泛型 <Phone> :对应的注解类型
* 第二个泛型 <String> :被校验字段的类型
*/
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// 重点:遇到 null 直接返回 true
// 原因:null 应该交给 @NotNull 去管,避免"空值 + 格式错"双重报错
if (value == null) {
return true;
}
return PATTERN.matcher(value).matches();
}
}
isValid 返回 true 表示通过,false 表示不通过、会触发 message() 里的错误提示。
4. 在实体类上使用
kotlin
package com.example.demo.dto;
import com.example.demo.validation.Phone;
import javax.validation.constraints.NotNull;
public class UserDTO {
@NotNull(message = "姓名不能为空")
private String name;
@Phone
private String mobile;
// getter / setter 省略
}
5. 在 Controller 里触发校验
关键是在入参上加 @Valid (Spring 的 @Validated 也行,区别见文末):
less
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping
public String create(@Valid @RequestBody UserDTO user) {
return "校验通过,创建用户:" + user.getName();
}
}
当传入的 mobile 不是合法手机号时,Spring 会自动返回 400,并在错误体里带上 "手机号格式不正确"。
小提示:如果你想在前端看到结构化错误信息,可以用
@ExceptionHandler(MethodArgumentNotValidException.class)捕获并统一返回格式,初学阶段先知道"会报错"即可。
四、进阶:让注解支持"参数"(动态配置)
上面 @Phone 规则是写死的。如果想做一个"数值范围"校验,允许调用方指定 min / max,怎么办?
很简单:在注解里加普通方法(不是 message/groups/payload 那三个),然后在校验器的 initialize 方法里读取。
注解:加 min / max
less
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = RangeValidator.class)
public @interface Range {
String message() default "数值超出允许范围";
int min() default 0; // 自定义参数
int max() default 100; // 自定义参数
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
校验器:用 initialize 读取参数
arduino
public class RangeValidator implements ConstraintValidator<Range, Integer> {
private int min;
private int max;
// 初始化时,把注解上的 min/max 读进来
@Override
public void initialize(Range annotation) {
this.min = annotation.min();
this.max = annotation.max();
}
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
if (value == null) {
return true;
}
return value >= min && value <= max;
}
}
使用:调用方自由指定范围
arduino
@Range(min = 18, max = 65, message = "年龄必须在 18 到 65 之间")
private Integer age;
五、自定义更灵活的错误提示
有时候你想在提示里带出具体值,比如"18 不在 0~10 之间"。两种办法:
办法 A:占位符(推荐,配合国际化)
在注解的 message 里用 {} 引用注解属性:
arduino
String message() default "数值 {value} 不在允许范围内";
前提是注解里有对应属性 value(),或者你用 Bean Validation 内置的 ValidationMessages.properties 做键值映射。
办法 B:在代码里动态覆盖提示
arduino
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
if (value != null && (value < min || value > max)) {
// 关闭默认提示
context.disableDefaultConstraintViolation();
// 动态生成提示并添加
context.buildConstraintViolationWithTemplate(
"数值 " + value + " 不在 " + min + "~" + max + " 之间")
.addConstraintViolation();
return false;
}
return true;
}
六、在 Service 方法参数上校验(不止 Controller)
除了 Controller 的 @RequestBody,你还经常想在 Service 方法入参上直接校验。这时要用 @Validated (Spring 提供的,不是 javax.validation 的 @Valid),并加在类上开启方法级校验:
less
@Service
@Validated // 开启方法参数校验
public class UserService {
public void register(@Phone String mobile, @NotNull String name) {
// 如果 mobile 不合法,会抛 ConstraintViolationException
}
}
⚠️ 容易踩的点:
@Valid用在方法参数上一般只触发嵌套对象校验,@Validated才能触发 方法参数 / 返回值 的校验。Service 层统一用@Validated最稳。
七、常见坑(初学者必看)
- 漏写
groups()/payload():Bean Validation 规范要求注解里必须有这三个方法,少一个启动报错。直接照抄模板即可。 isValid里没处理null:导致空值也报"格式错误",和@NotNull重复报错。约定俗成:null 交给@NotNull,isValid里直接return true。@Constraint(validatedBy = ...)写错类 :编译不报错,但运行时校验器不生效,排查起来很懵。确认指向的ConstraintValidator实现类正确。- 忘了加
spring-boot-starter-validation依赖 :Spring Boot 2.3+ 默认不带,没引的话@Valid完全不生效,还很安静地不报错。 - 注解的
@Retention不是RUNTIME:必须是运行时保留,否则框架读不到注解。 - 泛型类型不匹配 :
ConstraintValidator<注解, 字段类型>,比如字段是String就写<Phone, String>,是Integer就写<Range, Integer>,写错会编译/绑定失败。 @Validvs@Validated混用 :Controller 入参对象用@Valid(@Validated也行);Service 方法参数校验必须用@Validated且加在类上。
八、小结
自定义校验就三步:
- 写注解 :加
@Constraint(validatedBy = XxxValidator.class),模板三件套message/groups/payload照抄,需要动态配置就加自定义属性。 - 写校验器 :实现
ConstraintValidator<你的注解, 字段类型>,在isValid里写规则(null返回true),需要读参数就重写initialize。 - 用起来 :Controller 入参加
@Valid,Service 方法参数加@Validated(类上也要加)。
掌握之后,你会发现原来 @Email、@Size 这些"官方注解"也是用同样的方式实现的------你现在已经能造自己的"官方级"注解了。🚀