从零开始 Spring Boot 46:@Lookup
图源:简书 (jianshu.com)
在前文中,我介绍了 Spring Bean 的作用域(Scope),且讨论了将一个短生命周期的 bean (比如request
作用域的 bean)注入到长生命周期的 bean (比如singleton
作用域的 bean)时所面临的问题,此类问题都需要我们对短生命周期的 bean 通过代理注入来解决。
实际上,即使都是长生命周期的bean,比如singleton
作用域和prototype
作用域的 bean,注入也存在一些问题。
注入问题
这里用一个示例说明将 prototype
作用域的 bean 注入 singleton
作用域的 bean 会出现什么问题:
@Value
public class Book {
String name;
String author;
String isbn;
}
public class BookStore {
@Autowired
private Book book;
public Book getBook(){
return book;
}
}
@Configuration
public class WebConfig {
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Bean
public Book book() {
return new Book("哈利波特与魔法石", "JK罗琳", "9787020033430");
}
@Bean
public BookStore bookStore() {
return new BookStore();
}
}
在这个例子中,BookStore
bean 的作用域是单例,Book
的作用域是原型。这是我们故意为之,因为我们想通过getBook
方法从书店中获取图书时每次都获取到一本新书。
但实际测试就会发现结果并不是我们预期的那样:
@SpringJUnitConfig(classes = {WebConfig.class})
public class BookStoreTests {
@Test
void testBookInject(@Autowired 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 {
@Autowired
private ApplicationContext applicationContext;
public Book getBook() {
return applicationContext.getBean(Book.class);
}
}
现在每次获取到的都是新的Book
对象:
@TestConfiguration
public class BookConfig {
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Bean
public Book book() {
return new Book("哈利波特与魔法石", "JK罗琳", "9787020033430");
}
}
@SpringJUnitConfig
public class BookStore2Tests {
@Configuration
@Import(BookConfig.class)
static class Config {
@Bean
public BookStore2 bookStore2() {
return new BookStore2();
}
}
@Test
void testBookInject(@Autowired BookStore2 bookStore2) {
var book1 = bookStore2.getBook();
var book2 = bookStore2.getBook();
Assertions.assertNotSame(book1, book2);
}
}
就像我们之前提到的,虽然这样可以解决问题,但并不建议直接使用ApplicationContext
,这样会导致我们的代码与 Spring 框架“强耦合”。
为了方便后续的测试用例编写,这里将
Book
bean 的相关配置拆分出来,并用@Import
导入到当前测试用例中,更多的 Spring 测试相关内容,可以阅读我的这篇文章。
为此,Spring 提供了一个@Lookup
注解来解决上述问题。
@Lookup
直接看示例:
@Component
public class BookStore3 {
@Lookup
public Book getBook() {
return null;
}
}
用@Lookup
标记的 bean 方法,在调用时会被代理,实际上 Spring 会通过ApplicationContext.getBean(Book.class)
获取一个 bean 并返回。
- 注意,这里的
BookStore3
使用@Component
添加 bean 定义,原因在后面说明。- 因为用
@Lookup
标记的方法会被代理,所以这里的getBook
方法的内容和返回值无关紧要,实际上充当一个占位桩(stub),因此大多数情况下用@Lookup
标记的方法直接返回null
即可。
所以,使用@Lookup
可以解决诸如“将原型 bean 注入 单例 bean”的问题。
这点可以通过以下测试用例验证:
@SpringJUnitConfig(classes = LookupApplication.class)
public class BookStore3Tests {
@Test
void testBookStore3(@Autowired 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 {
@Lookup
public Book getBook() {
return null;
}
}
@SpringJUnitConfig
public class BookStore4Tests {
@Configuration
@Import(BookConfig.class)
static class Config {
@Bean
public BookStore4 bookStore4() {
return new BookStore4();
}
}
@Test
void testBookStore4(@Autowired 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
方法返回的类型必须是一个“具体类型”,不能是抽象类,比如:
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@Component
@EqualsAndHashCode
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;
}
}
@Component
public abstract class BookStore7 {
@Lookup
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,谢谢阅读。
本文的完整示例可以从这里获取。
参考资料
- spring注解@Lookup使用原理和注意点以及其他替换实现方案
- @Lookup Annotation in Spring | Baeldung
- Injecting Prototype Beans into a Singleton Instance in Spring | Baeldung
- 从零开始 Spring Boot 43:DI 注解 - 红茶的个人站点 (icexmoon.cn)
- 从零开始 Spring Boot 27:IoC - 红茶的个人站点 (icexmoon.cn)