Spring Boot加載application.properties探究

背景

基於Spring Boot的多Module項目中,有許多公共的配置項,爲避免在每個接入層都配置一遍,一個設想是在公共依賴的Module的application.properties(application.yml)中進行配置。原來的配置文件位於接入層的classpath,可由Spring Boot打包插件打入,一旦置於公共Module,配置文件就不再直接被打入jar包,而是位於內嵌的jar包中,並不確認Spring Boot會去掃內嵌於jar包中的application文件,因此可行性有待驗證。

探索

實驗準備,項目結構如下所示:

Demo
 - web(接入層)
  - src
   - main
    - java
    - resources
     - application.properties // 1 
   - test
  - pom.xml
 - common(公共層)
  - src
   - main
    - java
    - resources
     - application-dev.properties // 2
 - pom.xml(父Module pom)

接入層爲web,在resources下存在application.properties,內容爲spring.profiles.active=dev,目的是爲了激活dev的profile

公共同爲common,在在resources下存在application-dev.properties,內容爲name=demo_test

因此,如果配置項name=demo_test能夠被應用成功讀取到,那麼就驗證了在背景中提及的設想

實驗結果:成功讀取

原理分析

一般地,Spring Boot 默認的配置文件名稱爲:application.propertiesapplication.yml,爲方便描述,統一爲application.properties。從Spring Boot 官方文檔得知,Spring Boot可以從下述位置按順序加載配置文件

  1. A /config subdirectory of the current directory(file:./config/)
  2. The current directory(file:./)
  3. A classpath /config package(classpath:/config/)
  4. The classpath root(classpath:/)

優先級表述如下:

The list is ordered by precedence (properties defined in locations higher in the list override those defined in lower locations).

也即是說,排在前邊的優先級高於排在後邊的。這裏有幾層隱含的含義,在官方文檔中並沒有表述清楚,爲方便記憶與理解,總結如下:

  1. 上邊的4個位置均可放置配置文件(application.properties),它們之間是一個並集關係而不是互斥關係,Spring Boot 默認都會加載到它們,而不是加載到高優先級的配置文件之後就停止加載低優先級的
  2. 如果在兩個以上的application.properties裏配置了同一個配置項(如: name=demo),那麼優先級高的配置項會生效

舉個例子,項目結構如下

src
 - main
  - resources
   - config
    - application.properties // 3 (k1=v1, k2=v2)
   - application.properties // 4  (k1=v3, k4=v4)

在優先級排名第3的配置文件中,存在兩個配置項(k1=v1, k2=v2);在優先級排名第4的配置文件中,存在兩個配置項(k1=v3, k4=v4)。內存中,四個配置項都存在,但生效的配置項只有三個:k1=v1,k2=v2,k4=v4,而k1=v3由於優先級比較低,並不生效

在Spring Boot應用啓動過程中,需要創建ConfigurableEnvironment,當Environment創建完,Spring 會發布ApplicationEnvironmentPreparedEvent事件,告知Environment創建完畢。ConfigFileApplicationListener會監聽這個事件,在事件處理中,使用Spring SPI機制加載EnvironmentPostProcessor集合,並回調EnvironmentPostProcessor#postProcessEnvironment方法。很巧的是,ConfigFileApplicationListener同時也實現了EnvironmentPostProcessor,因此,會回調到自身的postProcessEnvironment方法中。

注:下邊的源碼基於Spring Boot 2.1.10.RELEASE
// org.springframework.boot.SpringApplication#run(java.lang.String...)
public ConfigurableApplicationContext run(String... args) {
	// ...(省略)
	listeners.starting();
	try {
		ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
		// 創建ConfigurableEnvironment
		ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);
		// ...(省略)
}
// org.springframework.boot.SpringApplication#run(java.lang.String...)
private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
		ApplicationArguments applicationArguments) {
	// Create and configure the environment
	ConfigurableEnvironment environment = getOrCreateEnvironment();
	configureEnvironment(environment, applicationArguments.getSourceArgs());
	ConfigurationPropertySources.attach(environment);
	// 發佈ApplicationEnvironmentPreparedEvent事件
	listeners.environmentPrepared(environment);
	// ...(省略)
}

// org.springframework.boot.SpringApplicationRunListeners#environmentPrepared
public void environmentPrepared(ConfigurableEnvironment environment) {
	for (SpringApplicationRunListener listener : this.listeners) {
		listener.environmentPrepared(environment);
	}
}

// org.springframework.boot.context.event.EventPublishingRunListener#environmentPrepared
public void environmentPrepared(ConfigurableEnvironment environment) {
    // 發佈ApplicationEnvironmentPreparedEvent事件
	this.initialMulticaster
			.multicastEvent(new ApplicationEnvironmentPreparedEvent(this.application, this.args, environment));
}
// org.springframework.boot.context.config.ConfigFileApplicationListener
private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {
	// 利用Spring SPI機制加載EnvironmentPostProcessor
	List<EnvironmentPostProcessor> postProcessors = loadPostProcessors();
	postProcessors.add(this);
	AnnotationAwareOrderComparator.sort(postProcessors);
	for (EnvironmentPostProcessor postProcessor : postProcessors) {
		// 回調
		postProcessor.postProcessEnvironment(event.getEnvironment(), event.getSpringApplication());
	}
}

postProcessEnvironment回調中,添加了RandomValuePropertySource,並調用內部類Loader的load方法,對application.properties進行加載

// org.springframework.boot.context.config.ConfigFileApplicationListener
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
	addPropertySources(environment, application.getResourceLoader());
}

protected void addPropertySources(ConfigurableEnvironment environment, ResourceLoader resourceLoader) {
    // 添加`RandomValuePropertySource`到Environment
	RandomValuePropertySource.addToEnvironment(environment);
	// load()方法是重點;
	new Loader(environment, resourceLoader).load();
}
// org.springframework.boot.context.config.ConfigFileApplicationListener.Loader#load()

public void load() {
	this.profiles = new LinkedList<>();
	this.processedProfiles = new LinkedList<>();
	this.activatedProfiles = false;
	this.loaded = new LinkedHashMap<>();
	// 以上四個變量默認狀態爲空集合或false,用於在下邊迭代的過程中收集數據
	// 初始化profiles集合,如果存在active的profile,會將activatedProfiles變量設置爲true
	initializeProfiles();
	while (!this.profiles.isEmpty()) {
		Profile profile = this.profiles.poll();
		if (profile != null && !profile.isDefaultProfile()) {
			addProfileToEnvironment(profile.getName());
		}
		load(profile, this::getPositiveProfileFilter, addToLoaded(MutablePropertySources::addLast, false));
		this.processedProfiles.add(profile);
	}
	resetEnvironmentProfiles(this.processedProfiles);
	load(null, this::getNegativeProfileFilter, addToLoaded(MutablePropertySources::addFirst, true));
	addLoadedPropertySources();
}

初始化profiles集合,如果存在active的profile,會將activatedProfiles變量設置爲true。這裏需要注意的是,在案例demo中,是將spring.profiles.active=dev寫在classpath的application.properties,而此時application.properties都還沒有讀取,所以該配置項並未生效。故此,active的profile指的是那些通過system propertysystem enviroment、手動調用AbstractEnvironment#setActiveProfiles等方式設置active profile,他們的共同特點是優先級都較高,配置項初始化早,在執行load方法前就已生效

先往profiles集合添加null,表示將要加載那些跟profile無關的application.properties,並且如果沒有active profile,那還會加載名爲default的profile

private void initializeProfiles() {
	// The default profile for these purposes is represented as null. We add it
	// first so that it is processed first and has lowest priority.
	this.profiles.add(null);
	Set<Profile> activatedViaProperty = getProfilesActivatedViaProperty();
	this.profiles.addAll(getOtherActiveProfiles(activatedViaProperty));
	// Any pre-existing active profiles set via property sources (e.g.
	// System properties) take precedence over those added in config files.
	addActiveProfiles(activatedViaProperty);
	if (this.profiles.size() == 1) { // only has null profile
		for (String defaultProfileName : this.environment.getDefaultProfiles()) {
		    // 加載名爲`default`的profile
			Profile defaultProfile = new Profile(defaultProfileName, true);
			this.profiles.add(defaultProfile);
		}
	}
}

initializeProfiles方法執行完畢之後,只要profiles非空,就從隊首取出並進行加載。profiles是個雙端隊列,加載的過程有可能往隊列裏添加或者移除元素,因此使用的是while (!this.profiles.isEmpty())的判斷方式。
接着看load(profile, this::getPositiveProfileFilter, addToLoaded(MutablePropertySources::addLast, false));

該方法結構很清晰,迭代每一個location(搜索路徑),如果搜索路徑是個目錄(以/結尾),則獲取配置文件名,然後結合搜索路徑+配件文件名對配置文件進行加載。這兒隱含一層意思:location可以直接指定爲配置文件,但是此種方式不被推薦使用,因爲這會導致Profile機制失效,建議還是按正常的姿勢去使用

private void load(Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) {
	getSearchLocations().forEach((location) -> {
		boolean isFolder = location.endsWith("/");
		Set<String> names = isFolder ? getSearchNames() : NO_SEARCH_NAMES;
		names.forEach((name) -> load(location, name, profile, filterFactory, consumer));
	});
}

獲取搜索路徑,
可由spring.config.location指定或者spring.config.additional-location + classpath:/,classpath:/config/,file:./,file:./config/。注意此處,spring.config.location指定的搜索順序跟定義的順序相反,例如指定的位置爲a, b, c,則按c, b, a的順序進行搜索,而搜索順序反應的是配置項的優先級,在上邊已提過,不再贅述

private Set<String> getSearchLocations() {
	// 若通過 spring.config.location 指定配置文件目錄,則到指定路徑查找,不再走默認的搜索路徑和額外添加的路徑,可以指定多個,以逗號進行分隔
	// CONFIG_LOCATION_PROPERTY = spring.config.location
	if (this.environment.containsProperty(CONFIG_LOCATION_PROPERTY)) {
		return getSearchLocations(CONFIG_LOCATION_PROPERTY);
	}
	
	// 除了默認路徑,還可以通過 spring.config.additional-location 指定額外的搜索路徑
	// CONFIG_ADDITIONAL_LOCATION_PROPERTY = spring.config.additional-location
	Set<String> locations = getSearchLocations(CONFIG_ADDITIONAL_LOCATION_PROPERTY);
	
	// 默認搜索路徑
	// DEFAULT_SEARCH_LOCATIONS = classpath:/,classpath:/config/,file:./,file:./config/
	locations.addAll(
			asResolvedSet(ConfigFileApplicationListener.this.searchLocations, DEFAULT_SEARCH_LOCATIONS));
	return locations;
}

該方法將搜索路徑或者指定的配置文件名以逗號分割後倒置

private Set<String> asResolvedSet(String value, String fallback) {
	List<String> list = Arrays.asList(StringUtils.trimArrayElements(StringUtils.commaDelimitedListToStringArray(
			(value != null) ? this.environment.resolvePlaceholders(value) : fallback)));
	Collections.reverse(list);
	return new LinkedHashSet<>(list);
}

獲取待搜索的配置文件名,可由spring.config.name指定或者使用默認值application,同上面的搜索路徑一樣,spring.config.name指定的搜索順序跟定義的順序相反

private Set<String> getSearchNames() {
	// 若通過 spring.config.name 指定配置文件名稱,則只會搜索該名稱的配置文件,可以指定多個,以逗號進行分隔
	// CONFIG_NAME_PROPERTY = spring.config.name
	if (this.environment.containsProperty(CONFIG_NAME_PROPERTY)) {
		String property = this.environment.getProperty(CONFIG_NAME_PROPERTY);
		return asResolvedSet(property, null);
	}

	// 默認搜索的配置文件名稱爲application
	// DEFAULT_NAMES = application
	return asResolvedSet(ConfigFileApplicationListener.this.names, DEFAULT_NAMES);
}

在我們的案例中,沒有通過spring.config.location指定配置文件目錄,也沒有通過spring.config.name指定配置文件名,因此都採用默認值,且順序倒置:

  • localtion:file:./config/, file:./, classpath:/config/, classpath:/
  • config.name: application

且只在classpath:/放有配置文件application.properties與application-dev.properties

接着,遍歷propertySourceLoaders對配置文件進行加載。propertySourceLoaders是在構造Loader類時進行初始化的,它利用Spring SPI機制對實現類進行加載,默認實現類有兩個

  • PropertiesPropertySourceLoader: 加載.properties.xml的配置文件
  • YamlPropertySourceLoader: 加載.yml.yaml的配置文件
private void load(String location, String name, Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) {
	// ...(省略)
	Set<String> processed = new HashSet<>();
	for (PropertySourceLoader loader : this.propertySourceLoaders) {
		for (String fileExtension : loader.getFileExtensions()) {
		    // .properties\.xml\.yml\.yaml
			if (processed.add(fileExtension)) {
				loadForFileExtension(loader, location + name, "." + fileExtension, profile, filterFactory,
						consumer);
			}
		}
	}
}
private void loadForFileExtension(PropertySourceLoader loader, String prefix, String fileExtension, Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) {
	DocumentFilter defaultFilter = filterFactory.getDocumentFilter(null);
	DocumentFilter profileFilter = filterFactory.getDocumentFilter(profile);
	if (profile != null) {
		// Try profile-specific file & profile section in profile file (gh-340)
		// profileSpecificFile = file:./application-dev.properties
		String profileSpecificFile = prefix + "-" + profile + fileExtension;
		// 加載profile對應的配置文件
		load(loader, profileSpecificFile, profile, defaultFilter, consumer);
		load(loader, profileSpecificFile, profile, profileFilter, consumer);
		// Try profile specific sections in files we've already processed
		for (Profile processedProfile : this.processedProfiles) {
			if (processedProfile != null) {
				String previouslyLoaded = prefix + "-" + processedProfile + fileExtension;
				load(loader, previouslyLoaded, profile, profileFilter, consumer);
			}
		}
	}
	// Also try the profile-specific section (if any) of the normal file
	// 加載非profile的配置文件
	load(loader, prefix + fileExtension, profile, profileFilter, consumer);
}

使用resourceLoader到location獲取配置文件資源,resourceLoader也是在Loader類構造的時候初始化的,默認是DefaultResourceLoader,它是Spring提供的ResourceLoader的默認實現類,能夠獲取classpath資源以及URL資源或類URL資源,資源用Resource進行抽象表示。

此處,已經可以解釋文章探索實驗的結果:資源的獲取是靠Spring提供的DefaultResourceLoader實現的,它能夠實現classpath的掃描,進而加載資源,因此,只要是classpath下的配置文件,無論是否在內嵌jar包內,最終都能加載到

有了Loader,以及Resource,就可以進行資源的加載,加載的結果是List,代表對配置文件屬性源的抽象以及封裝。用DocumentFilter對滿足條件的Document進行過濾,滿足條件的則被添加進MutablePropertySources中

private void load(PropertySourceLoader loader, String location, Profile profile, DocumentFilter filter, DocumentConsumer consumer) {
	try {
		Resource resource = this.resourceLoader.getResource(location);
		// ...(省略)
		String name = "applicationConfig: [" + location + "]";
		List<Document> documents = loadDocuments(loader, name, resource);
		// ...(省略)
		List<Document> loaded = new ArrayList<>();
		for (Document document : documents) {
			if (filter.match(document)) {
				addActiveProfiles(document.getActiveProfiles());
				addIncludedProfiles(document.getIncludeProfiles());
				loaded.add(document);
			}
		}
		Collections.reverse(loaded);
		if (!loaded.isEmpty()) {
			loaded.forEach((document) -> consumer.accept(profile, document));
			// ...(省略)
}

最終,被加載的配置文件存在loaded變量中,調用addLoadedPropertySources方法,將loaded倒置之後添加進environment的PropertySources中,倒置的目的,是爲了使profile的配置文件優先級更高。而一旦將配置項添加進environment的屬性源集合中,應用程序就能正確取讀到配置項。

// org.springframework.boot.context.config.ConfigFileApplicationListener.Loader#addLoadedPropertySources
private void addLoadedPropertySources() {
	MutablePropertySources destination = this.environment.getPropertySources();
	List<MutablePropertySources> loaded = new ArrayList<>(this.loaded.values());
	Collections.reverse(loaded);
	String lastAdded = null;
	Set<String> added = new HashSet<>();
	for (MutablePropertySources sources : loaded) {
		for (PropertySource<?> source : sources) {
			if (added.add(source.getName())) {
				addLoadedPropertySource(destination, lastAdded, source);
				lastAdded = source.getName();
			}
		}
	}
}

其實,application-{profile}.properties配置文件加載位置同標準的application.properties,但是它有一點顯著不同的是,無論application-{profile}.properties放哪,profile類的配置文件優先級最高,當配置項衝突時,總是"覆蓋"一切非profile的配置文件

總結

本文開篇提出一個問題:在依賴的公共Module的classpath放置application.properties,Spring Boot應用能否正確讀取?之後通過案例進行實驗,證明了此行爲的可行性。爲了瞭解Spring Boot對application.properties加載的過程,先是閱讀了Spring Boot 官方文檔對application.properties的介紹,並對其中關於配置項優先級的模糊描述做了進一步的解釋。接着從源碼的角度,對application.properties的加載過程從頭到尾簡單介紹了一遍,瞭解到ResourceLoader及其默認實現類DefaultResourceLoader正是用於從classpath加載資源,因此能成功加載內嵌jar包中位於classpath的application.properties
。最後,介紹了Spring Boot對於PropertySource優先級處理的原則:後贏策略(last-wins),加載的過程按代碼定義的順序先加載,放入數據源之前進行倒置(reverse)放入,在後邊的反而優先級高

題外話

  1. 配置文件前2優先級位置分別是:file:./config/file:./,在IDEA中是指當前項目的/config目錄以及當前項目根目錄。如果是多module項目,那麼當前項目指的是父module目錄。其實在IDEA環境中使用這倆位置的配置文件意義不大,更多的,是與發佈系統結合,發佈系統將服務打成Executable Jar之後,將應用相關的基礎配置信息(如server.port、Apollo apollo.meta\env )配置在./config/或者./,用以覆蓋項目內有可能誤配或漏配的選項
  2. 本文的一些規律,不單適用於application.properties,還適用於別的配置文件。例如:配置項優先級原則,基本思想是:由Spring加載所有的屬性源到Environment中,通過屬性源的方式將配置項進行隔離,不同的屬性源互不干擾,在此基礎上,靠前的屬性源的配置項優先級高。這種行爲是Spring默認的行爲,該行爲定義在PropertySourcesPropertyResolver,也意味着,我們可以自定義PropertyResolver,來改變這種默認的行爲,實現自定義的優先級順序,達到我們的目的
  3. 關於application.properties的加載過程,還有很多細節未曾提及,這並非意味着不重要,而是一篇文章難以面面俱到,而陷入源碼細節容易一葉障目。從問題出發,梳理主幹脈絡,把握核心思想,是爲首要條件,之後每次根據需要,像剝洋蔥般一層層深入,能更容易掌握知識
發佈了15 篇原創文章 · 獲贊 2 · 訪問量 3225
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章