前言
按照 Spring Boot 应用的运行环境可以将其运行方式大概分为两类,开发环境下可以通过在 IDE 中直接运行 main 方法或通过 mvn spring-boot:run 运行,生产环境可以部署到 Tomcat 或通过 java -jar 的方式运行,上篇我们讨论了 mvn spring-boot:run 怎样通过 spring-boot-maven-plugin 插件运行 Spring Boot 应用的,这篇我们来尝试了解 java -jar 背后的秘密。
初探 spring-boot-maven-plugin repackage 目标
当我们使用 Spring Boot 框架时,总会在 maven 的 pom 文件中引入一个 spring-boot-maven-plugin 插件,示例如下。
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <id>repackage</id> <phase>package</phase> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> </build>
如果我们项目的 pom 文件指定了 parent 为 spring-boot-starter-parent,我们还可以省略 executions 的配置。这个插件具有多个目标,我们将其 repackage 目标与 maven 的生命周期阶段 package 进行绑定,这样到达 package 生命周期阶段时就会调用 repackage 目标。
通过 mvn help:describe -Dplugin=spring-boot -Ddetail -Dgoal=repackage 命令了解下这个目标的作用。
➜ spring-boot-demo mvn help:describe -Dplugin=spring-boot -Ddetail -Dgoal=repackage [INFO] Scanning for projects... [INFO] [INFO] ---------------------< com.zzuhkp:project-parent >---------------------- [INFO] Building project-parent 1.0-SNAPSHOT [INFO] --------------------------------[ jar ]--------------------------------- [INFO] [INFO] --- maven-help-plugin:3.2.0:describe (default-cli) @ project-parent --- [INFO] Mojo: 'spring-boot:repackage' spring-boot:repackage Description: Repackages existing JAR and WAR archives so that they can be executed from the command line using java -jar. With layout=NONE can also be used simply to package a JAR with nested dependencies (and no main class, so not executable). Implementation: org.springframework.boot.maven.RepackageMojo Language: java Bound to phase: package Available parameters: attach (Default: true) ... 省略部分内容
上述帮助信息表明,repackage 目标可以打包现有的 jar 包或 war 包,以便可以通过 java -jar 的方式运行,那么重新打包后的 jar 和标准打包的 jar 有何不同呢?为什么重新打包的 jar 就可以直接运行?
Spring Boot Fat Jar
下面我们准备看下 spring-boot-maven-plugin 插件打包后的 jar 和标准打包的 jar 有何不同。
先看下我们示例项目 pom 文件内容。
整个项目结构如下。
. ├── pom.xml ├── spring-boot-demo.iml └── src └── main └── java └── com └── zzuhkp └── DemoApplication.java
项目中仅仅引入了 Spring MVC 依赖,并指定了一个启动应用的主类 DemoApplication。执行 mvn clean package
命令进行打包,然后进入 target 目录看下打包后的文件。
➜ target tree -h . ├── [ 96] classes │ └── [ 96] com │ └── [ 96] zzuhkp │ └── [ 721] DemoApplication.class ├── [ 96] generated-sources │ └── [ 64] annotations ├── [ 96] maven-archiver │ └── [ 68] pom.properties ├── [ 96] maven-status │ └── [ 96] maven-compiler-plugin │ └── [ 96] compile │ └── [ 128] default-compile │ ├── [ 33] createdFiles.lst │ └── [ 89] inputFiles.lst ├── [ 17M] spring-boot-demo-1.0-SNAPSHOT.jar └── [2.3K] spring-boot-demo-1.0-SNAPSHOT.jar.original
除了标准编译后的文件,在 target 目录中可以看到有两个文件比较相似,分别是 spring-boot-demo-1.0-SNAPSHOT.jar 和 spring-boot-demo-1.0-SNAPSHOT.jar.original,前者的大小为 17M,而后者的文件大小仅有 2.3K,根据名字可以猜测 *.jar.original 为 maven 标准打包生成的原始文件,而 .jar 文件为重新打包生成的文件。
由于标准打包生成的文件中缺少依赖的 class,因此不能直接启动,重新打包后的文件又有何不同呢?对其解压查看如下。
➜ target unzip spring-boot-demo-1.0-SNAPSHOT.jar -d temp Archive: spring-boot-demo-1.0-SNAPSHOT.jar creating: temp/META-INF/ inflating: temp/META-INF/MANIFEST.MF creating: temp/org/ creating: temp/org/springframework/ ...省略部分内容 ➜ target cd temp ➜ temp tree . . ├── BOOT-INF │ ├── classes │ │ └── com │ │ └── zzuhkp │ │ └── DemoApplication.class │ └── lib │ ├── spring-web-5.2.6.RELEASE.jar │ ├── spring-webmvc-5.2.6.RELEASE.jar │ ... 省略部分内容 ├── META-INF │ ├── MANIFEST.MF │ └── maven │ └── com.zzuhkp │ └── spring-boot-demo │ ├── pom.properties │ └── pom.xml └── org └── springframework └── boot └── loader ├── ExecutableArchiveLauncher.class ├── JarLauncher.class ... 省略部分内容 17 directories, 94 files
可以看到,重新打包后的 jar 包将我们的 class 移动到了 /BOOT-INF/classes 目录中,并将依赖添加到了 /META-INF/lib 目录,此外还添加了 org.springframework.boot.loader 包中的类到 jar 包中。查看 /META-INF/MANIFEST.MF 清单文件内容,也有不同的地方。
➜ temp cat META-INF/MANIFEST.MF Manifest-Version: 1.0 Created-By: Maven Archiver 3.4.0 Build-Jdk-Spec: 16 Implementation-Title: spring-boot-demo Implementation-Version: 1.0-SNAPSHOT Main-Class: org.springframework.boot.loader.JarLauncher Start-Class: com.zzuhkp.DemoApplication Spring-Boot-Version: 2.2.7.RELEASE Spring-Boot-Classes: BOOT-INF/classes/ Spring-Boot-Lib: BOOT-INF/lib/
清单文件中表示主类的 Main-Class 本应该是我们定义的 DemoApplication,而被修改成了 org.springframework.boot.loader.JarLauncher,而我们的 DemoApplication 则被配置到了 Start-Class 属性中。
可以猜测的是 Spring Boot 项目打包成 jar 之后,使用了 JarLauncher 作为主类进行启动,并将添加到 jar 包的依赖 jar 做为类路径的一部分,然后调用我们的 main 方法。重新打包后的 jar 包含了依赖,因此也常被成为 Fat Jar。
再探 spring-boot-maven-plugin repackage
对于 Fat Jar 我们不免有一个疑问: JarLauncher 这个类是从哪来的?要回答这个问题,还得看 spring-boot-maven-plugin 插件的 repackage 目标是如何打包的。
通过帮助信息可以了解到这个 repackage 的实现是 org.springframework.boot.maven.RepackageMojo,那么我们就看下这个类有哪些内容。
@Mojo(name = "repackage", defaultPhase = LifecyclePhase.PACKAGE, requiresProject = true, threadSafe = true, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME) public class RepackageMojo extends AbstractDependencyFilterMojo { @Parameter(defaultValue = "${project}", readonly = true, required = true) private MavenProject project; @Parameter(property = "spring-boot.repackage.skip", defaultValue = "false") private boolean skip; @Override public void execute() throws MojoExecutionException, MojoFailureException { if (this.project.getPackaging().equals("pom")) { getLog().debug("repackage goal could not be applied to pom project."); return; } if (this.skip) { getLog().debug("skipping repackaging as per configuration."); return; } // 重新打包 repackage(); } }
repackage
目标跳过了打包方式为 pom
的项目,并通过属性检查是否需要跳过打包,如果需要打包则简单调用了 #repacakge
方法进行重新打包。
private void repackage() throws MojoExecutionException { // 标准打包的构件信息 Artifact source = getSourceArtifact(); // 重新打包后存储的文件 File target = getTargetFile(); Repackager repackager = getRepackager(source.getFile()); // 查找依赖 Set<Artifact> artifacts = filterDependencies(this.project.getArtifacts(), getFilters(getAdditionalFilters())); Libraries libraries = new ArtifactsLibraries(artifacts, this.requiresUnpack, getLog()); try { // 重新打包 LaunchScript launchScript = getLaunchScript(); repackager.repackage(target, libraries, launchScript); } catch (IOException ex) { throw new MojoExecutionException(ex.getMessage(), ex); } updateArtifact(source, target, repackager.getBackupFile()); }
至于 #repackage
方法只是获取到了项目的依赖,然后将打包的工作委托给了 Repackager#repackage
方法,继续跟踪代码。
public class Repackager { // 源文件 private final File source; // 重新打包后的文件信息 private Layout layout; // Layout 工厂 private LayoutFactory layoutFactory; public void repackage(File destination, Libraries libraries, LaunchScript launchScript) throws IOException { if (destination == null || destination.isDirectory()) { throw new IllegalArgumentException("Invalid destination"); } if (libraries == null) { throw new IllegalArgumentException("Libraries must not be null"); } if (this.layout == null) { this.layout = getLayoutFactory().getLayout(this.source); } destination = destination.getAbsoluteFile(); File workingSource = this.source; if (alreadyRepackaged() && this.source.equals(destination)) { // 已经打包过,不再处理 return; } if (this.source.equals(destination)) { // 源文件和目标文件是相同的文件 workingSource = getBackupFile(); workingSource.delete(); // 重命名源文件 renameFile(this.source, workingSource); } destination.delete(); try { try (JarFile jarFileSource = new JarFile(workingSource)) { // 重新打包 repackage(jarFileSource, destination, libraries, launchScript); } } finally { if (!this.backupSource && !this.source.equals(workingSource)) { deleteFile(workingSource); } } } }
Repackager#repackage 方法先根据标准打包生成的源文件初始化 Layout,然后判断源文件是否为重新打包后的文件避免重复打包,如果源文件和目标文件一致还会对源文件重命名,这也就是我们上述看到 /target 目录存在 *.jar.original 文件的原因,最后就开始重新打包了。
需要注意的是源码中出现的 Layout 接口,这个接口内部包含了新打包文件的布局信息,例如对于 Fat Jar 文件来说依赖存放在 /BOOT-INF/lib 目录下,标准打包生成的 class 文件放在 /BOOT-INF/class 文件下,接口如下。
public interface Layout { // 启动器类名 String getLauncherClassName(); // 依赖在打包的文件中的路径 String getLibraryDestination(String libraryName, LibraryScope scope); // 类在打包的文件中的路径 String getClassesLocation(); // 打包的文件能否执行 boolean isExecutable(); }
Spring 为什么抽象出这么一个接口呢?是因为 spring-boot-maven-plugin
的 repackage
不仅支持打包 jar
,还支持打包 war
,实现如下。
public interface RepackagingLayout extends Layout { // 标准打包生成的类在重新打包文件中的路径 String getRepackagedClassesLocation(); } public static class Jar implements RepackagingLayout { @Override public String getLauncherClassName() { return "org.springframework.boot.loader.JarLauncher"; } @Override public String getLibraryDestination(String libraryName, LibraryScope scope) { return "BOOT-INF/lib/"; } @Override public String getClassesLocation() { return ""; } @Override public String getRepackagedClassesLocation() { return "BOOT-INF/classes/"; } @Override public boolean isExecutable() { return true; } } public static class War implements Layout { private static final Map<LibraryScope, String> SCOPE_DESTINATIONS; static { Map<LibraryScope, String> map = new HashMap<>(); map.put(LibraryScope.COMPILE, "WEB-INF/lib/"); map.put(LibraryScope.CUSTOM, "WEB-INF/lib/"); map.put(LibraryScope.RUNTIME, "WEB-INF/lib/"); map.put(LibraryScope.PROVIDED, "WEB-INF/lib-provided/"); SCOPE_DESTINATIONS = Collections.unmodifiableMap(map); } @Override public String getLauncherClassName() { return "org.springframework.boot.loader.WarLauncher"; } @Override public String getLibraryDestination(String libraryName, LibraryScope scope) { return SCOPE_DESTINATIONS.get(scope); } @Override public String getClassesLocation() { return "WEB-INF/classes/"; } @Override public boolean isExecutable() { return true; } }
到了这里,终于看到 JarLauncher 在何处定义了,另外我们还发现 Spring Boot 支持对 war 重新打包以便直接通过 java -jar 的方式启动 war 包,并且将 provided 作用范围的依赖添加到了 WEB-INF/lib-provided/,以便与标准的 war 包兼容。
不过 JarLauncher 到底来自哪呢?还得继续跟踪 Repackager 中的源码。
private void repackage(JarFile sourceJar, File destination, Libraries libraries, LaunchScript launchScript) throws IOException { WritableLibraries writeableLibraries = new WritableLibraries(libraries); try (JarWriter writer = new JarWriter(destination, launchScript)) { // 先写 Manifest 文件 writer.writeManifest(buildManifest(sourceJar)); // 写 spring-boot-loader 中的类 writeLoaderClasses(writer); if (this.layout instanceof RepackagingLayout) { // 写标准打包中的类到新路径 writer.writeEntries(sourceJar, new RenamingEntryTransformer(((RepackagingLayout) this.layout).getRepackagedClassesLocation()), writeableLibraries); } else { writer.writeEntries(sourceJar, writeableLibraries); } // 写依赖的 jar 包 writeableLibraries.write(writer); } }
这里先写入了 Manifest
文件,然后写入了 spring-boot-loader
中的类,然后将标准打包生成的类写入到新路径,最后又写入了项目中的依赖。看来代码 JarLauncher
就在 #writeLoaderClasses
方法中写入了。
private void writeLoaderClasses(JarWriter writer) throws IOException { if (this.layout instanceof CustomLoaderLayout) { ((CustomLoaderLayout) this.layout).writeLoadedClasses(writer); } else if (this.layout.isExecutable()) { writer.writeLoaderClasses(); } }
由于我们没有自定义布局,因此会调用 JarWriter#writeLoaderClasses
方法写入 class。
public class JarWriter implements LoaderClassesWriter, AutoCloseable { private static final String NESTED_LOADER_JAR = "META-INF/loader/spring-boot-loader.jar"; @Override public void writeLoaderClasses() throws IOException { writeLoaderClasses(NESTED_LOADER_JAR); } }
到了这里,终于找到 JarLauncher 原始位置了,就在 spring-boot-loader.jar 中,而这个 jar 包正是 Spring Boot 项目中的 spring-boot-loader 模块。
spring-boot-loader
我们从 spring-boot-loader 模块找到 JarLauncher,看下它到底怎么通过 jar -jar 的方式启动 Spring Boot 应用的。
public class JarLauncher extends ExecutableArchiveLauncher { static final String BOOT_INF_CLASSES = "BOOT-INF/classes/"; static final String BOOT_INF_LIB = "BOOT-INF/lib/"; public JarLauncher() { } protected JarLauncher(Archive archive) { super(archive); } @Override protected boolean isNestedArchive(Archive.Entry entry) { if (entry.isDirectory()) { return entry.getName().equals(BOOT_INF_CLASSES); } return entry.getName().startsWith(BOOT_INF_LIB); } public static void main(String[] args) throws Exception { new JarLauncher().launch(args); } }
JarLauncher
类 main 方法对其实例化然后调用了 #launch
方法,这个方法定义在父类中,看这个方法怎么运行的。
public abstract class Launcher { protected void launch(String[] args) throws Exception { JarFile.registerUrlProtocolHandler(); ClassLoader classLoader = createClassLoader(getClassPathArchives()); launch(args, getMainClass(), classLoader); } }
我们将重点放在这个方法中,首先它调用了 JarFile.registerUrlProtocolHandler(); 注册了自定义的 URL 协议处理器,URL 内容的获取是根据 UrlStreamHandler 获取的,不同的实现支持不同的协议,具体可参见我前面文章 《认识 Java 中的 URL》。看下这个方法的实现:
public class JarFile extends java.util.jar.JarFile { private static final String PROTOCOL_HANDLER = "java.protocol.handler.pkgs"; private static final String HANDLERS_PACKAGE = "org.springframework.boot.loader"; public static void registerUrlProtocolHandler() { String handlers = System.getProperty(PROTOCOL_HANDLER, ""); System.setProperty(PROTOCOL_HANDLER, ("".equals(handlers) ? HANDLERS_PACKAGE : handlers + "|" + HANDLERS_PACKAGE)); resetCachedUrlHandlers(); } }
Spring 将 URL 协议处理器所在包设置到了系统属性中,这样就会自动使用这个包下面的处理器处理对应的协议了,看下这个包下面有什么内容。
原来 Spring Boot 是添加了支持 jar 协议的处理器,不过 Java 默认已经添加了对 jar 协议的支持,为什么 Spring Boot 还要多此一举呢?
这是因为默认的 jar 协议只支持从 jar 包中获取 class 文件作为类,而 jar 包内嵌的 jar 包中的 class 文件不会处理,因此 Spring Boot 才会覆盖默认 jar 协议处理器,从而支持将内嵌的 jar 作为类路径。
再看 #launch 方法调用的 #getClassPathArchives() 方法,这是个抽象方法由子类进行实现。
public abstract class ExecutableArchiveLauncher extends Launcher { private final Archive archive; public ExecutableArchiveLauncher() { try { this.archive = createArchive(); } catch (Exception ex) { throw new IllegalStateException(ex); } } @Override protected List<Archive> getClassPathArchives() throws Exception { List<Archive> archives = new ArrayList<>(this.archive.getNestedArchives(this::isNestedArchive)); postProcessClassPathArchives(archives); return archives; } }
这里从归档文件 this.archive 中获取到了内嵌的归档文件,将其作为类路径,this.archive 在无参的构造方法中实现,由于 JarLauncher 实现了这个类,因此会被自动调用。
另外这个方法还调用了 #isNestedArchive 方法用于判断当前归档文件是否为内嵌的归档文件, #isNestedArchive 方法正是由 JarLauncher 实现。随后就根据归档文件列表获取 ClassLoader。
protected ClassLoader createClassLoader(List<Archive> archives) throws Exception { List<URL> urls = new ArrayList<>(archives.size()); for (Archive archive : archives) { urls.add(archive.getUrl()); } return createClassLoader(urls.toArray(new URL[0])); }
这里将归档文件的 URL 作为类路径创建了 ClassLoader。最后 #launch
方法调用了重载的方法。
protected void launch(String[] args, String mainClass, ClassLoader classLoader) throws Exception { Thread.currentThread().setContextClassLoader(classLoader); createMainMethodRunner(mainClass, args, classLoader).run(); } protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) { return new MainMethodRunner(mainClass, args); }
该方法将 ClassLoader 设置到线程上下文,然后调用了 MainMethodRunner#run
方法。
public class MainMethodRunner { private final String mainClassName; private final String[] args; public MainMethodRunner(String mainClass, String[] args) { this.mainClassName = mainClass; this.args = (args != null) ? args.clone() : null; } public void run() throws Exception { Class<?> mainClass = Thread.currentThread().getContextClassLoader().loadClass(this.mainClassName); Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); mainMethod.invoke(null, new Object[]{this.args}); } }
MainMethodRunner 类比较简单,从上下文中的 ClassLoader 获取到了应用自定义的主类,然后调用了 main 方法,至此整个流程结束。
总结
Spring Boot 可执行 jar 整体实现还是包括多个技术细节的,首先使用 spring-boot-maven-plugin 插件重新打包了归档文件,添加了依赖的 jar 包到重新打包的 jar 包中,然后使用自定义的 JarLauncher 作为主类,利用 URL 自定义协议的方式支持了获取 jar 包内嵌 jar 包中的 class,最后调用了应用定义主类的 main 方法,从而支持了 jar -jar 启动 Spring Boot 应用。