Java基础加强:单元测试、反射、注解与代理实战解析
我学JavaSE的时候有好几次差点“入土”其中有一章直接让我怀疑人生就是标题里说的“Java基础加强篇”。这一章把单元测试、反射、注解、代理四样东西一次性砸过来每一样单拎出来都够琢磨一阵合在一起直接劝退不少人。但是当你真正把它们啃下来之后再回头去看Spring、MyBatis这些框架你会发现很多东西都是在这四个基础能力上盖起来的。这篇学习记录不是官方文档的复读是我自己从零开始折腾这些知识点时踩过的坑、总结出来的理解路径还有能直接跑起来的示例代码。适合正在学JavaSE、准备开启框架学习但总觉得底层原理隔层纱的人。在正式展开之前我先给出一个贯穿全章的理解框架注解负责“描述意图”反射负责“读取意图”代理负责“执行意图”单元测试负责“验证意图”。这不是什么严谨的学术定义但对我这种从工程角度入手的人来说是特别好用的记忆锚点。下面这四块我会按照自己实际学习时摸索出来的顺序一个一个拆开讲每个部分都带可复现的代码最后一节还会出一个整合案例把这四样东西串成一个极简版的“框架雏形”。1. 为什么这四个东西被强行绑在一起很多刚学Java的同学学到这一章都会有同样的疑问单元测试、反射、注解、代理这四个东西看起来八竿子打不着为什么要放在一起学我刚开始也困惑了很久直到我把Spring Boot项目的启动过程跟着源码捋了一遍才彻底明白这四个东西根本不是孤立的它们恰好是Java生态从“语言”走向“框架”的四块基石。单元测试负责验证代码的正确性反射让程序能在运行时动态查看和操作自身注解提供了一种声明式的元数据描述方式代理则负责在一个方法调用的前后插入额外逻辑。Spring的Transactional为什么能让一个普通方法自动开启事务、提交事务、回滚事务答案就是把注解、反射、代理三者连在一起用注解标记哪些方法需要事务反射用来读取和扫描这些标记代理负责在方法调用前后真正加上事务逻辑。而单元测试是检验这套机制是否按预期工作的最佳手段。所以这一章的学习目标不是让你背下四个概念的定义而是建立一条完整的链路通过注解描述意图通过反射读取描述通过代理执行增强通过测试验证结果。把这四个齿轮咬合在一起你以后看任何框架源码都不会觉得是看天书。这四个知识点分开看各自都有一定难度但真正让你头皮发麻的是它们组合起来的时候。我见过很多人在单独学反射的时候觉得自己懂了一进Spring源码立刻懵圈问题恰恰出在没把“反射注解代理”这条链路的交互关系理清楚。2. 单元测试让代码的每一次改动都有兜底2.1 单元测试到底在测什么单元测试Unit Testing的目标是把一个类中的某个方法当成一个独立单元给它输入、看它输出验证行为是否符合预期。我第一次学的时候觉得这东西是浪费时间的代码写完跑一下main方法能出结果不就完了直到后来我接了一个老项目改一个计算库存的方法结果把另一个模块的折扣逻辑带崩了测试直接红了一大片我才真正意识到单元测试的价值它不是在测“当前代码能不能跑”而是在保护“未来每一次重构不会把旧功能改坏”。在Java领域最主流的单元测试框架是JUnit。目前我用的是JUnit 5它和之前很常见的JUnit 4相比最大的变化是提供了一套新的编程模型比如Test注解、BeforeEach、AfterEach这些生命周期注解还有更灵活的断言API。如果你用的是Spring Boot 2.2以上的版本spring-boot-starter-test里面自带的默认就是JUnit 5不需要额外引入这点很方便。2.2 从零开始写第一个测试我习惯用Maven管理项目所以只需要在pom.xml里加一段依赖dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.2/version scopetest/scope /dependency然后建一个非常简单的计算器类里面有一个除法方法。为什么用除法因为除法的边界情况多除数不能为零、浮点数精度问题都能展示出来。public class Calculator { public int divide(int a, int b) { if (b 0) { throw new IllegalArgumentException(除数不能为0); } return a / b; } public double add(double a, double b) { return a b; } }对应的测试类长这样import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; public class CalculatorTest { private final Calculator calculator new Calculator(); Test void testDivide() { assertEquals(2, calculator.divide(10, 5)); } Test void testDivideByZero() { assertThrows(IllegalArgumentException.class, () - calculator.divide(10, 0)); } }注意assertEquals的第一个参数是期望值第二个参数是实际值。写反了程序大概率也能跑过但报错信息会完全反过来排查问题时会非常困惑。这里还有一个浮点数断言的坑。如果你直接写assertEquals(0.3, calculator.add(0.1, 0.2))十有八九会失败因为0.1加0.2在二进制浮点表示里结果是0.30000000000000004。正确的姿势是给断言加一个误差范围deltaassertEquals(0.3, calculator.add(0.1, 0.2), 1e-9);这个坑我当年踩得很惨印象极深。写单元测试浮点数比较永远别用精确相等。2.3 生命周期注解与测试隔离JUnit 5里每个测试方法都是独立执行的默认会为每个Test方法创建一个新的测试实例PER_METHOD生命周期。这个设计很关键它保证了测试方法之间不会因为共享字段而互相污染。如果你确实想在所有测试方法执行前后只做一次初始化可以用BeforeAll配合static方法或者把生命周期改成PER_CLASS。BeforeAll static void initAll() { System.out.println(所有测试开始前执行一次且必须为static); } BeforeEach void init() { System.out.println(每个测试方法开始前执行); } AfterEach void tearDown() { System.out.println(每个测试方法结束后执行); }我个人的实践建议是测试数据尽量在每个方法里自给自足不要把过多状态放到共享字段上。单元测试一旦出现“换个顺序就红”的诡异现象90%是因为共享状态没有隔离干净。还有一个容易被忽视的问题测试类里的成员变量初始化时机。如果你把Calculator的初始化放到BeforeEach里那每个测试方法都会拿到一个全新的实例这通常也是我们想要的隔离效果。2.4 单元测试的常见误区写单元测试不是比谁方法名起得花而是比谁覆盖的边界多。我整理了自己踩过和看到别人踩过的三个典型误区。第一个误区只测快乐路径。新手写测试特别喜欢把正常输入测一遍就完工但实际项目里90%的Bug都藏在边界情况空值、超出范围的值、非法状态。所以assertThrows、assertNull、assertFalse这类“反着测”的断言要养成习惯。我见过有人写一个除法工具类测了一百个正整数除法就是不测除数为零结果上线第一天就被客户用参数边界打爆了。第二个误区测试代码里访问了数据库、调用了远程接口。一旦测试依赖外部资源它就不再是单元测试而是集成测试。这类测试会让你在本地跑得好好的换一台新机器就全线飘红。真正的单元测试中外部依赖应该用Mockito这类工具来打桩。第三个误区不看测试覆盖率但也不迷信覆盖率。覆盖率是参考不是目标。很多团队把覆盖率当成KPI结果开发为了凑指标写了一堆断言“对象不为null”的空壳测试价值极低。性价比最高的做法是先保证核心业务逻辑、异常分支和边界条件都有测试覆盖再逐步拓宽覆盖范围。3. 反射让代码在运行时“重新认识自己”3.1 反射的基本概念反射Reflection是Java语言里非常标志性的一个能力它允许程序在运行期间动态地获取某个类的完整结构信息包括类名、父类、接口、构造器、方法、字段和注解并且可以直接调用这些成员。平时我们写代码都是编译期就定死了这个对象是Calculator类型它有个divide方法。反射的存在让这个“定死”的规则被打破了只要给我一个Class对象我就能在运行期拿到类的全部细节哪怕这个类在编译期根本不存在只要运行期的类路径里有就能加载并操作它。为什么说反射是框架的基石因为框架要处理的类是“别人写的”框架作者在写框架时根本不知道以后会有什么类传进来。Spring要管理用户定义的Service它不能靠import一个个写死只能靠Class.forName、扫描注解、运行时创建实例这一套反射手段。没有反射依赖注入、ORM映射这些东西统统做不了。3.2 获取Class对象的三种方式创建反射的入口一般有三种方式实际开发中各有各的使用场景// 方式一通过类名.class获取 ClassCalculator clazz1 Calculator.class; // 方式二通过对象实例获取 Calculator calc new Calculator(); Class? extends Calculator clazz2 calc.getClass(); // 方式三通过全限定类名字符串获取最常用因为字符串可以运行时组装 Class? clazz3 Class.forName(com.example.Calculator);第三种方式尤其重要因为Class.forName的参数是字符串意味着你可以通过配置文件、数据库记录、注解属性这些运行时才知道的内容去动态加载一个类。很多框架的SPI机制底层就是这种方式。我刚开始学的时候总觉得前两种不就行了何必用字符串直到自己动手写工具类才发现字符串是运行时唯一灵活的东西——类名可以从配置中心动态下发这是编译器永远做不到的。3.3 使用反射调用方法手写一个“万能执行器”反射获取类结构之后最核心的几个操作是创建实例、获取方法、调用方法。我写了一段非常典型的代码模拟的是一个极简的“字符串方法执行器”给定类名和方法名加上参数反射就帮你把方法调用出来。public class ReflectInvoker { public static Object invokeMethod(String className, String methodName, Object... args) throws Exception { // 1. 加载类 Class? clazz Class.forName(className); // 2. 获取无参构造器并创建实例 Object instance clazz.getDeclaredConstructor().newInstance(); // 3. 获取方法参数类型需要从args里一个个推断 Class?[] paramTypes new Class?[args.length]; for (int i 0; i args.length; i) { paramTypes[i] args[i].getClass(); } // 4. 获取方法并调用 Method method clazz.getMethod(methodName, paramTypes); return method.invoke(instance, args); } public static void main(String[] args) throws Exception { // 反射调用Calculator类的divide方法传参 10 和 5 Object result invokeMethod(com.example.Calculator, divide, 10, 5); System.out.println(result); } }这段代码几乎每次教反射我都会用因为它把一个完整反射链路展示出来了加载类、创建实例、匹配方法、调用方法。但代码里有个细节非常容易出问题参数类型推断。如果args传的是int基本类型实际进入数组后封装成的是Integer对象那paramTypes里就是Integer.class。而目标方法divide(int a, int b)的参数类型是int.class也就是int基本类型。提示getMethod的参数类型匹配严格区分int.class和Integer.class不会自动装箱拆箱。写通用反射工具时尤为注意否则会莫名其妙地NoSuchMethodException。3.4 暴力访问setAccessible与它的边界反射还可以访问私有成员方法就是先getDeclaredField或getDeclaredMethod然后调用setAccessible(true)。比如我想绕过类里的private修饰符强行修改一个字段的值可以这样写Class? clazz Class.forName(com.example.Calculator); Object obj clazz.getDeclaredConstructor().newInstance(); Field countField clazz.getDeclaredField(count); countField.setAccessible(true); countField.set(obj, 100);这种“暴力访问”在真实业务代码里要非常克制。我自己的原则是业务代码里绝不使用框架内部如果确实需要也要严格限定在读取尽量不做修改。因为一旦setAccessible(true)你就等于告诉Java“我不管这个成员是private还是public我就是要碰它”它破坏了封装性也绕过了编译器对访问控制的安全检查。在Java 17之后如果模块是强封装的这种操作还会触发InaccessibleObjectException。你在跑一些老框架时如果看到这个异常多半就是模块访问边界的问题。另外反射调用是有性能损耗的。每次Method.invoke都要做访问检查和参数绑定虽然现代JVM对反射做了很多优化但在极高频率的热点路径上反射依旧比直接调用慢。实践中我一般会缓存Method或Constructor对象到Map里避免每次都getMethod。把反射元数据缓存起来能显著减少重复解析的开销。3.5 反射在框架里的身影反射最典型的实际场景有三个。第一个是Spring的依赖注入容器扫描类上的注解用反射获取字段通过Constructor或Setter注入依赖。第二个是MyBatis的SQL映射把数据库查询结果通过反射映射到实体对象的属性上。第三个是各种配置框架和SPI扩展点像数据库驱动加载就是Class.forName(com.mysql.cj.jdbc.Driver)这样的方式把驱动类加载进来。理解了反射你再看框架源码会发现整个世界透亮了。框架不再是什么黑魔法它只是用反射把“写死的代码”变成了“可配置的代码”。我记得第一次手动跟完Spring的一个Bean创建流程心里最大的感受就是原来它也就是不停地getDeclaredMethods、setAccessible、invoke本质上就是我在这章写的小玩具的复杂版。4. 注解贴着代码上的“元数据标签”4.1 注解的本质注解Annotation在Java里本质上是一种“元数据”也就是描述数据的数据。它本身不直接改变代码行为而是像便利贴一样贴在类、方法、字段或者参数上由编译器或框架来决定怎么处理这些便利贴。它和注释不同注释是给人看的注解是给程序框架、编译器、JVM看的。注解看起来像是一种“特殊接口”。事实上在JVM层面注解类型确实就是一个接口只是这个接口的所有抽象方法变成了注解的“属性”。理解这一点很有用因为它解释了为什么注解可以定义属性、可以有默认值也解释了为什么注解在使用时是“属性名属性值”的键值对写法。4.2 自定义注解从零写一个MyField定义注解用的是interface关键字。我们写一个用于标记实体类字段的注解通常包含字段映射名、字段长度、是否主键这些属性。import java.lang.annotation.*; Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface MyField { String name() default ; int length() default 255; boolean primaryKey() default false; }这里两个元注解特别关键。Target决定这个注解可以贴在什么地方我这里限定FIELD就只能贴在字段上Retention决定注解存活到哪个阶段我设成RUNTIME表示编译后仍保留在class文件中并且在运行时也能通过反射读取。如果只设成CLASS或SOURCE运行时反射是读不到的——很多新手自定义注解之后发现反射读出来全是空第一反应就是回去看Retention。4.3 注解的解析方式运行时反射和编译期注解处理注解主要有两类解析时机。第一类是我们最常遇到的运行时解析也就是通过反射读取注解信息再根据这些信息做逻辑处理。第二类是编译期解析代表是APTAnnotation Processing Tool像Lombok的Getter/Setter、MapStruct的Mapper就是在编译阶段基于注解生成额外代码这些注解甚至可以不保留到运行时。举个例子Lombok的Getter注解如果你把它的Retention打开看会发现是SOURCE级别它的使命在源码编译阶段就结束了编译器在生成class文件时已经把getter方法写进字节码里了运行时根本不需要反射再去解析。这也是为什么用Lombok的类需要先装IDE插件因为IDE自己也要在编辑阶段解析这些注解否则代码即时提示会失灵。4.4 注解反射实现一个极简的MyAutowired我一直觉得注解单独学是虚的一定要配合反射才有生命力。下面这个是我学习时写的极简依赖注入IoC雏形定义一个容器类扫描某个类的字段凡是贴了MyAutowired的字段就用反射自动创建实例并注入进去省掉手动new的步骤。public class TestAutowired { MyAutowired private Calculator calculator; public void show() throws Exception { if (calculator null) { System.out.println(calculator 没有被注入); } else { System.out.println(calculator 注入成功: calculator.divide(10, 5)); } } }容器初始化方法如下public class MyIoCContainer { public static void inject(Object target) throws Exception { Class? clazz target.getClass(); Field[] fields clazz.getDeclaredFields(); for (Field field : fields) { // 只有标注了MyAutowired的字段才处理 if (field.isAnnotationPresent(MyAutowired.class)) { field.setAccessible(true); // 根据字段类型反射创建实例 Object instance field.getType().getDeclaredConstructor().newInstance(); field.set(target, instance); } } } public static void main(String[] args) throws Exception { TestAutowired test new TestAutowired(); MyIoCContainer.inject(test); test.show(); } }运行结果就是“calculator 注入成功2”。就这么一小段代码其实已经摸到了Spring依赖注入的核心通过注解标记依赖、通过反射解析字段、通过构造器创建实例、最终把实例设置到字段上。Spring做的当然比这个复杂得多但思想完全一致。我在写这个例子时掉过一个坑getDeclaredFields只能拿到当前类声明的字段拿不到父类字段。如果字段定义在父类里要用getSuperclass()一层层往上找。所以很多框架扫描字段时会写一个循环顺着继承链逐级读取这就是从坑里总结出来的经验。4.5 注解使用中的几个坑注解这个知识点的坑主要集中在三点。第一注解属性不能传null。注解的属性只能是基本类型、String、Class、枚举、注解或这些类型的数组直接传null是不行的你会得到编译错误。第二Inherited只对类上的注解生效对方法和字段上的注解无效。子类会继承父类上的类级注解但父类方法上的注解子类覆写后发现读不到这个我实际遇到过排查了很久。第三同一个注解不能重复贴在同一个位置除非你定义成Repeatable。比如一个方法可能不需要重复贴同一个注解但如果业务上确实要贴多个值就要包一层容器注解。5. 代理方法调用的“中间商”5.1 代理模式解决了什么问题学代理模式之前先想一个场景现在有一个现成的Calculator类我不想改它的源码但我想在它每次divide方法执行前记录日志、执行后统计耗时甚至加一层参数校验怎么办最直觉的答案是直接去改方法体把日志代码加进去。但这样会有两个问题一是污染核心业务代码二是如果很多类都要加日志你会改到手抽筋。代理模式就是来解决这个问题的。它的核心思路是创建一个代理对象由代理对象来包住真正的目标对象外部调用的其实是代理对象代理对象在原方法执行前后插入辅助逻辑然后再调用真正的目标方法。这样做的好处是目标类完全不需要修改额外的逻辑全部集中到代理类里维护。5.2 静态代理的实现静态代理是最直观的代理方式手动写一个和目标类实现相同接口的代理类。我们给Calculator加一个接口然后写一个CalculatorProxypublic interface Operation { int divide(int a, int b); } public class Calculator implements Operation { Override public int divide(int a, int b) { return a / b; } } public class CalculatorProxy implements Operation { private final Calculator target; public CalculatorProxy(Calculator target) { this.target target; } Override public int divide(int a, int b) { System.out.println(开始执行除法参数: a , b); int result target.divide(a, b); System.out.println(执行完毕结果: result); return result; } }静态代理的缺点是显而易见的每代理一个类就要手动写一个代理类如果目标类有一百个方法代理类就要把转发逻辑原样写一百遍代码重复非常严重。实际开发中静态代理一般只用于代理逻辑简单、代理类数量有限的场景比如对个别接口做权限校验。5.3 JDK动态代理动态代理解决了静态代理代码重复的问题。JDK自带了一套动态代理机制核心是java.lang.reflect.Proxy和InvocationHandler接口。你不再手动写代理类而是在运行时动态生成一个代理类这个代理类实现了目标类所实现的接口所有方法调用都会统一走到InvocationHandler.invoke方法里由你在invoke方法里决定加什么额外逻辑。public class LogHandler implements InvocationHandler { private final Object target; public LogHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(代理准备调用方法: method.getName()); long start System.currentTimeMillis(); Object result method.invoke(target, args); long cost System.currentTimeMillis() - start; System.out.println(方法执行耗时: cost ms); return result; } } public class ProxyFactory { public static Operation createProxy(Operation target) { return (Operation) Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new LogHandler(target) ); } }使用时Operation proxy ProxyFactory.createProxy(new Calculator()); int result proxy.divide(10, 5);这个过程中invoke方法里的method就是被调用方法的反射对象所以代理和反射还有一层隐形联动方法调用最终要通过method.invoke(target, args)来路由到真实对象。JDK动态代理有一个强制条件目标类必须实现接口。因为生成的代理类本身是继承java.lang.reflect.Proxy的Java是单继承体系所以它只能通过“实现接口”的方式在类型上伪装成目标接口的实例。如果一个类没有实现任何接口JDK动态代理就派不上用场了。5.4 CGLIB动态代理面向无接口类CGLIBCode Generation Library正是来解决“类没有接口”这个问题的。它的方案是通过生成目标类的一个子类来代理目标类子类会覆写父类也就是目标类的方法然后在覆写方法里调用父类的方法同时加上额外逻辑。public class MyInterceptor implements MethodInterceptor { Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { System.out.println(CGLIB 前置拦截: method.getName()); Object result proxy.invokeSuper(obj, args); System.out.println(CGLIB 后置拦截: method.getName()); return result; } } public class CglibProxyFactory { public static Object createProxy(Class? targetClass) { Enhancer enhancer new Enhancer(); enhancer.setSuperclass(targetClass); enhancer.setCallback(new MyInterceptor()); return enhancer.create(); } }使用Calculator calc (Calculator) CglibProxyFactory.createProxy(Calculator.class); calc.divide(10, 5);用CGLIB要特别注意两点。第一final类无法被CGLIB代理因为final类不能有子类final方法也无法被增强因为子类可以继承但不能覆写final方法。第二如果被代理的类里有方法A调用了同类中的方法B那么通过代理对象访问时方法A会走代理逻辑但方法A内部的方法B调用是this.methodB不会再次走代理。这个问题在Spring AOP里被称为“自调用失效”非常经典。重要代理对象的内部自调用不会再次进入代理逻辑。很多人排查“为什么切面没生效”时先怀疑配置问题最后发现是方法内部直接this调用绕过了代理。5.5 JDK动态代理与CGLIB的选型对比我把两种动态代理整理成一个表格方便复习对比维度JDK动态代理CGLIB动态代理实现原理基于接口生成目标接口的实现类基于继承生成目标类的子类目标类要求必须实现接口不能是final类方法要求接口方法均可代理final方法无法代理性能特点代理类生成快早期版本反射调用略慢代理类生成较慢但长期运行后方法调用性能更好异常类型代理类转换失败多与接口有关遇到final类会直接报错典型应用Spring默认对接口类使用Spring对无接口类使用Spring的AOP默认策略其实很简单目标对象实现了接口就用JDK动态代理没有实现接口就尝试用CGLIB。Spring Boot从2.x开始默认强制使用CGLIB原因是CGLIB生成的代理和JDK代理在类型转换、依赖注入等场景下更不容易出问题而且现代CGLIB性能已经足够好。到这里你回头看这章的四个知识点会发现它们已经串成了一条线代理在执行目标方法时要靠反射去调用真实方法框架识别哪些方法需要代理靠的是注解而这些机制是否正常工作最终要靠单元测试来验证。6. 综合实战把四个知识点串成一个极简IoC框架只看单个知识点永远容易飘我强烈建议你学完这一章后自己动手做一个整合练习。我自己做的练习是写一个极简版的“IoC容器”核心功能有两个一是通过注解标记需要注入的字段二是为被标记了MyTransactional的方法动态生成代理在方法执行前后打印事务开启和提交的日志最后用JUnit验证整个流程。项目结构很简单先定义两个注解Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface MyAutowired { } Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface MyTransactional { }然后是接口和实现类public interface UserService { void addUser(String name); } public class UserServiceImpl implements UserService { Override MyTransactional public void addUser(String name) { System.out.println(插入用户: name); } }MiniSpring容器类负责反射注入和代理创建public class MiniSpring { // 反射为目标对象的MyAutowired字段注入实例 public static void injectDependencies(Object target) throws Exception { Class? clazz target.getClass(); for (Field field : clazz.getDeclaredFields()) { if (field.isAnnotationPresent(MyAutowired.class)) { field.setAccessible(true); Object instance field.getType().getDeclaredConstructor().newInstance(); field.set(target, instance); } } } // 代理为MyTransactional方法生成JDK动态代理 public static Object createProxy(Object target) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), new TransactionHandler(target) ); } }事务处理器public class TransactionHandler implements InvocationHandler { private final Object target; public TransactionHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Method realMethod target.getClass().getMethod(method.getName(), method.getParameterTypes()); if (realMethod.isAnnotationPresent(MyTransactional.class)) { System.out.println(开启事务); try { Object result method.invoke(target, args); System.out.println(提交事务); return result; } catch (Exception e) { System.out.println(回滚事务); throw e; } } return method.invoke(target, args); } }最后用JUnit把这个链路完整测一遍public class MiniSpringTest { MyAutowired private UserService userService; BeforeEach void init() throws Exception { // 1. 反射注入依赖 userService new UserServiceImpl(); MiniSpring.injectDependencies(userService); // 2. 获取代理对象 userService (UserService) MiniSpring.createProxy(userService); } Test void testAddUser() { // 3. 通过代理访问方法走事务逻辑 userService.addUser(张三); } }运行测试后控制台会依次打印“开启事务”“插入用户张三”“提交事务”。整个过程其实就是Spring声明式事务的最简化版通过注解标记需要事务的方法容器在启动时创建代理对象代理对象在方法执行前后统一处理事务逻辑。这里有个特别想强调的坑在invoke方法里如果你想判断被调用的方法上有没有贴MyTransactional不能直接用method.isAnnotationPresent()。原因是这个method是代理接口上的方法对象而注解往往贴在实现类的方法上。我前面的代码里用target.getClass().getMethod(method.getName(), method.getParameterTypes())拿到实现类上真实的方法然后再判断注解这样才能生效。这个问题线上排查时很可能头大踩过一次就长记性了。7. 学习心法与避坑清单7.1 学习顺序和三种心态最后这部分不是总结更像是我自己复盘之后的建议。如果你想把这四块知识真正内化我推荐按照“注解 - 反射 - 代理 - 单元测试”这个顺序来学。为什么先学注解你就有了一个可以写在代码上的“标记”接下来学反射你才知道这些标记怎么被读取然后学代理你才知道读取到标记之后怎么在方法调用前后做文章最后用单元测试把所有机制验证一遍。如果倒过来先学单元测试你连断言的对象都还没有学起来会很空。调试反射相关代码的时候一定要学会打印Class对象的结构信息。我用得最多的手段是clazz.getDeclaredFields()、clazz.getDeclaredMethods()把类里的成员全部打出来看一遍很多时候比干瞪眼代码快得多。用System.out.println打出来虽然粗糙但绝对够用。等真到了需要排查框架问题的时候再配合IDE的Debugger看实际Class对象效率会高很多。这四个知识点在面试里出现频率很高但多数业务开发真正每天都会用的其实只是注解和单元测试反射和代理更多是框架内部在使用。所以你要有心理准备花大力气学的反射和代理可能在业务代码里一年都用不上一次但一旦遇到性能排查、动态增强、框架二次开发的情况它们的价值就彻底体现出来了。别因为短期用不上就不学这些恰恰是区分“会用框架”和“懂框架”的分水岭。7.2 常见问题速查表我把自己遇到过的、以及身边同事请教过的问题整理成了一张速查表平时遇到对应报错可以先来这里对号入座问题现象常见原因排查思路反射调用方法时报NoSuchMethodException基本类型和包装类型不匹配打印paramTypes和getMethod的参数类型对比注解反射读取出来为空Retention设置成了CLASS或SOURCE改成RUNTIMEJDK动态代理报ClassCastException目标类没有实现接口改用CGLIBCGLIB报错无法继承目标类目标类是final类去掉final或换其他方案MySQL驱动加载失败Class.forName的类名写错核对全限定类名和classpath代理对象内部方法不触发增强自调用问题内部this调用绕过代理把调用改成通过代理对象单元测试偶发失败换顺序就红测试共享状态未隔离检查静态变量和共享字段改用BeforeEach独立初始化这张表不算完整但覆盖了我学习过程中遇到的大部分“鬼打墙”场景。每次遇到诡异现象先对照表格排除常见原因再深入查代码效率高很多。我自己学完这一章最直接的感受是原来框架里那些看起来很高深的功能本质都是JavaSE基础能力的组合和放大。单元测试保证了正确性注解提供了声明反射提供了动态能力代理提供了扩展能力。四者凑齐任何“框架魔法”在你眼里都会变成看得懂的工程逻辑。如果你也正在为这一章头秃别急照着上面的例子一个个敲一遍敲完再对自己说一句Java从入门到入土路还长着呢。