在项目有读取特殊配置文件的地方(不是 Spring 的 application 配置),项目打包为 jar 后,无法从外部替换默认的配置文件。
我自己尝试了 java -cp
的方式,发现没法启动(Spring Boot 打的包很特殊)。
通过谷歌搜索查到:Spring Boot Executable Jar with Classpath
其中 Peter Tarlos 的答案是完整的,本文的内容也是以这里为起点,通过查找官方文档来说明如何实现。
1. 关键的 PropertiesLauncher
Executable Jars Spring Boot’s executable jars, their launchers, and their format.
在 Spring Boot 中,存在 3 种类型的启动器:
-
JarLauncher
-
WarLauncher
-
PropertiesLauncher
当打包为 jar 或 war 时选择的前两个,JarLauncher
从 BOOT-INF/lib/
目录加载 jars,WarLauncher
从 WEB-INF/lib/
和 WEB-INF/lib-provided/
加载 jars,如果想添加额外的 jars 就需要往这些目录添加。
第三个 PropertiesLauncher
默认从 BOOT-INF/lib/
目录加载 jars,你还可以通过 LOADER_PATH
或者 loader.properties 中的 loader.path
配置额外的位置(多个位置逗号隔开),所以这个是我们需要的启动类。
启动类最终是在打包文件中的 MANIFEST.MF
中配置的,例如 jar 方式:
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class:
想要使用 PropertiesLauncher
,可以通过官方的配置来启用。
2. 如何配置使用 PropertiesLauncher
Build Tool Plugins Maven Plugin, Gradle Plugin, Antlib, and more.
在官方打包工具中,有 Maven 和 Gradle 的两种方式。
Maven 配置
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>${start.class}</mainClass>
<layout>ZIP</layout>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
这里的 <layout>ZIP</layout>
配置可以选择使用哪个启动器,默认根据 <packing>
打包类型( jar 或 war)确定,可以配置下面可选值:
- JAR
- WAR
- ZIP:使用
PropertiesLauncher
- NONE: 不捆绑引导加载程序
通过选择 ZIP
即可使用 PropertiesLauncher
。
Gradle 配置
配置起来更直接,配置如下:
tasks.named("bootWar") {
manifest {
attributes 'Main-Class': 'org.springframework.boot.loader.PropertiesLauncher'
}
}
3. 指定其他类加载路径
详细的配置可以参考官方文档: PropertiesLauncher Features,这里就简单举例用用:
java -Dloader.path=file:/config -jar spring-boot-app.jar
通过 -Dloader.path=file:/config
指定路径后,就能通过这种方式覆盖 jar 包中的文件了。
使用
-Dloader.dubug=true
会通过 System.out.println
输出日志信息。
4. 类路径的加载顺序(优先级)
为了确保替换配置文件的方式有效,最后还要确认一下类路径的加载顺序,只有当我提供的配置先加载时,才能确保替换默认的配置文件,官方没有明确说明加载顺序,因此只能通过代码来确认。
在 PropertiesLauncher
中存在 main
方法:
public static void main(String[] args) throws Exception {
PropertiesLauncher launcher = new PropertiesLauncher();
args = launcher.getArgs(args);
launcher.launch(args);
}
这里调用了父类 Launcher
的 launch
方法:
protected void launch(String[] args) throws Exception {
if (!isExploded()) {
JarFile.registerUrlProtocolHandler();
}
ClassLoader classLoader = createClassLoader(getClassPathArchivesIterator());
String jarMode = System.getProperty("jarmode");
String launchClass = (jarMode != null && !jarMode.isEmpty()) ? JAR_MODE_LAUNCHER : getMainClass();
launch(args, launchClass, classLoader);
}
在创建 ClassLoader
时,调用了 getClassPathArchivesIterator()
方法,这个方法会获取所有类路径下面的资源文件和jar包,这个方法就是我们要重点关注的方法:
@Override
protected Iterator<Archive> getClassPathArchivesIterator() throws Exception {
ClassPathArchives classPathArchives = this.classPathArchives;
if (classPathArchives == null) {
classPathArchives = new ClassPathArchives();
this.classPathArchives = classPathArchives;
}
return classPathArchives.iterator();
}
这里创建了 ClassPathArchives
的单例,在构造方法中:
ClassPathArchives() throws Exception {
this.classPathArchives = new ArrayList<>();
for (String path : PropertiesLauncher.this.paths) {
for (Archive archive : getClassPathArchives(path)) {
debug("paths: " + archive.getUrl());
addClassPathArchive(archive);
}
}
addNestedEntries();
}
这里的 PropertiesLauncher.this.paths
就是通过 loader.path
配置的所有路径,这部分内容首先添加进去了,从这儿已经可以看出 loader.path
的优先级更高,因此通过这种方式设置的外部配置文件会优先使用。
上面代码后面的 addNestedEntries
在 jar 包启动时,就是加载 jar 包中的内容:
private void addNestedEntries() {
// The parent archive might have "BOOT-INF/lib/" and "BOOT-INF/classes/"
// directories, meaning we are running from an executable JAR. We add nested
// entries from there with low priority (i.e. at end).
try {
Iterator<Archive> archives = PropertiesLauncher.this.parent.getNestedArchives(null,
JarLauncher.NESTED_ARCHIVE_ENTRY_FILTER);
while (archives.hasNext()) {
this.classPathArchives.add(archives.next());
}
}
catch (IOException ex) {
// Ignore
}
}
在这里的 PropertiesLauncher.this.parent
对应的就是启动的 jar,这里调用获取嵌套的包,使用 JarLauncher.NESTED_ARCHIVE_ENTRY_FILTER
作为过滤条件,过滤条件定义:
static final EntryFilter NESTED_ARCHIVE_ENTRY_FILTER = (entry) -> {
if (entry.isDirectory()) {
return entry.getName().equals("BOOT-INF/classes/");
}
return entry.getName().startsWith("BOOT-INF/lib/");
};
可以看到当遍历当前 jar 包时,只会匹配 BOOT-INF/classes/
目录和 BOOT-INF/lib/
下面的所有文件。
5. 不在深入一点吗?
到这里本文关注的内容就结束了,但是如果看源码只看到这种程度就够了吗?
看源码最好是有目的的看,看到感兴趣的地方时再深入看,看上面代码时你最有兴趣的地方在哪里?
我最感兴趣的是 ClassLoader classLoader = createClassLoader(getClassPathArchivesIterator());
这里,以及后续对该 classLoader
的使用,这部分的内容应该是 Spring Boot 能打成一个特殊 fat jar 启动的核心,Spring Boot 包和 Apache Maven Shade Plugin 插件的区别在于 “Spring Boot 中依赖的 jar 包仍然是独立的 jar,存在于 BOOT-INF/lib
中,Shade 插件打的是一个真正的大 jar 包,把所有依赖的 jar 都抽取到了大的 jar 中,这会存在同路径和名称文件的覆盖问题”。如果后续有时间再单独从这个角度再分析看看。