《Java 高级编程》2.2 注解与运行时元数据:@Audit 与 @Retry

libraryd 的借阅方法既要记审计,又要在目录服务短暂超时时重试,手写 try/catch 会把业务代码淹掉。本节定义 @Audit 与 @Retry 两个运行时注解,用反射读取 action 与重试参数,实现一个带退避的调用器,并说明保留级别、默认值和幂等性边界。

本节目标:把「记审计」和「失败重试」从服务方法体里挪到注解上,并用反射在调用边界统一执行。
适用版本:JDK 25(Temurin 25.0.5),javac --release 25。

承接的问题

libraryd 的 LibraryService.borrow 现在长这样:

public String borrow(String isbn, String memberId) {
    audit.begin("borrow", new Object[]{isbn, memberId});
    try {
        String loan = catalog.reserve(isbn) + ":" + memberId;
        audit.end("borrow", loan);
        return loan;
    } catch (RuntimeException e) {
        audit.failed("borrow", e);
        throw e;
    }
}

这还只是审计。目录服务偶发超时后,业务希望在 3 次以内重试,每次间隔 10ms。加上重试循环、异常判断和退避,borrow 会变成四五十行,真正的业务只有一行 catalog.reserve。

更麻烦的是,renew、returnBook、reserve 都要重复这套结构。审计动作名不同,重试次数可能不同,但控制流一模一样。这一节把控制流抽到 AnnotatedInvoker,把差异写成注解:

@Audit(action = "borrow")
@Retry(maxAttempts = 3, backoffMillis = 10)
public String borrow(String isbn, String memberId) {
    return catalog.reserve(isbn) + ":" + memberId;
}

要让这套机制成立,需要先确认三件事:注解在运行时还活着(保留级别)、注解能标在方法上(目标)、调用方能在方法被调用前读到它(反射)。下面按这个顺序展开。

保留级别与目标

Java 的注解有三个保留级别:

保留级别编译器写入 class 文件运行时可通过反射读取典型用途
SOURCE否否@Override、编译期检查
CLASS是否字节码工具、编译期处理器
RUNTIME是是运行时框架、审计、重试

不写 @Retention 时默认是 CLASS,这是最常见的坑。注解能编译,字节码里也有,但 method.getAnnotation(...) 返回 null。很多「注解不生效」的问题,根源就是漏了 @Retention(RetentionPolicy.RUNTIME)。

@Target 限定注解能出现在哪些位置。@Audit 和 @Retry 都只应该标在方法上,所以写 ElementType.METHOD。如果不写 @Target,注解可以出现在类、字段、参数、局部变量等任何位置,编译器不会帮忙挡住误用。

两个注解的定义如下:

package com.plume.library.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Audit {
    String action();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry {
    int maxAttempts() default 3;
    long backoffMillis() default 50;
}

@Audit 的 action 没有默认值,使用时必须写;@Retry 的两个成员都有默认值,@Retry 等价于 @Retry(maxAttempts = 3, backoffMillis = 50)。注解成员的类型只能是基本类型、String、Class、枚举、注解或它们的数组,不能是任意对象。

注解本身不执行任何逻辑。它只是 class 文件里的一段元数据,必须有代码去读。读取方可以是反射、注解处理器,也可以是第 3 章的字节码 Agent;本节用反射。

运行时读取注解

读取注解的入口是 Method:

Method borrow = LibraryService.class.getMethod("borrow", String.class, String.class);
Audit audit = borrow.getAnnotation(Audit.class);
Retry retry = borrow.getAnnotation(Retry.class);
System.out.println("action=" + audit.action());
System.out.println("retry=" + retry.maxAttempts() + "/" + retry.backoffMillis());

本机输出:

action=borrow
retry=3/10

getAnnotation 返回的是 JDK 在运行时生成的代理对象,调用 action()、maxAttempts() 实际是在读 class 文件里的常量。取不到注解时返回 null,不是抛异常;调用方必须先判空。

Method.getAnnotations() 返回该方法上的全部注解,包括 @Audit、@Retry 以及可能的第三方注解。顺序没有约定,不能依赖声明顺序做逻辑。需要「同一个注解多个实例」时用 @Repeatable 和 getAnnotationsByType,本节不需要。

注解的默认值也可以通过反射读:

Method renew = LibraryService.class.getMethod("renew", String.class);
Retry defaults = renew.getAnnotation(Retry.class);
System.out.println("renewRetry=" + defaults.maxAttempts() + "/" + defaults.backoffMillis());

本机输出:

renewRetry=3/50

renew 上只写了 @Retry,没有写参数,反射读到的是声明里的默认值。默认值在编译期被写入注解类型,不在使用处重复;改默认值只需要改注解定义,但已经编译的调用方不会自动更新,升级时要重新编译。

重试执行器

有了注解,控制流可以集中到一个调用器里。核心是「读注解 → 决定尝试次数 → 循环调用 → 失败后按退避等待」:

public Object invoke(Object target, String methodName, Object... args) {
    Method method = find(target.getClass(), methodName, args.length);
    Audit auditAnnotation = method.getAnnotation(Audit.class);
    Retry retry = method.getAnnotation(Retry.class);
    int maxAttempts = retry == null ? 1 : Math.max(1, retry.maxAttempts());

    if (auditAnnotation != null) {
        audit.begin(auditAnnotation.action(), args);
    }
    try {
        Object result = invokeWithRetry(target, method, maxAttempts,
                retry == null ? 0 : retry.backoffMillis(), args);
        if (auditAnnotation != null) {
            audit.end(auditAnnotation.action(), result);
        }
        return result;
    } catch (RuntimeException e) {
        if (auditAnnotation != null) {
            audit.failed(auditAnnotation.action(), e);
        }
        throw e;
    }
}

invokeWithRetry 只处理 RuntimeException:

private static Object invokeWithRetry(Object target, Method method, int maxAttempts,
                                      long backoffMillis, Object[] args) {
    RuntimeException last = null;
    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            return method.invoke(target, args);
        } catch (InvocationTargetException e) {
            Throwable cause = e.getCause();
            if (cause instanceof RuntimeException runtime) {
                last = runtime;
                System.out.println("attempt=" + attempt + " failed="
                        + runtime.getClass().getSimpleName());
            } else if (cause instanceof Error error) {
                throw error;
            } else {
                throw new IllegalStateException("非运行时异常: " + cause, cause);
            }
        } catch (IllegalAccessException e) {
            throw new IllegalStateException("无法访问 " + method.getName(), e);
        }
        if (attempt < maxAttempts) {
            sleep(backoffMillis);
        }
    }
    throw last;
}

把 FlakyCatalog 接进来,前两次抛 CatalogUnavailableException,第三次成功:

AuditTrail audit = new AuditTrail();
LibraryService service = new LibraryService(new FlakyCatalog());
AnnotatedInvoker invoker = new AnnotatedInvoker(audit);
Object loan = invoker.invoke(service, "borrow", "9780132350884", "m-001");
System.out.println("result=" + loan);

本机输出:

attempt=1 failed=CatalogUnavailableException
attempt=2 failed=CatalogUnavailableException
result=LOAN-3:m-001

这里有两个设计选择需要说清楚。第一,只重试 RuntimeException。受检异常通常表示调用方需要处理的业务结果(比如参数不合法),自动重试没有意义;把重试范围限制在「瞬时故障」的异常类型上,比 catch (Exception) 安全。第二,退避是固定的 Thread.sleep(backoffMillis),没有指数退避和抖动。生产环境如果多个实例同时重试,固定间隔会造成尖峰;更完整的实现应该加随机抖动和上限。

审计记录也在本机输出里:

audit=BEGIN borrow args=[9780132350884, m-001]
audit=END borrow result=LOAN-3:m-001

AuditTrail.begin 记录方法名和参数,end 记录结果。注意这里没有 FAILED 记录,因为最终成功;如果三次都失败,invoke 的 catch 会写一条 FAILED,然后把最后一次异常抛给调用方。

在 libraryd 里落地

LibraryService 只保留业务逻辑,控制流交给注解:

public final class LibraryService {

    private final CatalogGateway catalog;

    public LibraryService(CatalogGateway catalog) {
        this.catalog = catalog;
    }

    @Audit(action = "borrow")
    @Retry(maxAttempts = 3, backoffMillis = 10)
    public String borrow(String isbn, String memberId) {
        return catalog.reserve(isbn) + ":" + memberId;
    }

    @Audit(action = "renew")
    @Retry
    public String renew(String loanId) {
        return "RENEWED-" + loanId;
    }
}

borrow 的重试参数是显式的,renew 用默认值。读起来,方法的业务意图和它的运维属性在同一处,不需要跳到别的地方找配置。

第二个落点是审计动作名。@Audit(action = "borrow") 里的字符串会直接进入审计日志,所以它应该是稳定的、可检索的标识,不要写中文句子或带参数的描述。参数和结果由 AuditTrail 另外记录,动作名只负责分类。

第三个落点是调用边界。AnnotatedInvoker 应该放在服务层的外面,由它调用 LibraryService 的 public 方法:

Object result = invoker.invoke(service, "borrow", isbn, memberId);

不要让 LibraryService 自己调用 invoker.invoke(this, ...)。自调用会绕过代理和调用器,注解不会生效;而且业务类依赖调用器,会把基础设施和领域逻辑绑在一起。第 2.3 节的动态代理会把「调用边界」做得更自然,但原理相同:注解只在外部调用穿过边界时才有机会被读取。

第四个落点是事务。@Retry 和 @Transactional 同时存在时,顺序很关键。如果重试包在事务外面,每次重试都是一个新事务,失败回滚后可以重新开始;如果重试包在事务里面,同一个事务已经因为异常标记为 rollback-only,再重试也没有意义。libraryd 的规则是:重试在最外层,事务在内层。AnnotatedInvoker 负责重试,TransactionalInvocationHandler 负责事务,组合顺序在装配处固定。

常见错误与边界

「漏写 @Retention(RUNTIME)。」 注解默认是 CLASS,编译能过,运行时 getAnnotation 返回 null。如果代码里直接 method.getAnnotation(Retry.class).maxAttempts(),会抛 NullPointerException,错误信息还指不到注解定义。先检查注解上的 @Retention。

「@Target 写成了 TYPE。」 把 @Audit 标在方法上会编译失败:

error: annotation interface not applicable to this kind of declaration
@Audit(action = "x")
^

@Target(ElementType.METHOD) 是最小权限;如果以后需要标在类上做默认动作,再显式加 ElementType.TYPE,不要一开始就放开所有目标。

「注解不会继承。」 子类覆盖父类方法时,父类方法上的 @Audit 不会自动出现在子类方法上,除非在子类方法上重新标注。@Inherited 只对类注解有效,对方法注解无效。libraryd 的仓储实现如果覆盖了接口方法,注解要写在接口方法上(代理读取的是接口方法),或者两边都写并明确优先级。

「重试非幂等操作。」 borrow 的重试之所以安全,是因为 CatalogGateway.reserve 被设计成幂等:同一个 ISBN 重复 reserve 返回同一个结果。如果下游是「扣库存」或「发邮件」,重试可能产生两次副作用。重试前先确认操作幂等,或者用幂等键让下游去重;不要因为注解写起来方便就到处加 @Retry。

「重试所有异常。」 catch (Exception) 会把参数错误、权限错误、数据冲突都重试一遍,浪费三次调用后仍然失败,还可能放大故障。只重试明确的瞬时异常类型,并且给重试次数和总时长上限。

「忽略中断。」 Thread.sleep 抛 InterruptedException 时,必须恢复中断标志(Thread.currentThread().interrupt())并停止重试。吞掉中断会让关闭流程卡住,虚拟线程场景下尤其明显。

小结

  • 注解默认保留级别是 CLASS,运行时读取必须显式写 @Retention(RetentionPolicy.RUNTIME)。
  • @Target(ElementType.METHOD) 把注解限制在方法上,编译期就能挡住误用。
  • @Audit 的 action 提供稳定分类,@Retry 的 maxAttempts 和 backoffMillis 提供重试参数,默认值写在注解定义里。
  • 反射用 Method.getAnnotation 读取注解,取不到返回 null,调用方必须判空。
  • 重试只包住瞬时异常和幂等操作,退避要有上限;重试应在事务外层,避免 rollback-only 事务被重复使用。
  • 注解只在外部调用穿过边界时生效,自调用会绕过调用器。

注解把「做什么」和「失败了怎么办」写在方法声明上,但调用方仍然要显式经过 AnnotatedInvoker。下一节用 JDK 动态代理把这条边界藏到仓储接口后面,让事务和重试对业务代码透明。

阅读导航:上一节:2.1 反射核心:Class、Method 与访问控制 · 下一节:2.3 动态代理:给仓储加事务与重试 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发