Guide to @DynamicPropertySource in Spring – Spring中的@DynamicPropertySource指南

最后修改: 2020年 8月 31日

中文/混合/英文(键盘快捷键:t)

1. Overview

1.概述

Today’s applications don’t live in isolation: we usually need to connect to various external components such as PostgreSQL, Apache Kafka, Cassandra, Redis, and other external APIs.

今天的应用程序并不是孤立存在的:我们通常需要连接到各种外部组件,如PostgreSQL、Apache Kafka、Cassandra、Redis和其他外部API。

In this tutorial, we’re going to see how Spring Framework 5.2.5 facilitates testing such applications with the introduction of dynamic properties.

在本教程中,我们将看到Spring Framework 5.2.5如何通过引入动态属性来方便测试此类应用程序。

First, we’ll start by defining the problem and seeing how we used to solve the problem in a less than ideal way. Then, we’ll introduce the @DynamicPropertySource annotation and see how it offers a better solution to the same problem. In the end, we’ll also take a look at another solution from test frameworks that can be superior compared to pure Spring solutions.

首先,我们将从定义问题开始,看看我们过去是如何以一种不太理想的方式来解决这个问题的。然后,我们将介绍@DynamicPropertySource注解,看看它是如何为同一问题提供更好的解决方案的。最后,我们还将看看另一种来自测试框架的解决方案,与纯粹的Spring解决方案相比,它可能更有优势。

2. The Problem: Dynamic Properties

2.问题:动态属性

Let’s suppose we’re developing a typical application that uses PostgreSQL as its database. We’ll begin with a simple JPA entity:

让我们假设我们正在开发一个使用PostgreSQL作为数据库的典型应用程序。我们将从一个简单的JPA entity开始。

@Entity
@Table(name = "articles")
public class Article {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    private String title;

    private String content;

    // getters and setters
}

To make sure this entity works as expected, we should write a test for it to verify its database interactions. Since this test needs to talk to a real database, we should set up a PostgreSQL instance beforehand.

为了确保这个实体按预期工作,我们应该为它写一个测试来验证它的数据库交互。由于这个测试需要与一个真正的数据库对话,我们应该事先设置一个PostgreSQL实例。

There are different approaches to set up such infrastructural tools during test executions. As a matter of fact, there are three main categories of such solutions:

在测试执行过程中,有不同的方法来设置这种基础设施工具。事实上,这种解决方案主要有三类。

  • Set up a separate database server somewhere just for the tests
  • Use some lightweight, test-specific alternatives or fakes such as H2
  • Let the test itself manage the lifecycle of the database

As we shouldn’t differentiate between our test and production environments, there are better alternatives compared to using test doubles such as H2. The third option, in addition to working with a real database, offers better isolation for tests. Moreover, with technologies like Docker and Testcontainers, it’s easy to implement the third option.

由于我们不应该区分我们的测试和生产环境,与使用测试替身(如H2)相比,有更好的选择。第三种选择,除了与真正的数据库一起工作外,还为测试提供了更好的隔离。此外,利用Docker和Testcontainers等技术,很容易实现第三个选项。

Here’s what our test workflow will look like if we use technologies like Testcontainers:

如果我们使用Testcontainers这样的技术,我们的测试工作流程将是这样的。

  1. Set up a component such as PostgreSQL before all tests. Usually, these components listen to random ports.
  2. Run the tests.
  3. Tear down the component.

If our PostgreSQL container is going to listen to a random port every time, then we should somehow set and change the spring.datasource.url configuration property dynamically. Basically, each test should have its own version of that configuration property.

如果我们的PostgreSQL容器每次都要监听一个随机的端口,那么我们应该以某种方式动态地设置和改变spring.datasource.url配置属性。基本上,每个测试都应该有自己版本的配置属性。

When the configurations are static, we can easily manage them using Spring Boot’s configuration management facility. However, when we’re facing dynamic configurations, the same task can be challenging.

当配置是静态的时候,我们可以使用Spring Boot的配置管理设施来轻松管理它们。但是,当我们面对动态配置时,同样的任务可能具有挑战性。

Now that we know the problem, let’s see a traditional solution for it.

现在我们知道了这个问题,让我们看看这个问题的传统解决方案。

3. Traditional Solution

3.传统的解决方案

The first approach to implement dynamic properties is to use a custom ApplicationContextInitializer. Basically, we set up our infrastructure first and use the information from the first step to customize the ApplicationContext:

实现动态属性的第一个方法是使用一个自定义的ApplicationContextInitializer。基本上,我们先设置好我们的基础设施,然后使用第一步的信息来定制ApplicationContext

@SpringBootTest
@Testcontainers
@ContextConfiguration(initializers = ArticleTraditionalLiveTest.EnvInitializer.class)
class ArticleTraditionalLiveTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:11")
      .withDatabaseName("prop")
      .withUsername("postgres")
      .withPassword("pass")
      .withExposedPorts(5432);

    static class EnvInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

        @Override
        public void initialize(ConfigurableApplicationContext applicationContext) {
            TestPropertyValues.of(
              String.format("spring.datasource.url=jdbc:postgresql://localhost:%d/prop", postgres.getFirstMappedPort()),
              "spring.datasource.username=postgres",
              "spring.datasource.password=pass"
            ).applyTo(applicationContext);
        }
    }

    // omitted 
}

Let’s walk through this somewhat complex setup. JUnit will create and start the container before anything else. After the container is ready, the Spring extension will call the initializer to apply the dynamic configuration to the Spring Environment. Clearly, this approach is a bit verbose and complicated.

让我们来看看这个有点复杂的设置。JUnit将在其他事情之前创建并启动容器。容器准备好后,Spring扩展将调用初始化器,将动态配置应用于Spring Environment显然,这种方法有点冗长和复杂。

Only after these steps can we write our test:

只有在这些步骤之后,我们才能编写我们的测试。

@Autowired
private ArticleRepository articleRepository;

@Test
void givenAnArticle_whenPersisted_thenShouldBeAbleToReadIt() {
    Article article = new Article();
    article.setTitle("A Guide to @DynamicPropertySource in Spring");
    article.setContent("Today's applications...");

    articleRepository.save(article);

    Article persisted = articleRepository.findAll().get(0);
    assertThat(persisted.getId()).isNotNull();
    assertThat(persisted.getTitle()).isEqualTo("A Guide to @DynamicPropertySource in Spring");
    assertThat(persisted.getContent()).isEqualTo("Today's applications...");
}

4. The @DynamicPropertySource

4.@DynamicPropertySource

Spring Framework 5.2.5 introduced the @DynamicPropertySource annotation to facilitate adding properties with dynamic values. All we have to do is to create a static method annotated with @DynamicPropertySource and having just a single DynamicPropertyRegistry instance as the input:

Spring Framework 5.2.5引入了@DynamicPropertySource注解,以方便添加具有动态值的属性。我们所要做的就是创建一个带有@DynamicPropertySource注解的静态方法,并且只有一个DynamicPropertyRegistry实例作为输入。

@SpringBootTest
@Testcontainers
public class ArticleLiveTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:11")
      .withDatabaseName("prop")
      .withUsername("postgres")
      .withPassword("pass")
      .withExposedPorts(5432);

    @DynamicPropertySource
    static void registerPgProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", 
          () -> String.format("jdbc:postgresql://localhost:%d/prop", postgres.getFirstMappedPort()));
        registry.add("spring.datasource.username", () -> "postgres");
        registry.add("spring.datasource.password", () -> "pass");
    }
    
    // tests are same as before
}

As shown above, we’re using the add(String, Supplier<Object>) method on the given DynamicPropertyRegistry to add some properties to the Spring Environment. This approach is much cleaner compared to the initializer one we saw earlier. Please note that methods annotated with @DynamicPropertySource must be declared as static and must accept only one argument of type DynamicPropertyRegistry

如上所示,我们使用add(String, Supplier<Object>) 方法在给定的DynamicPropertyRegistry 上添加一些属性到Spring Environment。与我们之前看到的初始化方法相比,这种方法要干净得多。请注意,用@DynamicPropertySource注释的方法必须被声明为static,并且必须只接受一个DynamicPropertyRegistry类型的参数。

Basically, the main motivation behind the @DynmicPropertySource annotation is to more easily facilitate something that was already possible. Although it was initially designed to work with Testcontainers, it’s possible to use it wherever we need to work with dynamic configurations.

基本上,@DynmicPropertySource注解背后的主要动机是为了更容易地促进已经可以做到的事情。尽管它最初被设计为与Testcontainers一起工作,但在我们需要与动态配置一起工作的任何地方,都可以使用它。

5. An Alternative: Test Fixtures

5.一个替代方案 测试夹具

So far, in both approaches, the fixture setup and the test code are tightly intertwined. Sometimes, this tight coupling of two concerns complicates the test code, especially when we have multiple things to set up. Imagine what the infrastructure setup would look like if we were using PostgreSQL and Apache Kafka in a single test.

到目前为止,在这两种方法中,夹具设置和测试代码是紧密结合的。有时,这种两个关注点的紧密耦合会使测试代码变得复杂,尤其是当我们有多个东西需要设置的时候。想象一下,如果我们在一个测试中使用PostgreSQL和Apache Kafka,基础设施的设置会是什么样子。

In addition to that, the infrastructure setup and applying dynamic configurations will be duplicated in all tests that need them.

除此之外,基础设施设置和应用动态配置将在所有需要它们的测试中重复进行

To avoid these drawbacks, we can use test fixtures facilities that most testing frameworks provide. For instance, in JUnit 5, we can define an extension that starts a PostgreSQL instance before all tests in our test class, configures Spring Boot, and stops the PostgreSQL instance after running tests:

为了避免这些缺点,我们可以使用大多数测试框架所提供的测试固定设施。例如,在JUnit 5中,我们可以定义一个extension,在我们的测试类的所有测试之前启动PostgreSQL实例,配置Spring Boot,并在运行测试后停止PostgreSQL实例。

public class PostgreSQLExtension implements BeforeAllCallback, AfterAllCallback {

    private PostgreSQLContainer<?> postgres;

    @Override
    public void beforeAll(ExtensionContext context) {
        postgres = new PostgreSQLContainer<>("postgres:11")
          .withDatabaseName("prop")
          .withUsername("postgres")
          .withPassword("pass")
          .withExposedPorts(5432);

        postgres.start();
        String jdbcUrl = String.format("jdbc:postgresql://localhost:%d/prop", postgres.getFirstMappedPort());
        System.setProperty("spring.datasource.url", jdbcUrl);
        System.setProperty("spring.datasource.username", "postgres");
        System.setProperty("spring.datasource.password", "pass");
    }

    @Override
    public void afterAll(ExtensionContext context) {
        // do nothing, Testcontainers handles container shutdown
    }
}

Here, we’re implementing AfterAllCallback and BeforeAllCallback to create a JUnit 5 extension. This way, JUnit 5 will execute the beforeAll() logic before running all the tests, and the logic in the afterAll() method after running the tests. With this approach, our test code will be as clean as:

在这里,我们正在实现AfterAllCallbackBeforeAllCallback来创建一个JUnit 5扩展。这样,JUnit 5将在运行所有测试之前执行beforeAll() 逻辑,并在运行测试之后执行afterAll() 方法中的逻辑。使用这种方法,我们的测试代码将是干净的。

@SpringBootTest
@ExtendWith(PostgreSQLExtension.class)
@DirtiesContext
public class ArticleTestFixtureLiveTest {
    // just the test code
}

Here, we’ve also added the @DirtiesContext annotation to the test class. Importantly, this recreates the application context and allows our test classes to interact with a separate PostgreSQL instance, running on a random port. As a results, this executes our tests in complete isolation from each other, against a separate database instance.

在这里,我们还向测试类添加了@DirtiesContext注解。重要的是,这重新创建了应用程序上下文,并允许我们的测试类与一个单独的PostgreSQL实例互动,在一个随机的端口上运行。作为一个结果,这将使我们的测试在完全相互隔离的情况下,针对一个单独的数据库实例执行。

In addition to being more readable, we can easily reuse the same functionality just by adding the @ExtendWith(PostgreSQLExtension.class) annotation. There’s no need to copy-paste the whole PostgreSQL setup everywhere we need it, as we did in the other two approaches.

除了更具可读性之外,我们还可以通过添加@ExtendWith(PostgreSQLExtension.class)注解来轻松重用相同的功能。我们没有必要像其他两种方法那样,在需要的地方复制粘贴整个PostgreSQL设置。

6. Conclusion

6.结语

In this tutorial, we first saw how hard can it be to test a Spring component that depends on something like a database. Then, we introduced three solutions for this problem, each improving upon what the previous solution had to offer.

在本教程中,我们首先看到了测试一个依赖数据库等东西的Spring组件有多难。然后,我们为这个问题介绍了三种解决方案,每一种都是在前一种解决方案的基础上改进的。

As usual, all the examples are available over on GitHub.

像往常一样,所有的例子都可以在GitHub上找到