跳到内容

第 9 卷 / 第 6 章 / 第 2 课

6.2 运行时元编程:反射、装饰器与类创建 ​

编译期生成器只能使用构建时知道的信息。依赖注入容器、序列化框架和测试工具还要根据运行时类型或配置选择行为,于是会使用反射、代理、装饰器或类创建钩子。它们获得了动态性,也把一部分错误推迟到了运行期。程序更灵活了,报错也可能更会挑时间。

反射:把程序结构当作数据查询 ​

Java 反射可以检查类、字段、方法和注解,并在运行时调用成员。下面的方法片段使用 Java 17,放在普通类中,并导入 java.lang.reflect.* 和 java.util.*:

java
static Map<String, Object> inspectRecord(Object value)
        throws ReflectiveOperationException {
    Class<?> type = Objects.requireNonNull(value, "value").getClass();
    if (!type.isRecord()) {
        throw new IllegalArgumentException("record required");
    }

    Map<String, Object> result = new LinkedHashMap<>();
    for (RecordComponent component : type.getRecordComponents()) {
        Method accessor = component.getAccessor();
        result.put(component.getName(), accessor.invoke(value));
    }
    return result;
}

对当前调用方可访问的 public record User(String name, int age),传入 new User("Ada", 36),结果是 {name=Ada, age=36}。传入普通字符串会得到 IllegalArgumentException。组件顺序来自 record 声明;非公开类型、模块未导出包或自行覆盖且会抛异常的访问器,仍可能让调用失败。

这段代码能处理调用方编译时未知的 record。反射机制的一般成本还包括:

  • 成员名称错误到运行时才暴露;
  • 访问控制和模块边界可能阻止深层反射;
  • 异常会被反射调用包装;
  • 重命名和静态分析工具难以追踪字符串引用;
  • 重复查找需要缓存,但缓存又要考虑类加载器卸载。

优先使用公开 API 和 MethodHandles.Lookup 支持的访问能力,不要把强行打开内部成员当成稳定扩展点。

动态代理:在调用边界插入行为 ​

Java 的接口代理可以把方法调用交给统一处理器。下面是处理器片段,假定 target 实现被代理接口,且 metrics.record 线程安全、不会抛异常:

java
InvocationHandler timing = (proxy, method, args) -> {
    long start = System.nanoTime();
    try {
        return method.invoke(target, args);
    } catch (InvocationTargetException error) {
        throw error.getCause();
    } finally {
        metrics.record(method.getName(), System.nanoTime() - start);
    }
};

Method.invoke 把目标异常包装成 InvocationTargetException;解包才能让调用方得到接口原本声明的异常或原始运行时异常。若处理器抛出接口未声明的受检异常,代理仍会转为 UndeclaredThrowableException。计时记录若在 finally 中抛错,也会覆盖原结果或异常,所以观测回调的失败策略必须明确。

事务、权限、追踪和重试框架常使用类似机制。但拦截层越多,真实调用路径越难从源码看见。尤其要明确:

  • 同一对象内部自调用是否经过代理;
  • 异常是否被解包或转换;
  • equals、hashCode、toString 怎样处理;直接委托给目标的 equals 甚至可能使 proxy.equals(proxy) 为假;
  • 异步返回后,计时和事务边界何时结束;
  • 代理是否保留注解和泛型信息。

字节码 instrumentation 可以在加载时转换 class;加载后是否支持重转换或重定义,以及允许修改什么,受 JVM 能力和规范限制,不能任意增删字段或方法。它比接口代理覆盖更广,也更容易与 JDK 版本、其他 Agent 和验证规则冲突。它适合观测和框架基础设施,不适合隐藏普通业务流程。

Python 装饰器发生在定义阶段 ​

下面使用 Python 3.10 以上版本。装饰器在定义语句执行时接收被装饰对象并返回替代对象,包装函数里的计时则在调用时运行:

python
from collections.abc import Callable
from functools import wraps
from time import perf_counter
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")

def timed(fn: Callable[P, R]) -> Callable[P, R]:
    @wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        started = perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            print(f"{fn.__qualname__}: {perf_counter() - started:.6f}s")
    return wrapper

@timed
def calculate(value: int) -> int:
    return value * 2

可把 @timed 近似理解为类或函数定义完成后执行一次赋值:

python
calculate = timed(calculate)

调用 calculate(5) 返回 10,同时打印一行耗时;calculate.__name__ 仍是 calculate。第二个赋值块只是解释语法的等价关系,不要再执行它把已经装饰的函数包装第二遍。

functools.wraps 保留名称、文档和 __wrapped__ 等元数据,有助于调试和工具识别,但不会让包装器自动具备异步或生成器语义。这个 timed 只计同步函数;用于 async def 时只会测到协程对象创建,异步版本需在包装器内部 await fn(...)。装饰器工厂还可以接收配置,但层层嵌套会使异常栈和类型推导变复杂。

元类控制类对象怎样创建 ​

Python 中类本身也是对象,元类定义创建类对象的过程:

python
class RegistryMeta(type):
    registry: dict[str, type] = {}

    def __new__(mcls, name, bases, namespace, **kwargs):
        cls = super().__new__(mcls, name, bases, namespace, **kwargs)
        key = namespace.get("plugin_name")
        if key is not None:
            if not isinstance(key, str) or not key:
                raise TypeError("plugin_name must be a nonempty string")
            if key in mcls.registry:
                raise TypeError(f"duplicate plugin: {key}")
            mcls.registry[key] = cls
        return cls

class Plugin(metaclass=RegistryMeta):
    pass

class CsvPlugin(Plugin):
    plugin_name = "csv"

执行后 RegistryMeta.registry["csv"] is CsvPlugin 为真;再定义一个 plugin_name = "csv" 的类会在定义时抛出 TypeError。这里只注册本次类体显式声明的 plugin_name,未重写它的子类不会被重复注册。注册表用强引用保留类,卸载时需要显式移除;一次失败的模块导入也不会自动回滚此前成功的注册。

这适合为一族类统一执行注册或约束。若只是修改一个类,类装饰器通常更局部;若只是复用行为,普通基类或组合更容易理解。元类冲突、导入时副作用和测试隔离是采用前必须评估的成本。

动态机制要留下静态边界 ​

即使内部依赖反射,外部也可以暴露普通类型接口:

java
interface Codec<T> {
    byte[] encode(T value);
    T decode(byte[] bytes);
}

interface CodecRegistry {
    <T> Codec<T> codecFor(Class<T> type);
}

这里定义的是接口契约,具体实现还需证明 Class<T> 与返回的 Codec<T> 对应,不能靠一次未经检查的强转就当作类型安全。把动态性封装在 registry、factory 或 adapter 后面,可以让大部分业务代码继续获得编译期检查。

运行时发现还应在启动阶段尽早验证,而不是等第一笔真实请求才发现缺少构造器或重复注册。服务启动时可以:

  • 扫描并建立元数据缓存;
  • 校验所有处理器签名;
  • 检测冲突和循环依赖;
  • 输出可查询的注册表;
  • 在失败时终止启动,而非降级成随机运行错误。

性能问题先测查找路径 ​

反射调用并不必然是系统瓶颈。常见的真正成本可能是:每次请求重复扫描 classpath、错误地创建代理、缓存持有类加载器,或动态层触发大量分配。

优化顺序应是:

  1. 缓存稳定的成员查找结果;
  2. 把扫描移到启动或构建阶段;
  3. 用方法句柄或生成代码减少热路径适配;
  4. 用 JFR 或基准测试验证,而不是只凭“反射很慢”。

安全边界 ​

不要让不可信输入直接决定:

  • 要加载的任意类名;
  • 要调用的任意方法名;
  • 可访问的文件或模块;
  • 要反序列化并实例化的具体类型。

按插件可信程度选择允许列表、受限接口或独立进程,并限制隔离进程的资源和权限。反射仍执行访问与参数检查,但如果应用允许外部输入任选类或方法,就可能把本不该开放的能力暴露出去。

完成检查 ​

为一个插件注册系统比较三种方案:显式 Map、注解扫描、Python 元类式自动注册。回答:

  1. 错误在构建、启动还是首个请求时出现;
  2. IDE 能否找到所有实现;
  3. 插件卸载后缓存是否释放类加载器;
  4. 重复名称怎样报告;
  5. 不可信插件怎样隔离。

参考资料 ​

Built with VitePress | Software Systems Atlas