本节目标:用 JDK 动态代理把事务边界从每个仓储方法里抽出来,并看清代理对象、默认方法和异常传播的真实行为。
适用版本:JDK 25(Temurin 25.0.5),javac --release 25。
承接的问题
2.2 的 AnnotatedInvoker 解决了「谁在调用方法」这一层,但仓储的事务还没有统一。JdbcBookRepository.save 里要拿连接、setAutoCommit(false)、执行、提交、回滚、恢复自动提交;JdbcLoanRepository.save 也要来一遍。复制到第三个仓储时,很难确认每个 catch 都回滚、每个 finally 都恢复连接。
libraryd 卷二已经有 TransactionManager:
public interface TransactionManager {
<T> T inTransaction(Supplier<T> work);
}
它的实现 ThreadLocalTransactionManager 负责开启、提交、回滚,并用 ThreadLocal 记录嵌套深度。仓储方法只要把数据库操作包进 inTransaction,就不需要自己碰 Connection 的事务 API。
问题是,每个方法都手写一层 lambda 仍然重复:
public void save(Book book) {
transactions.inTransaction(() -> {
jdbc.insert(book);
return null;
});
}
这一节用 JDK 动态代理把这一层放到调用边界:业务代码拿到的是 BookRepository 接口,代理在调用 save、deleteById 时自动包事务。仓储实现本身只写 SQL,不写事务。
Proxy.newProxyInstance 与 InvocationHandler
动态代理由两个东西组成:
InvocationHandler:所有代理方法调用都会进到它的invoke(proxy, method, args)。Proxy.newProxyInstance(loader, interfaces, handler):在运行时生成一个实现指定接口的类。
最小例子:
BookRepository target = new InMemoryBookRepository();
BookRepository proxy = (BookRepository) Proxy.newProxyInstance(
BookRepository.class.getClassLoader(),
new Class<?>[]{BookRepository.class},
(p, method, args) -> {
System.out.println("call=" + method.getName());
return method.invoke(target, args);
});
proxy.save(new Book("9780132350884", "Clean Code", "Robert C. Martin", 2008));
本机输出:
call=save
method 是接口上的 Method,不是实现类上的。代理类只实现接口,不继承实现类,所以 method.invoke(target, args) 通过接口方法找到实现类的覆盖版本。args 为 null 表示无参方法,不是空数组;遍历前要判空。
代理类的名字由 JVM 生成,本机是:
proxyClass=jdk.proxy1.$Proxy0
isInterface=false
targetType=com.plume.library.persistence.InMemoryBookRepository
instanceOfTarget=false
$Proxy0 是一个真实类,不是接口,也不是 InMemoryBookRepository 的子类。所以 proxy instanceof InMemoryBookRepository 是 false。需要判断「是不是代理」时,用 Proxy.isProxyClass(proxy.getClass()),不要用 instanceof 实现类。
代理类还会覆盖 equals、hashCode、toString,这些调用同样进入 InvocationHandler。如果不特殊处理,toString() 会变成 method.invoke(target),输出实现类的 toString;equals 会退化成「目标对象与代理对象比较」,通常返回 false;hashCode 返回目标对象的哈希,代理和目标在 HashMap 里的行为可能不一致。处理器必须先把 Object 方法拦下来:
if (method.getDeclaringClass() == Object.class) {
return switch (method.getName()) {
case "equals" -> proxy == args[0];
case "hashCode" -> System.identityHashCode(proxy);
case "toString" -> "TransactionalProxy(" + target + ")";
default -> throw new UnsupportedOperationException(method.getName());
};
}
事务处理器:接上 inTransaction
把事务逻辑放进 InvocationHandler:
import com.plume.library.annotation.Transactional;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
public final class TransactionalInvocationHandler implements InvocationHandler {
private final Object target;
private final TransactionManager transactions;
public TransactionalInvocationHandler(Object target, TransactionManager transactions) {
this.target = target;
this.transactions = transactions;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getDeclaringClass() == Object.class) {
return invokeObjectMethod(proxy, method, args);
}
if (method.isDefault()) {
return InvocationHandler.invokeDefault(proxy, method, args);
}
if (method.isAnnotationPresent(Transactional.class)) {
return transactions.inTransaction(() -> invokeTarget(method, args));
}
return invokeTarget(method, args);
}
}
三个分支的顺序有讲究:
Object方法必须最先拦,否则equals/hashCode会被当成仓储方法代理。- 默认方法用
InvocationHandler.invokeDefault(proxy, method, args)调用;method.invoke(target)虽然也能执行默认实现,但this绑定到目标对象,默认方法里再调用findAll()就不会经过代理。 - 只有标了
@Transactional的方法才进事务,查询方法不加事务,避免只读请求也占连接。
invokeTarget 负责拆 InvocationTargetException:
private Object invokeTarget(Method method, Object[] args) {
try {
return method.invoke(target, args);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
if (cause instanceof Error error) {
throw error;
}
throw new IllegalStateException("受检异常需要显式声明: " + cause, cause);
} catch (IllegalAccessException e) {
throw new IllegalStateException("无法访问 " + method.getName(), e);
}
}
invoke 声明了 throws Throwable,所以受检异常理论上可以原样抛出。但 InvocationHandler 的 lambda 在 inTransaction(Supplier<T>) 里不能抛受检异常,Supplier.get() 只允许运行时异常。这是 TransactionManager 接口用 Supplier 而不是 Callable 的直接后果:仓储方法如果要抛受检异常,事务代理就装不进去。libraryd 的仓储方法统一抛 LibraryException(运行时),所以这里把受检异常包装成 IllegalStateException 并明确报错,避免悄悄吞掉。
把代理接上真实实现:
BookRepository target = new InMemoryBookRepository();
ThreadLocalTransactionManager transactions = new ThreadLocalTransactionManager();
BookRepository repository = TransactionalInvocationHandler.wrap(
target, BookRepository.class, transactions);
repository.save(new Book("9780132350884", "Clean Code", "Robert C. Martin", 2008));
System.out.println("find=" + repository.findById("9780132350884")
.map(Book::title).orElse("<none>"));
本机输出:
tx begin
tx commit
find=Clean Code
save 上标了 @Transactional,所以代理开了事务;findById 没标,直接调用目标。ThreadLocalTransactionManager 的嵌套逻辑是:如果当前线程已经在事务里(depth > 0),直接执行 work,不再开新事务。这意味着仓储方法互相调用时不会重复提交,但也意味着内层方法失败时,外层事务仍然需要感知到异常并回滚。
失败路径的本机输出:
tx begin
tx rollback: IllegalStateException
saveError=disk full
代理没有吞异常,inTransaction 的 catch 打印回滚,然后把原异常抛回调用方。仓储实现只负责抛错,事务边界由代理负责。
默认方法与受检异常
BookRepository 有一个默认方法:
default List<Book> findAllOrEmpty() {
return findAll();
}
默认方法在接口里有方法体,实现类没有覆盖它。代理调用 findAllOrEmpty 时,直接 method.invoke(target) 并不会失败,本机验证返回了 1 条记录;但 this 绑定的是 target,默认方法内部的 findAll() 落在目标对象上,不经过代理。正确做法是 InvocationHandler.invokeDefault(proxy, method, args),它让 JVM 按接口默认实现执行,并把 this 绑定到代理,内部的 findAll() 会继续穿过处理器。本机用计数器验证,代理先后收到 findAllOrEmpty 和 findAll 两次调用。
本机输出:
defaultMethod=1
findAllOrEmpty 返回 1,说明默认方法执行成功;结合上面的计数器,它内部调用的 findAll() 也经过了代理。这一点和「自调用绕过代理」不矛盾:默认方法是通过 invokeDefault 在代理实例上执行的,this 是代理;而实现类内部 this.save() 的 this 是目标对象,不经过代理。
受检异常是另一个边界。假设仓储方法声明 throws SQLException:
void save(Book book) throws SQLException;
代理的 invoke 虽然声明 throws Throwable,但 TransactionalInvocationHandler 把目标调用包在 Supplier 里,Supplier.get() 不能抛受检异常,编译器会要求你在 lambda 里 catch。结果是:要么把受检异常包成运行时异常,要么放弃用 Supplier 做事务模板。libraryd 选择前者,仓储端口不声明受检异常,SQL 异常在实现里翻译成 LibraryException。
Proxy 还有一个限制:只能代理接口。InMemoryBookRepository 是 final 类,JdbcBookRepository 也可以设计成 final;代理不需要继承它们,只需要实现接口。这也是 brief 要求 BookRepository、LoanRepository 必须是接口的原因。如果某个仓储只有类没有接口,JDK 动态代理帮不上忙,需要 CGLIB 或 Byte Buddy 这类字节码生成方案(第 3 章会用到 Byte Buddy,但那是 Agent 场景,不是仓储代理)。
在 libraryd 里落地
装配处只暴露代理,不暴露实现:
public final class PersistenceModule {
public static BookRepository books(DataSource dataSource,
TransactionManager transactions) {
BookRepository jdbc = new JdbcBookRepository(dataSource);
return TransactionalInvocationHandler.wrap(
jdbc, BookRepository.class, transactions);
}
public static LoanRepository loans(DataSource dataSource,
TransactionManager transactions) {
LoanRepository jdbc = new JdbcLoanRepository(dataSource);
return TransactionalInvocationHandler.wrap(
jdbc, LoanRepository.class, transactions);
}
}
服务层注入的是 BookRepository,它不知道背后是 JDBC 还是内存实现,也不知道有没有代理。事务注解写在接口方法上:
public interface BookRepository extends Repository<Book, String> {
@Override
@Transactional
void save(Book entity);
@Override
@Transactional
void deleteById(String isbn);
}
代理读取的是接口方法上的注解,因为 method 来自 BookRepository.class。如果把 @Transactional 只写在 JdbcBookRepository.save 上,代理看不到;这是「注解位置」和「代理边界」必须对齐的地方。
第二个落点是重试与事务的组合。2.2 的 AnnotatedInvoker 在代理外层,先重试、再进事务。装配顺序是:
调用方 → AnnotatedInvoker(重试/审计) → TransactionalProxy(事务) → JdbcBookRepository
如果顺序反过来,事务代理在外层,重试在内层,那么每次重试都在同一个事务里,第一次失败已经把事务标记为回滚,后续重试不会成功。顺序不是风格问题,是正确性问题。
第三个落点是自调用。JdbcBookRepository.save 内部如果调用 this.audit(...),这个调用不经过代理;只有从外部穿过代理的调用才有事务。服务层不要在一个 @Transactional 方法里直接调用同一个类的另一个 @Transactional 方法并指望它们各自开事务;需要拆分时,把内层方法放到另一个 bean/对象里,通过注入的代理调用。
常见错误与边界
「代理一个类而不是接口。」 Proxy.newProxyInstance 的第二个参数必须是接口数组,传 InMemoryBookRepository.class 会抛 IllegalArgumentException: not an interface。JDK 动态代理不支持类代理;需要代理类时用字节码库,但优先考虑把端口改成接口。
「用 instanceof 判断代理背后的实现。」 proxy instanceof JdbcBookRepository 永远是 false,因为代理类和实现类是兄弟关系,不是继承关系。需要按实现类型分支时,在装配处决定,不要把判断塞进业务代码。
「忘记处理 equals/hashCode/toString。」 默认的 method.invoke(target, args) 对 equals 会把代理对象传给 target.equals,通常得到 false;hashCode 返回目标对象的哈希,导致代理和目标在 HashMap 里行为不一致;toString 输出的是目标对象而不是代理。处理器必须先拦 Object 方法。
「默认方法用 method.invoke(target)。」 它能执行默认实现,但 this 是目标对象,默认方法内部对其他接口方法的调用绕过代理,事务、重试、审计都不会触发。JDK 16 起用 InvocationHandler.invokeDefault(proxy, method, args),它把 this 绑定到代理。
「受检异常被包成 UndeclaredThrowableException。」 如果 InvocationHandler.invoke 抛出的受检异常不在接口方法声明里,Proxy 会包成 UndeclaredThrowableException。要么让接口声明该异常,要么在处理器里转换成运行时异常;不要指望调用方能按原始异常类型捕获。
「自调用以为走了代理。」 目标对象内部 this.save(...) 不经过代理,事务、重试、审计都不会触发。这是所有代理方案共同的边界,不是 JDK 动态代理的缺陷。需要自调用生效时,注入代理自身或拆分对象。
小结
- JDK 动态代理由
InvocationHandler和Proxy.newProxyInstance组成,只能代理接口,生成$ProxyN类。 - 代理类不继承实现类,
instanceof实现类为false;判断代理用Proxy.isProxyClass。 Object的equals、hashCode、toString也会进处理器,必须优先拦截。- 默认方法用
InvocationHandler.invokeDefault调用,不能直接method.invoke(target)。 TransactionManager.inTransaction(Supplier<T>)只能承载运行时异常;受检异常要在仓储实现里翻译成领域异常。- 重试应在事务外层,自调用不会经过代理;装配顺序和注解位置都是正确性的一部分。
第 1、2 章都在「应用自己的类」里工作:泛型、反射、注解、代理都假定类已经由应用类加载器加载。第 3 章换到更底层:类从哪里来、字节码长什么样、JVM 启动时怎样在类加载前插入逻辑。
阅读导航:上一节:2.2 注解与运行时元数据:@Audit 与 @Retry · 下一节:3.1 类加载器与双亲委派:从平台加载器到插件加载器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。