IoC 与 DI 彻底讲透:控制反转到底反转了什么

bee2026-10-0847 分钟0 次阅读
从一段会「连环爆炸」的订单代码讲起,把 IoC 的三种理解、DI 的三种注入方式、DIP 与好莱坞原则一次讲清——这是理解整个 Spring 内核的第一块基石。
1 / 126
小节
〇、30 秒看懂
2 / 126

这一篇是整个 Spring 内核的观念转折点,前面五篇教的都是「怎么写」,这里要改的是「谁来做决定」。

3 / 126

先说人话:你写的每一个类,都需要别的类陪它干活(订单服务需要库存仓库、支付客户端、邮件发送器)。问题从来不是「怎么协作」,而是「谁来准备这些伙伴」。传统写法是你自己在代码里 new 出来;Spring 的写法是你只写一句「我要一个库存仓库」,剩下的交给一个专门管对象的大管家——容器(就是 ApplicationContext 这个对象,你的应用启动时它会先把所有 Bean 造好、装配好、摆在架子上等你取)。

4 / 126
类比

点外卖。你不需要会做菜,也不需要知道哪口锅归谁、几点生火——在 App 上下单「一份番茄炒蛋」,菜就端到面前。自己 new 依赖,等于每吃一顿都从买菜、洗锅、热油开始;交给容器,你只保留「说清楚我要什么」。反转的从来不是代码量,而是谁主动:以前是你的代码主动出门找依赖,现在是容器主动把依赖送到你手上。

5 / 126
架构图
图 · 本篇地图:IoC 与 DI 到底在讲哪四件事
图 · 本篇地图:IoC 与 DI 到底在讲哪四件事
6 / 126

学完这一篇,你应该能回答三个问题:

7 / 126
  • IoC 反转的具体是哪三件事?DI 和 DIP 又各自站在哪个位置?
  • 构造器注入、setter 注入、字段注入差在哪一步时序上?为什么官方首选构造器?
  • A↔B 互相依赖时,为什么字段注入能救、构造器注入只能报错?
8 / 126
小节
一、现场:一个会"连环爆炸"的 OrderService
9 / 126

先看一段你觉得眼熟、但一改需求就爆炸的代码:

10 / 126
java
package com.example.order;import com.example.pay.AlipayClient;import com.example.stock.MysqlStockRepository;import com.example.mail.SmtpMailSender;public class OrderService {    // 依赖被"焊死"在三处 new 里    private final MysqlStockRepository stockRepo = new MysqlStockRepository("jdbc:mysql://...");    private final AlipayClient alipayClient = new AlipayClient("APP_ID", "PRIVATE_KEY");    private final SmtpMailSender mailSender = new SmtpMailSender("smtp.example.com", 465);    public void placeOrder(Long userId, Long productId, int count) {        stockRepo.deduct(productId, count);        alipayClient.pay(userId, productId, count);        mailSender.send(userId, "订单已支付");    }}
11 / 126

这段代码"能跑",却把三个具体实现死死焊在了业务逻辑上。改一处,坏三处:

12 / 126
  • 想换成微信支付?得改 OrderService 的字段和所有相关调用
  • 单元测试想用假的库存仓库?做不到——new MysqlStockRepository(...) 在构造对象时就执行了,一测就得连真实数据库
  • 想让库存服务支持分库分表?又得回来动业务类
13 / 126

根因不是"代码写得丑",而是"谁来决定依赖的实现"这件事被搞反了。OrderService 既负责"处理订单业务",又负责"决定用什么库存、什么支付、什么邮件"——两个职责挤在一个类里,于是任何一方的变化都会波及另一方。

14 / 126
小节
二、控制反转的三种理解
15 / 126
架构图
图 1 · 反转前 vs 反转后
图 1 · 反转前 vs 反转后
16 / 126

"控制反转(IoC)"四个字听起来玄乎,其实可以拆成三件具体的事:

17 / 126
对照表
反转的是谁反转前反转后
谁负责创建对象业务类自己 new容器统一创建
谁负责装配依赖业务类自己挑实现、自己赋值容器按声明把依赖塞进来
谁负责生命周期想用就 new,用完等 GC容器管创建、初始化、销毁、单例缓存
18 / 126

三者的共同点是:创建与装配的主动权,从"使用者"转移到了"外部容器"。业务类从"导演 + 演员"退化成"只演戏的演员"——它不再关心搭档从哪来,只在需要时声明"我要一个库存仓库"。

19 / 126
要点

IoC 不是某个具体技术,而是一类设计思想。Spring 之所以叫"容器",正是因为它把这三件事都接了过去:你手里拿到接口就能干活,具体实现由容器在运行时决定。

20 / 126
小节
三、DI 是 IoC 的实现方式:三种注入对比
21 / 126

IoC 是目标,依赖注入(DI)是达成它的主要手段。同一个依赖,有三种注入写法:

22 / 126
java
@Servicepublic class OrderService {    // ① 构造器注入(推荐)    private final StockRepository stockRepo;    private final PayClient payClient;    public OrderService(StockRepository stockRepo, PayClient payClient) {        this.stockRepo = stockRepo;        this.payClient = payClient;    }}
23 / 126
java
@Servicepublic class OrderService {    // ② Setter 注入    private StockRepository stockRepo;    @Autowired    public void setStockRepo(StockRepository stockRepo) {        this.stockRepo = stockRepo;    }}
24 / 126
java
@Servicepublic class OrderService {    // ③ 字段注入(最省事,也最不推荐)    @Autowired    private StockRepository stockRepo;}
25 / 126
对照表
维度构造器注入Setter 注入字段注入
可测试性直接 new 传 Mock,无需容器需先构造再 set必须靠反射或容器才能注入
不可变性final 字段,建后不变可变可变
循环依赖立刻暴露,启动失败可借助提前暴露破环依赖容器容错
官方推荐推荐可选依赖时用不推荐
缺失依赖编译期就报错运行期才发现运行期才发现
26 / 126
提示

Spring 官方文档明确建议——必需的依赖用构造器注入,可选的依赖用 setter 注入,字段注入只用于最简单的场景,甚至完全不用。原因很实在:构造器注入让"依赖不完整"的对象根本无法被创建出来。

27 / 126
小节
四、依赖倒置原则 DIP:谁是手段,谁是原则
28 / 126

面试常追问:"IoC 和 DIP 到底是什么关系?"答案其实很清晰:DIP 是设计原则,IoC 是实现该原则的手段之一。

29 / 126
java
// ✗ 违反 DIP:高层业务直接依赖低层细节public class OrderService {    private final AlipayClient alipayClient = new AlipayClient();  // 依赖具体实现}// ✓ 满足 DIP:双方都依赖抽象public class OrderService {    private final PayClient payClient;   // 只认接口    public OrderService(PayClient payClient) {        this.payClient = payClient;    }}
30 / 126

DIP 有两条"倒置":

31 / 126
  • 高层模块不应依赖低层模块,二者都应依赖抽象。OrderService(高层)不该盯着 AlipayClient(低层),而应面向 PayClient 接口。
  • 抽象不应依赖细节,细节应依赖抽象。PayClient 接口不该知道支付宝的签名细节;恰恰相反,是 AlipayClient 去实现接口。
32 / 126

所以关系链是:DIP 说"依赖抽象"→ DI 提供"把抽象的具体实现注入进去"的机制 → IoC 容器负责"在运行时决定注入哪个实现"。一句话:原则定方向,容器干脏活。

33 / 126
注意

new 本身没有错。判断是否违反 DIP 的标准不是"有没有 new",而是"高层模块是否依赖了具体实现类型"。在工厂方法或配置类里 new 一个具体实现,恰恰是把依赖方向掉转回来的正确做法。

34 / 126
小节
五、好莱坞原则:别找容器,容器会来找你
35 / 126
原理动画
动图 · 好莱坞原则:别找容器,容器会来找你
动图 · 好莱坞原则:别找容器,容器会来找你
36 / 126

"Don't call us, we'll call you"是好莱坞选角导演的名言:演员别天天打电话问有没有角色,展现好你的能力,角色来了自然会联系你。IoC 容器正是那个导演。上面那张动图给了全景,下面这六格则能一格一格点——尤其第 ③ 格,它解释了一件很多人从没想过的事:创建顺序不是你写的。

37 / 126
交互图解
流程好莱坞六步:容器是怎么找到你的1 / 6
从 ① 点到 ⑥,重点看第 ③ 格——顺序是算出来的,不是你写的
→
→
→
→
→
① 你声明依赖
构造器参数就是写在合同里的「我要一个 StockRepository」。注意这一行只有接口名,没有任何实现类,也没有一行 getBean。
全部看懂了六步里你只写了第 ① 步,剩下五步全是容器做的——这就是「反转」的全部内容。
38 / 126

对比"服务定位器"模式——业务代码主动 ctx.getBean(...) 去要东西,那是"我打电话找导演",查找逻辑耦合在业务类里;而 DI 是"导演来找我",业务类完全不知道容器的存在。主动查找 vs 被动接收,这就是控制反转最直观的体现。

39 / 126
小节
六、容器能力清单:你手动做要多少代码
40 / 126

很多人以为 DI 只是"少写几个 new",其实容器替你做的事远不止于此:

41 / 126
对照表
能力自己实现要写的代码容器提供
单例管理手写双重检查锁 + 静态字段scope="singleton" 一步到位
依赖解析手动拓扑排序,处理创建顺序自动解析依赖图并排序
生命周期回调自己约定 init/destroy 方法并在合适时机调用@PostConstruct / @PreDestroy
条件装配手写 if/else 判断环境@Conditional 系列注解
AOP 集成手写 JDK / CGLIB 代理包装声明 @Aspect 即生效
配置注入手动读配置文件并解析@Value / @ConfigurationProperties
42 / 126
说明

这张表的意义不是"容器多厉害",而是让你在评估"要不要上容器"时有依据。当你的需求正好落在表里,容器就是在帮你省掉成百上千行胶水代码。

43 / 126
小节
七、什么时候不值得上容器
44 / 126

容器不是万能药,它的能力都建立在"对象需要被统一管理"这个前提上。以下场景硬上容器反而是负担:

45 / 126
代码对照
代码java
// 一个 30 行的数据清洗脚本:为它搭一个 ApplicationContext 纯属自找麻烦public class CsvOneOff {    public static void main(String[] args) throws Exception {        try (var reader = Files.newBufferedReader(Path.of(args[0]))) {            reader.lines()                  .filter(line -> !line.startsWith("#"))                  .forEach(System.out::println);        }    }}
解读
  • 一次性任务、CLI 小工具、单个 main 跑完就退出的脚本——启动容器的开销和心智成本都不划算
  • 纯算法 / 纯函数库:没有依赖需要装配,容器没有插手的地方
  • 需要极致启动速度的极简函数计算:容器的反射与扫描会拖慢冷启动

总结一句话:容器解决的是"对象之间如何组装"的问题。当你的代码里根本没有"多个需要协作的对象"时,就没有 IoC 可反转。

46 / 126
小节
八、上手体验:容器接管之后
47 / 126

下面这个演示展示依赖注入在容器里真实发生的过程。切换条件开关,观察装配链的连锁变化(它是内核实验;第十一节那个才是可改参数、立刻看结果的沙盘):

48 / 126
内核实验
49 / 126
小节
九、四种注入方式放一张图上:先选位置,再谈写法
50 / 126

把四种写法放到同一个坐标系里(横轴:好不好测;纵轴:依赖定下来之后还能不能改),选择就变得直观了。这里的单元测试指不启动整个应用、只用 new 就能跑起来的小测试;Mock 指你临时造的「假依赖」,用来顶替真实数据库或支付接口。

51 / 126
架构图
图 · 四种注入方式:不可变↔可变 × 易测试↔难测试
图 · 四种注入方式:不可变↔可变 × 易测试↔难测试
52 / 126

四种写法各站在哪个象限,图上已经一目了然。麻烦的是另一半:每种写法各自会把你怎么着。这种事背表背不住,来玩一局——先点左边的写法,再点你认为的后果,配错了当场告诉你为什么。

53 / 126
配对闯关
闯关四种写法配四种后果已配对 0/6 · 配错 0
左边是你能写下的代码,右边是它换来的代价或红利
先点左边一个
54 / 126

三种写法的装配时序完全不同,这正是「字段注入的对象为什么能带着 null 出生」的答案。第一个实验看最干净的构造器注入:依赖是构造参数,对象诞生那一刻就完整。

55 / 126
内核实验
TeaVM构造器注入:对象一出生就完整未启动
盯住日志顺序:先造下层 Bean,再调你的构造器
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
56 / 126

第二个实验切到字段注入,同一批 Bean、同一条依赖链,装配时机挪到了实例化之后。看到「实例已创建」和「依赖已注入」之间出现一段空隙,你就理解了第十节所有坑的来源。

57 / 126
内核实验
TeaVM字段注入:先有对象,后补依赖未启动
对比上一个实验:构造器执行完时,字段还是 null
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
58 / 126

第三个实验是最有价值的一格:A 要 B、B 也要 A,而且用的是构造器注入。你会看到容器在第四步直接放弃——因为此时连一个「半成品 A」都还不存在,没有任何引用可以借出去。

59 / 126
内核实验
TeaVM构造器注入遇上 A↔B 循环依赖:没有半成品可借未启动
注意报错抛出的时机:还没进入装配阶段就已经失败
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
60 / 126
类比

DI 就像组装电脑。主板说明书只写「这里要插一张显卡,接口 PCIe 5.0」,它不关心你买哪个牌子;只要接口对得上,A 卡 N 卡都能点亮。你的类就是那块主板:构造器参数是插槽规格,容器是那个帮你把卡插好的人。插槽规格写得越死(final + 构造器),装机出错就越早被发现——线没插紧却在开机后才冒烟的机器,没人敢用。

61 / 126
小节
十、循环依赖:三级缓存救得了谁,救不了谁
62 / 126

循环依赖指两个或多个对象互相需要:A 里有 B,B 里又有 A。三级缓存是容器解环用的三个 Map,其中最关键的是第三级 singletonFactories——它存的不是对象本体,而是一张「取货单」:一个能在别人急需时先交出半成品引用的工厂。

63 / 126
原理动画
动图 · A↔B 循环依赖:三级缓存救得了字段注入、救不了构造器注入
动图 · A↔B 循环依赖:三级缓存救得了字段注入、救不了构造器注入
64 / 126

动画的第 2 步与第 3 步是全部关键:实例化(反射调构造器,得到一个空壳对象)发生在装配(往里填依赖)之前。字段注入和 setter 注入都属于「先有空壳、后填内容」,所以空壳在第 3 步就能被挂出去救急;构造器注入要求「内容就是入参」,空壳根本不存在,也就无从可借。

65 / 126

开与不开三级缓存,差别就在这两段日志上:

66 / 126
内核实验
TeaVM三级缓存开与关:同一个环,两种命运未启动
先点「开启三级缓存」,再点「关闭三级缓存」,对比报错出现在哪一步
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
67 / 126
对照表
组合结果为什么
单例 + 字段/setter 注入能启动实例化后立刻有半成品,取货单能兑
单例 + 构造器注入启动即失败还没有任何引用可交出去
prototype + 任意注入一律失败原型从不进单例池,压根没有仓库可提前借
68 / 126
坑

Spring Boot 2.6 起 spring.main.allow-circular-references 默认为 false,也就是说即使是三级缓存本来能解的环,也会在启动时被提前拒绝。别急着把它改回 true——那只是允许坏结构继续存在,不是新的解法。真正的修法是把公共部分抽成第三个 Bean,或者在注入点加 @Lazy(详见第 10 篇)。

69 / 126
小节
十一、容器给了对象,但给几个:scope 与生命周期
70 / 126

前九节都在讲「怎么把依赖送进来」,还有一件事必须分清:同一个类型,容器给你一个实例还是多个。scope(作用域)就是这个开关——singleton(默认,全容器一份)或 prototype(每次索取新建一份)。

71 / 126

先看清一个 Bean 从出生到销毁要过几站,@PostConstruct(初始化回调)与 @PreDestroy(销毁回调)分别落在哪一站:

72 / 126
内核实验
TeaVMBean 的一生:单例走完全程未启动
记住每一站的顺序,第十二节的报错速查表会用到它
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
73 / 126

把范围切成原型,你会发现最后一站不再执行:容器把对象交出去之后就撒手不管了。

74 / 126
内核实验
TeaVM换成 prototype:容器只管到交付未启动
对比销毁回调那一行有没有出现
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
75 / 126

但上面两个实验只走了 singleton 与 prototype 两档。真实项目里还有 request / session 两档,外加两个专职坑:prototype 的销毁回调根本不执行、request 作用域的 Bean 注入进单例就废了。下面这个实验把「此刻容器里到底有几个活着的对象」做成一个计数器,逐次索取,数字当场给你看:

76 / 126
内核实验
TeaVM四种作用域各活几个实例:亲手数一遍未启动
反复点索取实例那一步,盯住计数器:singleton 永远是 1,prototype 每点一次加 1
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
77 / 126
内核实验
TeaVMrequest 作用域与 scoped proxy 陷阱未启动
先看 request Bean 被注进单例时发生了什么,再看 proxyMode=targetMode 之后为什么就对了
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
78 / 126

现在回答小白最容易懵的那个问题:同一个 UserService 我调用两次 getBean,拿到的是同一个对象吗? 左边改 scope 与 @Lazy,右边立刻给出 hashCode 的形状和结论。

79 / 126
沙盘
沙盘同一个 UserService 调两次 getBean,是同一个对象吗
运行结果
getBean #1 -> UserService@7a3b19
getBean #2 -> UserService@7a3b19
== 比较:true
singletonObjects: {userService=UserService@7a3b19}
# 启动阶段就创建好,第一次取用零耗时
默认答案:同一个对象。整个应用共享这一份,所以它有状态就等于全局状态。
80 / 126
说明

沙盘里那句 UserService@7a3b19 就是 toString() 打出的 hashCode 十六进制形状——同一个哈希 = 同一个对象。以后判断「我到底拿到的是不是同一份」,最快的办法就是在两个地方各打一行 hashCode,而不是靠猜。

81 / 126

沙盘看的是「两种写法各给什么结果」,而下面这台控制台给你的是容器此刻的真实账本:命令发进浏览器里那个真容器,回显由它算出来。先 boot 再 beans,然后逐条把本篇结论敲一遍:

82 / 126
内核控制台
83 / 126
提示

beans 与 di 的差别值得记住——beans 报的是「容器里有谁」(名字、scope、是否懒加载),di 报的是「谁靠谁活着」。第十二节那些报错,九成能用这两条命令各敲一次定位到。

84 / 126
小节
十二、常见报错速查
85 / 126

下面每一行的「报错原文」都可以整段粘进搜索框,别意译、别缩写:

86 / 126
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
java.lang.NullPointerException: Cannot invoke "com.example.StockRepository.deduct(java.lang.Long, int)" because "this.stockRepo" is null你手上的对象是自己 new 出来的,不在容器里,@Autowired 字段永远不会被填改成从容器取:ctx.getBean(OrderService.class);需要断点验证就在构造后打印 stockRepo 是否为 null本篇第一、五节 · 第 5 篇
org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.OrderService' available容器里没有这个类型的 Bean:类忘标 @Service/@Component,或它的包不在扫描路径内先看类上有无组件注解,再看主类是否在它的父包;都对了就打一行 getBeanNamesForType(OrderService.class) 确认第 5 篇 · 第 7 篇
org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'com.example.PayClient' available: expected single matching bean but found 2: alipayClient,wechatPayClient同一接口有两个实现都被扫进来了,容器不知道该给你哪个在注入点用 @Qualifier("alipayClient") 点名,或在默认实现上标 @Primary本篇第九节象限图 · 第 7 篇
org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?A↔B 互相等待,而且用的是构造器注入(或 scope 是 prototype),三级缓存没有半成品可借先读那句 The dependencies of some of the beans in the application context form a cycle 下面的环图,再把构造器改成字段/setter,或抽出公共依赖 C本篇第十节 · 第 10 篇
@Autowired 标在 static 字段或 static 方法上毫无反应,调用时 NullPointerException注入是「往某个实例的成员上填值」,static 属于类而不属于实例,容器的装配步骤根本没有落点去掉 static 改实例成员;确实要静态工具方法就把依赖当参数传进去本篇第三节
加了 @Qualifier 还是选不出来,或者两处注入想要不同实现@Primary 管「默认给谁」(全局兜底),@Qualifier 管「这次点名要谁」(局部精确),两者混用时以 Qualifier 为准一个接口只留一个 @Primary;每个注入点按需写 @Qualifier,或直接换用 Map<String, PayClient> / List<PayClient> 全量接收第 7 篇 · 第 8 篇
87 / 126
提示

搜这类报错时只搜冒号后面那段英文原文(例如 expected single matching bean but found 2),命中率比搜整句高得多——不同 Spring 版本的尾部话术会变。

88 / 126

表格里第三行就是本篇出场率最高的一次崩溃。先别看结论,在下面这段真实堆栈里点出你认为的「凶手行」——点错了也会告诉你为什么不是它:

89 / 126
报错急救
报错急救NoUniqueBeanDefinitionException

把支付实现从支付宝换成微信之后,你自己的机器跑得好好的,同事拉下代码一启动就崩在 half-second 里。

APPLICATION FAILED TO START
org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'com.example.pay.PayClient' available: expected single matching bean but found 2: alipayClient,wechatPayClient
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveNamedBean(DefaultListableBeanFactory.java:1289)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveBean(DefaultListableBeanFactory.java:494)
at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:412)
at com.example.order.OrderService.<init>(OrderService.java:22)
... 41 more
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
90 / 126
小节
十三、随堂自测
91 / 126
随堂自测
随堂自测改用构造器注入以后,`private final StockRepository stockRepo;` 这一行还需要额外的注解才能被注入吗?
先自己选一个,选中立刻告诉你对不对
92 / 126
随堂自测
随堂自测字段注入时,为什么「构造器执行完」的那一刻依赖还是 null?
先自己选一个,选中立刻告诉你对不对
93 / 126
小节
十四、自己 new 还是交给容器:一次只看一行的取舍
94 / 126

把本篇的观念压成一张对照图。左边那条路你其实一直在走,只是没意识到代价长在哪儿。

95 / 126
原理动画
动图 · 控制权交接:一个对象的四件事归了谁
动图 · 控制权交接:一个对象的四件事归了谁
96 / 126
对照表
场景自己 new交给容器
换一份实现20 处调用点全要改改一行配置或换一个注解
写单元测试得连带起数据库、邮件服务传一个 Mock 进构造器就行
临时加一层缓存每个调用点各包一层给缓存实现标 @Primary
依赖关系写错编译器不拦,全靠人肉找启动即失败,当场告诉你缺谁
97 / 126
类比

IoC 就是点外卖。你不需要会做菜、不需要知道哪口锅归谁、更不用自己种菜——只要在 App 上下单说「来一份番茄炒蛋」,菜就端到面前。过去你自己 new,等于每吃一顿都要从买菜、洗锅、热油开始;现在你把「做菜」整件事外包给厨房(容器),自己只保留「说清楚我要什么」。反转的从来不是代码量,而是谁主动:以前是你的代码主动出门找依赖,现在是容器主动把依赖送到你手上,你只需在构造器参数里留好收货地址。

98 / 126

下面这个首页同款的容器装配演示值得多玩几分钟。切换条件开关你会发现:某个 Bean 的条件不满足时,报错往往在下游第一个需要它的地方冒出来,而不是在它自己身上——这正是第十二节那行 NoSuchBeanDefinitionException 的真实成因。

99 / 126
内核实验
100 / 126
小节
十五、动手练习
101 / 126
小节
第一档 · 照做
102 / 126

目标:亲手制造并读懂本篇最前面的两条报错。注意这里故意不用容器:

103 / 126
java
package com.example.order;import com.example.stock.StockRepository;import org.springframework.beans.factory.annotation.Autowired;public class OrderService {    @Autowired    private StockRepository stockRepo;   // 字段注入    public void placeOrder(Long productId, int count) {        stockRepo.deduct(productId, count);   // ← 在这里炸    }    public StockRepository getStockRepo() {        return stockRepo;                     // 只为演示:看一眼它是不是 null    }}
104 / 126
java
package com.example.demo;import com.example.order.OrderService;public class ManualNewDemo {    public static void main(String[] args) {        OrderService service = new OrderService();  // ← 病根在这一行:它不在容器里        System.out.println("stockRepo = " + service.getStockRepo());   // null        service.placeOrder(1L, 2);                  // ← 下一行就炸    }}
105 / 126

预期输出(JDK 17+ 的 NPE 文案会把缺失的变量名直接写给你):

106 / 126
text
Exception in thread "main" java.lang.NullPointerException:    Cannot invoke "com.example.StockRepository.deduct(java.lang.Long, int)" because "this.stockRepo" is null        at com.example.order.OrderService.placeOrder(OrderService.java:12)        at com.example.demo.ManualNewDemo.main(ManualNewDemo.java:9)
107 / 126

再把 main 改成从容器取,并在配置类里补上一个 StockRepository 的 Bean,同一行业务代码就通了:

108 / 126
java
package com.example.demo;import com.example.order.OrderService;import com.example.stock.FakeStockRepository;import com.example.stock.StockRepository;import org.springframework.context.annotation.AnnotationConfigApplicationContext;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;@Configurationclass AppConfig {    @Bean    StockRepository stockRepository() {        return new FakeStockRepository();   // 测试替身,不连数据库    }    @Bean    OrderService orderService() {        return new OrderService();          // 交给容器实例化,字段注入才会发生    }}public class ContainerDemo {    public static void main(String[] args) {        try (AnnotationConfigApplicationContext ctx =                     new AnnotationConfigApplicationContext(AppConfig.class)) {            ctx.getBean(OrderService.class).placeOrder(1L, 2);  // 装配由容器完成        }    }}
109 / 126

验收清单:你能口头说出「为什么第一种写法里 @Autowired 完全没反应」——因为这个对象根本没走过容器的装配步骤。

110 / 126
小节
第二档 · 变体
111 / 126

三个小改动,一次只动一个变量,各自记录你观察到什么:

112 / 126
  1. 把上面的字段注入改成构造器注入,然后仍然用 new OrderService()。你会观察到编译期直接报 constructor OrderService in class OrderService cannot be applied to given types——依赖缺失被提前到写代码那一刻,这就是象限图左上角的红利。
  2. 让 AServiceImpl 与 BServiceImpl 互相以构造器注入。你会观察到启动失败,日志里有 The dependencies of some of the beans in the application context form a cycle 和一段 ASCII 环图;把其中一边改成字段注入,环立刻被三级缓存解开。再回第十节把 circular 实验切到「关闭三级缓存」,看连字段注入也一起崩掉的样子。
  3. 给 UserService 加上 @Scope("prototype"),在两处各打印一次 getBean(UserService.class) 的 hashCode。你会观察到两个哈希不同;删掉注解又变回相同——第十一节的沙盘就是这一对结果的静态版本。
113 / 126
小节
第三档 · 造一个
114 / 126

做一个「三种注入方式对比演示工程」,把本篇观念变成可运行的证据。

115 / 126
  • 一个接口 PayClient,两个实现 AlipayClient、WechatPayClient(构造器里各打一行日志,方便看创建时机)
  • 三个业务类分别用构造器注入、setter 注入、字段注入拿到 PayClient
  • 一个 main:先用 AnnotationConfigApplicationContext 正常启动,打印三者的 hashCode 以及依赖是否为 null;再用 new 直接创建第三个类,复现那条 NPE
  • 最后一步:两个实现同时存在会抛 NoUniqueBeanDefinitionException,用 @Primary 与 @Qualifier 各修一次,比较两种写法的适用范围
116 / 126

验收清单:① 只用 JUnit + new 就能测通构造器注入那个类,全程不启动 Spring;② 字段注入那个类用同样写法必然失败,你能指出原因出在哪一步;③ 用三句话说清「谁主动创建对象」的差别,讲给同事听也能懂。

117 / 126
小节
十六、要点自查
118 / 126
自检

不看上文,说出 IoC 反转的三件事(谁创建、谁装配、谁管生命周期),各举一个「反转前 / 反转后」的写法。

119 / 126
自检

IoC、DI、DIP 三个词各管什么?用一句话把它们串成因果链。

120 / 126
自检

为什么构造器注入能让字段是 final,字段注入不能?答案藏在哪个阶段的顺序里?

121 / 126
自检

A↔B 循环依赖,字段注入救得了、构造器注入救不了,分界线落在「实例化」还是「装配」?prototype 为什么一律救不了?

122 / 126
自检

同一类型有两个实现时,@Primary 和 @Qualifier 分别解决什么问题?什么时候该改用 List<T> 全量接收?

123 / 126
口诀

你 new 是你主动,容器给才是反转;构造器定终身,字段注入补后课。

124 / 126
小节
十七、字段注入到底能不能用?
125 / 126
决策
决策你在一个三人小团队里维护一个 Spring Boot 服务,同事大量使用 `@Autowired` 字段注入,理由是"代码短、看得清"。你要不要花时间把它们改成构造器注入?
126 / 126
总结

IoC 反转的是"创建与装配的主动权";DI 是实现 IoC 的手段,首选构造器注入;DIP 是背后的设计原则,容器是它的执行者;好莱坞原则用一句话概括这一切——别找容器,容器会来找你。判断要不要用容器,只看一件事:你的代码里是否存在"多个需要协作的对象"。