6 種方式讀取 Springboot 的設定,老鳥都這麼玩(原理+實戰)

2023-06-16 12:01:10

大家好,我是小富~

從組態檔中獲取屬性應該是SpringBoot開發中最為常用的功能之一,但就是這麼常用的功能,仍然有很多開發者在這個方面踩坑。

我整理了幾種獲取設定屬性的方式,目的不僅是要讓大家學會如何使用,更重要的是弄清設定載入、讀取的底層原理,一旦出現問題可以分析出其癥結所在,而不是一報錯取不到屬性,無頭蒼蠅般的重啟專案,在句句臥槽中逐漸抓狂~

以下範例原始碼 Springboot 版本均為 2.7.6

下邊我們一一過下這幾種玩法和原理,看看有哪些是你沒用過的!話不多說,開始搞~

一、Environment

使用 Environment 方式來獲取設定屬性值非常簡單,只要注入Environment類呼叫其方法getProperty(屬性key)即可,但知其然知其所以然,簡單瞭解下它的原理,因為後續的幾種獲取設定的方法都和它息息相關。

@Slf4j
@SpringBootTest
public class EnvironmentTest {

    @Resource
    private Environment env;

    @Test
    public void var1Test() {
        String var1 = env.getProperty("env101.var1");
        log.info("Environment 設定獲取 {}", var1);
    }
}

1、什麼是 Environment?

Environment 是 springboot 核心的環境設定介面,它提供了簡單的方法來存取應用程式屬性,包括系統屬性、作業系統環境變數、命令列引數、和應用程式組態檔中定義的屬性等等。

2、設定初始化

Springboot 程式啟動載入流程裡,會執行SpringApplication.run中的prepareEnvironment()方法進行設定的初始化,那初始化過程每一步都做了什麼呢?

private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
			DefaultBootstrapContext bootstrapContext, ApplicationArguments applicationArguments) {
      /** 
      * 1、建立 ConfigurableEnvironment 物件:首先呼叫 getOrCreateEnvironment() 方法獲取或建立
      * ConfigurableEnvironment 物件,該物件用於儲存環境引數。如果已經存在 ConfigurableEnvironment 物件,則直接使用它;否則,根據使用者的設定和預設設定建立一個新的。
      */
      ConfigurableEnvironment environment = getOrCreateEnvironment();
      /**
      * 2、解析並載入使用者指定的組態檔,將其作為 PropertySource 新增到環境物件中。該方法預設會解析 application.properties 和 application.yml 檔案,並將其新增到 ConfigurableEnvironment 物件中。
      * PropertySource 或 PropertySourcesPlaceholderConfigurer 載入應用程式的客製化化設定。
      */
      configureEnvironment(environment, applicationArguments.getSourceArgs());
      // 3、載入所有的系統屬性,並將它們新增到 ConfigurableEnvironment 物件中
      ConfigurationPropertySources.attach(environment);
      // 4、通知監聽器環境引數已經準備就緒
      listeners.environmentPrepared(bootstrapContext, environment);
      /**
      *  5、將預設的屬性源中的所有屬性值移到環境物件的佇列末尾,
      這樣使用者自定義的屬性值就可以覆蓋預設的屬性值。這是為了避免使用者無意中覆蓋了 Spring Boot 所提供的預設屬性。
      */
      DefaultPropertiesPropertySource.moveToEnd(environment);
      Assert.state(!environment.containsProperty("spring.main.environment-prefix"),
          "Environment prefix cannot be set via properties.");
      // 6、將 Spring Boot 應用程式的屬性繫結到環境物件上,以便能夠正確地讀取和使用這些設定屬性
      bindToSpringApplication(environment);
      // 7、如果沒有自定義的環境型別,則使用 EnvironmentConverter 型別將環境物件轉換為標準的環境型別,並新增到 ConfigurableEnvironment 物件中。
      if (!this.isCustomEnvironment) {
        EnvironmentConverter environmentConverter = new EnvironmentConverter(getClassLoader());
        environment = environmentConverter.convertEnvironmentIfNecessary(environment, deduceEnvironmentClass());
      }
      // 8、再次載入系統設定,以防止被其他設定覆蓋
      ConfigurationPropertySources.attach(environment);
      return environment;
}

看看它的設定載入流程步驟:

  • 建立 環境物件 ConfigurableEnvironment 用於儲存環境引數;
  • configureEnvironment 方法載入預設的 application.propertiesapplication.yml 組態檔;以及使用者指定的組態檔,將其封裝為 PropertySource 新增到環境物件中;
  • attach(): 載入所有的系統屬性,並將它們新增到環境物件中;
  • listeners.environmentPrepared(): 傳送環境引數設定已經準備就緒的監聽通知;
  • moveToEnd(): 將 系統預設 的屬性源中的所有屬性值移到環境物件的佇列末尾,這樣使用者自定義的屬性值就可以覆蓋預設的屬性值。
  • bindToSpringApplication: 應用程式的屬性繫結到 Bean 物件上;
  • attach(): 再次載入系統設定,以防止被其他設定覆蓋;

上邊的設定載入流程中,各種設定屬性會封裝成一個個抽象的資料結構 PropertySource中,這個資料結構程式碼格式如下,key-value形式。


public abstract class PropertySource<T> {
    protected final String name; // 屬性源名稱
    protected final T source; // 屬性源值(一個泛型,比如Map,Property)
    public String getName();  // 獲取屬性源的名字  
    public T getSource(); // 獲取屬性源值  
    public boolean containsProperty(String name);  //是否包含某個屬性  
    public abstract Object getProperty(String name);   //得到屬性名對應的屬性值   
} 

PropertySource 有諸多的實現類用於管理應用程式的設定屬性。不同的 PropertySource 實現類可以從不同的來源獲取設定屬性,例如檔案、環境變數、命令列引數等。其中涉及到的一些實現類有:

  • MapPropertySource: Map 鍵值對的物件轉換為 PropertySource 物件的介面卡;
  • PropertiesPropertySource: Properties 物件中的所有設定屬性轉換為 Spring 環境中的屬性值;
  • ResourcePropertySource: 從檔案系統或者 classpath 中載入設定屬性,封裝成 PropertySource物件;
  • ServletConfigPropertySource: Servlet 設定中讀取設定屬性,封裝成 PropertySource 物件;
  • ServletContextPropertySource: Servlet 上下文中讀取設定屬性,封裝成 PropertySource 物件;
  • StubPropertySource: 是個空的實現類,它的作用僅僅是給 CompositePropertySource 類作為預設的父級屬性源,以避免空指標異常;
  • CompositePropertySource: 是個複合型的實現類,內部維護了 PropertySource集合佇列,可以將多個 PropertySource 物件合併;
  • SystemEnvironmentPropertySource: 作業系統環境變數中讀取設定屬性,封裝成 PropertySource 物件;

上邊各類設定初始化生成的 PropertySource 物件會被維護到集合佇列中。

List<PropertySource<?>> sources = new ArrayList<PropertySource<?>>()

設定初始化完畢,應用程式上下文AbstractApplicationContext會載入設定,這樣程式在執行時就可以隨時獲取設定資訊了。

	private void prepareContext(DefaultBootstrapContext bootstrapContext, ConfigurableApplicationContext context,
			ConfigurableEnvironment environment, SpringApplicationRunListeners listeners,
			ApplicationArguments applicationArguments, Banner printedBanner) {
    // 應用上下文載入環境物件
		context.setEnvironment(environment);
		postProcessApplicationContext(context);
    .........
  }

3、讀取設定

看明白上邊設定載入的流程,其實讀取設定就容易理解了,無非就是遍歷佇列裡的PropertySource,拿屬性名稱name匹配對應的屬性值source

PropertyResolver是獲取設定的關鍵類,其內部提供了操作PropertySource 佇列的方法,核心方法getProperty(key)獲取設定值,看了下這個類的依賴關係,發現 Environment 是它子類。

那麼直接用 PropertyResolver 來獲取設定屬性其實也是可以的,到這我們就大致明白了 Springboot 設定的載入和讀取了。

@Slf4j
@SpringBootTest
public class EnvironmentTest {

    @Resource
    private PropertyResolver env;
    
    @Test
    public void var1Test() {
        String var1 = env.getProperty("env101.var1");
        log.info("Environment 設定獲取 {}", var1);
    }
}

二、@Value 註解

@Value註解是Spring框架提供的用於注入設定屬性值的註解,它可用於類的成員變數方法引數建構函式引數上,這個記住很重要!

在應用程式啟動時,使用 @Value 註解的 Bean 會被範例化。所有使用了 @Value 註解的 Bean 會被加入到 PropertySourcesPlaceholderConfigurer 的後置處理器集合中。

當後置處理器開始執行時,它會讀取 Bean 中所有 @Value 註解所標註的值,並通過反射將解析後的屬性值賦值給標有 @Value 註解的成員變數、方法引數和建構函式引數。

需要注意,在使用 @Value 註解時需要確保注入的屬性值已經載入到 Spring 容器中,否則會導致注入失敗。

如何使用

src/main/resources目錄下的application.yml組態檔中新增env101.var1屬性。

env101:
  var1: var1-公眾號:程式設計師小富

只要在變數上加註解 @Value("${env101.var1}")就可以了,@Value 註解會自動將組態檔中的env101.var1屬性值注入到var1欄位中,跑個單元測試看一下結果。

@Slf4j
@SpringBootTest
public class EnvVariablesTest {

    @Value("${env101.var1}")
    private String var1;
    
    @Test
    public void var1Test(){
        log.info("組態檔屬性: {}",var1);
    }
}

毫無懸念,成功拿到設定資料。

雖然@Value註解方式使用起來很簡單,如果使用不當還會遇到不少坑。

1、缺失設定

如果在程式碼中參照變數,組態檔中未進行配值,就會出現類似下圖所示的錯誤。

為了避免此類錯誤導致服務啟動異常,我們可以在參照變數的同時給它賦一個預設值,以確保即使在未正確配值的情況下,程式依然能夠正常執行。

@Value("${env101.var1:我是小富}")
private String var1;

2、靜態變數(static)賦值

還有一種常見的使用誤區,就是將 @Value 註解加到靜態變數上,這樣做是無法獲取屬性值的。靜態變數是類的屬性,並不屬於物件的屬性,而 Spring是基於物件的屬性進行依賴注入的,類在應用啟動時靜態變數就被初始化,此時 Bean還未被範例化,因此不可能通過 @Value 注入屬性值。

@Slf4j
@SpringBootTest
public class EnvVariablesTest {

    @Value("${env101.var1}")
    private static String var1;
    
    @Test
    public void var1Test(){
        log.info("組態檔屬性: {}",var1);
    }
}

即使 @Value 註解無法直接用在靜態變數上,我們仍然可以通過獲取已有 Bean範例化後的屬性值,再將其賦值給靜態變數來實現給靜態變數賦值。

我們可以先通過 @Value 註解將屬性值注入到普通 Bean中,然後在獲取該 Bean對應的屬性值,並將其賦值給靜態變數。這樣,就可以在靜態變數中使用該屬性值了。

@Slf4j
@SpringBootTest
public class EnvVariablesTest {

    private static String var3;
    
    private static String var4;
    
    @Value("${env101.var3}")
    public void setVar3(String var3) {
        var3 = var3;
    }
    
    EnvVariablesTest(@Value("${env101.var4}") String var4){
        var4 = var4;
    }
    
    public static String getVar4() {
        return var4;
    }

    public static String getVar3() {
        return var3;
    }
}

3、常數(final)賦值

@Value 註解加到final關鍵字上同樣也無法獲取屬性值,因為 final 變數必須在構造方法中進行初始化,並且一旦被賦值便不能再次更改。而 @Value 註解是在 bean 範例化之後才進行屬性注入的,因此無法在構造方法中初始化 final 變數。

@Slf4j
@SpringBootTest
public class EnvVariables2Test {

    private final String var6;

    @Autowired
    EnvVariables2Test( @Value("${env101.var6}")  String var6) {

        this.var6 = var6;
    }

    /**
     * @value註解 final 獲取
     */
    @Test
    public void var1Test() {
        log.info("final 注入: {}", var6);
    }
}

4、非註冊的類中使用

只有標註了@Component@Service@Controller@Repository@Configuration容器管理註解的類,由 Spring 管理的 bean 中使用 @Value註解才會生效。而對於普通的POJO類,則無法使用 @Value註解進行屬性注入。

/**
 * @value註解 非註冊的類中使用
 * `@Component`、`@Service`、`@Controller`、`@Repository` 或 `@Configuration` 等
 * 容器管理註解的類中使用 @Value註解才會生效
 */
@Data
@Slf4j
@Component
public class TestService {

    @Value("${env101.var7}")
    private String var7;

    public String getVar7(){
       return this.var7;
    }
}

5、參照方式不對

如果我們想要獲取 TestService 類中的某個變數的屬性值,需要使用依賴注入的方式,而不能使用 new 的方式。通過依賴注入的方式建立 TestService 物件,Spring 會在建立物件時將物件所需的屬性值注入到其中。


  /**
   * @value註解 參照方式不對
   */
  @Test
  public void var7_1Test() {

      TestService testService = new TestService();
      log.info("參照方式不對 注入: {}", testService.getVar7());
  }

最後總結一下 @Value註解要在 Bean的生命週期內使用才能生效。

三、@ConfigurationProperties 註解

@ConfigurationProperties註解是 SpringBoot 提供的一種更加便捷來處理組態檔中的屬性值的方式,可以通過自動繫結和型別轉換等機制,將指定字首的屬性集合自動繫結到一個Bean物件上。

載入原理

在 Springboot 啟動流程載入設定的 prepareEnvironment() 方法中,有一個重要的步驟方法 bindToSpringApplication(environment),它的作用是將組態檔中的屬性值繫結到被 @ConfigurationProperties 註解標記的 Bean物件中。但此時這些物件還沒有被 Spring 容器管理,因此無法完成屬性的自動注入。

那麼這些Bean物件又是什麼時候被註冊到 Spring 容器中的呢?

這就涉及到了 ConfigurationPropertiesBindingPostProcessor 類,它是 Bean後置處理器,負責掃描容器中所有被 @ConfigurationProperties 註解所標記的 Bean物件。如果找到了,則會使用 Binder 元件將外部屬性的值繫結到它們身上,從而實現自動注入。

  • bindToSpringApplication 主要是將屬性值繫結到 Bean 物件中;
  • ConfigurationPropertiesBindingPostProcessor 負責在 Spring 容器啟動時將被註解標記的 Bean 物件註冊到容器中,並完成後續的屬性注入操作;

如何使用

演示使用 @ConfigurationProperties 註解,在 application.yml 組態檔中新增設定項:

env101:
  var1: var1-公眾號:程式設計師小富
  var2: var2-公眾號:程式設計師小富

建立一個 MyConf 類用於承載所有字首為env101的設定屬性。

@Data
@Configuration
@ConfigurationProperties(prefix = "env101")
public class MyConf {

    private String var1;
    
    private String var2;
}

在需要使用var1var2屬性值的地方,將 MyConf 物件注入到依賴物件中即可。

@Slf4j
@SpringBootTest
public class ConfTest {

    @Resource
    private MyConf myConf;

    @Test
    public void myConfTest() {
        log.info("@ConfigurationProperties註解 設定獲取 {}", JSON.toJSONString(myConf));
    }
}

四、@PropertySources 註解

除了系統預設的 application.yml 或者 application.properties 檔案外,我們還可能需要使用自定義的組態檔來實現更加靈活和個性化的設定。與預設的組態檔不同的是,自定義的組態檔無法被應用自動載入,需要我們手動指定載入。

@PropertySources 註解的實現原理相對簡單,應用程式啟動時掃描所有被該註解標註的類,獲取到註解中指定自定義組態檔的路徑,將指定路徑下的組態檔內容載入到 Environment 中,這樣可以通過 @Value 註解或 Environment.getProperty() 方法來獲取其中定義的屬性值了。

如何使用

在 src/main/resources/ 目錄下建立自定義組態檔 xiaofu.properties,增加兩個屬性。

env101.var9=var9-程式設計師小富
env101.var10=var10-程式設計師小富

在需要使用自定義組態檔的類上新增 @PropertySources 註解,註解 value屬性中指定自定義組態檔的路徑,可以指定多個路徑,用逗號隔開。

@Data
@Configuration
@PropertySources({
        @PropertySource(value = "classpath:xiaofu.properties",encoding = "utf-8"),
        @PropertySource(value = "classpath:xiaofu.properties",encoding = "utf-8")
})
public class PropertySourcesConf {

    @Value("${env101.var10}")
    private String var10;

    @Value("${env101.var9}")
    private String var9;
}

成功獲取設定了

但是當我試圖載入.yaml檔案時,啟動專案居然報錯了,經過一番摸索我發現,@PropertySources 註解只內建了PropertySourceFactory介面卡。也就是說它只能載入.properties檔案。

那如果我想要載入一個.yaml型別檔案,則需要自行實現yaml的介面卡 YamlPropertySourceFactory

public class YamlPropertySourceFactory implements PropertySourceFactory {

    @Override
    public PropertySource<?> createPropertySource(String name, EncodedResource encodedResource) throws IOException {
        YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
        factory.setResources(encodedResource.getResource());

        Properties properties = factory.getObject();

        return new PropertiesPropertySource(encodedResource.getResource().getFilename(), properties);
    }
}

而在載入設定時要顯示的指定使用 YamlPropertySourceFactory介面卡,這樣就完成了@PropertySource註解載入 yaml 檔案。

@Data
@Configuration
@PropertySources({
        @PropertySource(value = "classpath:xiaofu.yaml", encoding = "utf-8", factory = YamlPropertySourceFactory.class)
})
public class PropertySourcesConf2 {

    @Value("${env101.var10}")
    private String var10;

    @Value("${env101.var9}")
    private String var9;
}

五、YamlPropertiesFactoryBean 載入 YAML 檔案

我們可以使用 YamlPropertiesFactoryBean 類將 YAML 組態檔中的屬性值注入到 Bean 中。

@Configuration
public class MyYamlConfig {

    @Bean
    public static PropertySourcesPlaceholderConfigurer yamlConfigurer() {
        PropertySourcesPlaceholderConfigurer configurer = new PropertySourcesPlaceholderConfigurer();
        YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
        yaml.setResources(new ClassPathResource("xiaofu.yml"));
        configurer.setProperties(Objects.requireNonNull(yaml.getObject()));
        return configurer;
    }
}

可以通過 @Value 註解或 Environment.getProperty() 方法來獲取其中定義的屬性值。

@Slf4j
@SpringBootTest
public class YamlTest {

    @Value("${env101.var11}")
    private String var11;

    @Test
    public void  myYamlTest() {
        log.info("Yaml 設定獲取 {}", var11);
    }
}

六、自定義讀取

如果上邊的幾種讀取設定的方式你都不喜歡,就想自己寫個更流批的輪子,那也很好辦。我們直接注入PropertySources獲取所有屬性的設定佇列,你是想用註解實現還是其他什麼方式,就可以為所欲為了。

@Slf4j
@SpringBootTest
public class CustomTest {

    @Autowired
    private PropertySources propertySources;

    @Test
    public void customTest() {
        for (PropertySource<?> propertySource : propertySources) {
            log.info("自定義獲取 設定獲取 name {} ,{}", propertySource.getName(), propertySource.getSource());
        }
    }
}

總結

我們可以通過 @Value 註解、Environment 類、@ConfigurationProperties 註解、@PropertySource 註解等方式來獲取設定資訊。

其中,@Value 註解適用於單個值的注入,而其他幾種方式適用於批次設定的注入。不同的方式在效率、靈活性、易用性等方面存在差異,在選擇設定獲取方式時,還需要考慮個人程式設計習慣和業務需求。

如果重視程式碼的可讀性和可維護性,則可以選擇使用 @ConfigurationProperties 註解;如果更注重執行效率,則可以選擇使用 Environment 類。總之,不同的場景需要選擇不同的方式,以達到最優的效果。

我是小富,下期見~

以上案例地址:https://github.com/chengxy-nds/Springboot-Notebook