第 14 章 Spring 核心:IoC、DI 与 AOP(把魔法翻译成 Map<String, Object>)
你第 N 次看到这段代码:
@RestControllerpublic class UserController { @Autowired private UserService userService; @GetMapping("/user/{id}") public User get(@PathVariable Long id) { return userService.findById(id); // userService 是谁 new 的?userService 里的 repository 谁塞的? }}
你找不到 new UserService(),找不到 userService.setRepository(...),但代码能跑。在 Go 里这不可能——你一定在某个 main() 里 new 了它、注入了它。Spring 把这件事吞了。
这一章把”吞掉的东西”全部吐出来:谁创建了 Bean、谁注入了依赖、注解怎么变成动作、以及为什么你的 @Transactional 有时候不生效。
14.1 IoC:控制反转到底反转了什么
先别急着背定义。我们看两段能跑的代码,然后你告诉我它们的区别到底在哪。
第一段,Go 味儿的”正转”写法(虽然这是 Java,但逻辑是 Go 的):
// 控制正转:你自己控制对象的创建和装配public class OrderApp { public static void main(String[] args) { OrderRepository repo = new OrderRepositoryImpl(new DataSource()); OrderService service = new OrderServiceImpl(repo); // 你 new 的 service.createOrder(...); // 你调用的 }}
第二段,Spring 味儿的”反转”写法:
@RestControllerpublic class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { // 你只"要"一个 OrderService this.orderService = orderService; // 谁传进来的?你不知道,也不该关心 }}
区别不在语法,在控制权在谁手里。第一段里,OrderServiceImpl 的创建、repo 的装配,全在你 main() 的掌控下——你控制对象的生死和关系。第二段里,你只声明”我需要一个 OrderService”,至于它怎么来、它的 repo 是谁、是 MySQL 实现还是内存实现,控制权反转给了容器。
这就引出本章最核心的一句定性:
IoC(Inversion of Control,控制反转)是一种设计原则——好莱坞原则:“don’t call us, we’ll call you”(别打电话给我们,我们会打给你)。DI(Dependency Injection,依赖注入)是实现 IoC 的一种手段:容器把依赖”注入”给你的对象,而不是你自己去拿。
注意措辞:IoC 是”原则”,DI 是”手段”。这俩不是同义词。
问题 1: 既然 IoC 是更宽泛的概念,那还有别的手段吗?
有。最典型的另一个实现叫 Service Locator(服务定位器)。它长这样:
// Service Locator:主动去容器里"取"OrderService service = ServiceLocator.getBean(OrderService.class);service.createOrder(...);
你看,Service Locator 也是 IoC——框架(容器)控制着对象的创建,你只是去取。但它和 DI 有个致命区别:Service Locator 要求你的业务代码里到处带着容器引用(ctx.getBean(...)),业务类被容器侵入了。DI 不侵入:你只写构造器参数,容器在背后帮你填,业务代码根本不知道容器的存在。
这就是为什么 Spring 选了 DI 而非 Service Locator。票根上写的是:DI 让业务代码保持纯净,可测试性更好(你可以直接 new OrderService(mockRepo))。
【思考】为什么叫”控制反转”而不是”依赖注入”?IoC 和 DI 到底什么关系?
参考答案
直接答案:IoC 是”框架调用你的代码”这个更宽泛的设计思想(最早泛指”控制权从应用代码转移到框架”),DI 只是 IoC 的一种具体实现方式。两者是”父类-子类”关系:IoC 是抽象,DI 是 Concrete 之一。
为什么不直接叫 DI?
“控制反转”这个词是 1988 年 Johnson 和 Foote 提出的,当时描述的是一种更普遍的现象:传统程序里你主动调用库,反转之后变成框架主动调用你的代码(模板方法、回调、事件监听都是 IoC)。依赖注入是 2004 年 Martin Fowler 才定名的,它是 IoC 在”对象装配”这个子问题上的具体落地。
除 DI 外,IoC 还有别的实现:
Service Locator:主动 ctx.getBean(Xxx.class) 去取。它也把”创建对象”的控制权交给了框架,所以也是 IoC。但如正文所说,它侵入业务代码(到处传 container)。
// DI 风格:容器帮你注入,业务代码无感知@Servicepublic class OrderService { private final OrderRepository repo; public OrderService(OrderRepository repo) { this.repo = repo; } // 干净}// Service Locator 风格:业务代码依赖容器public class OrderService { private final OrderRepository repo = (OrderRepository) Container.current().getBean("orderRepo"); // 侵入}
DI 风格下 OrderService 是个普通类,任何环境都能 new(测试时传 mock);Service Locator 风格下它绑定了某个全局容器,脱离了容器就废了。
Go 对照:你用的 wire 也是 DI——但它是编译期 DI。wire 在编译时生成 wire_gen.go,里面是显式的 new OrderServiceImpl(new OrderRepositoryImpl(...)),等价于把 DI 的结果”固化”成代码。Spring 是运行时 DI,靠反射 + 注解在启动时装配。这是 00 章卡点五那张对照表的本质:
维度
Go (wire)
Java (Spring)
依赖图解析时机
编译期,生成代码
运行时,反射扫描
看依赖图
打开 wire_gen.go
看 /actuator/beans 或启动日志
出错时机
编译失败
启动失败,或更糟:注入错实现
更深一层:IoC 这个名字起得其实有点抽象,导致初学者以为它是某种”高级技术”。它本质是一种责任转移——把”对象怎么来、依赖怎么组装”这件事,从手写代码转移给一个统一的机制。你付出的代价是:代码里再也看不到 new 和装配,可追踪性下降(所以才需要 00 章讲的 Actuator 工具恢复可观测性)。你得到的收益是:实现可替换、可插拔、无需重编译。这笔交易划不划算,取决于你的项目规模——小项目亏,大项目赚。
Go 对照:你在 Go 里手动 wired(在 main() 里 new + 注入,或 wire 生成)。Spring 把这件事推迟到运行时 + 用注解声明。代价与收益在 00 章讲过,这里给一个”手写 DI → Spring 注解 DI”的并排演进:
直接答案:构造器注入要求”先有依赖才能构造出本对象”,而循环依赖里 A 的构造要 B、B 的构造要 A,死锁在构造阶段就暴露;字段注入允许”先 new 出空壳对象、再回头填字段”,把循环依赖的环悄悄解开了,于是坏设计不报错。
展开看机制:
构造器注入下,Spring 想创建 A,必须先拿到 A 的构造参数 B;去创建 B,又要 B 的构造参数 A。此时 A 还没被构造出来(构造和实例化是一步,没有”半成品”空隙),Spring 直接抛 BeanCurrentlyInCreationException。循环在启动期就报错,你立刻知道设计有问题。
字段注入下,Spring 的执行顺序是:① 反射 new A() 拿到一个空壳(字段还是 null)→ ② 把 A 放进”正在创建”集合 → ③ 反射填 A 的字段,发现要 B → ④ new B() 空壳 → ⑤ 填 B 的字段要 A → ⑥ 从”正在创建”集合里拿到 A 的早期引用(半成品)填进去 → ⑦ B 完成 → ⑧ 回到 A 把 B 填进去。环被”先有空壳再填字段”的机制化解了,启动不报错,但 A 和 B 各自持有一个字段可能未完全初始化的对象,直到运行时某次调用踩到 null 才炸。
代码锚点——构造器循环依赖的报错:
@Servicepublic class A { public A(B b) {} } // 构造要 B@Servicepublic class B { public B(A a) {} } // 构造要 A// 启动直接:// BeanCurrentlyInCreationException:// Requested bean is currently in creation: Is there an unresolvable circular reference?
更深一层:这是编译期 DI 的结构性优势。wire 生成的代码就是一串 NewXxx(...),每个构造函数参数都被类型系统强制,漏传一个就编译失败——错误被推到编译期。Spring 的运行时 DI 把错误推到启动期甚至运行期,换来了”换实现不用改代码”的灵活。两种选择,两种代价,没有谁绝对好。你作为 Go 老哥,初学 Spring 会觉得”这也能炸?“,其实是范式差异:你习惯了”编译期兜底”,Spring 习惯了”启动期兜底”,而启动期兜底的前提是你真的会去看启动日志。
问题 3: Lombok 的 @RequiredArgsConstructor 标在类上,它生成的构造器只含 final 字段。那非 final 的必填依赖它能处理吗?
不能。@RequiredArgsConstructor 只收集 final 和 @NonNull 字段。所以想用 Lombok 省构造器,就把所有必填依赖声明成 final——这反而逼你写出不可变的 Bean,正合 Spring 的意。
14.3 Bean:容器里的对象长什么样
“Bean” 这个词你到处都见,但它的精确定义值得花三十秒:
Bean 就是被 Spring 容器管理的对象。你自己 new 出来的,不是 Bean——它没有生命周期、没被代理、没被纳入容器管理。
这是 Go 老哥最容易混淆的点:在 Go 里你 new 出来的对象就是对象,无所谓”是否被框架管理”。Spring 里这道界线是硬的——只有进了容器(Map<String, Object>)的那个实例,才享有代理、作用域、生命周期回调等一切能力。你 new UserService() 手动造的,@Transactional 在它身上绝不生效,因为它绕过了容器。
问题 4: 那容器怎么知道要把哪些类变成 Bean?靠 BeanDefinition。
BeanDefinition 是容器里描述”一个 Bean 该怎么造”的元数据:类名、scope、构造器参数、要注入的属性、init/destroy 方法、是否懒加载……它是 Spring 把”类”翻译成”可管理对象”的中间表示。你可以把它理解为:容器先扫一遍 classpath,给每个候选类生成一份”建造说明书”(BeanDefinition),之后照着说明书实例化。
Go 对照:你在 Go 里 r.GET("/user/:id", func(c *gin.Context){...}),handler 通常是每次请求 new 的(或 handler 结构体里只放无状态的 *Repo,请求数据走 c 传)。Go 习惯”把状态放结构体字段”,所以 new 一个 handler 是自然的。Spring 反过来:状态要么放方法局部变量,要么放专门的缓存组件(Redis/Caffeine/@Cacheable),Bean 本身保持无状态才能安全地单例。
更深一层:这解释了为什么 00 章说”Spring 的魔法本质是拿编译期确定性换运行时灵活性”。单例 + 无状态是 Spring 性能好的根因(省内存、省 GC),但它对你的编码纪律提出了要求:单例 Bean 必须是无状态的。Go 没有这个约束(因为每个 handler 是新对象),所以 Go 老哥转到 Spring 第一坑就是”在单例里存了请求状态”。记住这条:
singleton 解决循环依赖靠的是 14.6 要讲的”三级缓存”:A 实例化出空壳后,把一个能返回 A 早期引用的工厂放进三级缓存;等 B 需要 A 时,从工厂拿到 A 的早期引用填进去,环就解了。这套机制成立的前提是:A 的实例最终会进一级缓存被所有人共享,所以”B 手里那份早期引用”和”容器手里那份成品”最终是同一个对象(或同一个代理),一致性有保证。
prototype 语义是”每次取都 new 一个”,压根没有”共享的那个 A”。如果 A 要 B、B 要 A,且都是 prototype:Spring 给 A 造了个 B,B 又要一个全新的 A,这个新 A 又要 B……无限递归,而且即使造出来也没法回填给”上上个 A”(因为它们不是同一个实例)。所以 Spring 对 prototype 的循环依赖直接放弃,启动就报错。
代码锚点:
@Scope("prototype")@Servicepublic class A { public A(B b) {} }@Scope("prototype")@Servicepublic class B { public B(A a) {} }// 启动时即抛: Requested bean is currently in creation: prototype scoped bean...
假设只有二级缓存,里面直接放 A 的原始空壳(没有代理)。流程走到”B 要 A”时,从二级缓存拿到原始 A(不是代理)。于是 B 的字段 a 指向原始 A。但 A 本身有 @Transactional,容器在第 6 步最终要放进一级缓存的是A 的代理。结果:容器里是代理 A,而 B 手里是原始 A——同一个 Bean 在系统里出现了”两个不一样的对象”,B 调用 a.method() 走的是原始对象,事务不生效。这就是不一致。
三级缓存怎么解?三级缓存放的不是对象,是工厂 () -> getEarlyBeanReference(A)。当 B 需要 A 时,调工厂:工厂内部会跑 SmartInstantiationAwareBeanPostProcessor(包括 AOP 的 AbstractAutoProxyCreator),此时才决定——A 需要代理就生成代理,不需要就返回原始。生成后把结果(代理)移入二级缓存,后续任何人再来取都拿到同一个(同一个代理)。这样 B 手里的 A 和最终一级缓存里的 A 是同一个代理,一致性保住。
更深一层:三级缓存不是”多一级更牛”,而是”用工厂把代理决策延迟并去重”。它的存在暴露了一个事实:Spring 的循环依赖解决是在”保证 AOP 一致性”这个硬约束下设计的。如果 Spring 不支持 AOP,两级缓存就够。但 AOP 要求代理和原始对象一一对应,所以必须有一层”按需生成且只生成一次”的工厂。理解这点,你就理解了为什么 Spring 宁可多维护一个缓存也不简化——因为它要同时兑现”能解循环依赖”和”AOP 代理一致”两个承诺。
顺带:这也解释了为什么构造器注入的循环依赖解不了——构造器注入没有”先 new 空壳再填属性”的空隙,工厂根本没机会被注册,见下一个【思考】。
【思考】为什么构造器注入的循环依赖 Spring 解决不了?
参考答案
直接答案:构造器注入要求”构造 A 前必须先把 A 的所有构造参数(含 B)备齐”,但构造 B 又要求先把 A 备齐——构造阶段就死锁(BeanCurrentlyInCreationException)。三级缓存依赖”先实例化空壳、再填充属性”之间的空隙来暴露早期引用,而构造器注入把”构造”和”实例化”合为一步,没有这个空隙。
构造器注入:new A(B b)——构造 A 的同一刻就必须有 B。要 B 就得先 new B(A a),要 A 又得先构造 A。两者互相等待,Spring 在尝试创建 A 时发现”A 正在创建中”又来要 A,立刻抛 BeanCurrentlyInCreationException。根本没有”先造空壳”的机会,三级缓存无从介入。
代码锚点:
@Servicepublic class A { public A(B b) {} } // 构造即需 B@Servicepublic class B { public B(A a) {} } // 构造即需 A// 启动即失败:// BeanCurrentlyInCreationException:// Requested bean is currently in creation: Is there an unresolvable circular reference?
@Servicepublic class A { @Autowired private B b; // 字段注入 public A() { // ❌ 构造器里调 b 的方法:此时 b 还是 null(字段还没填) b.initSomething(); // NullPointerException! }}@Servicepublic class B { @Autowired private C c; // c 也还没填 public void initSomething() { c.work(); } // 即使 b 非 null,c 也还是 null}
为什么? 回到 14.4 生命周期:A 的构造器在第 1 步(实例化),此时 b 字段尚未填充(第 2 步)。即使 Spring 先造了 B,B 的字段 c 也还没填。所以你在 A 构造器里碰 B,触发的连锁访问里全是 null。
正确写法:
@Servicepublic class A { private final B b; public A(B b) { this.b = b; } // 构造器只接依赖,不调业务 @PostConstruct public void init() { // ✅ 此刻 b 已注入,b.c 也已注入 b.initSomething(); }}
题 3:为什么 Spring Boot 2.0 之后默认用 CGLIB 代理而不是 JDK 动态代理?
参考答案
直接答案:因为 CGLIB 能代理类(不只是接口),于是 @Transactional 标在类上、或标在”没接口声明的方法”上也能生效,行为更一致;代价是 final 类 / final 方法无法被代理(增强失效)。
展开:
Spring 早期(及纯 Spring、非 Boot)默认优先 JDK 动态代理——目标类实现了接口就走 JDK 代理。问题:JDK 代理只为接口生成实现,目标类自己特有的 public 方法(不在接口里声明的)不会被代理。如果你的 @Transactional 标在一个没接口的类的方法上,或标在类本身(JDK 代理只看接口方法),增强可能不生效,行为诡异难查。
Spring Boot 2.0 起把 spring.aop.proxy-target-class 默认设为 true,即优先 CGLIB。CGLIB 生成目标类的子类,重写所有非 final 方法,所以无论有没有接口、方法是不是接口声明的,都能被增强。@Transactional 一致性大幅提升。
代码锚点——final 方法的代价:
@Servicepublic class OrderService { @Transactional public final void pay() { ... } // ❌ final,CGLIB 无法重写 => 事务失效}// 去掉 final 才生效;这是 Spring Boot 默认 CGLIB 后的新坑:// "加了注解怎么不生效?" 先检查是不是 final
结论:Go 倾向于”显式但啰嗦”,Spring 倾向于”声明但隐式”。Go 的等价物要么侵入 handler 签名(middleware/context),要么靠代码生成(hook/generate)。没有谁更”对”,但作为从 Go 过来的人,你会更珍惜 Spring 的”加注解就好”,也会更警惕它的”代理边界”——这正是本章反复锤的那条:声明式能力的代价,是你要懂它的边界。
更深一层:这道题其实是全书的元问题——Java 和 Go 在”显式 vs 隐式”上的取舍。你学 Java 不是抛弃 Go 的价值观,而是把”显式可追踪”当成尺子,去量 Spring 的”隐式简洁”到底付出了什么。等你第 22 章回看,会发现学 Java 最大的收获,是反过来把 Go 那些”理所当然的显式”理解得更深。