本节目标:把「记审计」和「失败重试」从服务方法体里挪到注解上,并用反射在调用边界统一执行。
适用版本: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 动态代理:给仓储加事务与重试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。