1. 现实问题:框架为什么能“找到”你的方法
Spring、JUnit 和序列化框架经常根据注解、类名或方法签名自动工作。初学者看到 @Test、@Bean,容易把它们当成改变程序的魔法。实际上,注解只是元数据;框架在启动或运行时读取类、方法和参数,再决定注册对象、执行测试或生成结果。
这一课不要求手写框架,而是做一个可观察的缩小版:用注解标记字段,反射读取它们,再用 Stream 把数字转换、过滤、汇总。最后把纯函数交给 JUnit 5 测试,建立“基础设施可以复杂,业务逻辑仍应可直接测试”的边界。
2. 最小可运行示例:元数据读取与流处理
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.reflect.Field;
import java.util.List;
public class IntrospectionDemo {
@Retention(RetentionPolicy.RUNTIME)
@interface ColumnName { String value(); }
record User(@ColumnName("user_id") long id, String name) {}
static List<Integer> positiveSquares(List<Integer> values) {
return values.stream().filter(value -> value > 0)
.map(value -> value * value).toList();
}
public static void main(String[] args) {
for (Field field : User.class.getDeclaredFields()) {
ColumnName mark = field.getAnnotation(ColumnName.class);
if (mark != null) System.out.println(field.getName() + " -> " + mark.value());
}
System.out.println(positiveSquares(List.of(-1, 2, 3)));
}
}
记录组件编译后会产生字段和访问方法,反射拿到的是运行时 Class 元数据。RetentionPolicy.RUNTIME 才能在运行时读到注解;只为编译器服务的注解不需要保留到运行时。Stream 的 filter/map 是惰性中间操作,toList 才触发遍历。
3. 调用链与对象变化
User.class 返回 Class 对象,getDeclaredFields 创建 Field 数组;每个 Field 调用 getAnnotation 检查注解元数据并返回注解代理对象或 null。反射绕过普通编译期调用,运行时根据名字和类型访问,因此异常通常晚于直接代码。
Stream 取得 List 的元素引用,lambda 被转换为函数式接口实例;filter 只让正数继续,map 为每个元素计算新 Integer,toList 收集新结果。原始 List 不变。若在 lambda 中修改外部可变变量,管道就有隐藏副作用,测试和并行执行会更难。
4. 为什么这样设计
注解把声明式意图附着在代码结构上,框架可以统一读取;反射换来扩展性,也付出启动、可读性、类型检查和安全访问成本。业务核心不应为了省几行代码而把所有逻辑变成反射,稳定路径优先使用直接方法调用。
Stream 适合表达“数据经过几步转换”的管道,复杂分支、调试和性能关键循环可以用普通 for。JUnit 5 通过扩展模型发现测试方法,但被测业务最好不依赖 JUnit 的运行时,测试只是调用它的公开契约并检查返回值或副作用。
5. 项目落点:框架边界和业务边界分开
自定义 ORM、参数绑定或插件机制可以使用注解与反射,但应在启动阶段扫描并缓存元数据,错误尽早失败。Service 里的金额计算、权限判断和状态迁移使用普通类型和方法,JUnit 可以直接 new 对象测试。把反射收口,后续替换框架不会污染核心业务。
练习:写 @Required 注解,启动时反射检查字段是否为空;再为 positiveSquares 写 JUnit 5 参数化测试,覆盖空列表、负数、重复数和整数溢出。测试名称要说明业务规则,而不是只叫 test1。
6. 易错排查
- 运行时读不到注解:检查 Retention 是否为 RUNTIME,以及读取的是字段还是记录组件。
- 反射
setAccessible失败:模块和访问边界可能阻止它,先问是否真的需要绕过封装。 - Stream 管道没有执行:缺少终端操作;构造流不等于结果已经计算。
- 测试依赖真实时间或共享 List:导致顺序和环境相关,改成注入 Clock、复制输入和独立夹具。
7. 一页复习
注解是元数据,反射是运行时读取与调用,Lambda 是函数式接口的实现,Stream 是惰性数据管道,JUnit 是测试发现与执行框架。越靠近框架边界越需要反射,越靠近业务核心越要保留显式类型和直接调用。测试验证调用链的结果,而不是验证注解“看起来存在”。
反射实验可以增加一个 @Required 字段并记录三种状态:字段存在且有值,字段存在但为空,字段本身没有标记。Class/Field/Annotation 是元数据对象,真实 User 实例是另一类对象;反射读取标记不会自动改变 User 的 name。若要改变字段,必须明确访问权限、转换类型和副作用,最好在启动扫描阶段失败,而不是在请求中第一次遇到时失败。
Stream 管道的输入是 [ -1, 2, 3 ],filter 后变成 [2,3],map 后变成 [4,9],toList 后得到不可变结果。这个中间状态可用普通 for 循环逐步打印验证;如果 lambda 捕获一个外部 ArrayList 并在 map 中追加,管道就不再是单纯转换,顺序、并发和测试隔离都会变差。先让函数无副作用,再考虑并行。
项目文件可以把注解定义放在 infrastructure/metadata,把扫描器放在启动模块,把业务校验放在 domain;JUnit 测试只调用公开方法,不直接断言私有反射细节。若框架扫描数百个类,启动阶段应缓存 Field/Method 信息并在日志中输出扫描数量和耗时,避免每个请求重复 getDeclaredFields。
排错时注解读不到,先查 Retention 和目标位置;Stream 结果不对,先拆成普通循环确认 filter/map 哪一步改变;测试偶尔失败,检查共享静态集合和当前时间;反射访问异常,检查模块边界和 private 字段,不要用 setAccessible 无条件绕过封装。练习是为 positiveSquares 写空输入、null 元素、溢出和重复值测试,并记录每个失败应归谁处理。
框架式调用的状态可以这样看:启动扫描前只有 class 文件;扫描后得到一份元数据索引;请求到来时按索引找到字段或方法;业务输入经过转换后得到结果;错误要在启动时或调用时明确失败。把索引缓存起来能减少反射成本,但缓存失效和类加载器边界也要纳入测试,不能把一个静态 Map 当永久正确。
JUnit 测试文件应与被测类保持同一意图:PositiveSquaresTest 只关心输入列表、输出列表和异常,不验证 lambda 的具体实现;注解扫描器测试关心缺失标记、重复标记和非法字段。项目里把框架适配放在 infrastructure/reflection,把纯转换放在 domain, 测试启动扫描只需少量集成测,其余用普通 Java 单测。
排错时先把 Stream 改成普通循环,确认元素在哪一步消失;再打印 Field.getName 与 annotation 类型,区分读取位置错误和 Retention 错误;最后检查测试 extension 是否创建了新夹具。反射异常还可能来自模块访问、构造器不可见和类型转换,不能只加 catch。练习是把一个反射字段绑定器改成启动时预校验,错误带类名、字段名和期望类型。
反射和 Stream 的练习最终要回到普通业务输入输出:一个字段标记是否生效,一组数字是否得到预期新列表,失败是否能指出类/字段或元素位置。框架复杂度应停在基础设施边界,测试仍保持可读。
验证清单:为 @Required 准备有值、空值和无注解字段,断言扫描器分别返回成功、字段错误和跳过;为 positiveSquares 准备负数、零、重复数、空列表和 null 元素,检查 filter/map/toList 后的顺序与异常契约。再执行一次启动扫描,比较第一次建立的元数据索引和第二次复用的缓存,确认请求阶段没有重复反射。复盘测试时标出哪些是纯单测、哪些必须启动框架,若一个数字转换测试需要完整 Spring 上下文,说明层次边界放错了。
进阶附录:MethodHandle 与测试扩展
高频动态调用可以研究 MethodHandle 或提前生成访问器,但这属于性能和框架基础设施问题。JUnit 5 的 Extension 可以统一准备数据库、替换时间和收集测试上下文;扩展越强,测试越要说明它隐含了什么资源和生命周期。
本课按「Java 21 反射、函数式接口、注解与测试入门」的学习范围组织,正文与示例均为本站原创整理。