Spring Boot Bean 使用规范
Bean 的加载机制、生命周期与三级缓存见启动原理。本文只回答工程问题:Bean 怎么定义、怎么注入、什么时候偏离单例,以及每个决策对应的排错线索。代码基于 Spring Boot 4.x(Spring Framework 7、Jakarta EE 11、Java 17+)。
Bean 定义:按来源选方式
| 来源 | 方式 | 要点 |
|---|---|---|
| 自己的业务类 | @Service / @Repository / @Controller | 语义即文档,扫描注册 |
| 第三方库组件 | @Configuration + @Bean | 需要定制创建过程 |
| 需强制加载的配置 | @Import | 组合配置类,替代包扫描 |
| 需编程批量注册 | BeanRegistrar(4.x 新增) | 循环/条件注册,替代单方法多 Bean 的反模式 |
1 | |
一个 @Bean 方法只注册一个 Bean、返回最具体类型,这是容器推断类型的依据。需要按循环或条件批量注册时,4.x 提供 BeanRegistrar 编程式注册:
1 | |
两个决策点:
proxyBeanMethods = false关闭配置类代理。类内@Bean方法互相调用时不再被拦截、不保证单例——只要不在配置类内部互调方法,就该关掉,省去 CGLIB 代理开销。- Bean 默认名是类名首字母小写,但前两个字母都大写时原名保留:
URLClient的 Bean 名仍是URLClient。@Qualifier写错大小写是常见注入失败原因。
依赖注入:构造器为默认
强制依赖
1 | |
字段注入的问题不在语法,在于它把依赖藏起来:构造器签名就是依赖清单,一眼看清协作对象,测试时直接 new。保持全部构造器注入,"这个类依赖太多"的坏味道会在构造器参数上一行暴露出来,而不是散落在字段间。
可选依赖:ObjectProvider
@Autowired(required = false) 把"可能缺失"固化成 null 判断,散落在各处。ObjectProvider 把缺失处理收敛到使用点:
1 | |
多实现:从 @Primary 到 Map 路由
容器里同类型 Bean 不止一个且未消歧时,启动报 NoUniqueBeanDefinitionException。按需求选解法:
| 需求 | 写法 |
|---|---|
| 有明确默认实现 | 目标类标 @Primary |
| 注入点显式选择 | 构造器参数加 @Qualifier("beanName") |
| 使用全部实现 | 注入 List<T> 或 Map<String, T> |
1 | |
字段名匹配(private PaymentService alipayService 自动命中同名 Bean)是 Spring 消歧的最后回退,不是设计手段——重命名字段就静默换实现,禁止依赖。
实现会持续新增的场景(支付渠道、通知渠道),用 Map 收集全部实现,以开闭原则路由:
1 | |
新增渠道只需新增一个 @Service 实现,路由器零改动。
泛型接口不需要任何消歧:Spring 按泛型参数匹配,Repository<User> 与 Repository<Order> 互不冲突。
注入注解的选择一条规则说清:统一 @Autowired(按类型)+ @Qualifier(消歧)。@Resource 按名称注入,是 Jakarta 标准注解,与 Spring API 混用只会让团队在两套语义间摇摆。
注入失败的排错线索
注入失败在启动期暴露,全部快于运行期。看异常链最底层的 caused-by:
| 异常 | 含义 | 排查方向 |
|---|---|---|
NoSuchBeanDefinitionException | 找不到候选 | 实现类缺注解、包不在扫描范围、条件注解未命中 |
NoUniqueBeanDefinitionException | 候选多个未消歧 | 补 @Primary 或 @Qualifier |
UnsatisfiedDependencyException | 上两者的包装 | 看底层层因,栈顶即注入点 |
作用域:默认单例,谨慎偏离
单例的契约是无状态。违反它不报错,只在并发下出错:
1 | |
正确做法是无状态 + 参数传递;确需上下文时用 request 作用域。
prototype 注入陷阱
单例里注入 prototype,注入只发生一次——拿到的永远是同一个实例,作用域声明形同虚设:
1 | |
Web 作用域必须开代理
1 | |
不开代理,容器会在启动期把真实实例注入单例,而此时不存在 HTTP 请求,直接报 No thread-bound request found。proxyMode 让注入的是代理,实际解析延迟到每次方法调用时。
生命周期回调
1 | |
执行顺序:
1 | |
三个决策点:
@PostConstruct/@PreDestroy是标准注解(jakarta.annotation包),不耦合 Spring 接口,优先使用。注意 4.x 所基于的 Framework 7 已彻底移除javax.annotation/javax.inject注解支持,javax.*老代码无法启动,必须完成 Jakarta 迁移。@PostConstruct在 AOP 代理创建之前执行,方法内this.xxx()自调用不经过代理,@Transactional、@Async全部失效。InitializingBean、DisposableBean、各类Aware接口把代码绑死在 Spring API 上,只在库开发中使用。
初始化过重(全量预热、远程拉取)时不要堆进 @PostConstruct——它阻塞启动。改用 ApplicationRunner 或监听 ApplicationReadyEvent。
条件化装配
@ConditionalOnProperty 匹配规则
| 属性状态 | havingValue 省略 | havingValue = “true” | matchIfMissing = true |
|---|---|---|---|
| 属性 = true | 匹配 | 匹配 | — |
| 属性 = false | 不匹配 | 不匹配 | — |
| 属性 = “abc” | 匹配 | 不匹配 | — |
| 属性不存在 | 不匹配 | 不匹配 | 匹配 |
规则归纳:省略 havingValue 时,属性存在且不为 false 即匹配;matchIfMissing 决定属性缺失时走默认开还是默认关。
1 | |
matchIfMissing = true 只加在默认实现上,保证不配置时恰好有一个 Bean 生效。属性名支持宽松绑定:payment.provider 能匹配环境变量 PAYMENT_PROVIDER。name 传数组时必须全部满足才匹配。
@ConditionalOnMissingBean 的边界
它实现"用户配置优先于自动配置",但只在自动配置类里有这语义。普通应用里用它,Bean 是否生效取决于注册顺序,条件评估结果难以推断——应用代码需要互斥时,用 @ConditionalOnProperty 这种显式开关。
循环依赖
1 | |
Spring Boot 2.6 起默认拒绝循环依赖,构造器注入的环在 Spring Framework 层面本就无法成立。处理优先级:
- 重构(正解):循环依赖是两个类职责纠缠的信号。抽取第三个类承载公共逻辑,或用事件反转通知方向——
OrderService发布OrderPaidEvent,另一方监听,依赖单向化。 @Lazy(明确止血):注入的是代理,首次调用才解析目标。适用于确有环形领域模型且改动成本高的存量代码,新代码里出现就是坏味道。
三级缓存只救"singleton + setter/字段注入"的环,且自 Boot 2.6 起默认关闭;prototype 的环任何手段都无解。机制细节见启动原理。
检查清单
| 检查项 | 标准 |
|---|---|
| Bean 定义 | 业务类用语义注解;第三方用 @Bean,proxyBeanMethods = false;批量/条件注册用 BeanRegistrar |
| 注入 | 构造器 + final 字段;可选依赖用 ObjectProvider |
| 作用域 | 单例必须无状态;prototype 注入单例改用 ObjectProvider |
| Web 作用域 | 显式 proxyMode = TARGET_CLASS |
| 生命周期 | @PostConstruct / @PreDestroy;重初始化移入 ApplicationRunner |
| 条件装配 | 默认实现配 matchIfMissing = true;@ConditionalOnMissingBean 只用于库 |
| 循环依赖 | 默认拒绝;重构优先,@Lazy 仅止血 |



