上一章介绍了 IoC 容器如何读取配置元数据、创建对象并解析依赖。本章把视角移到容器中的对象:普通 Java 类怎样成为 Spring Bean,Bean 定义与实例有什么区别,以及容器存在多个同类型对象时怎样选择。
当前 HealthController、HealthService 和 HealthProperties 仍然共同完成心跳接口。本章只讨论 Bean 的注册与查找,作用域和生命周期放到后面的独立文章。
1. Spring Bean
Spring Bean 是由 Spring IoC 容器创建、装配和管理的对象。它在 Java 语法层面仍然是普通对象,仍然通过类描述结构、通过构造方法创建、通过实例方法完成工作。
下面的类本身只是普通 Java 类型:
1package cn.usehook.spring.health;23public class HealthService {45public HealthStatus check() {6return new HealthStatus("UP", "spring");7}8}
应用代码主动调用 new HealthService() 时,得到普通对象。给类添加 @Service 并让它处于组件扫描范围内后,容器会为它注册 Bean 定义,再根据定义创建实例:
01package cn.usehook.spring.health;0203import org.springframework.stereotype.Service;0405@Service06public class HealthService {0708public HealthStatus check() {09return new HealthStatus("UP", "spring");10}11}
| 管理内容 | 普通对象 | Spring Bean |
|---|---|---|
| 创建者 | 应用代码主动 new | 容器根据 Bean 定义创建 |
| 依赖来源 | 调用方手动提供 | 容器解析并注入 |
| 实例数量 | 由业务代码决定 | 由 Bean 作用域决定 |
| 初始化和销毁 | 业务代码安排 | 可以参与容器生命周期 |
| 框架增强 | 原始对象 | 必要时可由代理包装 |
Spring Bean 不等于传统 JavaBean。JavaBean 通常强调无参构造、属性 getter 和 setter 等约定;Spring Bean 强调对象由 Spring 容器管理,它可以只有一个带参数的构造方法,也不要求为所有字段提供 setter。
2. 定义与实例
类、Bean 定义和 Bean 实例属于三个层次:
| 概念 | 心跳项目中的示例 | 作用 |
|---|---|---|
| Java 类 | HealthService.class | 描述字段、构造方法和行为 |
| Bean 定义 | 名为 healthService 的容器元数据 | 描述怎样创建和管理对象 |
| Bean 实例 | 注入 Controller 的具体对象 | 真正执行 check() |
Bean 定义通常包含类型、名称、创建方式、构造依赖、作用域、懒加载和生命周期回调。一个 .java 文件只提供类型声明,不会自动产生 Bean;容器必须先获得定义,才能决定何时创建对象。
同一个 Java 类可以对应多个 Bean。下面两个方法都返回 Clock,却注册两个名称不同的定义:
01package cn.usehook.spring.config;0203import java.time.Clock;04import java.time.ZoneId;05import org.springframework.context.annotation.Bean;06import org.springframework.context.annotation.Configuration;0708@Configuration09public class TimeConfiguration {1011@Bean12public Clock utcClock() {13return Clock.systemUTC();14}1516@Bean17public Clock shanghaiClock() {18return Clock.system(ZoneId.of("Asia/Shanghai"));19}20}
容器现在拥有 utcClock 和 shanghaiClock 两个 Bean。它们类型相同但定义不同,也会产生不同实例。反过来,一个 Bean 定义能产生几个实例由作用域决定,而不是由 Java 类决定。
3. Bean 的来源
Spring Boot 项目中的 Bean 主要来自组件扫描、@Bean 方法和自动配置。
组件扫描
入口类的 @SpringBootApplication 会从 cn.usehook.spring 根包开始扫描。项目维护的组件使用与职责匹配的注解:
| 注解 | 常见职责 | 当前案例 |
|---|---|---|
@Component | 通用组件 | 没有更具体角色的协作对象 |
@Service | 业务逻辑 | HealthService |
@Repository | 数据访问 | 后面的 Repository |
@Controller | MVC 页面控制器 | 返回服务器页面的控制器 |
@RestController | REST 接口控制器 | HealthController |
这些角色注解都建立在 @Component 之上。使用具体注解不会改变普通 Java 语法,但能让类的职责和框架处理意图更清楚。
@Bean 工厂方法
不能修改源码的第三方类型,或者对象创建过程需要明确参数时,可以使用 @Bean。上一章的普通 HealthProperties 就通过这种方式注册:
1@Configuration2public class HealthConfiguration {34@Bean5public HealthProperties healthProperties() {6return new HealthProperties("UP", "spring");7}8}
默认 Bean 名称是方法名 healthProperties,声明类型来自返回类型,实例来自方法返回值。@Bean 方法也可以通过参数声明依赖,容器会在调用方法前先解析这些参数。
一个类通常选择组件扫描或 @Bean 中的一种注册方式。给 HealthService 添加 @Service 后又声明一个返回 HealthService 的 @Bean,会产生两个同类型候选对象;除非业务确实需要两个实例,否则这是重复注册。
自动配置
Spring Boot 根据 classpath、配置和容器中已有对象注册框架 Bean。引入 Web MVC Starter 后,处理请求所需的 MVC 基础设施才会出现。应用主动提供某些自定义 Bean 时,对应默认配置通常会退让。
自动配置产生的对象仍然是 Bean,也遵守相同的名称、依赖解析、作用域和生命周期规则。
4. 名称与候选对象
未显式指定时,组件类通常以首字母小写的类名作为 Bean 名称,@Bean 方法则使用方法名:
| 注册代码 | 默认名称 | 类型 |
|---|---|---|
@Service class HealthService | healthService | HealthService |
@RestController class HealthController | healthController | HealthController |
@Bean Clock utcClock() | utcClock | Clock |
构造器注入先根据参数类型寻找候选对象。容器中只有一个 HealthService 时,参数名不必和 Bean 名称一致:
1public HealthController(HealthService service) {2this.healthService = service;3}
存在两个 Clock 时,仅凭类型无法确定唯一值。可以用 @Primary 指定没有额外条件时的默认候选:
01@Bean02@Primary03public Clock utcClock() {04return Clock.systemUTC();05}0607@Bean08public Clock shanghaiClock() {09return Clock.system(ZoneId.of("Asia/Shanghai"));10}
某个使用方明确需要上海时区时,使用 @Qualifier 继续筛选同类型对象:
1public HealthService(@Qualifier("shanghaiClock") Clock clock) {2this.clock = clock;3}
@Qualifier 不是绕过类型检查的字符串查找。容器先确定 Clock 类型候选,再用限定值缩小范围。
| 需求 | 方式 |
|---|---|
| 为大多数注入点提供默认候选 | @Primary |
| 某个注入点选择特定候选 | @Qualifier |
| 把公共实现降为备用候选 | @Fallback |
@Fallback 适合框架或公共模块提供默认实现:只要存在普通候选,备用对象就不会成为优先选择。心跳项目只有自己的实现时不需要使用它。
5. 单值与集合注入
只有一个业务实现时,构造器直接声明具体类型通常最清楚,不需要机械地创建同名接口。真正存在多个实现且调用方需要全部对象时,可以注入集合或映射:
01package cn.usehook.spring.health;0203import java.time.Clock;04import java.util.Map;05import org.springframework.stereotype.Component;0607@Component08public class ClockRegistry {0910private final Map<String, Clock> clocks;1112public ClockRegistry(Map<String, Clock> clocks) {13this.clocks = Map.copyOf(clocks);14}15}
映射的键是 Bean 名称,值是对应的 Clock。@Primary 只影响单值依赖,不会从集合中移除其他候选。
并非运行期间出现的每个对象都应成为 Bean:
| 对象 | 是否适合 | 原因 |
|---|---|---|
HealthController | 是 | 由 MVC 调用并依赖服务 |
HealthService | 是 | 被控制器依赖的共享业务组件 |
HealthProperties | 是 | 被服务共享的配置依赖 |
Clock | 是 | 需要统一配置并方便替换测试 |
HealthStatus | 通常不是 | 一次心跳调用产生的结果 |
| 请求 DTO | 通常不是 | 属于一次请求,由 MVC 参数解析创建 |
IoC 的目的不是消灭 new,而是管理组件之间长期存在的依赖。短生命周期值对象、返回值和局部数据继续由业务代码创建。
6. 观察容器
为了验证名称和候选结果,可以临时从启动完成的上下文中读取 Bean:
01ConfigurableApplicationContext context = SpringApplication.run(02Application.class,03args04);0506HealthService first = context.getBean(HealthService.class);07HealthService second = context.getBean("healthService", HealthService.class);08Map<String, Clock> clocks = context.getBeansOfType(Clock.class);0910System.out.println(first == second);11System.out.println(clocks.keySet());
按类型和按名称取得的是同一个 healthService 引用,因为它当前使用默认单例作用域。getBeansOfType() 返回所有匹配对象,可用于观察多个 Clock 是否都已注册。
这段代码只适合教学和诊断,不应让普通业务类保存 ApplicationContext 再主动查找依赖。业务依赖仍然通过构造器声明,关系才会直接体现在类结构中。
现在已经知道 Bean 怎样注册和选择。下一篇将上一章写死的 HealthProperties 工厂方法替换为外部配置绑定;随后再学习作用域如何决定实例数量,以及容器怎样处理初始化和销毁。