1. 现实问题:new 写在业务里,替换实现就要改代码
订单服务需要通知发送器、时钟和仓储。如果 Service 内部直接 new EmailSender(),测试不能替换,配置不能切换,启动时也无法统一检查依赖。IoC(控制反转)把“对象由谁创建、依赖如何组装”交给容器;业务代码仍然调用对象,只是不再负责拼装整个对象图。
理解 IoC 要抓住一个具体问题:容器启动时如何发现候选 Bean,如何选择实现,如何调用构造器;请求进入后,Service 字段里到底保存的是哪个对象。
2. 最小可运行示例:构造器注入和配置 Bean
public interface NoticeSender {
void send(String orderId);
}
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Service;
@Configuration
class NoticeConfig {
@Bean
NoticeSender noticeSender() {
return orderId -> System.out.println("send notice: " + orderId);
}
}
@Service
class OrderService {
private final NoticeSender sender;
OrderService(NoticeSender sender) {
this.sender = sender;
}
void paid(String orderId) {
if (orderId == null || orderId.isBlank()) throw new IllegalArgumentException("订单号为空");
sender.send(orderId);
}
}
在 Spring Boot 4.1 项目里,组件扫描和配置类由应用上下文处理;构造器只有一个时可以不写 @Autowired。如果存在多个 NoticeSender,使用 @Qualifier 或 @Primary 明确选择,不要依赖扫描顺序。
3. 调用链与对象变化
应用启动时,SpringApplication 创建 ApplicationContext,扫描配置和组件元数据,发现 NoticeConfig 与 OrderService。容器调用配置方法创建 NoticeSender,再把该 Bean 作为构造器参数传给 OrderService,保存完整的对象引用。启动阶段依赖缺失会失败,避免请求到来后才发现 null。
请求调用 OrderService.paid 时,sender 已经是容器组装好的实现;Service 不知道它是 lambda、邮件客户端还是测试 fake。默认单例 Bean 只创建一次,但单例不等于线程安全,若对象持有请求级可变状态就会污染并发请求。
4. 为什么这样设计
构造器注入让依赖成为对象成立的前提,测试可直接 new OrderService(fake);字段注入隐藏依赖,配置缺失容易延后暴露。IoC 还提供生命周期、作用域、条件装配和代理扩展,但核心价值是把对象图集中管理。
Bean 是容器中的对象定义和实例,不是所有类自动成为 Bean。@Component、@Service 和 @Repository 是扫描标记,@Bean 适合第三方客户端或需要显式配置的对象。容器越大,启动扫描、条件和循环依赖越需要可观察性。
5. 项目落点:分层但保持依赖方向
博客系统的 Controller 依赖 ArticleService,Service 依赖 ArticleRepository、Clock 和 CachePort,具体 MyBatis、Redis、系统时钟在配置或基础设施层提供。测试用 fake Clock 固定时间,fake Repository 记录调用。IoC 只负责组装,业务规则仍应在 Service/领域对象中。
练习:给 NoticeSender 增加 console 和 noop 两个实现,用配置属性决定是否发送;写一个 ApplicationContext 启动测试,验证缺少配置时失败信息清楚。再把 Service 改成构造器注入,比较字段注入前后的测试可读性。
6. 易错排查
NoSuchBeanDefinitionException:检查扫描包、Bean 条件、配置 profile 和接口实现是否存在。NoUniqueBeanDefinitionException:多个候选实现没有 Qualifier/Primary,显式表达选择。- 循环依赖:A 构造器依赖 B,B 又依赖 A;拆分职责或引入事件,而不是随意改字段注入。
- 单例 Bean 保存请求数据:并发请求共享同一个字段,改为方法局部变量或合适 scope。
7. 一页复习
容器启动发现 Bean、解析依赖、调用构造器并保存对象;请求进入时,业务只调用已经组装好的引用。构造器注入让依赖显式、可测试、早失败;IoC 不会自动消除循环依赖和线程安全问题。追踪 Bean 的创建地点和运行时类型,比背注解更有用。
IoC 的对象状态可以按启动阶段追踪:扫描前只有类和注解元数据;注册后有 BeanDefinition;实例化后构造器收到 NoticeSender;初始化和代理处理完成后,Controller 拿到的是容器引用。若配置中有两个实现,容器必须在构造前通过 Qualifier/Primary 解决,否则应用应启动失败,而不是随机选择。
@ConfigurationProperties、第三方客户端和 fake 测试实现的注册方式不同:配置属性要被扫描或显式启用,第三方对象通过 @Bean 创建,业务组件通过扫描注册。项目可以把 BlogApplication、配置属性和 NoticeConfig 放在启动/config 包,把端口接口放 application,把实现放 infrastructure。文件位置对应依赖方向,容器只负责装配。
排错时 NoSuchBeanDefinition 查扫描包、profile、条件和注册方式;NoUniqueBeanDefinition 查实现数量和 qualifier;启动成功后请求间互相污染,查单例字段是否保存 request state;构造器循环依赖,查两个 Bean 是否把协作职责互相吞并。把对象改成构造器注入后,测试可以直接 new 并传 fake,错误更早出现。
练习是增加 EmailSender、ConsoleSender 和 NoopSender 三个实现,配置一个明确的 active profile,并写上下文启动测试断言注入的运行时类。再把一个依赖改成缺失,观察启动失败信息;把一个实现从扫描改成 @Bean,比较包扫描和显式注册的可见性。
IoC 最容易被误解的地方是“加注解就有对象”。真正的输入是扫描范围、配置类、条件、BeanDefinition 和构造器参数,输出是一个可从容器取得的实例或启动失败。配置属性 record 需要被扫描/启用,第三方客户端需要 @Bean,带 stereotype 的业务类需要落在扫描包内;每种注册方式都要在启动测试中证明。
Bean 生命周期还包括初始化、后处理、销毁和代理。单例 Bean 在多个请求间共享,所以字段只能保存稳定配置或线程安全协作者;请求 id、当前用户和临时结果放方法局部变量。若需要 request scope,要明确异步和线程切换如何传播。对象创建成功不是业务健康,数据库连接、文件目录和外部客户端仍需单独探活。
项目文件的依赖方向可以画成 Controller → Service → Port ← Adapter,Config 负责把 Adapter 组装成 Port。测试中直接 new Service(fake) 不需要 Spring,少量上下文测试检查 Bean 注册、Qualifier 和配置绑定。这样 IoC 复杂度集中在启动边界,领域逻辑仍能用普通 Java 追踪输入和返回。
排错时 Bean 找不到查包扫描、profile、条件和显式启用;多个候选查 qualifier/primary;循环依赖查职责是否应该拆成事件或端口;请求串数据查单例字段;测试注入 null 查是否绕过容器或构造器参数未提供。练习是为 NoticeSender 写两个 profile 实现并故意删掉一个注册注解,记录应用启动失败和修复后的运行时类。
验证清单:启动一个最小应用,先确认 BeanDefinition、配置属性 record、NoticeSender 实现和 Controller 的注册来源,再发一次请求记录 Controller 持有的实际运行时类型;配置缺失、两个实现未指定 qualifier、扫描包移出启动类时,都应在启动阶段给出可定位的失败。对 Service 使用 new Service(fakeSender) 做普通单测,另用一次上下文测试检查容器装配,比较两类测试覆盖的边界。复盘时明确扫描、显式 Bean、配置属性扫描或显式启用各自提供什么输入,注解只是注册线索不是运行时对象本身。
一个课题专属实验是让配置 notice.channel=console 选择 ConsoleSender,改为 email 选择 EmailSender;配置解析得到值,条件/工厂得到 Bean,Controller 只拿到 NoticeSender 接口。启动后检查两个请求不会改变单例内部的当前用户字段;若需要保存请求信息,把它作为方法参数传递。这样能同时验证 profile、条件注册、构造器注入和单例线程安全。
进阶附录:作用域与 BeanPostProcessor
singleton 适合无状态协作者,prototype、request 等作用域改变创建和生命周期。BeanPostProcessor 可以在初始化前后加工对象,AOP 代理就是常见结果之一。看到一个 Bean 行为“被包了一层”时,要同时查看原对象、代理类型和调用是否经过容器引用。
本课按「Spring Boot 4.1 / Spring Framework 7 IoC 与 Bean 生命周期」的学习范围组织,正文与示例均为本站原创整理。