本节目标:掌握构造器、Setter、字段三种注入方式的差异与取舍,理解官方推荐构造器注入的原因,会用 @Qualifier / @Primary 处理同类型多 Bean,并避开 prototype 注入 singleton 的陷阱。
适用版本:Spring Boot 4.1.x(Java 21)
三种注入方式
上一节我们写下 BookService 需要 BookRepository。把依赖「送进来」有三种写法。
构造器注入
@Service
public class BookService {
private final BookRepository repository;
public BookService(BookRepository repository) {
this.repository = repository;
}
public Book find(String isbn) {
return repository.findByIsbn(isbn);
}
}
Setter 注入
@Service
public class BookService {
private BookRepository repository;
@Autowired
public void setRepository(BookRepository repository) {
this.repository = repository;
}
}
字段注入
@Service
public class BookService {
@Autowired
private BookRepository repository;
}
三种写法都能跑,但它们的性质差别很大。逐项对比:
| 维度 | 构造器注入 | Setter 注入 | 字段注入 |
|---|---|---|---|
依赖能否 final | 能,不可变 | 不能 | 不能 |
| 依赖是否可能为 null | 不会 | 可能(漏配就 null) | 可能 |
| 对象能否脱离容器使用 | 能(直接 new) | 勉强(需手动调 setter) | 不能 |
| 单元测试难度 | 低(直接 new) | 中 | 高(必须靠容器/反射) |
| 循环依赖 | 启动即暴露 | 可能被掩盖 | 可能被掩盖 |
| 依赖是否显式 | 显式(构造器签名即契约) | 较显式 | 隐藏 |
| 官方推荐 | 推荐 | 可选依赖时用 | 不推荐 |
为什么官方推荐构造器注入
Spring 官方文档明确建议「用构造器注入强制依赖」。理由有四条:
- 依赖不可变。字段可以声明为
final,对象一旦建好,依赖就不会被换掉。 - 依赖不为空。构造器要求参数齐全,容器要么把依赖给你,要么启动直接失败,绝不会出现运行到一半才发现
repository是null。 - 暴露循环依赖。A 依赖 B、B 依赖 A 时,构造器注入会在启动阶段直接抛
BeanCurrentlyInCreationException,让你立刻发现问题;字段/Setter 注入则可能被容器「兜住」,把设计缺陷藏起来。 - 便于测试。测试里可以
new BookService(new FakeRepository()),完全不需要启动 Spring 容器。
一句话:构造器注入把依赖关系变成了编译期就可见的契约,而不是藏在字段上的注解。
单构造器可以省略 @Autowired
如果一个类只有一个构造器,Spring 会自动用它来注入,无需写 @Autowired:
@Service
public class BookService {
private final BookRepository repository;
// 只有一个构造器,省略 @Autowired,Spring 依然会注入
public BookService(BookRepository repository) {
this.repository = repository;
}
}
这个规则从 Spring 4.3 起就存在。所以现代 Spring Boot 代码里,@Autowired 在构造器上几乎绝迹——不是不能用,而是没必要。
注意:如果一个类有多个构造器,Spring 就不知道用哪个,这时必须至少给一个构造器加 @Autowired,否则会报找不到合适的构造器。
@Qualifier 与 @Primary 解决同类型多 Bean
假设图书检索支持两种数据源:内存版和数据库版,两个都实现了同一个接口:
public interface BookRepository {
Book findByIsbn(String isbn);
}
@Repository
public class InMemoryBookRepository implements BookRepository {
// ...
}
@Repository
public class JdbcBookRepository implements BookRepository {
// ...
}
此时 BookService 的构造器需要一个 BookRepository,容器发现有两个候选,就会抛 NoUniqueBeanDefinitionException 拒绝启动。两种解法:
解法一:@Primary 指定默认
@Primary
@Repository
public class JdbcBookRepository implements BookRepository {
// ...
}
@Primary 表示「当有多个候选时,优先选我」。这样不加任何额外限定的注入点,都会拿到 JdbcBookRepository。
解法二:@Qualifier 精确指定
当不同注入点需要不同实现时,用 @Qualifier 按 Bean 名点名:
@Service
public class BookService {
private final BookRepository repository;
public BookService(@Qualifier("inMemoryBookRepository") BookRepository repository) {
this.repository = repository;
}
}
两者可以叠加:给一个实现加 @Primary 作为默认,个别注入点再用 @Qualifier 覆盖。
| 场景 | 推荐做法 |
|---|---|
| 全局只想要一个默认实现 | @Primary |
| 个别位置要指定另一个实现 | @Qualifier("beanName") |
| 两者并存 | @Primary 定默认,@Qualifier 做例外 |
@Qualifier 的字符串就是 Bean 的名字,默认是类名首字母小写,也可以用 @Component("别名") 或 @Bean(name = "别名") 自定义。
@Value 注入简单值
注入的不是 Bean 而是配置项时,用 @Value:
@Service
public class BookService {
private final int maxPageSize;
public BookService(@Value("${book.page-size:20}") int maxPageSize) {
this.maxPageSize = maxPageSize;
}
}
${book.page-size:20}中冒号后面是默认值,配置缺失时用 20。@Value依赖占位符解析,支持${}与 SpEL(#{})两种语法。
不过,当配置项一多,满屏 @Value 会很难维护。更好的做法是用 @ConfigurationProperties 绑定一整个配置对象——这是 6.2 节的主题。@Value 适合零散的一两个值。
五种作用域
Bean 有五种作用域(scope),决定「容器什么时候创建、创建几份、活多久」:
| 作用域 | 含义 | 实例数量 | 典型场景 |
|---|---|---|---|
singleton | 容器内唯一实例(默认) | 1 | 无状态服务、Repository |
prototype | 每次获取都新建 | N | 有状态、需隔离的对象 |
request | 每个 HTTP 请求一份 | 每请求 1 | 请求级上下文 |
session | 每个 HTTP 会话一份 | 每会话 1 | 登录用户信息 |
application | 每个 ServletContext 一份 | 1(每应用) | 全局共享的 Web 级状态 |
指定方式:
@Component
@Scope("prototype")
public class BookQuery {
// ...
}
@Bean
@RequestScope
public RequestContext requestContext() {
return new RequestContext();
}
补充说明:
singleton是每个容器一份,不是 JVM 全局一份。一个应用通常只有一个容器,所以表现为「全局一份」。request、session、application只在 Web 环境有效。在非 Web 应用里使用它们会抛IllegalStateException。singleton默认在启动时预实例化;prototype每次getBean或每次被注入时才创建。
prototype 注入 singleton 的经典陷阱
这是最容易被忽视的坑。看下面这段:
@Component
@Scope("prototype")
public class BookQuery {
// 每个 BookQuery 想持有自己的查询状态
private int page = 0;
}
@Service
public class BookService {
private final BookQuery query;
public BookService(BookQuery query) {
this.query = query;
}
}
BookService 是单例,BookQuery 是 prototype。直觉上「每次注入 BookQuery 都应该是新的」,但实际上 BookService 只会被创建一次,构造器只执行一次,query 永远是同一个实例。prototype 在这里失效了。
原因:prototype Bean 的「多次创建」只发生在每次向容器索取时;而 BookService 是单例,它的构造器一辈子只跑一次,BookQuery 也就只被注入一次,之后一直复用。
解法一:ObjectProvider 延迟获取
把 ObjectProvider 注入进来,需要时再取,每次 getObject() 都是新的:
@Service
public class BookService {
private final ObjectProvider<BookQuery> queryProvider;
public BookService(ObjectProvider<BookQuery> queryProvider) {
this.queryProvider = queryProvider;
}
public void doQuery(String isbn) {
BookQuery query = queryProvider.getObject(); // 每次都是新实例
query.run(isbn);
}
}
ObjectProvider 是 Spring 提供的「延迟/多次获取」入口,还支持 getIfAvailable()、getIfUnique() 等安全取法。
解法二:@Lookup 方法注入
让容器在每次调用时都返回新实例:
@Service
public abstract class BookService {
public void doQuery(String isbn) {
BookQuery query = createQuery(); // 每次调用都是新实例
query.run(isbn);
}
@Lookup
protected abstract BookQuery createQuery();
}
@Lookup 要求方法是抽象方法(或可被 CGLIB 覆盖的非 private 方法),容器在运行时会生成子类覆盖它,每次调用都向容器要一个新的 prototype Bean。
两种解法怎么选:
| 解法 | 侵入性 | 适用 |
|---|---|---|
ObjectProvider | 低,普通字段即可 | 大多数场景首选 |
@Lookup | 较高,需抽象方法/子类 | 想保持调用点写法简洁时 |
更根本的建议:先问自己「这个对象真的需要 prototype 吗」。多数「想用 prototype」的场景,其实把状态作为方法参数传入更好——无状态单例 + 参数化调用,既没有作用域陷阱,也更容易测试。
常见坑
- 字段注入 +
final:字段注入无法配合final,很多人以为加了final更安全,其实编译不过或注入失败。 - 在
@Configuration类里用字段注入:@Configuration类的字段注入有生命周期顺序问题,应改用方法参数注入。 - 误以为 prototype 会「自动每次刷新」:如上一节,注入到单例里就只创建一次。
- 在非 Web 环境用 request/session 作用域:直接抛异常。测试切片(如
@WebMvcTest)下也要注意环境。 @Qualifier拼错 Bean 名:拼错不会报「名字错」,而是报找不到候选 Bean,容易误判。
小结
强制依赖用构造器注入并声明为 final,是 Spring 官方推荐、也最稳妥的写法;单构造器可省略 @Autowired;Setter 注入留给可选依赖。同类型多 Bean 时,用 @Primary 定默认、@Qualifier 点名。作用域里 singleton 是默认且最常用,prototype 一旦注入到单例就会「只创建一次」,需要 ObjectProvider 或 @Lookup 才能恢复多次创建——但更好的做法往往是让对象无状态、把状态改为方法参数。
阅读导航:上一节:5.1 容器与 Bean · 下一节:5.3 生命周期与初始化回调 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。