注解与自定义注解
注解与自定义注解:从元注解到运行期处理器
注解本身不产生任何行为,它只是一份贴在类、方法、字段上的结构化元数据。让注解"生效"的是读取它的代码:运行期通过反射读取,编译期通过注解处理器读取。
理解这条分界线,就能回答"为什么我的注解没生效"这类问题。本文从元注解讲起,给出一套完整的自定义注解实现,并说明编译期处理器的适用场景。
注解的本质
反编译一个注解接口:
public @interface Retry {
int maxAttempts() default 3;
long delayMillis() default 100L;
}
javap -v 显示它被编译成一个继承 java.lang.annotation.Annotation 的接口,方法都是抽象方法且无参数。注解实例在运行时是 JVM 动态生成的代理对象,读取属性等价于调用代理方法。
注解不能有 throws 子句,返回值只能是基本类型、String、Class、枚举、注解或它们的数组,且默认值必须是编译期常量。
五个元注解
| 元注解 | 作用 | 关键取值 |
|---|---|---|
@Retention |
保留到哪个阶段 | SOURCE / CLASS / RUNTIME |
@Target |
可标注的位置 | TYPE、FIELD、METHOD、PARAMETER、CONSTRUCTOR、ANNOTATION_TYPE、TYPE_USE 等 |
@Documented |
是否进入 Javadoc | 无参数 |
@Inherited |
子类是否继承父类的注解 | 只对类注解有效 |
@Repeatable |
是否可重复标注 | 需指定容器注解 |
RetentionPolicy 的三档含义:
SOURCE:编译后丢弃,只供注解处理器或 IDE 使用,例如@Override、Lombok 的注解。CLASS:写入 Class 文件但 JVM 不加载,反射读不到。这是默认值,也是最容易被忽略的坑。RUNTIME:写入 Class 文件并由 JVM 保留,反射可读。
注意:不写
@Retention时默认是 CLASS,getAnnotation返回 null,注解看起来"完全没生效"。
@Inherited 只影响类注解,且只沿父类链继承,不通过接口继承。字段、方法上的注解永远不会被继承。
一个完整的运行期注解
需求:标注在方法上,失败时按指定次数重试。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry {
int maxAttempts() default 3;
long delayMillis() default 100L;
Class<? extends Throwable>[] on() default {Exception.class};
}
处理器用动态代理包装目标接口:
public static <T> T wrap(T target, Class<T> iface) {
return iface.cast(Proxy.newProxyInstance(
iface.getClassLoader(),
new Class<?>[]{iface},
(proxy, method, args) -> {
Retry retry = method.getAnnotation(Retry.class);
if (retry == null) {
return method.invoke(target, args);
}
Throwable last = null;
for (int i = 0; i < retry.maxAttempts(); i++) {
try {
return method.invoke(target, args);
} catch (InvocationTargetException e) {
last = e.getCause();
if (!match(retry.on(), last)) {
throw last;
}
if (i < retry.maxAttempts() - 1) {
Thread.sleep(retry.delayMillis());
}
}
}
throw new IllegalStateException("重试次数耗尽", last);
}));
}
private static boolean match(Class<? extends Throwable>[] types, Throwable t) {
for (Class<? extends Throwable> type : types) {
if (type.isInstance(t)) {
return true;
}
}
return false;
}
使用:
interface Remote {
@Retry(maxAttempts = 3, delayMillis = 200, on = {IOException.class})
String fetch(String key);
}
注意 method.invoke 抛出的 InvocationTargetException 需要解包到 getCause(),否则重试逻辑判断的类型永远是包装类。
生产环境不建议自己造轮子,Spring Retry 与 Resilience4j 已经覆盖了退避策略、熔断、指标等需求。
组合注解
Spring 大量使用组合注解:@RestController 就是 @Controller 加 @ResponseBody。
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
@AliasFor(annotation = Controller.class)
String value() default "";
}
@AliasFor 让属性之间互为别名,Spring 的 MergedAnnotations 会展开组合关系。自己实现类似能力时,需要递归查找元注解,AnnotatedElement.getAnnotation 不会自动展开。
编译期注解处理器
@Retention(SOURCE) 的注解无法在运行期读取,需要 javax.annotation.processing.Processor(JDK 9 起包名改为 javax.annotation.processing,JDK 21 前后社区在推动 jakarta 迁移,注解处理本身仍是 JDK 标准)。
@SupportedAnnotationTypes("com.example.Builder")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class BuilderProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment env) {
for (Element e : env.getElementsAnnotatedWith(Builder.class)) {
// 通过 processingEnv.getFiler() 生成新的 .java 文件
}
return true;
}
}
Lombok 走的是另一条路:它修改编译器内部的抽象语法树(AST),因此 IDE 必须安装插件才能正确提示。MapStruct 则使用标准的注解处理器生成映射代码,是更规范的范例。
编译期处理的优势是零运行期开销,且错误可以在编译阶段报出;劣势是实现复杂,需要处理增量编译和 IDE 集成。
注解属性的类型规则
注解方法的返回值有严格限制:基本类型、String、Class、枚举、注解、以及这些类型的一维数组。不能是包装类、泛型、Object,也不能是二维数组。
public @interface Config {
String value() default ""; // 合法
Class<?> type() default Object.class; // 合法
Level level() default Level.INFO; // 枚举合法
Retry retry() default @Retry; // 嵌套注解合法
int[] ports() default {80, 443}; // 数组合法
Integer timeout() default 10; // 编译错误:不能用包装类
}
读取数组属性时每次返回的是新克隆的数组,修改它不会影响注解本身,但这也意味着频繁读取会产生额外的对象分配。@Retention(SOURCE) 的注解在编译期处理器中读的是语法树上的值,不受这个限制。
属性名为 value 时,使用时可以省略键名,例如 @Retry(3) 等价于 @Retry(maxAttempts = 3)。这个约定在单属性注解中很常见,多属性时反而会降低可读性。
注解与 AOP 的配合
运行期注解最常见的落地方式是配合 AOP:注解负责声明意图,切面负责实现行为。以 Spring Boot 为例,一个幂等注解可以这样工作:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String key() default "";
int expireSeconds() default 10;
}
切面通过 @Around 拦截,从 MethodSignature 拿到 Method 对象,再读取注解:
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
String key = resolveKey(pjp, idempotent);
Boolean ok = redis.opsForValue()
.setIfAbsent(key, "1", Duration.ofSeconds(idempotent.expireSeconds()));
if (Boolean.FALSE.equals(ok)) {
throw new IllegalStateException("重复请求, key=" + key);
}
return pjp.proceed();
}
这里有两个注解相关的细节。第一,切点表达式 @annotation(idempotent) 会把匹配到的注解实例直接注入方法参数,省去手写 getAnnotation。第二,注解必须加在代理生效的方法上,同类内部调用不经过代理,注解形同虚设,这是"注解不生效"最常见的非注解原因。
若需要读取接口方法上的注解而实现类方法上没有,MethodSignature.getMethod() 返回的是接口方法;反之在 CGLIB 代理下返回的是实现类方法。跨接口与实现类读取注解时,两边都要查。
注解不生效的排查顺序
- 检查
@Retention是否为 RUNTIME(运行期读取时)。 - 检查
@Target是否包含实际标注的位置。 - 检查读取方式:
getAnnotation只看本元素,组合注解需要展开;父类注解需要@Inherited。 - 检查代理层级:Spring AOP 默认只拦截 public 方法,同类内部调用不经过代理。
- 检查类加载器:注解类型被不同类加载器加载时,
isAnnotationPresent会返回 false。
常见坑 / 最佳实践
- 忘记写
@Retention(RUNTIME)。这是最高频的问题,默认 CLASS 导致反射读不到。 - 用
@Inherited期望方法注解被继承。它只对类生效,方法覆写后父类方法上的注解不会保留。 - 注解属性用可变对象。属性只能是编译期常量类型,数组属性返回的是克隆,修改它不会影响注解本身。
- 在注解里做逻辑。注解是数据,行为应放在处理器里,否则无法测试也无法复用。
- 重复注解忘记容器。使用
@Repeatable必须同时定义容器注解,且容器的value()返回注解数组。 - 忽略
@Documented。对外 API 的注解不加@Documented,生成的文档里看不到约束条件。 - 编译期处理器阻塞增量编译。正确实现
getSupportedOptions()并声明org.gradle.annotation.processing.aggregating等选项。
小结
- 注解是编译成接口的元数据,本身没有行为,行为来自运行期反射或编译期处理器。
@Retention默认 CLASS,运行期读取必须显式声明 RUNTIME。@Inherited仅作用于类,方法和字段的注解不会被继承。- 组合注解需要递归展开元注解,Spring 用
@AliasFor与MergedAnnotations处理。 - 编译期处理器零运行期开销,适合生成样板代码;Lombok 通过修改 AST 实现,属于特例。
- 点赞
- 收藏
- 关注作者
评论(0)