大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
IoC 与依赖注入
这一课你将能做到
- 解释控制反转(IoC)解决什么问题,并把一个
new出来的依赖改造成容器注入 - 熟练使用构造器注入,处理多实现冲突(@Primary / @Qualifier)和策略集合注入
- 诊断并修复
NoSuchBeanDefinitionException、循环依赖这两类高频报错
为什么要学
没有 IoC 时,对象之间的关系是「谁用谁 new」:
public class TaskService {
private EmailSender emailSender = new EmailSender(); // 自己 new,焊死了
}
问题立刻浮现:想换成短信通知要改源码;EmailSender 自己还依赖别的对象,你得一层层手动构造;写单测时没法把 EmailSender 换成假的。
IoC(控制反转):把「创建对象、组装依赖」的控制权,从你的代码反转给 Spring 容器。你只声明「我需要一个 EmailSender」,容器造好后**注入(DI)**给你。对象之间从「硬编码焊接」变成「接口插拔」,这正是分层架构能解耦的基础。本站后端的 Service 层依赖 Repository、Controller 依赖 Service,全部走容器注入。
先建立图像
你写代码时 容器启动时
┌─────────────────┐ ┌──────────────────────────┐
│ TaskService │ │ 扫描 @Component/@Service │
│ "我要一个 │ ──→ │ 发现 NotificationSender │
│ Notification- │ │ 有两个实现 → 决定用哪个 │
│ Sender" │ │ new 好它们,再 new │
└─────────────────┘ │ TaskService 并把依赖塞进去 │
└──────────────────────────┘
| 名词 | 人话 |
|---|---|
| IoC 容器 | Spring 的「对象工厂 + 仓库」,负责创建、组装、管理所有 Bean |
| Bean | 被容器管理的对象(默认全局一个实例) |
| 依赖注入(DI) | 容器把依赖「塞」给 Bean 的动作,是 IoC 的实现手段 |
| @ComponentScan | 启动时的「搜捕行动」,把标注了 stereotype 注解的类登记进容器 |
跟着做
步骤 1:先感受「new」的痛
// 痛点演示:业务代码硬编码依赖实现
public class TaskService {
// 想换 SmsSender?只能改这一行源码,重新编译。
// 写单测想 mock 掉发邮件?没办法,它是在构造时 new 死的。
private final EmailSender emailSender = new EmailSender();
}
先定义接口和两个实现,作为本课的领域模型:
package com.example.taskflow.notify;
public interface NotificationSender {
String channel();
void send(String to, String content);
}
package com.example.taskflow.notify;
import org.springframework.stereotype.Component;
@Component // 登记进容器
public class EmailSender implements NotificationSender {
@Override
public String channel() { return "email"; }
@Override
public void send(String to, String content) {
System.out.println("[邮件] 发给 " + to + ":" + content);
}
}
package com.example.taskflow.notify;
import org.springframework.stereotype.Component;
@Component
public class SmsSender implements NotificationSender {
@Override
public String channel() { return "sms"; }
@Override
public void send(String to, String content) {
System.out.println("[短信] 发给 " + to + ":" + content);
}
}
步骤 2:构造器注入(标准姿势)
新建 Service,注意只用构造器参数声明依赖,不加任何 @Autowired:
package com.example.taskflow.service;
import com.example.taskflow.notify.NotificationSender;
import com.example.taskflow.notify.SmsSender;
import org.springframework.stereotype.Service;
@Service
public class TaskService {
private final NotificationSender sender; // final:构造后不可变
// 单一实现时可省略 @Autowired;这里有两个实现,需要指明
public TaskService(@Qualifier("smsSender") NotificationSender sender) {
this.sender = sender;
}
public void notifyUser(String user) {
sender.send(user, "你有新的任务待处理");
}
}
// Controller 里同样构造器注入,链条串起来
@RestController
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping("/api/tasks/notify")
public String notify() {
taskService.notifyUser("zhangsan");
return "已通知";
}
}
访问 /api/tasks/notify,控制台输出 [短信] 发给 zhangsan:你有新的任务待处理。整个过程你没有 new 过任何东西——容器先创建 SmsSender,再创建 TaskService 时把它从构造器塞进去。
步骤 3:@Primary 与 @Qualifier 处理多实现
容器里现在有两个 NotificationSender,注入时必须消歧,二选一:
方案 A——@Qualifier 按名字精确指定(上面的写法):注入点逐个声明要哪个,灵活但每个注入点都要写。
方案 B——@Primary 设默认,多数场景不用改注入代码:
@Component
@Primary // 多个候选时,默认注入我
public class EmailSender implements NotificationSender { ... }
@Service
public class TaskService {
private final NotificationSender sender;
public TaskService(NotificationSender sender) { // 不写 @Qualifier,拿到的就是 EmailSender
this.sender = sender;
}
}
经验法则:大多数场景走 @Primary(设默认),特殊场景用 @Qualifier(点名)。
步骤 4:集合注入——一次拿全部实现(策略模式利器)
把注入类型改成 List<NotificationSender>,容器会把所有实现按序塞进 List:
package com.example.taskflow.service;
import com.example.taskflow.notify.NotificationSender;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class NotifyDispatcher {
private final Map<String, NotificationSender> senderMap;
public NotifyDispatcher(List<NotificationSender> senders) {
// 按 channel 归类:email -> EmailSender, sms -> SmsSender
this.senderMap = senders.stream()
.collect(Collectors.toMap(NotificationSender::channel, Function.identity()));
}
public void dispatch(String channel, String to, String content) {
NotificationSender sender = senderMap.get(channel);
if (sender == null) {
throw new IllegalArgumentException("未知渠道: " + channel);
}
sender.send(to, content);
}
}
这个模式值得背下来:新增一种通知方式(如钉钉)只需加一个 @Component 实现类,Dispatcher 一行不用改——这就是 IoC 带来的「对扩展开放」。消息路由、支付渠道、导出格式等真实场景全是这个套路。
步骤 5:验证单例与「同一个容器」
@RestController
public class ScopeCheckController {
private final TaskService taskService;
private final TaskService taskService2; // 注入两次
public ScopeCheckController(TaskService taskService,
org.springframework.context.ApplicationContext ctx) {
this.taskService = taskService;
this.taskService2 = ctx.getBean(TaskService.class);
}
@GetMapping("/api/scope-check")
public String check() {
return (taskService == taskService2) ? "同一个实例(singleton)" : "不同实例";
}
}
访问返回「同一个实例(singleton)」:默认作用域下,全容器只有一个 TaskService。这个事实决定了无状态的 Service 是线程安全的,有状态的成员变量要小心(下一课展开)。
步骤 6:循环依赖的识别与处理
A 依赖 B、B 又依赖 A 时,Boot 默认禁止这种互指(3.x 起默认 allow-circular-references=false),启动报:
The dependencies of some of the beans in the application context form a cycle
临时解法是 @Lazy 让其中一端先注入代理、用到时再解析:
@Service
public class OrderService {
public OrderService(@Lazy InventoryService inventoryService) { ... }
}
但出现循环依赖首先说明职责划分有问题:把公共逻辑抽成第三个类,比加 @Lazy 正确得多。
讲清楚要点
- 三类 stereotype 注解语义分层:
@Controller/@RestController(Web 层)、@Service(业务层)、@Repository(数据层,附加异常转换)、@Component(通用组件)。技术上都注册 Bean,语义上别混用。 - 构造器注入是唯一推荐方式:依赖可 final、不依赖容器即可单测、漏依赖在编译期就暴露。字段注入(字段上 @Autowired)隐藏依赖、无法 final、测试要靠反射,禁用。
- 单实现可省 @Autowired,多实现必须消歧:Spring 4.3 起单一构造器的 @Autowired 可省略;多实现时报
expected single matching bean but found 2,用 @Primary/@Qualifier 解决。 - 注入的是接口,装配的是实现:面向接口编程 + 容器装配 = 可替换、可测试。你在 Controller 里写的永远是
TaskService类型,测试时轻松换 mock。 - 循环依赖是设计坏味道:@Lazy 只是止痛药;抽公共组件、重新分层才是治疗。
你可能会懵的地方
-
@Autowired 和 @Resource 有什么区别? @Autowired 是 Spring 提供,默认按类型匹配,配 @Qualifier 按名字;@Resource 是 JDK 标准(jakarta.annotation),先按名字找、找不到再按类型。团队统一用 @Autowired + 构造器注入最省心。
-
为什么在代码里 new 一个 @Service 类,里面的注入全是 null? 你 new 的对象不在容器里,Spring 没有机会给它注入依赖。「从容器拿」而不是「自己造」:要么注入使用,要么把 new 的动作也交给 @Bean/@Component。
-
构造器注入遇上十几个依赖怎么办? 这是坏味道警报:一个类十几个依赖说明职责过多,先拆类。技术上无解但对的答案是——拆分职责,而不是改回字段注入让代码「看起来整洁」。
-
Bean 之间能互相调用,那「分层」还有什么意义? 分层约束的是调用方向和职责边界(Controller → Service → Repository),容器只是代为接线。没有分层约束的容器项目,会退化成一张谁都能调谁的网。
-
@Component 放在第三方库的类上没生效? 第三方类你改不了源码,加不了注解。用
@Configuration+@Bean方法把它们纳入容器(下一课详细讲),这是处理外部类的标准做法。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 字段上 @Autowired 注入 | 依赖隐藏、不可 final、难单测 | 构造器注入 |
注入点写具体实现类 EmailSender | 换实现、加代理(事务/AOP)都会失效 | 声明接口类型 NotificationSender |
| 两个实现没消歧直接注入 | 启动报 expected single matching bean but found 2 | @Primary 设默认或 @Qualifier 点名 |
| 忘了加 @Component/@Service | 报 NoSuchBeanDefinitionException | 确认类被注解标注且在扫描包内 |
| 用 @Lazy 硬解循环依赖不改设计 | 延迟爆炸,问题后置 | 抽公共类重构依赖方向 |
卡点急救
- 启动报
Parameter 0 of constructor in xxx required a bean of type 'yyy' that could not be found:被依赖的类没进容器。三查:类上有无 @Component 系注解;包是否在启动类扫描范围内;是第三方类的话改用 @Bean 注册。 - 启动报
expected single matching bean but found 2:多实现注入冲突。注入点加 @Qualifier 指定 Bean 名(默认为类名首字母小写),或在一个实现上加 @Primary。 - 启动报 bean cycle:读报错里的环路径
a -> b -> a,重构抽公共类;确需保留则在一端构造器参数加 @Lazy。 - 注入的对象运行时是 null:确认访问路径真的经过容器(反射 new 出来的、static 工具类里的注入都不行);工具类需要依赖时,注册为 Bean 而不是静态方法。
面试常问
-
什么是 IoC 和 DI?带来了什么好处? 控制反转:对象的创建与依赖组装交给容器。好处:解耦(面向接口可替换)、可测试(mock 注入)、生命周期统一管理、与 AOP/事务等容器能力天然协同。
-
为什么推荐构造器注入而不是字段注入? 依赖不可变(final)、依赖完整性强(构造即可用,缺依赖启动即失败)、不依赖容器也能 new 出来单测、利于发现「依赖过多」的设计问题。
-
Spring 怎么解决循环依赖?(三级缓存) 一级缓存放成品 Bean;二级缓存放提前暴露的早期引用;三级缓存放 ObjectFactory 用于必要时生成早期代理。A 实例化后先把工厂放进三级缓存再填充属性,B 需要 A 时从三级缓存拿到早期引用,打破环。(注意:仅限单例 + 字段/Setter 注入路径,构造器循环依赖无解,Boot 3 默认关闭自动容忍。)
-
@Component 与 @Bean 的区别? @Component 标注在类上,靠类路径扫描注册,用于自己写的类;@Bean 标注在 @Configuration 类的方法上,方法返回值注册为 Bean,用于第三方类和需要自定义构造逻辑的场景。
自测三问
- 「new 的痛」具体指哪几点?IoC 如何逐一解决?
- 容器里有两个 NotificationSender 实现,注入处有哪三种处理方式?
- 为什么字段注入被禁用?至少说出三个理由。
小结一句话
IoC 把「造对象、接线」交给容器,你用构造器声明需要什么——面向接口、可插拔、可测试的分层架构由此成立。
延伸
- Spring 官方文档:IoC 容器
- Spring 官方博客:构造器注入最佳实践
- 下一课:Bean 作用域与生命周期——单个 Bean 从创建到销毁的一生。