曹工談Spring Boot:Spring boot中怎麼進行外部化配置,一不留神摔一跤;一路debug,原來是我太年輕了

三國夢迴發表於2020-05-20

spring boot中怎麼進行外部化配置,一不留神摔一跤;一路debug,原來是我太年輕了

背景

我們公司這邊,目前都是spring boot專案,沒有引入spring cloud config,也就是說,配置檔案,還是放在resources下面的,為了區分多環境,是採用了profile這種方式,大致如下:

上面這裡,就定義了3個profile,實際還不止這點,對應了3個環境。

每次啟動的時候,只需要(省略無關 jvm 引數):

java -Dspring.profiles.active=dev -jar xxx.jar

這樣來指定要使用的profile即可。

然後每次發測試版本,我們這邊就得加1個profile,所以導致我們工作量也是巨大,因為我們這邊環境比較多,地址總變。後來,經過開發和測試那邊的協調,變成了我們只管jar包,不管測試環境的維護。每次發版本,只發個jar包過去,配置檔案裡的地址,由測試同學自己配置。

大致變成了如下的樣子:

-rw-r--r--. 1 root root 111978406 May 19 13:24 xxx.jar
drwxr-xr-x. 2 root root       120 May 20 13:25 config

[root@localhost cad]# ll config/
total 16
-rw-r--r--. 1 root root  498 May 20 13:31 application.properties
-rw-r--r--. 1 root root  601 May 20 13:31 application.yml

即,在jar包旁邊,放上一個config目錄,然後在config目錄裡,放我們的配置檔案,至於配置檔案裡的各種配置,比如資料庫ip、redis等等,就由測試同學自己配置了,這樣呢,我們的工作量,大大減小。

看起來很棒了,然而,前一陣,測試同學發現一個問題,即,只能在和config同級目錄下,執行java -jar,這種情況下,config裡面的配置才生效,換個目錄執行,config裡面的配置就不生效了。

[root@localhost cad]# ll   // 這裡啟動jar包,ok,沒問題;換個目錄執行,不行!
total 109356
-rw-r--r--. 1 root root 111978406 May 19 13:24 xxx.jar
drwxr-xr-x. 2 root root       120 May 20 13:25 config

還有這種事?我們看看到底怎麼回事。

官方文件

參考:https://docs.spring.io/spring-boot/docs/2.1.14.RELEASE/reference/html/boot-features-external-config.html

24.3 Application Property Files

SpringApplication loads properties from application.properties files in the following locations and adds them to the Spring Environment:

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

這裡說,SpringApplication載入application.properties配置檔案,從如下位置:

  1. 當前目錄的config子目錄下
  2. 當前目錄
  3. classpath下的config包
  4. classpath的根路徑

我們這裡,就是利用了第一點。但是,這個當前目錄下的config目錄,不是很清楚。當前目錄,怎麼才算當前目錄,我在jar包同級目錄算當前目錄;換個目錄用絕對路徑,啟動jar包,就不算當前目錄了嗎?

再往下翻一下看看。

Config locations are searched in reverse order. By default, the configured locations are classpath:/,classpath:/config/,file:./,file:./config/. The resulting search order is the following:

  1. file:./config/
  2. file:./
  3. classpath:/config/
  4. classpath:/

配置地址被以相反的順序搜尋,預設情況下,地址包括了:classpath:/,classpath:/config/,file:./,file:./config/,因此,被搜尋的順序如下:

  1. file:./config/
  2. file:./
  3. classpath:/config/
  4. classpath:/

這裡的第一項,file: ./config,應該就是我們目前的那種情況。

然後文件裡,沒提到我的問題,可能是太低階。。只能從原始碼找答案了。

原始碼分析

通過關鍵字查詢,大致定位原始碼

我們直接用前面的關鍵字,搜尋一波(記得把maven裡設定為下載原始碼)

果然看到了一處地方:

org.springframework.boot.context.config.ConfigFileApplicationListener#DEFAULT_SEARCH_LOCATIONS

private static final String DEFAULT_SEARCH_LOCATIONS = "classpath:/,classpath:/config/,file:./,file:./config/";

查詢這個變數被引用的地方:

org.springframework.boot.context.config.ConfigFileApplicationListener.Loader#getSearchLocations()		
	private Set<String> getSearchLocations() {
			if (this.environment.containsProperty(CONFIG_LOCATION_PROPERTY)) {
				return getSearchLocations(CONFIG_LOCATION_PROPERTY);
			}
			Set<String> locations = getSearchLocations(CONFIG_ADDITIONAL_LOCATION_PROPERTY);
  			// 1
			locations.addAll(
					asResolvedSet(ConfigFileApplicationListener.this.searchLocations, DEFAULT_SEARCH_LOCATIONS));
			return locations;
		}

這裡1處,就用到了前面的DEFAULT_SEARCH_LOCATIONS

接著看看,上面這個函式被呼叫的地方:

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

這裡的1處,就是前面的獲取config location;這裡1處,獲取到了集合後,對其進行foreach處理。

2處,這裡即會呼叫一個load函式,看名字就是載入,差不多可以猜到,是載入我們的那幾個目錄:

private static final String DEFAULT_SEARCH_LOCATIONS = "classpath:/,classpath:/config/,file:./,file:./config/";

好了,可以在這裡打個斷點,看看到底怎麼載入,因為前面的file:./config/是一個相對路徑,我們要看看,怎麼被解析為絕對路徑的。

斷點debug,探求謎底

斷點我們打在了load方法,執行專案,然後斷點果然停在了我們想要的地方:

這個圖就不多解釋了,直接看圈出來的地方,我們接著要看下面的函式:

		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) {
                // 1
				...
			}
			//2 Also try the profile-specific section (if any) of the normal file
			load(loader, prefix + fileExtension, profile, profileFilter, consumer);
		}
  • 1處,省略了profile相關內容,我們本次啟動,沒指定profile
  • 2處,繼續load

load處程式碼:

		private void load(PropertySourceLoader loader, String location, Profile profile, DocumentFilter filter, DocumentConsumer consumer) {
  				// 1
				Resource resource = this.resourceLoader.getResource(location);
  				// 2
				if (resource == null || !resource.exists()) {
					if (this.logger.isTraceEnabled()) {
						StringBuilder description = getDescription("Skipped missing config ", location, resource, profile);
						this.logger.trace(description);
					}
					return;
				}
              ...
            }
  • 1處,將location轉換為Resource,這裡傳入的location為:file:./config/application.properties
  • 2處,判斷resource是否存在

resourceLoader怎麼getResource

這裡的resourceLoader,為 org.springframework.core.io.DefaultResourceLoader。這個類,直接實現了org.springframework.core.io.ResourceLoader介面。

這個類,位於spring-core.jar中,基本是核心類了。

其註釋寫道:

* Default implementation of the {@link ResourceLoader} interface.
* Used by {@link ResourceEditor}, and serves as base class for
* {@link org.springframework.context.support.AbstractApplicationContext}.
* Can also be used standalone.
*
* <p>Will return a {@link UrlResource} if the location value is a URL,
* and a {@link ClassPathResource} if it is a non-URL path or a
* "classpath:" pseudo-URL.

大體翻譯:

ResourceLoader介面的預設實現,被ResourceEditor使用,同時,是AbstractApplicationContext的基類。

也能被單獨使用。

當傳入的value,是一個URL,則封裝為一個UrlResource並返回;

當傳入的是一個非URL,或者是一個類似於"classpath:"這樣的,則返回一個ClassPathResource

對其的介紹到此打住。繼續前面的程式碼:

	@Override
	public Resource getResource(String location) {
		Assert.notNull(location, "Location must not be null");

		...
		
        // 1
		if (location.startsWith("/")) {
			return getResourceByPath(location);
		} // 2
		else if (location.startsWith(CLASSPATH_URL_PREFIX)) {
			return new ClassPathResource(location.substring(CLASSPATH_URL_PREFIX.length()), getClassLoader());
		}
		else {
			try {
				// 3 Try to parse the location as a URL...
				URL url = new URL(location);
				return (ResourceUtils.isFileURL(url) ? new FileUrlResource(url) : new UrlResource(url));
			}
			catch (MalformedURLException ex) {
				// No URL -> resolve as resource path.
				return getResourceByPath(location);
			}
		}
	}
  • 1,判斷是否以 /開頭
  • 2,判斷是否以classpath開頭
  • 3,作為引數,看看能不能 被解析為一個URL

然後3處這裡,URL,是 jdk 的核心類,裡面 debug 進去挺深的,直接執行完這一句之後,我們看看url這個引數的值:

總的來說,這裡就是:你給一個字串,URL按照它的格式,來解析為各個欄位:比如,協議,host,port,query等等。但是,不代表這個URL就是可以訪問的,如果是file,不代表這個檔案就存在。這裡只是按照URL的格式去解析而已。

我們繼續下一句:

URL url = new URL(location);
// 1
return (ResourceUtils.isFileURL(url) ? new FileUrlResource(url) : new UrlResource(url));

這裡1處,判斷是否為file,如果是,則會new一個FileUrlResource。

該類的類結構如下:

前面呼叫了new ,我們看看:

	public FileUrlResource(URL url) {
		super(url);
	}

呼叫了父類:

	/**
	 * Original URI, if available; used for URI and File access.
	 */
	@Nullable
	private final URI uri;

	/**
	 * Original URL, used for actual access.
	 */
	private final URL url;

	/**
	 * Cleaned URL (with normalized path), used for comparisons.
	 */
	private final URL cleanedUrl;

	public UrlResource(URL url) {
		this.url = url;
		this.cleanedUrl = getCleanedUrl(this.url, url.toString());
		this.uri = null;
	}

總的來說,就是利用你傳入的URL,進行clean,然後儲存到了cleanedUrl。

我們這裡,經過clean後,

  • clean後,cleanedUrl的值為:file:config/application.properties
  • 原始的:file:./config/application.properties

差別不大,主要是去掉了開頭的./

至此,我們的FileUrlResource就構造結束了,至此,我們完成了下面這行的解析。

Resource resource = this.resourceLoader.getResource(location);

判斷resource是否存在

前面我們看到,FileUrlResource,繼承了org.springframework.core.io.AbstractFileResolvingResource介面。

而我們這裡呼叫:

resource.exists()

就會進入其父類的exists方法

	org.springframework.core.io.AbstractFileResolvingResource#exists
	public boolean exists() {
		try {
		    // 1
			URL url = getURL();
			if (ResourceUtils.isFileURL(url)) {
				//2 Proceed with file system resolution
				return getFile().exists();
			}
            ...
        }
  • 1,獲取url
  • 2,獲取file
  • 3,判斷是否存在。

其中2處,繼續:

	@Override
	public File getFile() throws IOException {
        // 1
		File file = this.file;
		if (file != null) {
			return file;
		}
        // 2
		file = super.getFile();
        // 3
		this.file = file;
		return file;
	}
  • 1,查詢本地是否快取
  • 2,沒快取,則呼叫super類的getFile去獲取
  • 3,快取。

繼續進入2處,

	org.springframework.core.io.UrlResource#getFile	
	public File getFile() throws IOException {
        // 1
		if (this.uri != null) {
			return super.getFile(this.uri);
		}
		else {
		    // 2
			return super.getFile();
		}
	}
  • 1,我們這裡uri是null,會進入2處
  • 2,呼叫父類。
	org.springframework.core.io.AbstractFileResolvingResource#getFile()	
	@Override
	public File getFile() throws IOException {
        // 1
		URL url = getURL();
        // 2
		return ResourceUtils.getFile(url, getDescription());
	}
  • 1處,獲取url
  • 2處,獲取file。

繼續進入2處:

	org.springframework.util.ResourceUtils#getFile(java.net.URL, java.lang.String)	
	public static File getFile(URL resourceUrl, String description) throws FileNotFoundException 	{
		try {
            // 1
			return new File(toURI(resourceUrl).getSchemeSpecificPart());
		}
		catch (URISyntaxException ex) {
			// Fallback for URLs that are not valid URIs (should hardly ever happen).
			return new File(resourceUrl.getFile());
		}
	}

這裡,傳入的resourceURL,型別為URL, 在idea中顯示為:

file:./config/application.properties

toURI,大家可以大致看下,

	org.springframework.util.ResourceUtils#toURI(java.net.URL)	
	public static URI toURI(URL url) throws URISyntaxException {
		return toURI(url.toString());
	}
	public static URI toURI(String location) throws URISyntaxException {
  		return new URI(StringUtils.replace(location, " ", "%20"));
	}

上面幹了啥,就是把路徑裡的" "換成了"%20"。然後new了一個URI。

URI、URL的差別簡述

這兩個東西,太學術了,簡單理解,就是URI,指代的東西更多,包含的範圍更廣,URI表示中國的話,URL可能只能表示臺灣省。(我他麼一顆紅心)

總的來說,uri 不一定可以訪問,url基本是可以的。

參考:https://www.jianshu.com/p/81dfc203ab4a

最終是如何new file,判斷file是否存在的

經過前面的步驟後,

return new File(toURI(resourceUrl).getSchemeSpecificPart());

我們獲取了一個URI,然後呼叫其getSchemeSpecificPart,最終拿到一個String,其值為:

./config/application.properties

然後傳入了 File,用於構造一個file。

然後接著呼叫

	java.io.File#exists
    public boolean exists() {
    	// 1
        return ((fs.getBooleanAttributes(this) & FileSystem.BA_EXISTS) != 0);
    }

然後這裡,1處呼叫了一個native方法:

java.io.WinNTFileSystem#getBooleanAttributes

public native int getBooleanAttributes(File f);

都到native方法了,沒法繼續了。

但是,最終呢,我們知道,現在的問題,變成了:

File file = new file("./config/application.properties");
file.exists();

new file,傳入相對路徑,這個相對路徑,到底相對於哪裡

經過我一番探索,最終寫了下面這個測試類,注意,該類使用預設包:


	public class Test {


    public static void main(String[] args) throws IOException{
        // 1
        File file = new File("a.txt");
        // 2
        if (file.exists()) {
            System.out.println("file exists.path:" + file.getAbsolutePath());
        } else {
            // 3
            boolean newFile = file.createNewFile();
            if (newFile) {
                System.out.println("create new file");
            } else {
                System.out.println("create failed. file exists.path:" + file.getAbsolutePath());
            }
        }
    }

}

  • 1,new file,使用了相對路徑,即當前路徑,模擬之前我們的問題
  • 2,判斷是否存在
  • 3,如果不存在,建立檔案。

idea中執行

我目前的idea中,project路徑為:

F:\workproject_codes\xxxx

第一次執行,結果:

create new file

說明檔案不存在,進行了檔案建立。然後我用everything搜尋了下該檔案,發現:

就在我的project路徑下。

然後我在想,為啥會建立到這個地方去?

然後我加了一段程式碼:

Properties properties = System.getProperties();
for (Map.Entry<Object, Object> entry : properties.entrySet()) {
  System.out.println(entry.getKey() + ", " + entry.getValue());
}

發現列印出來的properties中,有一個屬性:

user.dir, F:\workproject_codes\saltillo

說明這個地址,就是user.dir搞出來的。

在idea中,user.dir,就是project的路徑。

直接和class同級目錄下,java執行class

直接執行那個class檔案,我放到了centos下的/home/test目錄下:

[root@localhost test]# ll
-rw-r--r--. 1 root root 2013 May 20 13:40 Test.class

[root@localhost test]# java Test

這種情況下,建立的file,就是這個目錄下。

而且,看了下user.dir,就是當前目錄:

[root@localhost test]# java Test|grep user.dir
user.dir, /home/test

和class不在同級目錄下,java執行class

切換到上層目錄,即home下:

[root@localhost home]# pwd
/home

[root@localhost home]#  java -cp test/ Test |grep user.dir
user.dir, /home

...會在本目錄下生產a.txt,刪除後再次執行:
[root@localhost home]#  java -cp test/ Test |grep create
create new file result

看上面,此時的user.dir,就變成了/home目錄。

同時,建立了新的檔案a.txt,就在當前home目錄下。

打成spring boot jar後,在centos執行,結果如何

在spring boot jar包裡的main,註釋了原來的啟動程式碼,我加了這段程式碼:

@SpringBootApplication
@EnableTransactionManagement
@EnableAspectJAutoProxy(exposeProxy = true)
@EnableFeignClients
//@Slf4j
@Controller
@EnableScheduling
public class xxx {
    private static Logger log= null;
    static {
       

    public static void main(String[] args) throws IOException {
        Properties properties = System.getProperties();
        for (Map.Entry<Object, Object> entry : properties.entrySet()) {
            System.out.println(entry.getKey() + ", " + entry.getValue());
        }
        File file = new File("a.txt");
        if (file.exists()) {
            System.out.println("file exists.path:" + file.getAbsolutePath());
        } else {
            boolean newFile = file.createNewFile();
            if (newFile) {
                System.out.println("create new file");
            } else {
                System.out.println("create failed. file exists.path:" + file.getAbsolutePath());
            }
        }

//        new SpringApplicationBuilder(xxx.class).web(WebApplicationType.SERVLET).run(args);

    }
    
}

/root/tt下執行,用java -jar xxx.jar執行後,

user.dir, /root/tt
...
create new file

然後,果然,在/root/tt下,就建了一個a.txt檔案。

[root@localhost tt]# ll
total 109412
-rw-r--r--. 1 root root         0 May 20 16:41 a.txt
-rw-r--r--. 1 root root 112035602 May 20 16:40 xxx.jar
[root@localhost tt]# pwd
/root/tt

user.dir是個什麼東西

為此,我專門把那個class,拷貝到了root目錄下,執行:

[root@localhost ~]# java Test |grep user.dir
user.dir, /root

這,看起來,在哪裡執行java,user.dir就是哪兒啊,類似於pwd了。

大家如果直接去網上搜user.dir,基本都是很混亂,各說各的,大家按照上面這樣實踐下就知道了。

總結

我們已經找到了問題原因了,總的來說,就是spring boot外部化配置時,

file:./config/

這個路徑,相對路徑,相對的是user.dir。

而user.dir怎麼來,就是你在哪個目錄下執行java,哪個目錄就是user.dir。

我這邊的作業系統,pc是win7,centos是:

[root@localhost tt]# cat /etc/centos-release
CentOS Linux release 7.6.1810 (Core) 

謝謝大家。

相關文章