CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Spring Boot 实战入门/课时/IoC 与依赖注入
RawBlame
课时目录16 节

Spring Boot 实战入门

  1. 01Spring Boot 是什么
  2. 02Starter 与自动配置
  3. 03配置与 Profile
  4. 04IoC 与依赖注入
  5. 05Bean 作用域与生命周期
  6. 06Spring AOP 入门
  7. 07Spring MVC 与 REST
  8. 08参数校验与异常处理
  9. 09JDBC 与 MyBatis
  10. 10Spring Data JPA 入门
  11. 11事务管理
  12. 12Spring Security 基础
  13. 13JWT 与 API 鉴权
  14. 14单元测试与 MockMvc
  15. 15Actuator 与健康检查
  16. 16综合练习与进阶路线

IoC 与依赖注入

@Component、@Service、构造器注入、解耦 Controller 与 Service。

35 分钟

IoC 与依赖注入

这一课你将能做到

  1. 解释控制反转(IoC)解决什么问题,并把一个 new 出来的依赖改造成容器注入
  2. 熟练使用构造器注入,处理多实现冲突(@Primary / @Qualifier)和策略集合注入
  3. 诊断并修复 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 正确得多。

讲清楚要点

  1. 三类 stereotype 注解语义分层:@Controller/@RestController(Web 层)、@Service(业务层)、@Repository(数据层,附加异常转换)、@Component(通用组件)。技术上都注册 Bean,语义上别混用。
  2. 构造器注入是唯一推荐方式:依赖可 final、不依赖容器即可单测、漏依赖在编译期就暴露。字段注入(字段上 @Autowired)隐藏依赖、无法 final、测试要靠反射,禁用。
  3. 单实现可省 @Autowired,多实现必须消歧:Spring 4.3 起单一构造器的 @Autowired 可省略;多实现时报 expected single matching bean but found 2,用 @Primary/@Qualifier 解决。
  4. 注入的是接口,装配的是实现:面向接口编程 + 容器装配 = 可替换、可测试。你在 Controller 里写的永远是 TaskService 类型,测试时轻松换 mock。
  5. 循环依赖是设计坏味道:@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 硬解循环依赖不改设计延迟爆炸,问题后置抽公共类重构依赖方向

卡点急救

  1. 启动报 Parameter 0 of constructor in xxx required a bean of type 'yyy' that could not be found:被依赖的类没进容器。三查:类上有无 @Component 系注解;包是否在启动类扫描范围内;是第三方类的话改用 @Bean 注册。
  2. 启动报 expected single matching bean but found 2:多实现注入冲突。注入点加 @Qualifier 指定 Bean 名(默认为类名首字母小写),或在一个实现上加 @Primary。
  3. 启动报 bean cycle:读报错里的环路径 a -> b -> a,重构抽公共类;确需保留则在一端构造器参数加 @Lazy。
  4. 注入的对象运行时是 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,用于第三方类和需要自定义构造逻辑的场景。

自测三问

  1. 「new 的痛」具体指哪几点?IoC 如何逐一解决?
  2. 容器里有两个 NotificationSender 实现,注入处有哪三种处理方式?
  3. 为什么字段注入被禁用?至少说出三个理由。

小结一句话

IoC 把「造对象、接线」交给容器,你用构造器声明需要什么——面向接口、可插拔、可测试的分层架构由此成立。

延伸

  • Spring 官方文档:IoC 容器
  • Spring 官方博客:构造器注入最佳实践
  • 下一课:Bean 作用域与生命周期——单个 Bean 从创建到销毁的一生。
加载本节练习…
← 上一课配置与 Profile下一课 →Bean 作用域与生命周期