本节目标:用反射把 libraryd 的 record 字段读成审计事件,同时看清
Class、Method、访问控制和异常包装的边界。
适用版本:JDK 25(Temurin 25.0.5),javac --release 25。
承接的问题
libraryd 的审计日志要记录「谁在什么时候对哪条记录做了什么」。最小的实现是在每个服务方法里手写字段:
audit.record("borrow", Map.of(
"isbn", book.isbn(),
"title", book.title(),
"author", book.author(),
"year", book.year()));
三五个方法还能忍,等到 Book、Member、Loan、Reservation 都有审计需求时,这段代码会复制十几遍。每加一个字段,就要改所有复制点;漏掉一个,日志里就少一列。更麻烦的是,Loan 有七个字段,手写 Map.of 很容易超过十个参数或写错键名。
这一节用反射做一个通用的 RecordIntrospector:给它任意 record,返回「字段名 → 值」的映射。它需要回答四个问题:
Class对象从哪里来,Class.forName和类字面量有什么区别。- 怎样按名字和参数类型找到
Method,getMethods和getDeclaredMethods差在哪。 Method.invoke的返回值、异常和访问检查分别怎么处理。- 哪些私有成员可以打开,哪些在 JDK 25 的模块边界前必须放弃。
Class 对象的三种来源
反射的入口是 java.lang.Class<T>。常见来源有三种:
Book book = new Book("9780132350884", "Clean Code", "Robert C. Martin", 2008);
Class<?> fromInstance = book.getClass();
Class<?> fromLiteral = Book.class;
Class<?> fromName = Class.forName("com.plume.library.domain.Book");
System.out.println("sameClass=" + (fromInstance == fromLiteral && fromLiteral == fromName));
本机输出:
sameClass=true
三种写法指向同一个 Class 实例,因为一个类在同一个类加载器里只会被加载一次。Book.class 在编译期就能确定类型,不需要抛 ClassNotFoundException,是首选。Class.forName 适合「类名来自配置或插件」的场景,但它默认会初始化类;如果只想加载不初始化,用 Class.forName(name, false, loader)。
getClass() 返回的是运行时类型,不是声明类型。Book 被 SubBook 继承时,subBook.getClass() 是 SubBook.class。审计组件拿到的应该是运行时类型,否则子类新增的字段会被漏掉。
Class 还带着类加载器的身份。两个不同的 PluginClassLoader 各加载一次 com.example.validator.Isbn13Validator,会得到两个不相等的 Class 对象,即使字节码完全一样。第 3 章会展开这个坑;在反射代码里,判断「同一个类」要用 == 比较 Class 对象,不要用类名字符串。
Method 查找、调用与构造器
拿到 Class 之后,按名字和参数类型找方法:
Method title = Book.class.getMethod("title");
System.out.println("title=" + title.invoke(book));
System.out.println("returnType=" + title.getReturnType().getName());
本机输出:
title=Clean Code
returnType=java.lang.String
getMethod 只找 public 方法,并且会沿着父类和接口向上找。getDeclaredMethod 找当前类声明的所有方法,包括 private、protected 和包可见,但不包括继承来的。两个方法都需要精确的参数类型列表;getMethod("findByAuthor", String.class) 不能写成 getMethod("findByAuthor", Object.class)。
record 的访问器是普通方法,反射可以像调用其他方法一样调用。Book 的 getRecordComponents() 还会给出字段顺序和类型:
System.out.println("components=" + Arrays.stream(Book.class.getRecordComponents())
.map(c -> c.getName() + ":" + c.getType().getSimpleName())
.toList());
本机输出:
components=[isbn:String, title:String, author:String, year:int]
构造器同样可以反射调用:
Constructor<?> constructor = Book.class.getDeclaredConstructor(
String.class, String.class, String.class, int.class);
Book copy = (Book) constructor.newInstance(
"9780134685991", "Effective Java", "Joshua Bloch", 2018);
System.out.println("constructed=" + copy.title());
本机输出:
constructed=Effective Java
getDeclaredConstructor 拿到的是声明在 Book 上的 canonical constructor。record 的构造器是 public,所以 newInstance 直接成功;如果构造器是 private,需要先 setAccessible(true) 或 trySetAccessible()。
invoke 的返回值永远是 Object。返回 void 的方法得到 null;返回基本类型的方法会被装箱,int 变成 Integer。参数也一样:反射调用 fineCents(long) 时,传入的 Long 会被拆箱;如果传 null 给基本类型参数,会抛 IllegalArgumentException,而不是 NullPointerException。
访问控制与模块边界
setAccessible(true) 打开的是「抑制 Java 语言访问检查」,不是「绕过 JVM 的所有保护」。JDK 9 之后,模块系统给这个开关加了边界:只有成员所在的包打开给当前模块,反射才能访问非 public 成员。
先看自己的类:
Method secret = PrivateTarget.class.getDeclaredMethod("secret", String.class);
System.out.println("trySet=" + secret.trySetAccessible());
System.out.println("privateCall=" + secret.invoke(new PrivateTarget(), "id"));
本机输出:
trySet=true
privateCall=id-42
PrivateTarget 在无名模块(classpath)里,包没有显式导出或打开,但无名模块的包默认对反射开放,所以 trySetAccessible() 返回 true。这也是为什么大量老代码在 JDK 25 上仍然能反射自己的私有成员。
再看 JDK 内部:
var value = String.class.getDeclaredField("value");
System.out.println("jdkInternal=" + value.trySetAccessible());
本机输出:
jdkInternal=false
java.base 不把 java.lang 打开给无名模块,trySetAccessible() 返回 false,不会抛异常。如果继续 value.get(...),会得到 InaccessibleObjectException。用 setAccessible(true) 则直接抛 InaccessibleObjectException,因为它是「失败就抛」的版本。新代码应该优先用 trySetAccessible(),把「打不开」当成可处理的分支,而不是让异常穿透。
访问检查还有一个容易忽略的细节:Method 对象自己带着 accessible 标志。对同一个方法查找两次,得到两个不同的 Method 实例;对第一个调用 setAccessible(true),第二个仍然是关闭的。下面的代码会抛 IllegalAccessException:
Method declared = PrivateTarget.class.getDeclaredMethod("secret", String.class);
try {
declared.invoke(new PrivateTarget(), "x");
} catch (IllegalAccessException e) {
System.out.println("withoutSetAccessible=" + e.getClass().getSimpleName());
}
本机输出:
withoutSetAccessible=IllegalAccessException
如果确实需要复用,缓存已经打开的那个 Method 实例,不要在每次调用前重新查找。
在 libraryd 里落地
RecordIntrospector 只依赖 java.lang.reflect,放在 libraryd-domain 里,不引入第三方库:
package com.plume.library.domain;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.RecordComponent;
import java.util.LinkedHashMap;
import java.util.Map;
public final class RecordIntrospector {
private RecordIntrospector() {
}
public static Map<String, Object> toMap(Object record) {
Class<?> type = record.getClass();
if (!type.isRecord()) {
throw new IllegalArgumentException(type.getName() + " 不是 record");
}
Map<String, Object> values = new LinkedHashMap<>();
for (RecordComponent component : type.getRecordComponents()) {
try {
values.put(component.getName(), component.getAccessor().invoke(record));
} catch (IllegalAccessException | InvocationTargetException e) {
throw new IllegalStateException("读取 " + component.getName() + " 失败", e);
}
}
return values;
}
}
调用方只需要一行:
Book book = new Book("9780132350884", "Clean Code", "Robert C. Martin", 2008);
System.out.println("map=" + RecordIntrospector.toMap(book));
本机输出:
map={isbn=9780132350884, title=Clean Code, author=Robert C. Martin, year=2008}
getRecordComponents() 返回的顺序就是 record 声明字段的顺序,LinkedHashMap 保留这个顺序,审计日志读起来稳定。RecordComponent.getAccessor() 返回的是 Method,不需要按名字再查一次;这也是 record 比普通类更适合做字段提取的原因——组件列表是语言层面固定的。
第二个落点是审计事件。AuditTrail 不再关心具体实体,只接收映射:
import java.util.Map;
public final class AuditTrail {
public void record(String action, Object entity) {
Map<String, Object> fields = RecordIntrospector.toMap(entity);
System.out.println(action + " " + fields);
}
}
这样新增 Reservation 时,只要它是 record,审计代码不用改。反过来说,如果某个实体的字段需要脱敏(比如会员手机号),不能把 toMap 的结果直接写日志,要在实体或调用方做白名单过滤。反射让「拿到全部字段」变得容易,也让「不小心记录敏感字段」变得容易。
第三个落点是错误包装。Method.invoke 抛出的异常总是 InvocationTargetException,真正的原因在 getCause() 里。审计组件不应该把 InvocationTargetException 直接抛给业务层:
static Object invokeAndUnwrap(Method method, Object target, Object... args) {
try {
return method.invoke(target, args);
} catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
throw new IllegalStateException("调用 " + method.getName() + " 失败", cause);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("无法调用 " + method.getName(), e);
}
}
ReflectiveOperationException 是 NoSuchMethodException、IllegalAccessException、InvocationTargetException 的共同父类。分开捕获 InvocationTargetException 并拆 cause,可以让业务异常保持原来的类型;否则上层只能看到一层反射壳。
常见错误与边界
「用 getMethods 找私有方法。」 getMethods() 只返回 public 成员,私有方法必须用 getDeclaredMethod。反过来,getDeclaredMethods() 不含继承方法;要同时覆盖两者,需要沿 getSuperclass() 逐层查找。大多数业务代码只需要 getDeclaredMethod 加精确参数类型。
「按参数个数找重载。」 getMethod(name, parameterTypes) 需要精确类型。findByAuthor(String) 和 findByAuthor(Object) 是两个方法,传 Object.class 找到的是后者。如果参数类型来自运行时值,用 getClass() 可能拿到子类(比如 String 的匿名子类不存在,但集合实现类很多),最好用声明类型或显式白名单。
「把 InvocationTargetException 当成业务异常。」 反射调用的业务方法抛 LibraryException 时,invoke 抛的是 InvocationTargetException,getCause() 才是 LibraryException。不拆 cause 的话,上层按 LibraryException 捕获会失败,日志里也看不到真实错误码。
「在热路径上反复查找 Method。」 getMethod 每次都会遍历方法表,invoke 还有参数装箱和访问检查。审计、序列化这类每秒几千次的操作,应该把 Method 缓存在 Class 为键的 ConcurrentHashMap 里;更高频的路径不应该用反射,应该用代码生成或 MethodHandle。
「以为 setAccessible 能打开一切。」 JDK 内部的 java.lang、java.util 私有成员在 JDK 25 默认不打开,trySetAccessible() 返回 false。依赖 String.value、ArrayList.elementData 这类字段的代码,升级 JDK 时会直接失效。需要访问 JDK 内部结构时,优先找公开 API;找不到就说明方案本身依赖了实现细节。
小结
Class可以从实例、类字面量或Class.forName获得;同一个类加载器里它们是同一个对象,跨加载器则不相等。getMethod找 public 及继承方法,getDeclaredMethod找当前类所有声明方法;重载必须给精确参数类型。Method.invoke返回Object,基本类型会装箱;业务异常包在InvocationTargetException里,要拆 cause。trySetAccessible()返回布尔值,适合处理「可能打不开」;JDK 内部包默认不打开,String.value在 JDK 25 上返回false。- record 的
getRecordComponents()提供稳定的字段顺序和访问器,是审计字段提取的理想入口。 - 反射适合低频、通用的基础设施;高频路径要缓存
Method,更高频时改用 MethodHandle 或代码生成。
反射让我们能在运行时读方法、调方法,但每次都要手写「查找 + 调用 + 异常处理」。下一节把「什么时候重试、什么时候记审计」写进注解,让这些规则跟着方法声明走。
阅读导航:上一节:1.3 类型擦除与类型令牌:TypeReference 与反射补偿 · 下一节:2.2 注解与运行时元数据:@Audit 与 @Retry 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。