
图源:
在中,我介绍了 Spring Bean 的作用域(Scope),且讨论了将一个短生命周期的 bean (比如request作用域的 bean)注入到长生命周期的 bean (比如singleton
实际上,即使都是长生命周期的bean,比如singleton作用域和prototype作用域的 bean,注入也存在一些问题。
注入问题
这里用一个示例说明将 prototype 作用域的 bean 注入 singleton 作用域的 bean 会出现什么问题:
public class Book {
String name;
String author;
String isbn;
}
public class BookStore {
private Book book;
public Book getBook(){
return book;
}
}
public class WebConfig {
(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Book book() {
return new Book("哈利波特与魔法石", "JK罗琳", "9787020033430");
}
public BookStore bookStore() {
return new BookStore();
}
}
在这个例子中,BookStore bean 的作用域是单例,Book的作用域是原型。这是我们故意为之,因为我们想通过getBook方法从书店中获取图书时每次都获取到一本新书。
但实际测试就会发现结果并不是我们预期的那样:
(classes = {WebConfig.class})
public class BookStoreTests {
void testBookInject( BookStore bookStore) {
var book1 = bookStore.getBook();
var book2 = bookStore.getBook();
Assertions.assertSame(book1, book2);
}
}
两次调用获取到的是同一个Book对象。
这是因为虽然Book bean 的作用域是原型,但将Book注入到BookStore这个单例 bean 中的行为仅会发生一次——在BookStore bean 被创建后。之后每次调用getBook获取Book对象都是直接获取BookStore中的book依赖,而不会再触发注入或者从ApplicationContext中获取 bean。
当然,解决的方式也很容易,只需要改为从ApplicationContext中获取 bean 即可:
public class BookStore2 {
private ApplicationContext applicationContext;
public Book getBook() {
return applicationContext.getBean(Book.class);
}
}
现在每次获取到的都是新的Book对象:
public class BookConfig {
(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Book book() {
return new Book("哈利波特与魔法石", "JK罗琳", "9787020033430");
}
}
public class BookStore2Tests {
(BookConfig.class)
static class Config {
public BookStore2 bookStore2() {
return new BookStore2();
}
}
void testBookInject( BookStore2 bookStore2) {
var book1 = bookStore2.getBook();
var book2 = bookStore2.getBook();
Assertions.assertNotSame(book1, book2);
}
}
就像我们之前提到的,虽然这样可以解决问题,但并不建议直接使用ApplicationContext,这样会导致我们的代码与 Spring 框架“强耦合”。
为了方便后续的测试用例编写,这里将
Bookbean 的相关配置拆分出来,并用@Import导入到当前测试用例中,更多的 Spring 测试相关内容,可以阅读我的。
为此,Spring 提供了一个@Lookup注解来解决上述问题。
@Lookup
直接看示例:
public class BookStore3 {
public Book getBook() {
return null;
}
}
用@Lookup标记的 bean 方法,在调用时会被代理,实际上 Spring 会通过ApplicationContext.getBean(Book.class)获取一个 bean 并返回。
注意,这里的
BookStore3使用@Component添加 bean 定义,原因在后面说明。因为用
@Lookup标记的方法会被代理,所以这里的getBook方法的内容和返回值无关紧要,实际上充当一个占位桩(stub),因此大多数情况下用@Lookup标记的方法直接返回null即可。
所以,使用@Lookup可以解决诸如“将原型 bean 注入 单例 bean”的问题。
这点可以通过以下测试用例验证:
(classes = LookupApplication.class)
public class BookStore3Tests {
void testBookStore3( BookStore3 bookStore3) {
var book1 = bookStore3.getBook();
var book2 = bookStore3.getBook();
Assertions.assertNotSame(book1, book2);
}
}
通过@Lookup方法来获取 bean 的方式也被称作“方法注入”(method injection)。
限制
需要注意的是,使用@Lookup的 bean,必须使用@Component之类的注解直接添加 bean 定义,如果通过@bean方法的方式添加,@Lookup就不会起作用。
这点可以通过以下错误示例验证:
public class BookStore4 {
public Book getBook() {
return null;
}
}
public class BookStore4Tests {
(BookConfig.class)
static class Config {
public BookStore4 bookStore4() {
return new BookStore4();
}
}
void testBookStore4( BookStore4 bookStore4) {
var book1 = bookStore4.getBook();
var book2 = bookStore4.getBook();
Assertions.assertSame(null, book1);
Assertions.assertSame(null, book2);
}
}
因为使用@Bean方法添加BookStore4,所以其中@Lookup标记的getBook方法并不会被代理,所以这里bookStore4.get()返回的是null。
此外,用@Lookup方法返回的类型必须是一个“具体类型”,不能是抽象类,比如:
(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public abstract class Book3 {
private final String name;
private final String author;
private final String isbn;
public Book3(String name, String author, String isbn) {
this.name = name;
this.author = author;
this.isbn = isbn;
}
}
public abstract class BookStore7 {
public abstract Book3 getBook(String name, String author, String isbn);
}
这里的Book3是一个抽象类,而@Lookup代理并查找Book3类型的 bean 时会忽略抽象类的 bean,所以试图通过getBook方法获取 bean 时会产生一个NoSuchBeanDefinitionException异常:
@SpringJUnitConfig(classes = {LookupApplication.class})
public class BookStore7Tests {
@Test
void testBookStore7(@Autowired BookStore7 bookStore7) {
String bookName = "哈利波特与魔法石";
String bookAuthor = "JK罗琳";
String isbn = "9787020033430";
var book1 = bookStore7.getBook(bookName, bookAuthor, isbn);
var book2 = bookStore7.getBook(bookName, bookAuthor, isbn);
System.out.println(book1);
}
}
abstract
@Lookup还可以用于抽象方法:
@Component
public abstract class BookStore5 {
@Lookup
public abstract Book getBook();
}
测试用例与之前的类似,这里不再展示,感兴趣的可以看。
构造器
利用@Lookup还可以通过相应 bean 的带参构造器来创建对象,比如:
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Component
@Value
@EqualsAndHashCode
public class Book2 {
String name;
String author;
String isbn;
}
@Component
public abstract class BookStore6 {
@Lookup
public abstract Book2 getBook(String name, String author, String isbn);
}
这里的Book2不再是通过@Bean方法添加定义,而是用@Component添加 bean 定义。
@Lookup标记的方法需要通过代理创建一个Book2类型的 bean,显然的,Book2对象只能通过包含3个参数的构造器(使用 Lombok 注解@Value生成)来创建。换言之,我们必须“告诉”@Lookup方法Book2构造器所需的参数。要实现这点也很容易,只要在@Lookup方法中添加相应的形参,并在实际调用中传入即可。
下面是实际的测试用例:
@SpringJUnitConfig(classes = {LookupApplication.class})
public class BookStore6Tests {
@Test
void testBookStore6(@Autowired BookStore6 bookStore6){
String bookName = "哈利波特与魔法石";
String bookAuthor = "JK罗琳";
String isbn = "9787020033430";
var book1 = bookStore6.getBook(bookName, bookAuthor, isbn);
var book2 = bookStore6.getBook(bookName, bookAuthor, isbn);
Assertions.assertNotSame(book1, book2);
Assertions.assertEquals(book1, book2);
Assertions.assertEquals(book1, new Book2(bookName, bookAuthor, isbn));
}
}
可能这个例子多少有点“多余”,因为完全可以不用@Lookup,而直接在getBook方法中返回new Book2(...)。但是,这里没有直接new而是利用@Lookup让 Spring 创建 bean 并返回的好处在于——创建的Book2对象依然是 Spring Bean,所以在Book2中我们可以使用依赖注入,且使用生命周期回调等。
Provider
使用Provider同样可以解决这里的注入问题。
Provider属于jakarta.inject包,因此和使用@Inject一样,需要添加以下依赖:
<dependency>
<groupId>jakarta.inject</groupId>
<artifactId>jakarta.inject-api</artifactId>
<version>2.0.1</version>
</dependency>
使用Provider完成之前的示例:
@Component
public class BookStore8 {
@Autowired
private Provider<Book> bookProvider;
public Book getBook(){
return bookProvider.get();
}
}
这里我们不直接注入Book,而是注入Provider<Book>,并且在需要获取Book类型的 bean 时,通过bookProvider.get()获取。
测试用例:
@SpringJUnitConfig(classes = {LookupApplication.class})
public class BookStore8Tests {
@Test
void testBookStore8(@Autowired BookStore8 bookStore8) {
var book1 = bookStore8.getBook();
var book2 = bookStore8.getBook();
Assertions.assertNotSame(book1, book2);
}
}
所以,使用Provider可以起到@Lookup类似的作用。
与@Lookup不同的是,Provider依然可以在@bean方法添加 bean 定义时使用:
public class BookStore9 {
@Autowired
private Provider<Book> bookProvider;
public Book getBook(){
return bookProvider.get();
}
}
@SpringJUnitConfig
public class BookStore9Tests {
@Configuration
@Import(BookConfig.class)
static class Config {
@Bean
public BookStore9 bookStore9() {
return new BookStore9();
}
}
@Test
void testBookStore9(@Autowired BookStore9 bookStore9,@Autowired Book book) {
var book1 = bookStore9.getBook();
var book2 = bookStore9.getBook();
Assertions.assertNotSame(book1, book2);
Assertions.assertEquals(book1, book2);
Assertions.assertEquals(book1, book);
}
}
用
Provider获取 bean 时逻辑与@Lookup类似,如果目标 bean 是原型,每次都会获取到一个新的 bean 实例,如果目标 bean 是单例,每次都会获取到同一个 bean 实例。
ObjectFactory
Spring 框架有一个ObjectFactory<T>接口,其ObjectFactory.getObject()每次调用会返回一个泛型类型的对象。
@Component
public class BookStore11 {
@Autowired
private ObjectFactory<Book> bookFactory;
public Book getBook(){
return bookFactory.getObject();
}
}
上面这个示例中,如果Book bean 作用域是原型,那每次调用getBook会返回一个新对象,如果Book bean 是单例,那么返回的是同一个Book对象。
总的来说,ObjectFactory的用途与Provider或@Lookup是类似的。
Lamda
还可以用 Lamda 表达式的方式来解决此类问题:
@Configuration
public class WebConfig {
// ...
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Bean
public Book book() {
return new Book("哈利波特与魔法石", "JK罗琳", "9787020033430");
}
@Bean
public Supplier<Book> bookSupplier() {
return this::book;
}
}
这里定义了一个 Lamda 表达式的 bean,其实际上就是WebConfig.book()这个方法,而这个方法就是Book的@Bean工厂方法。
在书店类中,我们可以直接注入这个 Lamda 表达式:
@Component
public class BookStore12 {
@Autowired
private Supplier<Book> bookSupplier;
public Book getBook(){
return bookSupplier.get();
}
}
并且在getBook方法中通过 Lamda 表达式获取Book对象,其本质上是调用WebConfig.book()方法获取Book对象,而后者的调用又会被代理,所以实质上还是通过ApplicationContext.getBean获取 Book 对象。
最终的效果和Provider、ObjectFactory等类似,如果Book bean 是单例,每次会获得同一个对象,如果是原型,每次会获得一个新的对象。
特别的,使用 Lamda 表达式会产生一个类似 @Lookup 那样的好处,即我们可以在获取 bean 时指定一些参数:
@FunctionalInterface
public interface GetBookFunction {
Book get(String name, String author, String isbn);
}
@Component
public class BookStore13 {
@Autowired
private GetBookFunction getBookFunction;
public Book getBook(String name, String author, String isbn) {
return getBookFunction.get(name, author, isbn);
}
}
@SpringJUnitConfig
public class BookStore13Tests {
@Configuration
@Import(BookStore13.class)
static class Config {
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Bean
public Book book(String name, String author, String isbn) {
return new Book(name, author, isbn);
}
@Bean
public GetBookFunction getBookFunction() {
return this::book;
}
}
@Test
void testLamdaInject(@Autowired BookStore13 bookStore13){
String bookName = "哈利波特与魔法石";
String bookAuthor = "JK罗琳";
String isbn = "9787020033430";
var book1 = bookStore13.getBook(bookName, bookAuthor, isbn);
var book2 = bookStore13.getBook(bookName, bookAuthor, isbn);
Assertions.assertNotSame(book1, book2);
Assertions.assertNotNull(book1);
Assertions.assertNotNull(book2);
Assertions.assertEquals(book1, book2);
Assertions.assertEquals(book1, new Book(bookName, bookAuthor, isbn));
}
}
如果用这种方式获取一个单例 bean,就需要格外小心,此时会产生一些奇怪的现象,比如:
@SpringJUnitConfig
public class BookStore13V2Tests {
@Configuration
@Import(BookStore13.class)
static class Config {
@Lazy
@Bean
public Book book(String name, String author, String isbn) {
return new Book(name, author, isbn);
}
@Bean
public GetBookFunction getBookFunction() {
return this::book;
}
}
@Test
void testLamdaInject(@Autowired BookStore13 bookStore13) {
String bookName = "哈利波特与魔法石";
String bookAuthor = "JK罗琳";
String isbn = "9787020033430";
var book1 = bookStore13.getBook(bookName, bookAuthor, isbn);
var book2 = bookStore13.getBook(bookName, bookAuthor, isbn);
Assertions.assertSame(book1, book2);
Assertions.assertNotNull(book1);
Assertions.assertNotNull(book2);
Assertions.assertEquals(book1, book2);
Assertions.assertEquals(book1, new Book(bookName, bookAuthor, isbn));
var book3 = bookStore13.getBook("鳄鱼", "莫言", "123");
Assertions.assertSame(book1, book3);
Assertions.assertEquals(book1, book3);
Assertions.assertNotEquals(book3, new Book("鳄鱼", "莫言", "123"));
Assertions.assertEquals(book3, new Book(bookName, bookAuthor, isbn));
}
}
这里需要用@Lazy标记Config.book方法,否则 ApplicationContext 创建后会立即初始化所有的单例 bean,而Book bean 需要3个String参数,实际上并没有String bean 用于注入,就会导致程序运行出错。
此外,这里的Book bean 是单例,其余部分代码基本一致。
但观察测试用例就能发现,无论我们通过getBook方法调用时入参是否都相同,实际上获取到的都是最初创建的 bean。换言之,即使我们用了不同的参数获取 bean(book3),获取到的依然是第一次获取的 bean(book1)。
虽然这样看起来很奇怪,但至少保证了单例作用域的 bean 只会有一个实例。
作用域代理
说作用域代理(Scoped Proxy)也会对此类问题有效,但我实际编写用例测试发现即使将Book作用域指定为prototype并添加代理,通过getBook获取到的Book对象依然是同一个对象,不会产生新的对象。
具体见中的测试用例BookStore10Tests。
如果有网友对此类问题有研究,欢迎留言讨论。
The End,谢谢阅读。
本文的完整示例可以从获取。

文章评论