本节目标:搞懂 IoC 反转的到底是什么,理清 ApplicationContext 与 BeanFactory 的关系,掌握把类变成 Bean 的几种方式,并学会判断哪些类该交给容器。
适用版本:Spring Boot 4.1.x(Java 21)
从一段手写 new 的代码说起
假设你要写一个「图书管理」服务,最直白的写法是这样:
public class BookRepository {
private final Map<String, Book> store = new HashMap<>();
public Book findByIsbn(String isbn) {
return store.get(isbn);
}
public void save(Book book) {
store.put(book.isbn(), book);
}
}
public class BookService {
private final BookRepository repository = new BookRepository();
public Book find(String isbn) {
return repository.findByIsbn(isbn);
}
}
这段代码能跑,但它埋了四个问题:
- 实现被写死。
BookService直接new BookRepository(),想换成数据库版本,得改源码。 - 无法替换、无法测试。单元测试里想塞一个假的仓库,没有入口,只能改代码。
- 依赖关系散落。每一处
new BookService()都在各自决定它用哪个仓库,规则不统一。 - 生命周期无人负责。谁创建、创建几次、何时销毁,全看调用方临时决定。
第 4 个问题尤其致命:如果 BookRepository 持有一个数据库连接池,你希望全应用只有一份,而不是每次 new 都开一个新的。
IoC 反转的到底是什么
在没有 IoC 的世界里,是对象自己决定依赖从哪来——它主动 new。
在 IoC(Inversion of Control,控制反转)的世界里,对象只声明「我需要一个 BookRepository」,至于这个实例从哪来、是哪个实现、什么时候创建,全部交给容器去办。
对比一下同一件事的两种写法:
// 传统:我创建依赖
public class BookService {
private final BookRepository repository = new BookRepository();
}
// IoC:容器创建依赖并送进来
@Service
public class BookService {
private final BookRepository repository;
public BookService(BookRepository repository) {
this.repository = repository;
}
}
「反转」的是控制权:从「我创建依赖」变成「我被容器创建,并接受它塞进来的依赖」。
DI(Dependency Injection,依赖注入)是 IoC 的一种实现方式。IoC 是更宽的概念(模板方法、事件回调也算 IoC),但在 Spring 的语境里,说 IoC 基本就等于说 DI。你不用纠结这两个词的区别,记住「把依赖的创建权交出去」即可。
ApplicationContext 与 BeanFactory 的关系
Spring 的容器有两层接口,容易混淆:
BeanFactory:最底层的容器接口,负责实例化、装配、管理 Bean,提供getBean()、containsBean()等最基本能力。ApplicationContext:继承BeanFactory,并在其之上增加了面向应用的能力。
Spring Boot 启动后你拿到的 ApplicationContext,实际类型是 AnnotationConfigServletWebServerApplicationContext(Web 应用)或 AnnotationConfigApplicationContext(非 Web 应用),它们都是 ApplicationContext 的实现类。
两者能力对比:
| 能力 | BeanFactory | ApplicationContext |
|---|---|---|
获取 Bean(getBean) | 有 | 有(继承而来) |
| 单例实例化时机 | 默认懒加载,用到才创建 | 启动时预实例化单例 |
事件发布(ApplicationEvent) | 无 | 有 |
国际化(MessageSource) | 无 | 有 |
资源加载(Resource) | 无 | 有 |
自动注册 BeanPostProcessor | 无 | 有 |
| AOP 集成 | 无 | 有 |
实践中你几乎只会接触 ApplicationContext。BeanFactory 更多是理解内部机制的入口:Bean 的「实例化 → 属性填充 → 初始化」这套流程,就定义在 BeanFactory 的契约里。
把类变成 Bean 的三种方式
Bean 是「被容器管理的对象」。把一个类交给容器,有下面几种做法。
方式一:@Component 及其派生注解
最常用的方式,直接在类上打注解:
@Component
public class BookRepository {
private final Map<String, Book> store = new HashMap<>();
public Book findByIsbn(String isbn) {
return store.get(isbn);
}
public void save(Book book) {
store.put(book.isbn(), book);
}
}
@Service
public class BookService {
private final BookRepository repository;
public BookService(BookRepository repository) {
this.repository = repository;
}
public Book find(String isbn) {
return repository.findByIsbn(isbn);
}
}
@Service、@Repository、@Controller 都是 @Component 的派生注解,本质上都是让类被扫描进容器,区别只在语义和少量附加行为:
| 注解 | 语义 | 常用层 |
|---|---|---|
@Component | 通用组件 | 任意 |
@Service | 业务服务 | service |
@Repository | 数据访问 | repository / dao |
@Controller | Web 控制器 | controller |
@RestController | 控制器 + 响应体 | REST 接口 |
其中 @Repository 有一个额外行为:它会触发持久层异常翻译,把 JDBC/JPA 的原生异常包装成 Spring 的 DataAccessException 体系。其余几个在功能上没有区别,选哪个纯粹是为了让人一眼看懂这个类属于哪一层。
这些注解要被扫到才有用。负责扫描的是 @ComponentScan,而 @SpringBootApplication 已经把它包含进去了,默认扫描主类所在包及其子包。这也是为什么把所有类放进 com.example.book 及其子包下最省心。
方式二:@Bean 方法
当类来自第三方库、加不了注解,或者创建过程需要一段逻辑时,用 @Bean 方法手工产出:
@Configuration
public class BookConfig {
@Bean
public BookRepository bookRepository() {
BookRepository repository = new BookRepository();
// 可以在这里做初始化、读配置、按条件决定实现
return repository;
}
}
@Bean 方法的方法名默认就是 Bean 的名字(这里是 bookRepository),返回类型就是 Bean 的类型。
适合 @Bean 的场景:第三方类(如 RestClient、ObjectMapper)、需要复杂构造逻辑、需要根据配置决定返回哪个实现。
方式三:@Import
@Import 把一个或多个配置类/普通类显式导入当前容器,常用于「我不想依赖包扫描」或「类不在扫描路径下」:
@Configuration
@Import(BookConfig.class)
public class AppConfig {
}
@Import 的三种用法:
| 导入的目标 | 效果 |
|---|---|
| 普通类 | 当作 @Component 注册成 Bean |
@Configuration 类 | 引入其内部所有 @Bean |
ImportSelector / ImportBeanDefinitionRegistrar | 由代码动态决定注册什么 |
Spring Boot 的自动配置本身就是靠 @Import(通过 @EnableAutoConfiguration)把成百上千个配置类拉进来的,第 4 章已经见过。
方式四:XML(历史做法,了解即可)
Spring 早期完全靠 XML 声明 Bean:
<bean id="bookRepository" class="com.example.book.BookRepository"/>
Spring Boot 不推荐这种方式。只有在迁移遗留系统时,才可能用 @ImportResource 把旧 XML 引入进来:
@Configuration
@ImportResource("classpath:legacy-beans.xml")
public class LegacyConfig {
}
今天写新代码,请一律用注解方式,XML 只当作「能读懂老项目」的知识储备。
什么该做成 Bean,什么不该
这是初学者最容易搞混的地方。判断标准只有一条:这个对象是否无状态、被共享、需要依赖注入?
该做成 Bean 的:
- 无状态的服务:
BookService - 数据访问组件:
BookRepository - 配置持有者:带
@ConfigurationProperties的类 - 需要被注入、或需要注入别人的组件
不该做成 Bean 的:
- 数据对象:
Book、DTO、请求体、响应体 - 值对象
- 每次请求都不同、生命周期由业务控制的短命对象
- 只有静态方法的工具类(用静态方法即可,不需要容器)
其中「数据对象」这条最常被违反。Book 应该是一个普通记录类,而不是 Bean:
public record Book(String isbn, String title, String author) {
}
如果给 Book 加上 @Component,容器会创建一个单例 Book,全应用共享同一份数据——这几乎肯定是 bug。记住:Bean 默认是单例、无状态的;有状态的数据对象不该进容器。
实测:打印容器里的 Bean
写了注解不等于真的注册成功。用 ApplicationContext 可以直接把容器内容打印出来验证:
package com.example.book;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext;
@SpringBootApplication
public class BookApplication {
public static void main(String[] args) {
ApplicationContext ctx = SpringApplication.run(BookApplication.class, args);
System.out.println("Bean 总数: " + ctx.getBeanDefinitionCount());
for (String name : ctx.getBeanDefinitionNames()) {
Class<?> type = ctx.getType(name);
System.out.println(" " + name + " -> " + (type != null ? type.getSimpleName() : "?"));
}
}
}
getBeanDefinitionNames() 返回容器里所有 Bean 的名字,getType(name) 拿到类型(不触发实例化)。实测输出(节选,Web 应用下总数通常在 140 上下):
Bean 总数: 148
bookApplication -> BookApplication
bookService -> BookService
bookRepository -> BookRepository
org.springframework.context.annotation.internalConfigurationAnnotationProcessor -> ConfigurationAnnotationProcessor
org.springframework.context.annotation.internalAutowiredAnnotationProcessor -> AutowiredAnnotationBeanPostProcessor
tomcatServletWebServerFactory -> TomcatServletWebServerFactory
dispatcherServlet -> DispatcherServlet
requestMappingHandlerMapping -> RequestMappingHandlerMapping
...
能看到自己写的 bookService、bookRepository 确实在列,说明组件扫描生效了。名字默认是类名首字母小写。
按类型取 Bean 也验证一下:
BookService service = ctx.getBean(BookService.class);
BookRepository repo = ctx.getBean(BookRepository.class);
System.out.println(service.getClass()); // class com.example.book.BookService
System.out.println(service == ctx.getBean(BookService.class)); // true
class com.example.book.BookService
true
true 这一行证明了默认单例:两次 getBean 拿到的是同一个对象。
如果同类型存在多个 Bean,getBean(类型) 会抛 NoUniqueBeanDefinitionException。这正是 5.2 节要讲的 @Qualifier 与 @Primary 的用武之地。
常见坑
- 组件不在扫描路径下:把类放到主类所在包的兄弟包或上层包,扫描不到,启动时注入失败。解法:统一放进主类包及其子包,或用
@ComponentScan显式指定。 - 同名 Bean 冲突:默认
spring.main.allow-bean-definition-overriding=false,重复定义同名 Bean 会直接启动失败。这是好事,能及早暴露问题,不要随手把它改成true。 - 把实体当 Bean:给数据对象加
@Component,导致单例共享状态、并发出错。 @Bean方法互相调用:在@Configuration类里直接调用另一个@Bean方法,会被 CGLIB 代理拦截,仍返回单例;但如果类上只有@Component(而非@Configuration),调用就会真的new出新对象。要跨 Bean 引用,请用方法参数注入,别自己调用。
小结
IoC 把「依赖的创建权」从对象手里收归容器;ApplicationContext 是 BeanFactory 的超集,是 Boot 里真正使用的容器。把一个类变成 Bean,首选 @Component 及其派生注解,第三方类或需复杂构造时用 @Bean,需要显式引入时用 @Import,XML 仅作历史了解。判断标准始终是「无状态、被共享、需注入」——数据对象不该进容器。最后用 getBeanDefinitionNames() 打印一遍,是验证扫描是否生效最快的手段。
阅读导航:上一节:4.3 条件装配与开关 · 下一节:5.2 注入方式与作用域 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。