《Java 高级编程》附录 B 反射与代理模板

把第 2 章的反射与动态代理整理成可套用模板:三个运行时注解、按实现类查找注解的审计与事务拦截器、Repository<T, ID> 代理工厂、record 组件读取、MethodHandles 替代,以及 InaccessibleObjectException 与模块 opens 处理。

模板清单与依赖

这份附录把第 2 章的代码整理成可以整段抄走的模板,全部只依赖 JDK,不需要 Spring AOP、Byte Buddy 或 CGLIB:三个运行时注解、三个拦截器、仓储代理工厂、record 读取与 MethodHandles 替代、模块开放。

所有 Java 类在 /tmp/vol3-d/templates/ 下用 Temurin 25.0.5 编译通过,并由 TemplateDemo 实际运行;module-info 片段需要按实际模块名替换目标模块后再编译。模板里的 TransactionManager 是接口,libraryd 卷二已有 ThreadLocalTransactionManager 的 JDBC 实现;本附录只依赖接口,不重复实现提交与回滚。

注解定义

package com.plume.library.domain;
import java.lang.annotation.*;
@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;
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Transactional { }

三个注解都必须是 RUNTIME,否则代理运行时读不到;@Target(METHOD) 把使用范围限制在方法上,避免有人标到类上却期待生效。方法注解不会从接口继承到实现,也不会从实现回传到接口,这正是下一节要处理的问题。

注解查找:接口 Method 上为什么看不到

JDK 动态代理的 InvocationHandler.invoke 拿到的是接口方法的 Method 对象。如果注解写在 JdbcBookRepository 的实现方法上,直接在 method.getAnnotation(...) 里找会返回 null,事务和审计就静默失效。

泛型擦除还会让签名与注解对不上。Repository<T, ID>.save(T) 在接口字节码里擦除为 save(Object);如果 BookRepository 不重新声明 save(Book),代理收到的参数类型就是 Object。实现类里虽然会生成桥方法 save(Object),javac 也可能把注解复制到桥上,但这属于编译器实现细节,不能当作契约。模板的做法是:

  1. BookRepository 把四个泛型方法按具体类型重新声明一遍。
  2. 注解查找先按「方法名 + 参数类型」定位实现类方法,找不到再回退到接口方法。
package com.plume.library.persistence;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
public final class Annotations {
    private Annotations() {
    }
    public static <A extends Annotation> A find(
            Class<?> targetType, Method interfaceMethod, Class<A> annotationType) {
        try {
            Method impl = targetType.getMethod(
                    interfaceMethod.getName(), interfaceMethod.getParameterTypes());
            A found = impl.getAnnotation(annotationType);
            if (found != null) {
                return found;
            }
        } catch (NoSuchMethodException ignored) {
            // 接口方法可能由父接口声明,回退到接口方法本身。
        }
        return interfaceMethod.getAnnotation(annotationType);
    }
}
public interface BookRepository extends Repository<Book, String> {
    Optional<Book> findById(String isbn);
    List<Book> findAll();
    void save(Book entity);
    void deleteById(String isbn);
    List<Book> findByAuthor(String author);
}

重新声明不是多余代码,它让代理、反射和 getMethod 都看到具体参数类型。代价是接口变长,收益是注解和代理行为可预测。

AuditInvocationHandler

package com.plume.library.persistence;
import com.plume.library.domain.Audit;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public final class AuditInvocationHandler implements InvocationHandler {
    @FunctionalInterface
    public interface AuditSink {
        void record(String action, String method, long elapsedNanos, boolean success);
    }
    private final Object target;
    private final AuditSink sink;
    private final InvocationHandler next;

    public AuditInvocationHandler(Object target, AuditSink sink, InvocationHandler next) {
        this.target = target;
        this.sink = sink;
        this.next = next;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Audit audit = Annotations.find(target.getClass(), method, Audit.class);
        if (audit == null) {
            return next.invoke(proxy, method, args);
        }
        long start = System.nanoTime();
        boolean success = false;
        try {
            Object result = next.invoke(proxy, method, args);
            success = true;
            return result;
        } finally {
            sink.record(audit.action(), method.getName(), System.nanoTime() - start, success);
        }
    }
}

审计必须放在 finally:失败调用同样要留痕,否则只看成功日志会误判「没有异常」。success 只在正常返回前置真,Error 和异常都会留下 false。AuditSink 用函数式接口注入,生产环境接 SLF4J 或审计表,测试里可以收集到列表中断言。

libraryd 的 LibraryService.borrow、returnBook 用 @Audit(action = "loan.borrow") 这类动作名,审计记录包含会员、ISBN 和耗时;不要把密码、令牌等敏感参数拼进 action。

TransactionalInvocationHandler

TransactionManager 的契约是「把一段工作放进事务,返回结果」,卷二已有实现:

package com.plume.library.persistence;
import java.util.function.Supplier;
public interface TransactionManager {
    <T> T inTransaction(Supplier<T> work);
}
package com.plume.library.persistence;
import com.plume.library.domain.Transactional;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.UndeclaredThrowableException;
public final class TransactionalInvocationHandler implements InvocationHandler {
    private final Object target;
    private final TransactionManager transactionManager;
    private final InvocationHandler next;

    public TransactionalInvocationHandler(
            Object target, TransactionManager transactionManager, InvocationHandler next) {
        this.target = target;
        this.transactionManager = transactionManager;
        this.next = next;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (Annotations.find(target.getClass(), method, Transactional.class) == null) {
            return next.invoke(proxy, method, args);
        }
        return transactionManager.inTransaction(() -> {
            try {
                return next.invoke(proxy, method, args);
            } catch (RuntimeException | Error e) {
                throw e;
            } catch (Throwable t) {
                throw new UndeclaredThrowableException(t);
            }
        });
    }
}

Supplier<T> 不能抛受检异常,所以受检异常要包装成 UndeclaredThrowableException。仓储接口的方法都是 Optional、List、void,不会触发这条路径;如果以后给接口加受检异常,要么改 TransactionManager 的函数式接口,要么在代理里做受检异常还原。

void 方法返回 null 没有问题,代理会把 null 当返回值丢弃。基本类型返回值要避免:事务回调返回 null 时自动拆箱会抛 NullPointerException。

RetryInvocationHandler

package com.plume.library.persistence;
import com.plume.library.domain.Retry;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public final class RetryInvocationHandler implements InvocationHandler {
    private final Object target;
    private final InvocationHandler next;

    public RetryInvocationHandler(Object target, InvocationHandler next) {
        this.target = target;
        this.next = next;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Retry retry = Annotations.find(target.getClass(), method, Retry.class);
        if (retry == null) {
            return next.invoke(proxy, method, args);
        }
        Throwable last = null;
        for (int attempt = 1; attempt <= retry.maxAttempts(); attempt++) {
            try {
                return next.invoke(proxy, method, args);
            } catch (Throwable t) {
                last = t;
                if (attempt == retry.maxAttempts() || !isTransient(t)) {
                    throw t;
                }
                try {
                    Thread.sleep(retry.backoffMillis() * attempt);
                } catch (InterruptedException interrupted) {
                    Thread.currentThread().interrupt();
                    throw t;
                }
            }
        }
        throw new IllegalStateException("retry loop exited without result", last);
    }
    private static boolean isTransient(Throwable t) {
        return t instanceof RuntimeException;
    }
}

这个模板故意只重试 RuntimeException,因为「所有异常都重试」会把参数校验错误、BOOK_NOT_FOUND 也重试三遍,既慢又掩盖问题。生产代码应该按 SQLState 或异常类型白名单判断,例如只重试死锁 40P01、序列化失败 40001、连接瞬断。

链顺序是 Audit → Retry → Transactional → Target。重试放在事务外层,每次重试都开一个新事务;如果顺序反过来,失败事务被标记回滚后再重试,仍然在同一个事务里,重试没有意义。这也要求被重试的方法本身幂等,或者业务能接受重复执行。

代理工厂

package com.plume.library.persistence;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
public final class RepositoryProxyFactory {
    private RepositoryProxyFactory() {
    }
    @SuppressWarnings("unchecked")
    public static <T> T create(
            Class<T> repositoryInterface,
            T target,
            TransactionManager transactionManager,
            AuditInvocationHandler.AuditSink auditSink) {
        if (!repositoryInterface.isInterface()) {
            throw new IllegalArgumentException(
                    "dynamic proxy target must be an interface: " + repositoryInterface.getName());
        }
        InvocationHandler chain = new TargetInvocationHandler(target);
        chain = new TransactionalInvocationHandler(target, transactionManager, chain);
        chain = new RetryInvocationHandler(target, chain);
        chain = new AuditInvocationHandler(target, auditSink, chain);
        Object proxy = Proxy.newProxyInstance(
                repositoryInterface.getClassLoader(),
                new Class<?>[] {repositoryInterface},
                chain);
        return (T) proxy;
    }
}

TargetInvocationHandler 是链尾,负责真正调用实现并拆掉 InvocationTargetException:

public final class TargetInvocationHandler implements InvocationHandler {
    private final Object target;
    public TargetInvocationHandler(Object target) {
        this.target = target;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        try {
            return method.invoke(target, args);
        } catch (InvocationTargetException e) {
            throw e.getCause();
        }
    }
}

使用方式:

BookRepository repository = RepositoryProxyFactory.create(
        BookRepository.class, new JdbcBookRepository(dataSource), transactionManager, auditSink);
repository.save(book);

libraryd 的 JdbcBookRepository、JdbcLoanRepository 保持普通类,装配层决定是否套代理。测试里可以传真实的 JdbcBookRepository 加内存事务管理器,也可以传一个假实现验证审计和重试,不需要启动数据库。

编译与运行

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
javac --release 25 -d out $(find src -name '*.java')
java -cp out com.plume.library.demo.TemplateDemo

本机输出(耗时数值随机器和 JIT 变化):

audit action=book.save method=save cost=9.554ms success=true
findById=Book[isbn=978-7-115-54736-1, title=Java 高级编程, author=Leeting Yan, year=2026]
audit action=book.find_by_author method=findByAuthor cost=1.100ms success=true
findByAuthor=[Book[isbn=978-7-115-54736-1, title=Java 高级编程, author=Leeting Yan, year=2026]]
tx active after call=false
retry result=[Book[isbn=978-7-115-54736-1, title=Java 高级编程, author=Leeting Yan, year=2026]]
retry attempts=3

retry attempts=3 说明前两次抛出的 IllegalStateException 被重试,第三次成功。tx active after call=false 说明事务标记在调用结束后清理干净,没有泄漏到线程后续请求。

反射读取 record 组件

package com.plume.library.domain;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
import java.util.LinkedHashMap;
import java.util.Map;
public final class RecordReader {
    private RecordReader() {
    }
    public static Map<String, Object> read(Object value) throws ReflectiveOperationException {
        Class<?> type = value.getClass();
        if (!type.isRecord()) {
            throw new IllegalArgumentException(type.getName() + " is not a record");
        }
        Map<String, Object> result = new LinkedHashMap<>();
        for (RecordComponent component : type.getRecordComponents()) {
            Method accessor = component.getAccessor();
            result.put(component.getName(), accessor.invoke(value));
        }
        return result;
    }
}

getRecordComponents() 只对 record 返回非空数组,顺序与声明顺序一致;getAccessor() 返回公开的访问器方法,invoke 不需要 setAccessible(true)。本机运行:

record reflection={isbn=978-7-115-54736-1, title=Java 高级编程, author=Leeting Yan, year=2026}

libraryd 用这个模板做通用的 DTO 转换、审计字段提取和测试断言。注意 RecordComponent.getType() 是擦除后的类型,泛型组件要用 getGenericType();嵌套 record 只读取第一层,递归要自己控制深度和循环引用。

MethodHandles 替代

package com.plume.library.domain;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.RecordComponent;
import java.util.LinkedHashMap;
import java.util.Map;
public final class MethodHandleRecordReader {
    private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup();
    private MethodHandleRecordReader() {
    }
    public static Map<String, Object> read(Object value) throws Throwable {
        Class<?> type = value.getClass();
        if (!type.isRecord()) {
            throw new IllegalArgumentException(type.getName() + " is not a record");
        }
        Map<String, Object> result = new LinkedHashMap<>();
        for (RecordComponent component : type.getRecordComponents()) {
            MethodHandle accessor = LOOKUP.unreflect(component.getAccessor());
            result.put(component.getName(), accessor.invoke(value));
        }
        return result;
    }
}

本机输出与反射版本一致:

record methodhandle={isbn=978-7-115-54736-1, title=Java 高级编程, author=Leeting Yan, year=2026}

MethodHandle.invoke 是签名多态方法,调用点会被 JVM 特化;invokeExact 要求参数和返回类型完全匹配。它不会自动做反射那套访问检查和参数装箱,但是否更快必须用 JMH 测,不能凭「MethodHandle 更底层」下结论。需要频繁调用同一个访问器时,把 MethodHandle 缓存在 static final 或 ConcurrentHashMap 里,不要每次循环都 unreflect。

InaccessibleObjectException 与模块开放

JDK 9 之后,反射访问 JDK 内部 API 默认被强封装。下面这段代码在 JDK 25 上不带参数运行:

Field elementData = ArrayList.class.getDeclaredField("elementData");
elementData.setAccessible(true);

本机输出:

elementData exception=java.lang.reflect.InaccessibleObjectException
elementData message=Unable to make field transient java.lang.Object[] java.util.ArrayList.elementData accessible: module java.base does not "opens java.util" to unnamed module @610455d6

处理方式有三种,按优先级排列:

  1. 不用内部字段。 先找公开 API;ArrayList 的容量不是业务需要,通常说明思路错了。
  2. 命令行开放。 仅限本地排查或测试:
java --add-opens java.base/java.util=ALL-UNNAMED -cp out com.plume.library.demo.TemplateDemo

本机加上参数后输出变为 elementData accessible=true。

  1. 模块描述符开放。 如果是自己的模块给框架反射,在 module-info.java 里写 opens,不要要求用户加 --add-opens:
module com.plume.library.domain {
    exports com.plume.library.domain;
    opens com.plume.library.domain to com.fasterxml.jackson.databind;
}

opens 只开放反射访问,不导出编译期依赖;exports 才是给其他模块 requires 用的。限定目标模块比无限制 opens com.plume.library.domain; 安全,但目标模块改名时要同步改这里。

小结

  • 动态代理的 Method 来自接口,注解查找必须回到实现类;泛型方法要在子接口重新声明具体类型。
  • AuditInvocationHandler 用 finally 记录成功和失败,审计动作名不要包含敏感参数。
  • TransactionalInvocationHandler 把工作交给 TransactionManager,受检异常和基本类型返回值是两个边界。
  • RetryInvocationHandler 只重试瞬时错误,且必须位于事务外层,保证每次重试都有新事务。
  • record 组件可以通过 getRecordComponents() 读取,MethodHandles.lookup().unreflect 是等价替代。
  • InaccessibleObjectException 优先用公开 API 规避,其次命令行 --add-opens,最后才是模块 opens。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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