
从 TS 切到 Java 的开发者多半会在第一次用 Spring 时产生一个类似的疑惑一个类上写个Service它为什么就莫名其妙变成了容器里的 Bean一个Transactional标上去方法就自动有了事务在 TypeScript 里装饰器也见过但好像远远没有这么“魔法”。这些问题的答案其实都指向 Java 世界里两个互相配合的底层机制反射和注解。它俩一个负责在运行时读取类的结构一个负责给类“贴上标签”组合起来就构成了大量框架的底层逻辑。这篇文章会从 TypeScript 开发者的视角出发把反射和注解掰开揉碎从 JVM 的运行时模型讲到手写一个迷你 IoC 容器把框架背后的魔法一点点拆开。如果你正在学 Java、准备 Java 面试或者只是好奇为什么 Java 能玩出这么多框架而 TypeScript 生态里的依赖注入总感觉差口气那这篇文章应该对你有用。我不会讲太多教科书理论尽量用工程里真正踩过的坑和写过的代码来说明问题。1. TS 开发者遇到的第一堵墙Java 的运行时世界1.1 TypeScript 的“类型只是幻觉”TypeScript 的类型系统是设计给开发者和编辑器看的而不是设计给运行时看的。拿一个很常见的例子来说interface User { name: string; age: number; } const user: User { name: Alice, age: 18 }; console.log(user.name);这段代码经过tsc编译之后interface User会被直接擦除生成的 JavaScript 里根本没有User这个类型信息。因为 JavaScript 引擎在运行时只认对象、函数、原型链这些东西它不关心你的对象是不是实现了某个接口。TypeScript 的类型检查本质上是在编译期间做的一次“静态约定”一旦编译完成类型就变成了纯粹的开发期记忆。这也是为什么很多 TS 开发者刚接触 Java 反射时会觉得别扭Java 里竟然可以拿到一个类在运行时的完整“身份信息”——类名、父类、接口、字段、方法、注解全部可查。而在 TypeScript / JavaScript 里你顶多能做typeof、Object.keys()、name in obj这种级别的检查无法枚举出一个类自己的完整结构。两者根本不是同一个维度的能力理解这一点是解开所有困惑的前提。1.2 Java 为什么保留了完整的“类型基因”Java 从设计之初就是面向 JVM 字节码的而 JVM 的类文件格式中天生包含丰富的元数据类修饰符、父类、实现接口、字段表、方法表、方法参数、异常表、注解表……这些信息并不会因为程序运行而消失而是被加载进 JVM 的方法区Java 8 之后是元空间 Metaspace并统一通过java.lang.Class对象暴露出来。也就是说Java 程序在运行时依然可以问“这个类是谁它有哪些方法这些方法上标了什么注解” 这种能力官方术语叫反射Reflection更严格点叫“自省”Introspection——程序在运行过程中观察和操作自身结构的能力。TypeScript 因为编译后类型被擦除加上 JavaScript 本身是动态语言这方面的能力天然就弱。这里可以画一张对比表方便直观理解对比维度TypeScript / JavaScriptJava类型擦除编译后接口/类型被完全擦除编译后类信息仍留在字节码中运行时可读运行时类型检查typeof/instanceof/ininstanceof/getClass()/反射API能否枚举字段方法不能枚举类的完整结构可以拿到Field[]/Method[]等装饰器/注解装饰器是函数编译时有点魔法注解是元数据运行时可反射读取动态代理Proxy 是运行时构造函数JDK 动态代理 / CGLIB与反射配合使用框架依赖原理多依赖编译期生成代码或 Proxy大量依赖运行时反射注解Java 保留“类型基因”的另一个重要副作用是框架可以在完全不修改业务代码的情况下读取注解、创建对象、注入依赖、拦截方法。这也就是为什么 Spring、MyBatis、JPA 等 Java 框架能形成一套如此庞大的生态而 TypeScript 生态中做依赖注入往往需要额外配reflect-metadata和emitDecoratorMetadata效果还达不到 Java 这种“原生气质”。你可以把 Java 的类信息想象成一本完整的说明书框架只需要读说明书就能知道该怎么装配你而 TypeScript 的类编译完像一张白纸想玩元编程就得靠编译期那点魔法额外修补。1.3 三个关键词提前打个底反射、注解、代理后面的正文会反复提到三样东西先在这里统一交代一下免得后面看代码时被术语绕晕。反射Reflection是 Java 提供的一套 API它可以让你在运行时查看并操控类结构例如构造对象、调用方法、读写字段。注解Annotation是代码里的元数据标签本身不产生逻辑必须有人读取它才有意义。代理Proxy是一种设计模式也可以理解成一个“拦截层”Spring 的事务、缓存等能力都是通过代理在目标方法执行前后插入逻辑实现的。这三者通常不是独立作战。注解负责“声明需求”反射负责“读取声明”代理负责“执行增强”。一个典型的组合方式框架扫描某个注解通过反射获取注解所在类的结构创建代理对象代理对象在方法调用前后插入事务或日志逻辑。本文的核心就是把这套组合逻辑讲清楚并且让你自己能写出来。2. 反射拿到类的“体检报告”2.1 Class 对象从哪来、能做什么Java 里每个类在被 JVM 加载时都会生成一个java.lang.Class实例这个实例就是这个类在运行时的“体检报告”。拿到Class对象有几种常见方式// 方式一通过类字面量 ClassUser clazz1 User.class; // 方式二通过实例 User user new User(); Class? clazz2 user.getClass(); // 方式三通过全限定类名字符串最常用于框架 Class? clazz3 Class.forName(com.example.User);第三种方式尤其重要因为框架在扫描包的时候往往拿到的是“包名类名”这样的字符串比如com.example.service.UserService。它不知道这个类的具体类型只能通过Class.forName()把字符串变成真正的类对象。有了Class对象之后你可以做很多事情Class? clazz Class.forName(com.example.User); // 获取所有声明字段不含继承的 Field[] fields clazz.getDeclaredFields(); // 获取所有声明方法 Method[] methods clazz.getDeclaredMethods(); // 获取所有注解 Annotation[] annotations clazz.getAnnotations(); // 获取构造方法并创建实例默认无参构造 Object obj clazz.getDeclaredConstructor().newInstance();这里最容易被 TS 开发者忽略的细节是getFields()和getDeclaredFields()不一样。前者只返回 public 字段包括继承来的后者返回当前类里声明的所有字段包括 private但不管继承。Spring 注入私有字段时用的就是getDeclaredFields()setAccessible(true)。为什么要这么设计因为 Java 封装的设计理念希望类的内部状态不被外部随意访问但框架需要在不改变业务代码的前提下完成注入所以反射提供了一条“受控的后门”。2.2 setAccessible 与大括号背后的规则在 Java 8 之前setAccessible(true)几乎是万能钥匙私有字段、私有方法想碰就碰。但在 Java 9 引入模块系统JPMS之后规则变了如果你的代码运行在模块化应用中而且目标包没有对当前模块开放opens那么setAccessible(true)会抛InaccessibleObjectException。普通 Spring Boot 应用依然运行在 classpath 模式下不受此限制但一旦你要把应用转成 jlink 精简运行时或者打入 GraalVM native-image反射就变成了一等公民里的大麻烦。一个小经验平时写代码不要为了炫技到处setAccessible(true)能用构造器、public 方法解决的问题就老老实实用。框架里这么干是不得已而为之为了通用性只能“暴力开门”。但在原生应用优化场景下每次反射调用都像在高速路上突然刹车掉头性能和可维护性都很差GraalVM 要求你用配置或者RegisterReflectionForBinding显式声明反射目标。对于做普通 Java Web 开发的人来说知道这个限制就够了真踩到的时候再深入。另外有两个容易踩坑的细节。第一个是final字段反射修改private final字段在 JDK 12 里行为有变化某些情况下即使setAccessible(true)也改不掉或者改了但读取值依然是旧值比如String常量在编译期被内联。第二个是 Java 16 之后正式落地的recordrecord 的字段是private final的整体设计上不可变反射构造时推荐走 canonical constructor而不是绕过字段初始化去setAccessible改值。简单来说反射能打开门但不是每扇门都对暴力欢迎。2.3 反射的性能和管理反射慢这是老话题。慢在哪里主要有三块。第一方法查找通过字符串方法名getMethod(sayHello, String.class)需要在类的方法表里做匹配。第二访问检查每次调用前 JVM 都要检查调用者有没有权限这种安全检查在大型类上是有开销的JDK 靠字节码层面生成访问者类来降低开销但并不是每次都能成功。第三参数装箱反射方法调用统一走Object参数基本类型会装箱且返回值类型都当Object这对热点代码极其不友好。架构上有一个通用解法不要在循环内反复getMethod()、invoke()。你自己封装一个框架时至少要做两层优化。第一层缓存Method/Field/Constructor对象因为getMethod按字符串查找很贵而拿到对象后的invoke相对便宜一些。第二层如果追求极致用java.lang.invoke.MethodHandle或者动态生成字节码如 CGLIB、ByteBuddy来替代纯反射调用。Spring 本身也这么做ReflectiveMethodInvocation之外还有 CGLIB 生成的代理子类。我第一次接触到这个优化点时也觉得很吃惊框架为了速度根本不屑于只靠 JDK 反射而是直接“写字节码”来绕开反射调用。3. 注解编译期写下的“便签”运行期读取的“指令”3.1 定义注解与元注解Java 注解用interface来定义它不算接口也不算类而是独立的一种类型。定义一个简单注解很简单Retention(RetentionPolicy.RUNTIME) Target({ElementType.TYPE, ElementType.METHOD}) public interface LogAnno { String value() default ; boolean printArgs() default false; }这里面两个元注解太关键了必须掰开说。Retention决定注解保留到哪个阶段Java 提供了三档策略RetentionPolicy作用阶段反射能读到吗实际使用场景SOURCE源码阶段编译时丢弃不能SuppressWarnings这类给编译器看的CLASS编译进字节码但运行时丢弃不能一般场景下部分字节码工具会读但框架运行时读不到RUNTIME保留到运行时能Spring 注解、自定义业务注解很多新手写的自定义注解不生效九成原因就是Retention写成了SOURCE或者没写。注解默认的 Retention 是 CLASS框架运行时反着反射读注解根本读不到。所以只要你的注解是为了让框架在运行时处理的老老实实写Retention(RetentionPolicy.RUNTIME)。Target则是声明这个注解能贴在什么位置。可以是类上、方法上、字段上、参数上、构造器上甚至注解类型本身上。设置了Target之后编译器会帮你约束注解的使用位置避免有人把本该贴在方法上的注解贴到类上去这种编译期的兜底检查对工程化非常有用。除此之外还有Documented和Inherited。Inherited值得单独说它只对类上的注解有效表示子类可以继承父类类注解但方法上和字段上的注解即使加了Inherited也不会继承。Spring 的注解大都靠组合和元注解来实现类似继承的效果而不是依赖 Java 层面的Inherited这里先不展开后面讲常见问题时会具体说。3.2 运行时注解解析的经典姿势有了RUNTIME保留的注解之后怎么读它核心 API 就在java.lang.reflect.AnnotatedElement接口里而Class、Method、Field、Constructor、Parameter都实现了这个接口。常见解析场景是扫描一个类的方法找出带指定注解的方法并处理Method[] methods clazz.getDeclaredMethods(); for (Method method : methods) { LogAnno logAnno method.getAnnotation(LogAnno.class); if (logAnno ! null) { String value logAnno.value(); boolean printArgs logAnno.printArgs(); // 这里就拿到了注解上写的值可以做日志输出等业务逻辑 } }注意这里需要区分两个 APIgetAnnotation(Class)适合精确查找单个注解getAnnotations()返回类或方法上的所有注解还有一个isAnnotationPresent(Class)用来做快速判断。如果你继承或者组合了注解可能需要用到getAnnotationsByType()配合可重复注解Repeatable但这是进阶用法本文先不铺开。用 TS 视角来看TS 装饰器本质上是一个函数它在类定义时被调用典型写法是Component()背后有一个Component函数接收类对象并做处理。所以 TS 装饰器是“代码在装饰时执行”而 Java 注解是“数据在运行时被第三方读取”。这个差异极其重要。TS 的Component()在运行时其实是一个被调用的函数它可以直接产生副作用Java 的Component只是一个标记它自己什么也不做必须有人用反射去读取它。也就是说TypeScript 生态里的注入框架需要执行装饰器函数才能“构建元数据”Java 生态里的框架则是在启动阶段扫描类路径通过反射读取注解并构建容器。这也从设计哲学上解释了为什么 Java 框架能保持解耦业务代码里只写注解不写实现逻辑框架启动时统一解析按“约定声明”完成装配。这种方法的好处是任何类都可以被任何框架复用只要它贴了正确的注解不必显式继承某个基类或者被某个函数包装。4. 框架魔法当反射遇上注解4.1 Spring IoC 的最简自研实现既然已经理解了反射和注解的配合方式我们就能反过来想如果现在让我自己写一个简化版 Spring该怎么设计最核心的逻辑可以分成四步第一步扫描指定包路径下所有.class文件找出标了Component/Service/Repository注解的类。第二步通过Class.forName()把类名变成Class?对象。第三步使用clazz.getDeclaredConstructor().newInstance()创建实例。第四步遍历实例字段把标了Autowired的字段从容器缓存里取出来再反射塞进去。这就是依赖注入最灵魂的部分。简化版核心代码大致长这样public class MiniApplicationContext { private final MapString, Object beanMap new ConcurrentHashMap(); public void scan(String basePackage) throws Exception { // 第一步扫描包路径得到所有类的全限定名 SetString classNames PackageScanner.scan(basePackage); // 第二步找出所有标记了 Service/Component 的类 for (String className : classNames) { Class? clazz Class.forName(className); if (clazz.isAnnotationPresent(Service.class) || clazz.isAnnotationPresent(Component.class)) { String beanName getBeanName(clazz); Object instance clazz.getDeclaredConstructor().newInstance(); beanMap.put(beanName, instance); } } // 第三步遍历所有 bean 的字段完成依赖注入 for (Object bean : beanMap.values()) { Class? clazz bean.getClass(); for (Field field : clazz.getDeclaredFields()) { if (field.isAnnotationPresent(Autowired.class)) { field.setAccessible(true); Object dependency beanMap.get(camelCase(field.getName())); field.set(bean, dependency); } } } } public T T getBean(ClassT clazz) { return (T) beanMap.get(camelCase(clazz.getSimpleName())); } }真实 Spring 肯定比这复杂得多比如需要处理接口类型、多实现选择、循环依赖、作用域、AOP 代理等。但核心思想就是上面这段代码通过注解标记目标通过反射创建对象和注入依赖。如果你想动手验证自己对反射注解的理解完全可以照着这段思路写一个小框架把Service、Autowired跑通。做这件事比背 Spring 源码有价值得多。理解了 IoC 之后很多之前觉得玄乎的东西就慢慢能推理了。例如为什么要默认使用无参构造器因为框架不知道要用什么参数创建对象除非你明确告诉它配置信息或依赖。为什么字段注入在工程里经常被建议少用而推荐构造器注入因为反射直接改私有字段会绕过编译期不可变性和测试时的可预测性。这些工程教训跟不上不当用反射的诉求有关。4.2 Transactional 的事务魔法真相IoC 只是反射注解的组合之一事务注解则是另一个层面的故事代理。Spring 的Transactional本身没有任何事务逻辑。它只是一张贴在方法上的标签Spring 启动时发现某个 Bean 里有方法标了Transactional就会为这个 Bean 生成一个代理对象。调用方表面上拿到的是这个 Bean 的代理实际执行时代理会先开启事务然后调用真实目标方法最后根据方法是否抛出异常决定 commit 还是 rollback。这整套拦截逻辑并不在注解里而是在代理拦截器里。这里有一个 TS 和 Java 在理解上的共通点JS 也有Proxy可以对对象的属性访问和方法调用做拦截Spring 的代理本质上和这个类似。但 JDK 动态代理有个局限它只能基于接口来生成代理如果一个类没有实现接口JDK 动态代理就无能为力Spring 会退而使用 CGLIB 生成该类的子类通过继承实现代理。Spring Boot 2.x 默认已经走了 CGLIB 代理这也是为什么很多老项目里代理相关诡异的报错发生的行为变化。理解这一点对日常开发非常有用。比如你写了一个类内部方法a()调用了同类方法b()而b()标了Transactional那么事务会失效。原因很简单Spring 事务是通过代理生效的外部调用进入的是代理走拦截器但a()内部直接调this.b()走的是原生对象代理拦截器根本没机会介入。这就是网上常说的“自调用事务失效”。解决方式有几种通过ApplicationContext.getBean()获取代理对象再调用或者拆分到不同的 Bean或者使用AopContext.currentProxy()需要开启exposeProxy更现代一点可以自注入。但理解底层原因比背解决方案重要得多。4.3 Constraint / 参数校验背后的逻辑用过 Spring Boot 参数校验的人应该知道NotNull、Size、Pattern这一套。它们来自 Jakarta ValidationHibernate Validator 是其实现。如果你自定义过校验注解就会发现它需要配合Constraint(validatedBy XxxValidator.class)使用。这个机制同样采用了“反射 注解 工具类”的组合。自定义一个校验注解的关键步骤是这样Target({ElementType.FIELD, ElementType.PARAMETER}) Retention(RetentionPolicy.RUNTIME) Constraint(validatedBy NotEmptyValidator.class) public interface NotEmpty { String message() default 不能为空; Class?[] groups() default {}; Class? extends Payload[] payload() default {}; }而校验器实现ConstraintValidatorNotEmpty, String接口在isValid()方法里写实际逻辑。Hibernate Validator 通过反射扫描对象字段发现字段上有NotEmpty就读取该注解对应的ConstraintValidator实现类创建校验器并调用校验逻辑。如果你平时不习惯写自定义校验注解总觉得Constraint很神秘其实底层就三步找注解、找实现类、反射调用。从框架视角看这套机制还解决了“旁路逻辑”的问题校验逻辑与业务方法分离业务方法只管核心业务校验交给统一拦截器和注解声明。而且因为你可以在字段或参数上声明多条校验注解框架遍历执行时自然就完成了“注解驱动校验”的设计。这对工程整洁度帮助极大。4.4 其他框架角落Mapper 扫描与配置绑定类似的组合拳在 Java 生态里到处都有。MyBatis 的 Mapper 接口为什么不需要写实现类因为它扫描接口上的方法读Select、Insert或者 XML 里的 SQL 映射然后通过 JDK 动态代理生成一个接口实现类的代理实例方法调用时解析注解/XML 并执行 SQL。你写一个 Mapper 接口本质上是在描述要执行的 SQL 和参数绑定关系框架把这些描述解释成数据库操作。没有反射这个过程无法想象。再比如ConfigurationProperties(prefix app)Spring Boot 启动时找到这个类把配置文件中app.*前缀的属性值按字段名做映射然后反射塞进对象的字段里。注意这甚至不需要字段有 setterSpring Boot 会直接反射私有字段赋值。所以你会发现如果把配置类设计成纯字段代码反而更精简。这就是反射带来的便利也让某些“不应该被外部修改”的封装被框架打破了工程上需要辩证地看。5. 实操手写一个“小 Spring”——从扫描到装配5.1 项目结构与目标这一节我们把上面那些概念落到代码里。我会基于纯 JDK 实现一个极简的 IoC 容器不依赖 Spring。项目结构如下mini-framework/ ├── src/main/java/com/example/mini/ │ ├── annotation/ │ │ ├── Component.java │ │ ├── Autowired.java │ │ └── Transactional.java │ ├── context/ │ │ ├── MiniApplicationContext.java │ │ └── PackageScanner.java │ ├── processor/ │ │ └── TransactionProxy.java │ └── demo/ │ ├── UserService.java │ ├── UserDao.java │ └── Main.java └── src/main/java/module-info.java可选普通 classpath 不用目标很朴素启动时扫描com.example.mini.demo包把所有标了Component的类创建成单例 Bean把标了Autowired的字段注入依赖然后给标了Transactional的方法做一层代理拦截。为了控制篇幅我会把核心代码片段放出来其余部分给出设计说明。5.2 核心代码实现了什么先看注解定义。这里有个很容易忽略的细节注解的Retention必须写RUNTIME否则后面反射读取时什么都拿不到Target写清楚是类还是方法有利于编译期纠错。Retention(RetentionPolicy.RUNTIME) Target(ElementType.TYPE) public interface Component { String value() default ; } Retention(RetentionPolicy.RUNTIME) Target({ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.METHOD}) public interface Autowired { } Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface Transactional { }然后是扫描器。简单实现里可以通过ClassLoader的getResources()读取类路径下的目录再递归找.class文件把.class后缀和路径里的/转成包名。这段代码网上有很多实现就不整段贴了。核心就一个作用把指定包下面的所有类的全限定名找出来供Class.forName()使用。接下来是容器主体。创建 Bean 时我强制要求有无参构造器这既是简化也符合 Spring 大多数场景的默认行为。创建完之后遍历字段找Autowired并完成注入。public class MiniApplicationContext { private final MapString, Object beanMap new ConcurrentHashMap(); public void scan(String basePackage) throws Exception { SetString classNames PackageScanner.scan(basePackage); for (String className : classNames) { Class? clazz Class.forName(className); if (clazz.isAnnotationPresent(Component.class)) { String beanName beanName(clazz); // 默认使用无参构造器创建实例 Object instance clazz.getDeclaredConstructor().newInstance(); beanMap.put(beanName, instance); } } // 依赖注入 for (Object bean : beanMap.values()) { injectDependencies(bean); } // 代理包装结合事务注解 for (String beanName : new ArrayList(beanMap.keySet())) { Object bean beanMap.get(beanName); Object proxy TransactionProxy.wrapIfNecessary(bean); if (proxy ! bean) { beanMap.put(beanName, proxy); } } } private void injectDependencies(Object bean) throws IllegalAccessException { Class? clazz bean.getClass(); for (Field field : clazz.getDeclaredFields()) { if (field.isAnnotationPresent(Autowired.class)) { field.setAccessible(true); Object dependency beanMap.get(camelCase(field.getName())); if (dependency null) { throw new IllegalStateException(无法找到依赖: field.getName()); } field.set(bean, dependency); } } } public T T getBean(ClassT clazz) { return (T) beanMap.get(camelCase(clazz.getSimpleName())); } private String beanName(Class? clazz) { Component component clazz.getAnnotation(Component.class); return component.value().isEmpty() ? camelCase(clazz.getSimpleName()) : component.value(); } private String camelCase(String name) { return name.substring(0, 1).toLowerCase() name.substring(1); } }这段代码里最值得品的是TransactionProxy.wrapIfNecessary(bean)这一步它决定了Transactional到底是怎么生效的。如果某个 Bean 的方法上有Transactional注解我们就要创建一个 JDK 动态代理对象放在容器里外部拿到的 Bean 其实是代理而不是原始对象。简化版代理实现如下public class TransactionProxy { public static Object wrapIfNecessary(Object target) { Class? clazz target.getClass(); boolean hasTransactional Arrays.stream(clazz.getMethods()) .anyMatch(m - m.isAnnotationPresent(Transactional.class)); if (!hasTransactional) { return target; } // JDK动态代理要求目标类实现接口这里假设 UserService 实现了接口 return Proxy.newProxyInstance( clazz.getClassLoader(), clazz.getInterfaces(), (proxy, method, args) - { Method realMethod clazz.getMethod(method.getName(), method.getParameterTypes()); if (realMethod.isAnnotationPresent(Transactional.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); }); } }真实的Transactional远比这个复杂涉及事务传播、隔离级别、回滚条件等。但你能从这个简化代码里看到最核心的关系注解只是标记代理介入才是真正去做“事务增强”的执行者。换句话讲如果方法上没有注解代理方法就会原样转发有了注解代理就会先开事务再调真实方法。所谓“框架魔法”就是在这个流程里发生的。这里有一个非常现实的坑JDK 动态代理需要目标类实现接口。我在代码里假设UserService实现了自己的接口但如果你拿一个没有接口的类来跑Proxy.newProxyInstance会直接报错。Spring Boot 2.x 默认用 CGLIB 解决这个问题而我们这个 demo 里要么给服务类加接口要么用 CGLIB。这也是为什么 Spring Boot 从 1.x 切到 2.x 时很多人遇到莫名其妙的代理相关问题——本质上是在接口代理和类继承代理之间切换。5.3 亲手跑通全流程的感受把这段代码在本地跑起来你会看到启动日志大概是这样的扫描到 Bean: userDao 扫描到 Bean: userService 注入字段: userService.userDao [事务] 开启事务 调用 UserDao::findById [事务] 提交事务那一刻你会明白一个最简单的 Spring 雏形已经成型了。注解完成了“声明”反射完成了“装配”代理完成了“增强”。整套流程其实没有一滴魔法全是机制。看到日志时我对“框架是魔法”这句话的执念就彻底消失了。这也是我特别推荐每一位 TS 转 Java 的开发者自己动手写一遍的原因——你不需要读懂 Spring 全部源码只要亲手搭过这个最小闭环再去看 Spring 的源码、看 AOP 的扩展点感觉会完全不同。6. 常见问题与排查技巧实录6.1 运行时注解不生效先看 Retention如果自定义注解在反射读取时总是返回 null第一步要检查的一定是Retention(RetentionPolicy.RUNTIME)。这几乎是我见过最多新手踩的坑。很多博客里写注解定义时会省略Retention导致你照抄之后怎么都读不到。正确顺序是先确认注解上有没有Retention(RUNTIME)再确认反射代码是不是读错了注解类型。Spring 的注解为什么通常不会出现这种问题因为 Spring 已经帮你把大多数注解的 Retention 设置好了而且它还有一套AnnotatedElementUtils工具类能够处理组合注解、别名注解等复杂情况。比如GetMapping就是一个被RequestMapping(method RequestMethod.GET)元注解标注的组合注解。Spring 之所以能识别它并不是因为 Java 的注解本身支持继承而是 Spring 用了AliasFor和元注解查找机制。理解这点之后你在写自己的 Spring 场景注解时也能更清楚该如何组合。6.2 反射调用方法的 InvocationTargetException 陷阱反射调用方法时一个非常经典的坑是目标方法抛出的异常会被包装成InvocationTargetException而不是直接原样抛出。代码里如果只catch (Exception e)拿到的异常信息完全看不清经常出现“我明明抛了业务异常怎么日志打印的是 InvocationTargetException”。正确做法是捕获之后立刻检查e.getCause()try { method.invoke(obj, args); } catch (InvocationTargetException e) { Throwable cause e.getCause(); // 这里 cause 才是业务方法真正抛出的异常 log.error(调用失败, cause); }这一点跟 TypeScript 的异常模型差异很大。TS 里你直接throw new Error(业务错误)调用链上就是同一个错误对象。Java 的反射代理层等于把原始异常多包了一层皮许多新人不习惯。排查技巧其实一句话看到InvocationTargetException永远先往getCause()里看。6.3 接口代理与类代理的选择Spring AOP 的代理方式在 Spring Boot 2.x 之后默认使用 CGLIB也就是通过生成目标类的子类来拦截方法。这会带来两个潜在影响一是目标类不能是final的因为 CGLIB 子类化需要可继承二是被代理的方法不能是private、final、static因为子类无法覆盖这些方法。如果你在类上写了Transactional但方法没被增强很可能就是方法级别的问题。在 Spring Boot 1.x 时代默认走 JDK 动态代理只认接口所以很多老项目里 Service 都有接口类。到 2.x 之后接口不再是必要条件适配更简单但自调用和 final 方法的坑仍然存在。我的排查建议很简单Transactional不生效时第一看方法是不是public第二看是不是同类内自调用第三看代理工厂有没有把该类纳入代理范围。按这个顺序排查绝大多数事务失效问题都能定位。反射和代理里没有“银弹”任何一层包装都会带来新的约束。6.4 注解为什么不继承、组合注解与 AliasForJava 原生的注解继承非常弱。Inherited只对类上生效子类可以通过继承获取父类类上的注解而在方法上、字段上它几乎不起作用。Spring 为什么能支持GetMapping作为RequestMapping(method RequestMethod.GET)的组合注解本质是 Spring 自己做了“元注解递归查找”再配合AliasFor来合并注解属性。也就是说当你用GetMapping(/hello)时Spring 内部会把它解释为RequestMapping(value /hello, method RequestMethod.GET)但你用 Java 原生反射是读不到这层合并语义的必须通过 Spring 的MergedAnnotations工具。如果你正在写自己的框架建议先不要碰这些高阶语义老老实实用getAnnotation()获取单个注解处理简单场景。等到确实需要组合注解时再去读 Spring 的AliasFor设计。工程世界里最常见的问题是你在没理解基础反射的情况下就追求高阶框架特性然后被各种边缘情况折磨到怀疑人生。先把Retention、Target、isAnnotationPresent这些基础姿势练熟后面读 Spring 源码会轻松一半。6.5 关于性能反射不是洪水猛兽但必须可控不少开发者对反射有偏见觉得反射一定很慢于是对 Spring 的依赖注入也心生疑虑。事实是相对直接调用方法反射调用确实慢但这个损耗通常只在极高频的热点路径上才值得优化。Spring 启动时的类扫描和 Bean 创建花费的主要时间其实是 I/O、类加载和初始化逻辑而不是反射本身。而在运行时通过Transactional拦截方法Spring 内部也没有频繁做getMethod查找它会在创建代理时缓存方法签名调用层面已经接近直接方法调用。如果你自己写类似框架要注意的只有一点把 Method、Field、Constructor 缓存成 Map不要每次请求都走一遍字符串查找。能做到这一步性能已经足够应付绝大多数业务场景。真要追求极致再考虑 MethodHandle 和字节码增强。这一节的核心逻辑就是“把昂贵操作在初始化阶段完成运行阶段只剩轻量调用”。这个思路与所有高性能框架的设计是一致的。最后分享一下个人体会从 TypeScript 切到 Java如果你能跨过反射和注解这道坎那么读 Spring 源码、理解各种“框架魔法”就不再困难。我个人的体会是与其抱着源码从ClassPathBeanDefinitionScanner开始硬啃不如先去写一个能用MyComponent和MyAutowired完成注入的小框架。这跟学 TypeScript 时先手写一个简易EventEmitter是一个道理——动手写一遍再回头读源码你会知道源码里每一步到底在解决什么问题。写的过程中还有一个意外收获你会对 Java 的设计哲学有更深的感触。TypeScript 强调“结构类型”和“类型即约定”代码编译完就干干净净Java 则把元信息特地保留下来给运行时自省留下了空间。二者没有优劣但理解这种差异能帮你更敏锐地分辨什么时候该用装饰器什么时候该用注解什么时候反射不值得用。愿你在从 TS 到 Java 的路上少踩坑多写出自己也能玩转的“小魔法”。