SpringBoot如何載入jar包外面的配置文件?

  • 2020 年 2 月 17 日
  • 筆記

原創:小姐姐味道(微信公眾號ID:xjjdog),歡迎分享,轉載請保留出處。

雖然現在springboot提供了多環境的支援,但是通常修改一下配置文件,都需要重新打包。

在開發springboot框架集成時,我遇到一個問題,就是如何讓@PropertySource能夠「掃描」和載入jar包外面的properties文件。

這樣,我就可以隨時隨地的修改配置文件,不需要重新打包。

最粗暴的方式,就是用—classpath指定這些文件。但是這引入了其他問題,「易於部署」、「與容器無關」,讓人棘手。而且這個問題在測試環境、多機房部署、以及與配置中心協作時還是很難巧妙解決,因為這裡面涉及到不少的硬性規範、甚至溝通成本。

回到技術的本質,我希望基於spring容器,開發一個兼容性套件,能夠掃描jar外部的properties文件,考慮到實施便捷性,我們約定這些properties文件總是位於jar文件的臨近目錄中。

設計前提

1、文件目錄

文件目錄就類似於下面的樣式。可以看到配置文件是和jar包平行的。

----application.jar  (springboot項目,jarLaucher)       |       | sample.properties       | config/               |               | sample.properties

2、掃描策略(涉及到覆蓋優先順序問題)

1)我們約定默認配置文件目錄為config,也就是最優先的。其餘application.jar同級;相對路徑起始位置為jar路徑。

2)首先查找./config/sample.properties文件是否存在,如果存在則載入。

3)查找./sample.properties文件是否存在,如果存在則載入。

4)否則,使用classpath載入此文件。

3、開發策略

1)儘可能使用spring機制,即Resource載入機制,而不適用本地文件或者部署腳本干預等。

2)通過研究,擴展自定義的ResourceLoader可以達成此目標,但是潛在風險很高,因為springboot、cloud框架內部,對各種Context的支援都有各自的ResourceLoader實現,如果我們再擴展自己的loader會不會導致某些未知問題?於是放棄了此策略。

3)spring提供了ProtocolResolver機制,用於匹配自定義的文件schema來載入文件;而且不干擾ResourceLoader的機制,最重要的是它會添加到spring環境下的所有的loader中。我們只需要擴展一個ProtocolResolver類,並將它在合適的實際加入到ResourceLoader即可,此後載入properties文件時我們的ProtocolResolver總會被執行。

程式碼

下面是具體的程式碼實現。最主要的,就是配置文件解析器的編寫。注釋很詳細,就不多做介紹了。

1、XPathProtocolResolver.java

import org.springframework.core.io.ProtocolResolver;  import org.springframework.core.io.Resource;  import org.springframework.core.io.ResourceLoader;  import org.springframework.util.ResourceUtils;    import java.util.Collection;  import java.util.LinkedHashSet;    /**   * 用於載入jar外部的properties文件,擴展classpath : xjjdog   * -- app.jar   * -- config/a.property   INSIDE order=3   * -- a.property          INSIDE order=4   * -- config/a.property       OUTSIDE order=1   * -- a.property              OUTSIDE order=2   * <p>   * 例如:* 1、@PropertySource("::a.property")   * 查找路徑為:./config/a.property,./a.property,如果找不到則返回null,路徑相對於app.jar   * 2、@PropertySource("::x/a.property")   * 查找路徑為:./config/x/a.property,./x/a.property,路徑相對於app.jar   * 3、@PropertySource("*:a.property")   * 查找路徑為:./config/a.property,./a.property,CLASSPATH:/config/a.property,CLASSPATH:/a.property   * 4、@PropertySource("*:x/a.property")   * 查找路徑為:./config/x/a.property,./x/a.property,CLASSPATH:/config/x/a.property,CLASSPATH:/x/a.property   * <p>   * 如果指定了customConfigPath,上述路徑中的/config則會被替換   *   * @author xjjdog   **/  public class XPathProtocolResolver implements ProtocolResolver {        /**       * 查找OUTSIDE的配置路徑,如果找不到,則返回null       */      private static final String X_PATH_OUTSIDE_PREFIX = "::";        /**       * 查找OUTSIDE 和inside,其中inside將會轉換為CLASS_PATH       */      private static final String X_PATH_GLOBAL_PREFIX = "*:";        private String customConfigPath;        public XPathProtocolResolver(String configPath) {          this.customConfigPath = configPath;      }        @Override      public Resource resolve(String location, ResourceLoader resourceLoader) {          if (!location.startsWith(X_PATH_OUTSIDE_PREFIX) && !location.startsWith(X_PATH_GLOBAL_PREFIX)) {              return null;          }            String real = path(location);            Collection<String> fileLocations = searchLocationsForFile(real);          for (String path : fileLocations) {              Resource resource = resourceLoader.getResource(path);              if (resource != null && resource.exists()) {                  return resource;              }          }          boolean global = location.startsWith(X_PATH_GLOBAL_PREFIX);          if (!global) {              return null;          }            Collection<String> classpathLocations = searchLocationsForClasspath(real);          for (String path : classpathLocations) {              Resource resource = resourceLoader.getResource(path);              if (resource != null && resource.exists()) {                  return resource;              }          }          return resourceLoader.getResource(real);      }        private Collection<String> searchLocationsForFile(String location) {          Collection<String> locations = new LinkedHashSet<>();          String _location = shaping(location);          if (customConfigPath != null) {              String prefix = ResourceUtils.FILE_URL_PREFIX + customConfigPath;              if (!customConfigPath.endsWith("/")) {                  locations.add(prefix + "/" + _location);              } else {                  locations.add(prefix + _location);              }          } else {              locations.add(ResourceUtils.FILE_URL_PREFIX + "./config/" + _location);          }          locations.add(ResourceUtils.FILE_URL_PREFIX + "./" + _location);          return locations;      }        private Collection<String> searchLocationsForClasspath(String location) {          Collection<String> locations = new LinkedHashSet<>();          String _location = shaping(location);          if (customConfigPath != null) {              String prefix = ResourceUtils.CLASSPATH_URL_PREFIX + customConfigPath;              if (!customConfigPath.endsWith("/")) {                  locations.add(prefix + "/" + _location);              } else {                  locations.add(prefix + _location);              }          } else {              locations.add(ResourceUtils.CLASSPATH_URL_PREFIX + "/config/" + _location);          }            locations.add(ResourceUtils.CLASSPATH_URL_PREFIX + "/" + _location);          return locations;      }        private String shaping(String location) {          if (location.startsWith("./")) {              return location.substring(2);          }          if (location.startsWith("/")) {              return location.substring(1);          }          return location;      }        /**       * remove protocol       *       * @param location       * @return       */      private String path(String location) {          return location.substring(2);      }  }

2、ResourceLoaderPostProcessor.java

import org.springframework.context.ApplicationContextInitializer;  import org.springframework.context.ConfigurableApplicationContext;  import org.springframework.core.Ordered;  import org.springframework.core.env.Environment;    /**   * @author xjjdog   * 調整優化環境變數,對於boot框架會默認覆蓋一些環境變數,此時我們需要在processor中執行   * 我們不再需要使用單獨的yml文件來解決此問題。原則:* 1)所有設置為系統屬性的,初衷為"對系統管理員可見"、"對外部接入組件可見"(比如starter或者日誌組件等)   * 2)對設置為lastSource,表示"當用戶沒有通過yml"配置選項時的默認值--擔保策略。**/  public class ResourceLoaderPostProcessor implements ApplicationContextInitializer<ConfigurableApplicationContext>, Ordered {        @Override      public void initialize(ConfigurableApplicationContext applicationContext) {          Environment environment = applicationContext.getEnvironment();          String configPath = environment.getProperty("CONF_PATH");          if (configPath == null) {              configPath = environment.getProperty("config.path");          }          applicationContext.addProtocolResolver(new XPathProtocolResolver(configPath));      }        @Override      public int getOrder() {          return HIGHEST_PRECEDENCE + 100;      }  }

加上spring.factories,我們越來越像是在做一個starter了。沒錯,就是要做一個。

3、spring.factories

org.springframework.context.ApplicationContextInitializer=  com.github.xjjdog.commons.spring.io.ResourceLoaderPostProcessor

PropertyConfiguration.java (springboot環境下,properties載入器)

@Configuration  @PropertySources(      {              @PropertySource("*:login.properties"),              @PropertySource("*:ldap.properties")      }  )  public class PropertyConfiguration {        @Bean      @ConfigurationProperties(prefix = "login")      public LoginProperties loginProperties() {          return new LoginProperties();      }        @Bean      @ConfigurationProperties(prefix = "ldap")      public LdapProperties ldapProperties() {          return new LdapProperties();      }  }

這樣,我們的自定義載入器就完成了。我們也為SpringBoot組件,增加了新的功能。

End

SpringBoot通過設置」spring.profiles.active」可以指定不同的環境,但是需求總是多變的。比如本文的配置需求,可能就是某個公司蛋疼的約定。

SpringBoot提供了多種擴展方式來支援這些自定義的操作,這也是魅力所在。沒有什麼,不是開發一個spring boot starter不能解決的。

作者簡介:小姐姐味道 (xjjdog),一個不允許程式設計師走彎路的公眾號。聚焦基礎架構和Linux。十年架構,日百億流量,與你探討高並發世界,給你不一樣的味道。