《Spring Boot 入门》5.1 容器与 Bean

本节从一段手写 new 的代码讲起,说明 IoC 到底反转了什么,理清 ApplicationContext 与 BeanFactory 的关系,逐一演示 @Component、@Bean、@Import 把类变成 Bean 的三种方式,并用 getBeanDefinitionNames() 实测打印容器内容,帮你判断哪些类该交给容器、哪些不该。

本节目标:搞懂 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);
    }
}

这段代码能跑,但它埋了四个问题:

  1. 实现被写死。BookService 直接 new BookRepository(),想换成数据库版本,得改源码。
  2. 无法替换、无法测试。单元测试里想塞一个假的仓库,没有入口,只能改代码。
  3. 依赖关系散落。每一处 new BookService() 都在各自决定它用哪个仓库,规则不统一。
  4. 生命周期无人负责。谁创建、创建几次、何时销毁,全看调用方临时决定。

第 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 的实现类。

两者能力对比:

能力BeanFactoryApplicationContext
获取 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
@ControllerWeb 控制器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 注入方式与作用域 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计