Administrator
发布于 2019-01-29 / 2547 阅读
50

注解处理器与自定义注解:手写一个参数校验

一个 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 都处理过了。

参考