参数校验是业务开发中绕不开的环节。虽然Bean Validation规范配合Spring已经能覆盖大部分场景,但当校验对象不是标准的JavaBean,而是普通方法参数时,很多同学就会卡在“注解写了却读不到”的问题上。这篇文章从底层原理出发,完整讲解如何用反射拿到方法参数上的注解,并基于此实现一个可以投入使用的参数校验工具。

先搞懂注解为什么有时拿不到
在动手写代码之前,必须先理解注解的保留策略。@Retention注解决定了一个注解能“存活”到哪个阶段,它有三个可选值:RetentionPolicy.SOURCE表示只在源码阶段存在,编译后就被丢弃;RetentionPolicy.CLASS表示会写入class文件但运行时不可见;只有RetentionPolicy.RUNTIME才允许通过反射API在运行时读取。如果你定义的注解没有显式指定RUNTIME,反射拿到的永远是空数组,这是新手最常踩的坑。
除了保留策略,@Target也决定了注解能标注的位置。要想把注解放在方法参数上,必须在定义时声明ElementType.PARAMETER,否则编译器会直接报错。下面是一个规范的自定义校验注解定义,后面的例子都基于它展开:
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface NotBlank {
String message() default "参数不能为空";
}这里有个容易被忽略的细节:注解的属性如果只有一个,且名字为value,使用时可以省略属性名。属性必须有无默认值或者在标注时显式赋值,否则编译不通过。把这两个元注解理解清楚之后,反射读取注解才有意义。
用反射逐层拿到参数上的注解
反射的入口是Class对象。拿到Class之后可以通过getMethod或getDeclaredMethod定位到具体方法,前者只能取public方法(包括继承的),后者能取本类声明的所有方法但不含继承的。拿到Method对象后,调用getParameters可以得到参数数组,再对每个参数调用getParameterAnnotations方式并不存在——正确的方式是直接在Method上调用getParameterAnnotations(),它返回一个二维数组,第一维对应参数下标,第二维对应该参数上的所有注解。这一点和很多人的直觉不同:注解不是从Parameter对象上获取的,而是整体从Method上一次性取出的。
完整的读取示例如下:
public class DemoService {
public void register(@NotBlank String username, @NotBlank String email) {
// 业务逻辑
}
}
public static void main(String[] args) throws Exception {
Method method = DemoService.class
.getDeclaredMethod("register", String.class, String.class);
Annotation[][] annotations = method.getParameterAnnotations();
Parameter[] params = method.getParameters();
for (int i = 0; i < params.length; i++) {
System.out.println("参数名: " + params[i].getName());
for (Annotation anno : annotations[i]) {
if (anno instanceof NotBlank) {
NotBlank nb = (NotBlank) anno;
System.out.println("发现NotBlack注解, 提示语: " + nb.message());
}
}
}
}需要特别注意的是,getParameterAnnotations返回的二维数组长度等于参数个数,即使某个参数上没有任何注解,对应的内层数组也只是长度为零的空数组,而不是null。遍历时不用担心空指针,但要记得编译后的参数名可能变成arg0、arg1,除非编译时加上-parameters选项,Maven项目中可以在maven-compiler-plugin的configuration里加上这个compilerArgument来保留真实参数名。
另外,Annotation instanceof NotBlank这种判断方式在Spring环境中要小心。Spring为了支持组合注解,使用了代理对象实现注解接口,此时instanceof判断可能失效,更稳妥的写法是借助AnnotationUtils.findAnnotation去做查找。不过在纯JDK环境下,instanceof和强转都是安全的。
实现一个支持多种规则的校验工具
有了读取注解的能力,就可以设计一套完整的校验方案。先定义几个不同粒度的校验注解:@NotNull负责判空,@Length负责长度范围,@Pattern负责正则匹配。然后写一个校验器,输入方法对象和实参数组,输出校验失败的提示信息。核心思路是:遍历参数下标,取对应注解,逐个执行校验规则,收集所有错误一次性返回,而不是遇到第一个错误就中断,这样用户体验更好。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface Length {
int min() default 0;
int max() default Integer.MAX_VALUE;
String message() default "长度不合法";
}
public class ParamValidator {
public static List<String> validate(Method method, Object[] args) {
List<String> errors = new ArrayList<<>>();
Annotation[][] annos = method.getParameterAnnotations();
for (int i = 0; i < args.length; i++) {
Object value = args[i];
for (Annotation anno : annos[i]) {
if (anno instanceof NotBlank && !isValidNotBlank(value)) {
errors.add(((NotBlank) anno).message());
}
if (anno instanceof Length && !isValidLength(value, (Length) anno)) {
errors.add(((Length) anno).message());
}
}
}
return errors;
}
private static boolean isValidNotBlank(Object value) {
return value != null && !value.toString().trim().isEmpty();
}
private static boolean isValidLength(Object value, Length anno) {
if (value == null) return true; // 空值交给NotNull处理
int len = value.toString().length();
return len >= anno.min() && len <= anno.max();
}
}这套设计的好处是校验规则与业务代码完全解耦。校验器本身不知道任何业务语义,它只关心注解元数据和实参值,新增一种校验规则只需要增加一个注解和一段判断逻辑。如果想做得更优雅,可以参考Bean Validation的做法,把每个注解对应的校验逻辑抽象成独立的Validator类,注册到Map中,通过注解类型作为key来查找执行,这样符合开闭原则。
结合AOP让校验对业务代码零侵入
手动调用校验器虽然可行,但每个方法都要写一行校验代码,侵入性太强。更成熟的方案是配合动态代理或AOP,在方法执行前自动完成校验。以Spring AOP为例,定义一个切面拦截标注了自定义@Check注解的方法,在环绕通知里通过joinPoint.getSignature()拿到MethodSignature,进而获取Method对象和实参数组,调用前面的校验器即可。
@Aspect
@Component
public class ValidationAspect {
@Around("@annotation(check)")
public Object around(ProceedingJoinPoint pjp, Check check) throws Throwable {
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
List<String> errors = ParamValidator.validate(method, pjp.getArgs());
if (!errors.isEmpty()) {
throw new IllegalArgumentException(String.join("; ", errors));
}
return pjp.proceed();
}
}使用时只需在方法上标注@Check,参数照常标注校验注解,业务方法内部不写任何校验代码。Spring框架内部的校验本质上也是这条路:HandlerMethod在ArgumentResolver阶段解析参数注解,调用Validator完成校验,理解了反射读取参数注解的原理,再看Spring Validation的源码就通透了。
性能与工程上的注意事项
反射调用本身有一定开销,getParameterAnnotations每次调用都会重新解析并创建新的注解代理对象,绝不能在高频路径上反复调用。正确的做法是在启动阶段或首次调用时把Method的参数注解解析结果缓存起来,用方法签名做key存入ConcurrentHashMap,后续直接命中缓存。Spring中AnnotationUtils内部就大量使用了类似的缓存设计。
其次要注意代理场景下的方法归属问题。如果目标类被CGLIB代理,通过切面拿到的Method可能是代理类的方法,其参数注解与目标类一致,问题不大;但如果接口与实现类的注解不一致,要通过AopUtils.getMostSpecificMethod找到最终要执行的方法再解析注解,否则可能漏掉注解。最后,对于简单场景优先考虑现成的Bean Validation标准实现,自定义注解加反射的方案更适合无法引入框架或校验逻辑高度定制化的场景,两者并不冲突,理解原理才能在技术选型时做出准确判断。