《Spring Boot 入门》5.2 注入方式与作用域

本节用图书管理服务的 BookService 与 BookRepository 对比构造器、Setter、字段三种注入方式,解释官方为何推荐构造器注入,演示 @Qualifier 与 @Primary 解决同类型多 Bean,并拆解五种作用域与 prototype 注入 singleton 的经典陷阱及两种解法。

本节目标:掌握构造器、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 官方文档明确建议「用构造器注入强制依赖」。理由有四条:

  1. 依赖不可变。字段可以声明为 final,对象一旦建好,依赖就不会被换掉。
  2. 依赖不为空。构造器要求参数齐全,容器要么把依赖给你,要么启动直接失败,绝不会出现运行到一半才发现 repository 是 null。
  3. 暴露循环依赖。A 依赖 B、B 依赖 A 时,构造器注入会在启动阶段直接抛 BeanCurrentlyInCreationException,让你立刻发现问题;字段/Setter 注入则可能被容器「兜住」,把设计缺陷藏起来。
  4. 便于测试。测试里可以 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 生命周期与初始化回调 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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