《Java 高级编程》2.3 动态代理:给仓储加事务与重试

libraryd 的每个仓储写方法都包着 begin/commit/rollback,复制到第三个实现时已经很难保持一致。本节用 JDK 动态代理把 TransactionManager.inTransaction 接到仓储接口上,处理默认方法、Object 方法和受检异常,并说明为什么代理只能代理接口、自调用为什么会绕过它。

本节目标:用 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);
    }
}

三个分支的顺序有讲究:

  1. Object 方法必须最先拦,否则 equals/hashCode 会被当成仓储方法代理。
  2. 默认方法用 InvocationHandler.invokeDefault(proxy, method, args) 调用;method.invoke(target) 虽然也能执行默认实现,但 this 绑定到目标对象,默认方法里再调用 findAll() 就不会经过代理。
  3. 只有标了 @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 类加载器与双亲委派:从平台加载器到插件加载器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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