SpringBoot 在构建 Jar 包时不为人知的秘密

364 阅读6分钟

众所周知,SpringBoot 能够通过 java -jar xxx.jar 的形式,或者通过执行主启动类的 main 方法来启动整个项目。

那么 SpringBoot 在通过上述方式启动项目的整个过程,甚至从构建 Jar 包这个动作开始,SpringBoot 都做了哪些优化的手段?或者说,SpringBoot 与传统项目构建 Jar 包的方式有什么不同呢? 我们可以先简单地看一下,一个普通的 Java 项目在构建 Jar 包时存在什么缺点。(maven 项目跟普通 java 项目在处理依赖这方面,处理方式是相同的)

首先,我构建了一个 maven 项目,如下图所示: 可以发现,在这个项目中我引入了一个 google guava 依赖,并且创建了一个 package、类名都与 guava 中 DoubleUtil 完全一致的 Class。 区别仅在于,本地的 DoubleUtil 仅包含了一个 main 方法,且该方法将会作为程序执行入口。 紧接着,我通过 maven 将项目构建为可执行的 jar 包。

Maven 项目在打 jar 包时与普通 java 项目不同,需要通过 maven 插件来完成。 然后通过 java -jar xxx.jar 去执行

不出所料,很神奇的报错了,错误提示内容大概是:主启动类中并不存在 main 方法。如果正确配置了 Main-Class 的话,正常情况下应该会执行 Main-Class 的 main 方法,但这里却告诉我们不存在 main 方法。 问题很明显,本地的 DoubleUtils 被 google guava 中的 DoubleUtils 覆盖了。 当然,如果我们把 DoubleUtils 这个类名稍作修改,保持全局唯一,那么就不会导致文件覆盖的情况了:(针对这一点可以自己验证下)

带着问题,解压项目的 Jar 包后我们能够发现,maven 在处理项目依赖的策略是:将依赖项目原封不动地拷贝到本地。如果存在同名路径或者同名文件,那么会采取覆盖的策略。 毫无疑问,这种处理依赖方案在某些特殊情况下,是会导致程序执行出错的。并且本地文件与依赖文件交杂在一起,也会导致包结构非常混乱。

我们再反观 SpringBoot 处理依赖的方式,就优雅很多。

SpringBoot 的 Jar 包在解压后能得到如下三个目录,分别是:

  • BOOT-INF:包含了当前项目的字节码文件,以及项目所依赖的 jar 包。
  • META-INF:包含了项目的元数据信息,其中 MANIFEST.MF 这个文件配置了 Jar 包的主启动类、SpringBoot 的主启动类等信息。
  • org:该目录是 spring-boot-loader 这个依赖的根路径。 通过分析上述三个文件目录,不难发现,SpringBoot 将依赖包与本地代码进行了隔离

那么现在又衍生出两个问题:

  1. spring-boot-loader 作为一个外部依赖,为什么需要单独存放于项目的顶层目录下,而不是作为 Jar 包引入。

  2. 我们在使用 SpringBoot 时会通过 SpringBoot主启动类 来执行应用程序,但是 MANIFEST.MF 配置文件中却有如下两条定义:

Start-Class: com.wuhu.springboot.SpringbootApplication
Main-Class: org.springframework.boot.loader.JarLauncher

其中,{Main-Class} 定位的是 JarLauncher ,而不是 SpringbootApplication 。也就是说,从配置上来看,SpringBoot的程序入口应该是 JarLuncher 这个类的 main 方法,而不是 SpringBoot 启动类的 main 方法。但是程序在启动后,执行的确确实实是 SpringBoot 启动类的 main 方法。这是为何?

问题1:

既然 SpringBoot 对依赖包和本地字节码文件进行了隔离,那么 BOOT-INF 中的 jar 包与字节码文件是无法被 AppClassLoader 加载的。因为它们都位于子目录下,而 AppClassLoader 只会去加载位于 Jar 包顶层目录中的字节码文件。 为此,SpringBoot 需要自定义类加载器,去加载 BOOT-INF 下的字节码文件。而构建自定义类加载器的工作,都包含在 spring-boot-loader 这个项目中了。 为了保证 spring-boot-loader 的字节码文件能够正常地被类加载器加载,SpringBoot 项目在打包的时候,会将 spring-boot-loader 的代码放置在 Jar 包顶层目录下。

问题2:

通过问题1的解答,不难得知,位于 BOOT-INF 目录下的 SpringBoot 主启动类的 main 方法是无法充当程序入口的。毕竟字节码都无法被加载到内存,执行也就无从谈起了。 但 JarLuncher.main() 执行的结果确实是 SpringbootApplication.main() 的执行结果,因此我们可以大胆地猜测,作为程序入口类的 JarLuncher.main() 通过反射调用了 SpringBoot 主启动类的main方法。 下面通过 JarLuncher 的源码,验证上述的猜想:

public class JarLauncher extends ExecutableArchiveLauncher {
	
	//... 
	
	public static void main(String[] args) throws Exception {
		new JarLauncher().launch(args);
	}

}
public abstract class Launcher {

	/**
	 * Launch the application. This method is the initial entry point that should be
	 * called by a subclass {@code public static void main(String[] args)} method.
	 * @param args the incoming arguments
	 * @throws Exception if the application fails to launch
	 */
	protected void launch(String[] args) throws 
		if (!isExploded()) {
			JarFile.registerUrlProtocolHandler();
		}
		// 创建一个类加载器
		ClassLoader classLoader = createClassLoader(getClassPathArchivesIterator());
		// 这个属性我尝试过获取, 但value为空, 所以这里暂且把它作为空值
		String jarMode = System.getProperty("jarmode");
		// getMain() 这个方法内部会拿到 MANIFEST 文件中定义的 StartClass, 也就是 SpringBoot启动类的全限定类名
		String launchClass = (jarMode != null && !jarMode.isEmpty()) ? JAR_MODE_LAUNCHER : getMainClass();
		// 把全限定类名、类加载器作为参数传递到 launch() 方法
		launch(args, launchClass, classLoader);
	}

	protected void launch(String[] args, String launchClass, ClassLoader classLoader) throws Exception {
		// 这里会将类加载器设置为当前线程上下文类加载
		// 如果阅读过 Spring 的源码会发现, 项目中的Bean字节码文件在被加载时采用的类加载器都是线程上下文类加载器, 而不是AppClassLoader
		// 这里也解释了原因
		Thread.currentThread().setContextClassLoader(classLoader);
		// 这里具体执行的方法, 都位于MainMethodRunner中
		createMainMethodRunner(launchClass, args, classLoader).run();
	}

}
public class MainMethodRunner {

	private final String mainClassName;

	private final String[] args;

	/**
	 * Create a new {@link MainMethodRunner} instance.
	 * @param mainClass the main class
	 * @param args incoming arguments
	 */
	public MainMethodRunner(String mainClass, String[] args) {
		this.mainClassName = mainClass;
		this.args = (args != null) ? args.clone() : null;
	}

	// 通过反射获取到 mainClassName 的类对象、main方法,并执行这个main方法
	public void run() throws Exception {
		Class<?> mainClass = Class.forName(this.mainClassName, false, Thread.currentThread().getContextClassLoader());
		Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
		mainMethod.setAccessible(true);
		mainMethod.invoke(null, new Object[] { this.args });
	}
}

通过上述代码,我们可以明确:作为程序入口类的 JarLuncher.main() 通过反射调用了 SpringBoot 主启动类的main方法。对上述问题也有了清晰的解答。

以上就是 SpringBoot 在具体处理 Jar 包时采用的处理策略。其实大体上看,处理的思想与 SOLID 中的单一指责原则有异曲同工之处:项目的依赖于本地代码应该各自保持独立,而非耦合在一起。