从零开始 Spring Boot 46:@Lookup

从零开始 Spring Boot 46:@Lookup

从零开始 Spring Boot 46:@Lookup_第1张图片

图源:简书 (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.injectgroupId>
    <artifactId>jakarta.inject-apiartifactId>
    <version>2.0.1version>
dependency>

使用Provider完成之前的示例:

@Component
public class BookStore8 {
    @Autowired
    private Provider<Book> bookProvider;

    public Book getBook(){
        return bookProvider.get();
    }
}

这里我们不直接注入Book,而是注入Provider,并且在需要获取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接口,其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 对象。

最终的效果和ProviderObjectFactory等类似,如果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)

你可能感兴趣的:(JAVA,spring,boot,lookup,Provider)