注解与自定义注解

举报
yd_287908631 发表于 2026/10/21 13:37:23 2026/10/21
【摘要】 注解与自定义注解:从元注解到运行期处理器注解本身不产生任何行为,它只是一份贴在类、方法、字段上的结构化元数据。让注解"生效"的是读取它的代码:运行期通过反射读取,编译期通过注解处理器读取。理解这条分界线,就能回答"为什么我的注解没生效"这类问题。本文从元注解讲起,给出一套完整的自定义注解实现,并说明编译期处理器的适用场景。 注解的本质反编译一个注解接口:public @interface ...

注解与自定义注解:从元注解到运行期处理器

注解本身不产生任何行为,它只是一份贴在类、方法、字段上的结构化元数据。让注解"生效"的是读取它的代码:运行期通过反射读取,编译期通过注解处理器读取。

理解这条分界线,就能回答"为什么我的注解没生效"这类问题。本文从元注解讲起,给出一套完整的自定义注解实现,并说明编译期处理器的适用场景。

注解的本质

反编译一个注解接口:

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 代理下返回的是实现类方法。跨接口与实现类读取注解时,两边都要查。

注解不生效的排查顺序

  1. 检查 @Retention 是否为 RUNTIME(运行期读取时)。
  2. 检查 @Target 是否包含实际标注的位置。
  3. 检查读取方式:getAnnotation 只看本元素,组合注解需要展开;父类注解需要 @Inherited。
  4. 检查代理层级:Spring AOP 默认只拦截 public 方法,同类内部调用不经过代理。
  5. 检查类加载器:注解类型被不同类加载器加载时,isAnnotationPresent 会返回 false。

常见坑 / 最佳实践

  1. 忘记写 @Retention(RUNTIME)。这是最高频的问题,默认 CLASS 导致反射读不到。
  2. 用 @Inherited 期望方法注解被继承。它只对类生效,方法覆写后父类方法上的注解不会保留。
  3. 注解属性用可变对象。属性只能是编译期常量类型,数组属性返回的是克隆,修改它不会影响注解本身。
  4. 在注解里做逻辑。注解是数据,行为应放在处理器里,否则无法测试也无法复用。
  5. 重复注解忘记容器。使用 @Repeatable 必须同时定义容器注解,且容器的 value() 返回注解数组。
  6. 忽略 @Documented。对外 API 的注解不加 @Documented,生成的文档里看不到约束条件。
  7. 编译期处理器阻塞增量编译。正确实现 getSupportedOptions() 并声明 org.gradle.annotation.processing.aggregating 等选项。

小结

  • 注解是编译成接口的元数据,本身没有行为,行为来自运行期反射或编译期处理器。
  • @Retention 默认 CLASS,运行期读取必须显式声明 RUNTIME。
  • @Inherited 仅作用于类,方法和字段的注解不会被继承。
  • 组合注解需要递归展开元注解,Spring 用 @AliasFor 与 MergedAnnotations 处理。
  • 编译期处理器零运行期开销,适合生成样板代码;Lombok 通过修改 AST 实现,属于特例。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。