一个 DTO 我写了 47 行 if
年前接手了一个商户入驻的接口,参数对象有 12 个字段,原来的校验代码长这样:
public Result<?> apply(MerchantApplyDTO dto) {
if (dto.getMerchantName() == null || dto.getMerchantName().trim().isEmpty()) {
return Result.fail("商户名称不能为空");
}
if (dto.getMerchantName().length() > 64) {
return Result.fail("商户名称不能超过 64 个字符");
}
if (dto.getContactPhone() == null) {
return Result.fail("联系人手机号不能为空");
}
if (dto.getContactPhone() != null
&& !dto.getContactPhone().matches("^1[3-9]\\d{9}$")) {
return Result.fail("手机号格式不正确");
}
if (dto.getLicenseNo() == null || dto.getLicenseNo().length() != 18) {
return Result.fail("营业执照号必须为 18 位");
}
// 后面还有 7 个字段...
}
一个方法 60 行,业务代码只有 3 行。更烦的是这个 DTO 在三个接口里被复用,校验逻辑复制了三份,上个月产品要求把手机号校验放宽到支持固话,我改了两处,漏了一处,上线后被测试提了 bug。
那两周我正好在看 Hibernate Validator 的文档,想着能不能自己撸一个简易版,顺便把注解这块搞明白。
第一个坑:注解在运行时"看不见"
我兴冲冲地写了第一个注解:
public @interface NotBlank {
String message() default "不能为空";
}
然后在测试类里反射取,怎么取都是 null:
Field f = MerchantApplyDTO.class.getDeclaredField("merchantName");
System.out.println(f.getAnnotation(NotBlank.class)); // 输出 null
查了半天才知道,注解必须配 @Retention 声明保留策略,不写的话默认是 CLASS,也就是只保留到字节码文件里,JVM 加载类之后就丢掉了,反射取不到。
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME) // 关键:运行时可通过反射读取
public @interface NotBlank {
String message() default "不能为空";
}
三种保留策略的区别:
| RetentionPolicy | 存活到 | 能做什么 |
|---|---|---|
| SOURCE | 编译后丢弃 | 给编译器或 APT 看,如 @Override、Lombok 的注解 |
| CLASS(默认) | 字节码里,但不进 JVM | 字节码增强工具用,如一些 AOP 织入 |
| RUNTIME | 运行时可通过反射读 | 框架解析,如 Spring 的 @Autowired |
@Target 是限制注解能贴在哪:FIELD、METHOD、TYPE、PARAMETER、CONSTRUCTOR 这些,不写就哪儿都能贴。我一开始就漏了它,结果有人把 @NotBlank 贴到了方法上,编译能过,运行时静默失效。
撸一个能跑的校验器
定义了四个注解,覆盖当时用到的场景:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Length {
int min() default 0;
int max() default Integer.MAX_VALUE;
String message() default "长度不符合要求";
}
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Pattern {
String regexp();
String message() default "格式不正确";
}
DTO 上贴注解,注解承担了"声明约束"的职责,重复的三份校验变成了三处声明:
public class MerchantApplyDTO {
@NotBlank(message = "商户名称不能为空")
@Length(max = 64, message = "商户名称不能超过 64 个字符")
private String merchantName;
@NotBlank(message = "联系人手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String contactPhone;
@Length(min = 18, max = 18, message = "营业执照号必须为 18 位")
private String licenseNo;
}
核心的解析逻辑,思路是遍历字段、取注解、按顺序执行检查:
public final class Validator {
public static <T> List<String> validate(T target) {
if (target == null) {
return Collections.singletonList("参数对象不能为 null");
}
List<String> errors = new ArrayList<>();
for (Field field : target.getClass().getDeclaredFields()) {
field.setAccessible(true);
Object value;
try {
value = field.get(target);
} catch (IllegalAccessException e) {
throw new IllegalStateException("读取字段失败: " + field.getName(), e);
}
checkNotBlank(field, value, errors);
checkLength(field, value, errors);
checkPattern(field, value, errors);
}
return errors;
}
private static void checkNotBlank(Field f, Object value, List<String> errors) {
NotBlank ann = f.getAnnotation(NotBlank.class);
if (ann == null) return;
if (value == null || !(value instanceof String)
|| ((String) value).trim().isEmpty()) {
errors.add(f.getName() + ": " + ann.message());
}
}
private static void checkLength(Field f, Object value, List<String> errors) {
Length ann = f.getAnnotation(Length.class);
if (ann == null || !(value instanceof String)) return;
int len = ((String) value).length();
if (len < ann.min() || len > ann.max()) {
errors.add(f.getName() + ": " + ann.message());
}
}
private static void checkPattern(Field f, Object value, List<String> errors) {
Pattern ann = f.getAnnotation(Pattern.class);
if (ann == null || !(value instanceof String)) return;
if (!java.util.regex.Pattern.matches(ann.regexp(), (String) value)) {
errors.add(f.getName() + ": " + ann.message());
}
}
}
Controller 里就清爽了:
@PostMapping("/merchant/apply")
public Result<?> apply(@RequestBody MerchantApplyDTO dto) {
List<String> errors = Validator.validate(dto);
if (!errors.isEmpty()) {
return Result.fail(String.join("; ", errors));
}
return Result.ok(merchantService.apply(dto));
}
跑一遍,输入全空,输出符合预期:
merchantName: 商户名称不能为空; contactPhone: 联系人手机号不能为空; licenseNo: 长度不符合要求
反射的性能:第一次 3.1 毫秒,之后 0.08 毫秒
写完我有点心虚,反射会不会很慢?用 JMH 测了一下单个 DTO 的校验耗时:
| 场景 | 平均耗时 |
|---|---|
| 首次校验(含类加载、注解解析) | 3.12 ms |
| 预热 10000 次后的单次校验 | 0.084 ms |
| 原来的 if 版本(同样 12 字段) | 0.011 ms |
稳态下 84 微秒,比 if 慢了约 7 倍,但绝对值仍然很小。我们这个接口 QPS 只有 20,完全无所谓。不过我还是加了层缓存,把一个类的"字段 + 注解"元数据解析结果缓存起来,避免每次都走 getDeclaredFields() 和 getAnnotation():
private static final ConcurrentMap<Class<?>, List<Field>> FIELD_CACHE =
new ConcurrentHashMap<>();
private static List<Field> fieldsOf(Class<?> clazz) {
return FIELD_CACHE.computeIfAbsent(clazz, c -> {
Field[] fs = c.getDeclaredFields();
for (Field f : fs) {
f.setAccessible(true);
}
return Collections.unmodifiableList(Arrays.asList(fs));
});
}
加了缓存之后稳态降到 0.031 ms,缩小到 if 版的 3 倍以内。这里注意一点:缓存的 key 是 Class 对象,如果用 HashMap 会把 Class 强引用住,容器热部署时旧 ClassLoader 回收不掉,必须用弱引用或者像我这样在明确知道类不会卸载的场景下用 ConcurrentHashMap。
另一条路:编译期注解处理器
反射方案有个绕不开的问题:错误只能在运行时发现。想提前到编译期,就得用 APT(Annotation Processing Tool),也就是真正意义上的"注解处理器"。它工作在 javac 编译阶段,能读源码里的注解,还能生成新的 Java 文件。
@SupportedAnnotationTypes("com.example.validator.NotBlank")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class NotBlankProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment env) {
for (Element element : env.getElementsAnnotatedWith(NotBlank.class)) {
if (element.getKind() != ElementKind.FIELD) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"@NotBlank 只能标注在字段上", element);
}
}
return true;
}
}
配在 META-INF/services/javax.annotation.processing.Processor 里注册,javac 编译时就会自动执行。IDE 里能看到它报的错。Lombok 就是靠这套机制在编译期改 AST 生成 getter/setter 的。
我最后没在项目里用 APT,原因很实在:它只能检查"注解标得对不对",检查不了"运行时传进来的值对不对",而后者才是我们要解决的主要问题。而且引入 APT 会让构建流程变复杂,Maven 里要多配一段 annotationProcessorPaths。
往回看:校验框架到底在做什么
自己写了一遍之后,再看 Hibernate Validator(JSR 303/Bean Validation 的实现)就清楚多了,它做的事和我的玩具版是同一个套路,只是补齐了这几块:
- 注解和校验逻辑解耦:注解只声明约束,具体逻辑放在独立的
ConstraintValidator<A, T>实现里,一个注解可以有多种实现(比如@NotNull对 String 和 Collection 判定方式不同) - 分组校验:
@Validated(UpdateGroup.class),同一 DTO 在新增和修改时用不同规则 - 嵌套校验:字段上贴
@Valid才能递归校验内部对象,我这个版本遇到嵌套 DTO 就直接跳过了 - 校验结果的快速失败模式:
failFast,收集到第一个错误就返回
我们项目最后还是换回了 Hibernate Validator,Spring Boot 2.1 里加个 spring-boot-starter-validation 依赖就行,只在需要自定义规则时才写 ConstraintValidator。自己造轮子的价值在于:现在看到 @Valid 不生效、分组没起作用这类问题,我知道该往哪个方向查,而不是只会重启试试。
小结
- 自定义注解记得配
@Retention(RUNTIME),这是反射能读到的前提,也是我踩的第一个坑。 - 注解本身不包含任何逻辑,它只是一个"标记 + 参数"。真正干活的是解析它的那段代码,这也是所有注解驱动框架的共同结构。
- 反射有开销,稳态下大约比直接调用慢 3 到 10 倍。量大的地方把元数据缓存起来就行,不用谈反射色变。
- 编译时能发现的问题用 APT,运行时才有的数据用反射解析,别把两者搞混。
代码我放在了 GitHub 上(就那四个注解加一个 Validator 类,不到 200 行),纯当练手。生产环境还是别用自己写的,边界情况多到你想不到,比如继承来的字段、枚举的校验、集合元素逐个校验,这些 Hibernate Validator 都处理过了。