在使用spring-boot-maven-plugin插件执行mvn package命令构建可执行jar文件(Fat JAR)后用“java -jar”命令就可以直接运行应用程序。
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
执行打包命令后实际在target目录下产生了两个jar文件,一个为application-name.version-SNAPSHOT.jar,一个为application-name.version-SNAPSHOT.jar.original。后者仅包含应用编译后的本地资源,而前者引入了相关的第三方依赖,这点从文件大小也能看出。
将前者jar包解压查看目录结构如下:
该目录比使用传统jar命令打包结构更复杂一些,目录含义如下:
当使用java -jar命令执行Spring Boot应用的可执行jar文件时,该命令引导标准可执行的jar文件,读取在jar中META-INF/MANIFEST.MF文件的Main-Class属性值,该值代表应用程序执行入口类也就是包含main方法的类。
打开spring-boot可执行jar包解压后的META-INF/MANIFEST.MF文件发现其Main-Class属性值为org.springframework.boot.loader.JarLauncher,并且项目的引导类定义在Start-Class属性中,该属性并非Java标准META-INF/MANIFEST.MF文件属性,而是spring-boot引导程序启动需要的,JarLauncher是对应jar文件的地动器,org.springframework.boot.loader.WarLauncher是可执行war包的启动器。
启动类org.springframework.boot.loader.JarLauncher并非为项目中引入类,而是spring-boot-maven-plugin插件repackage追加进去的。
当执行java -jar命令或执行解压后的org.springframework.boot.loader.JarLauncher类时,JarLauncher会将BOOT-INF/classes下的类文件和BOOT-INF/lib下依赖的jar加入到classpath下,后调用META-INF/MANIFEST.MF文件Start-Class属性完成应用程序的启动。
打开spring-boot-loader模块源码找到org.springframework.boot.loader.JarLauncher类。
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);
}
}
此启动器假定依赖项jar包含在/BOOT-INF/lib目录中,并且应用程序类包含在/BOOT-INF/classes目录中。
isNestedArchive方法在父类ExecutableArchiveLauncher确定指定的JarEntry是否为应添加到类路径的嵌套项目。 该方法为jar包中每个条目调用一次。具体在ExecutableArchiveLauncher中再看。
在JarLauncher中定义了main方法,该方法直接调用JarLauncher实例的launch方法。launch方法是基类Launcher中定义的,而Launcher是ExecutableArchiveLauncher的父类,Launcher的子孙如下:
前面也提到了WarLauncher是可执行war包的启动器,与JarLauncher区别以及PropertiesLauncher后面再讨论。
public abstract class Launcher {
...
/**
* 启动应用程序。 此方法是子类公共静态void main(String [] args)方法应调用的初始入口点。
*/
protected void launch(String[] args) throws Exception {
JarFile.registerUrlProtocolHandler();
ClassLoader classLoader = createClassLoader(getClassPathArchives());
launch(args, getMainClass(), classLoader);
}
...
}
JarFile.registerUrlProtocolHandler()方法利用了Java URL协议实现扩展原理该方法将包名org.springframework.boot.loader追加到Java系统属性java.protocol.handler.pkgs中,该包下存在协议对应的Handler类,即org.springframework.boot.loader.jar.Handler其实现协议为jar。
/**
*注册一个“java.protocol.handler.pkgs”属性,以便定位URLStreamHandler来处理jar URL。
**/
public static void registerUrlProtocolHandler() {
String handlers = System.getProperty(PROTOCOL_HANDLER, "");
System.setProperty(PROTOCOL_HANDLER,
("".equals(handlers) ? HANDLERS_PACKAGE : handlers + "|" + HANDLERS_PACKAGE));
resetCachedUrlHandlers();
}
/**
* 重置URLStreamHandlerFactory以防万一已经使用了实现jar协议的URLStreamHandlerFactory
**/
private static void resetCachedUrlHandlers() {
try {
URL.setURLStreamHandlerFactory(null);
}
catch (Error ex) {
// Ignore
}
}
URL#getURLStreamHandler(String)方法的实现先读取Java系统属性java.protocol.handler.pkgs,无论是否存在再追加sun.net.www.protocol包,所以JDK内建实现作为兜底实现。
问题的关键在于为什么Spring Boot要自定义URL的jar协议实现覆盖JDK内建的实现呢?
前面提到过,Spring Boot Fat JAR除包含传统Java Jar中的资源外还包含依赖的第三方Jar文件,当Spring Boot Fat Jar被java -jar命令引导时,其内部的Jar文件无法被内建实现sun.net.www.protocol.jar.Handler当做classpath。
讨论完JarFile.registerUrlProtocolHandler()方法扩展URL协议的目的,下一步执行createClassLoader(getClassPathArchives())创建ClassLoader,其中getClassPathArchives()方法返回值作为参数,该方法为抽象方法具体实现在子类ExecutableArchiveLauncher:
@Override
protected List<Archive> getClassPathArchives() throws Exception {
List<Archive> archives = new ArrayList<>(this.archive.getNestedArchives(this::isNestedArchive));
// 此方法为空实现
postProcessClassPathArchives(archives);
return archives;
}
archive是Archive的实例,在构造方法中完成创建:
public ExecutableArchiveLauncher() {
try {
this.archive = createArchive();
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
protected final Archive createArchive() throws Exception {
ProtectionDomain protectionDomain = getClass().getProtectionDomain();
CodeSource codeSource = protectionDomain.getCodeSource();
URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null;
//如果直接执行.class文件那么会得到当前class的绝对路径。
//如果封装在jar包里面执行jar包那么会得到当前jar包的绝对路径。
String path = (location != null) ? location.getSchemeSpecificPart() : null;
if (path == null) {
throw new IllegalStateException("Unable to determine code source archive");
}
File root = new File(path);
if (!root.exists()) {
throw new IllegalStateException("Unable to determine code source archive from " + root);
}
return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root));
}
此方法主要通过当前启动器所在的介质返回一个相应的Archive归档实现:
Archive接口定义了一个getNestedArchives方法返回与指定过滤器匹配的条目的嵌套存档,在JarLauncher实现中传入的过滤器就是JarLauncher#isNestedArchive方法引用,这里可以回顾一下3.1节的实现。
下面是JarFileArchive的构造方法,成员变量jarFile是在JarFileArchive构造方法中通过当前归档文件路径作为JarFile的构造参数创建的。
public JarFileArchive(File file) throws IOException {
this(file, file.toURI().toURL());
}
public JarFileArchive(File file, URL url) throws IOException {
this(new JarFile(file));
this.url = url;
}
public JarFileArchive(JarFile jarFile) {
this.jarFile = jarFile;
}
下面就以JarFileArchive为例看下getNestedArchives方法具体实现:
@Override
public List<Archive> getNestedArchives(EntryFilter filter) throws IOException {
List<Archive> nestedArchives = new ArrayList<>();
for (Entry entry : this) {
if (filter.matches(entry)) {
nestedArchives.add(getNestedArchive(entry));
}
}
return Collections.unmodifiableList(nestedArchives);
}
由于Archive继承于Iterable
@Override
public Iterator<Entry> iterator() {
return new EntryIterator(this.jarFile.entries());
}
下面是JarFileArchive#getNestedArchive方法的实现:
protected Archive getNestedArchive(Entry entry) throws IOException {
JarEntry jarEntry = ((JarFileEntry) entry).getJarEntry();
if (jarEntry.getComment().startsWith(UNPACK_MARKER)) {
return getUnpackedNestedArchive(jarEntry);
}
try {
JarFile jarFile = this.jarFile.getNestedJarFile(jarEntry);
return new JarFileArchive(jarFile);
}
catch (Exception ex) {
throw new IllegalStateException("Failed to get nested archive for entry " + entry.getName(), ex);
}
}
jarFile是org.springframework.boot.loader.jar.JarFile的实例对象,继承于java.util.jar.JarFile,其行为方式相同,但提供以下附加功能:
public synchronized JarFile getNestedJarFile(ZipEntry entry) throws IOException {
return getNestedJarFile((JarEntry) entry);
}
public synchronized JarFile getNestedJarFile(JarEntry entry) throws IOException {
try {
return createJarFileFromEntry(entry);
}
catch (Exception ex) {
throw new IOException("Unable to open nested jar file '" + entry.getName() + "'", ex);
}
}
createJarFileFromEntry方法根据当前条目是目录还是文件来创建不同JarFileType的JarFile:
entries()方法返回一个可迭代其所有的条目的Enumeration
@Override
public Enumeration<java.util.jar.JarEntry> entries() {
final Iterator<JarEntry> iterator = this.entries.iterator();
return new Enumeration<java.util.jar.JarEntry>() {
@Override
public boolean hasMoreElements() {
return iterator.hasNext();
}
@Override
public java.util.jar.JarEntry nextElement() {
return iterator.next();
}
};
}
entries()中核心成员就是this.entries,它是在构造方法中赋值的,还有上面多出方法调用都会通过构造方法创建JarFile,下面就看一下JarFile的构造方法:
//创建一个由指定文件支持的JarFile
public JarFile(File file) throws IOException {
this(new RandomAccessDataFile(file));
}
JarFile(RandomAccessDataFile file) throws IOException {
this(file, "", file, JarFileType.DIRECT);
}
在JarFileArchive构造方法中就是通过上面这个构造方法创建的JarFile,由于是直接通过Jar文件的路径直接指定的,因此不需要其余构造方法中除file参数的额外参数,并且jarFileType为DIRECT(直接类型)。
上面的构造方法最终还是会调用带有6个参数的构造方法:
private JarFile(RandomAccessDataFile rootFile, String pathFromRoot, RandomAccessData data, JarEntryFilter filter,
JarFileType type, Supplier<Manifest> manifestSupplier) throws IOException {
super(rootFile.getFile());
this.rootFile = rootFile;
this.pathFromRoot = pathFromRoot;
CentralDirectoryParser parser = new CentralDirectoryParser();
this.entries = parser.addVisitor(new JarFileEntries(this, filter));
this.type = type;
parser.addVisitor(centralDirectoryVisitor());
try {
this.data = parser.parse(data, filter == null);
}
catch (RuntimeException ex) {
close();
throw ex;
}
this.manifestSupplier = (manifestSupplier != null) ? manifestSupplier : () -> {
try (InputStream inputStream = getInputStream(MANIFEST_NAME)) {
if (inputStream == null) {
return null;
}
return new Manifest(inputStream);
}
catch (IOException ex) {
throw new RuntimeException(ex);
}
};
}
这个方法被直接调动是在createJarFileFromEntry方法中,这样可以直接指定pathFromRoot和type等参数,在这个方法中可以跟通给定的JarFile得到代表jar文件的所有条目的JarFileEntries对象和代表MANIFEST.MF文件的Manifest引用对象。
以上就是ExecutableArchiveLauncher#getClassPathArchives()方法的全部实现,回过头来再看Launcher#createClassLoader(List)方法:
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]));
}
protected ClassLoader createClassLoader(URL[] urls) throws Exception {
return new LaunchedURLClassLoader(urls, getClass().getClassLoader());
}
createClassLoader()方法目的是创建一个类加载器,而LaunchedURLClassLoader继承于URLClassLoader,URLClassLoader需要一个URL数组,URLClassLoader本身加载的类都是通过URL定位的资源转换成JVM的内存对象,关于类加载器原理参考《一篇搞懂Java ClassLoader》。而这组URL的来源就是通过参数List,下面看一下JarFileArchive#getUrl()方法的实现:
@Override
public URL getUrl() throws MalformedURLException {
//通过URL直接new的JarFileArchive对象url就不为空,也就是最外侧的jar
if (this.url != null) {
return this.url;
}
//通过JarFileArchive#getNestedArchive返回的,url为空,也就是内部的jar
return this.jarFile.getUrl();
}
如果url!=null时直接返回代表最外层的Jar文件URL给URLClassLoader,URLClassLoader在加载时通过读取Java系统属性java.protocol.handler.pkgs使用org.springframework.boot.loader.jar.Handler来得到该URL代表的资源,关于URLClassLoader使用URL加载类的过程请自行参考java.net.URLClassLoader#findClass()方法。
如果url==null时,JarFile#getUrl()方法内部使用了org.springframework.boot.loader.jar.Handler来处理重新构建的URL,看一下具体实现:
public URL getUrl() throws MalformedURLException {
if (this.url == null) {
Handler handler = new Handler(this);
String file = this.rootFile.getFile().toURI() + this.pathFromRoot + "!/";
file = file.replace("file:", "file://"); // Fix UNC paths
this.url = new URL("jar", "", -1, file, handler);
}
return this.url;
}
org.springframework.boot.loader.jar.Handler#openConnection(java.net.URL)方法的实现:
@Override
protected URLConnection openConnection(URL url) throws IOException {
if (this.jarFile != null && isUrlInJarFile(url, this.jarFile)) {
return JarURLConnection.get(url, this.jarFile);
}
try {
return JarURLConnection.get(url, getRootJarFileFromUrl(url));
}
catch (Exception ex) {
return openFallbackConnection(url, ex);
}
}
可以看到org.springframework.boot.loader.jar.Handler返回的URLConnection是一个org.springframework.boot.loader.jar.JarURLConnection,而这个JarURLConnection和JDK内建jar协议实现sun.net.www.protocol.jar.Handler返回的sun.net.www.protocol.jar.JarURLConnection区别是前者支持嵌套jar文件的类加载,下面通过一个例子说明这个情况:
public static void main(String[] args) throws MalformedURLException, ClassNotFoundException {
System.setProperty("java.protocol.handler.pkgs","org.springframework.boot.loader");
URLClassLoader normal=new URLClassLoader(new URL[]{new URL("jar:file:///Users/test-1.0.0-SNAPSHOT.jar!/BOOT-INF/lib/guava-28.1-jre.jar!/")},null);
Class<?> aClass = normal.loadClass("com.google.common.base.JdkPattern");
System.out.println(aClass.getSimpleName());
}
test-1.0.0-SNAPSHOT.jar是使用spring-boot-maven-plugin插件打包的Fat Jar,guava-28.1-jre.jar是程序依赖的第三方jar包,在上面代码开启spring boot方式的jar加载,代码顺利打印"JdkPattern",而将第一行代码注释掉就会报如下异常:
根本原因就是Spring Boot提供的org.springframework.boot.loader.jar.JarFile可以提供对嵌套jar数据读取,而内建的sun.net.www.protocol.jar.URLJarFile不支持。
以上就是Spring Boot扩展URL协议实现的原因了,在得到可以加载嵌套式的jar文件后调用Launcher#launch(String[], String, ClassLoader)方法,读取Manifest的Start-Class属性利用反射执行main方法完成应用程序的启动。
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) {
//反射执行mainClass.main
return new MainMethodRunner(mainClass, args);
}
ExecutableArchiveLauncher#getMainClass:
@Override
protected String getMainClass() throws Exception {
Manifest manifest = this.archive.getManifest();
String mainClass = null;
if (manifest != null) {
mainClass = manifest.getMainAttributes().getValue("Start-Class");
}
if (mainClass == null) {
throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this);
}
return mainClass;
}
WarLauncher是可执行war包的启动器,与JarLauncher一样继承于ExecutableArchiveLauncher,唯一区别就是选取classpath文件。
public class WarLauncher extends ExecutableArchiveLauncher {
private static final String WEB_INF = "WEB-INF/";
private static final String WEB_INF_CLASSES = WEB_INF + "classes/";
private static final String WEB_INF_LIB = WEB_INF + "lib/";
private static final String WEB_INF_LIB_PROVIDED = WEB_INF + "lib-provided/";
public WarLauncher() {
}
protected WarLauncher(Archive archive) {
super(archive);
}
@Override
public boolean isNestedArchive(Archive.Entry entry) {
if (entry.isDirectory()) {
return entry.getName().equals(WEB_INF_CLASSES);
}
else {
return entry.getName().startsWith(WEB_INF_LIB) || entry.getName().startsWith(WEB_INF_LIB_PROVIDED);
}
}
public static void main(String[] args) throws Exception {
new WarLauncher().launch(args);
}
}
WEB-INF/classes/、WEB-INF/lib/、WEB-INF/lib-provided/均为LaunchedURLClassLoader的classpath,其中WEB-INF/classes/、WEB-INF/lib/是传统的Servlet应用的classpath路径,而WEB-INF/lib-provided/属于Spring Boot WarLauncher定制实现,这个目录专门存放Maven依赖provided的jar,这么做的目的是为什么呢?
传统的Servlet应用的classpath路径仅关注WEB-INF/classes/和WEB-INF/lib/,因此WEB-INF/lib-provided/中的jar将被Servlet容器忽略,如Servlet API,该API由Servlet容器提供。这样的设计的好处在于打包后war文件能够在Servlet容器中兼容运行。
总而言之,打包war文件是一种兼容措施,既能被WarLauncher启动又能兼容Servlet容器环境。换言之,WarLauncher与JarLauncher并无本质差别,所以建议Spring Boot应用使用非传统Web部署时尽可能地使用Jar归档方式。
通过属性文件使用用户配置的类路径和主类进行归档的启动器。与基于可执行jar的模型相比,该模型通常更灵活并且更适合创建行为良好的OS级别服务。
在不同位置查找属性文件以提取加载程序设置,默认情况下在当前类路径或当前工作目录中为loader.properties。可以通过设置系统属性loader.config.name来更改属性文件的名称(例如-Dloader.config.name=foo将查找foo.properties。如果该文件不存在,请尝试读取系统属性loader.config.location (带有允许的前缀classpath:和file:或任何有效的URL)。找到该文件后,将其转换为Properties并提取可选值(如果文件不存在,也可以将其重写为System属性):
/**
* Properties key for main class. As a manifest entry can also be specified as
* {@code Start-Class}.
*/
public static final String MAIN = "loader.main";
/**
* Properties key for classpath entries (directories possibly containing jars or
* jars). Multiple entries can be specified using a comma-separated list. {@code
* BOOT-INF/classes,BOOT-INF/lib} in the application archive are always used.
*/
public static final String PATH = "loader.path";
/**
* Properties key for home directory. This is the location of external configuration
* if not on classpath, and also the base path for any relative paths in the
* {@link #PATH loader path}. Defaults to current working directory (
* ${user.dir}
).
*/
public static final String HOME = "loader.home";
/**
* Properties key for default command line arguments. These arguments (if present) are
* prepended to the main method arguments before launching.
*/
public static final String ARGS = "loader.args";
/**
* Properties key for name of external configuration file (excluding suffix). Defaults
* to "application". Ignored if {@link #CONFIG_LOCATION loader config location} is
* provided instead.
*/
public static final String CONFIG_NAME = "loader.config.name";
/**
* Properties key for config file location (including optional classpath:, file: or
* URL prefix).
*/
public static final String CONFIG_LOCATION = "loader.config.location";
/**
* Properties key for boolean flag (default false) which if set will cause the
* external configuration properties to be copied to System properties (assuming that
* is allowed by Java security).
*/
public static final String SET_SYSTEM_PROPERTIES = "loader.system";
构造方法加载properties文件转换成Properties对象。
public PropertiesLauncher() {
try {
this.home = getHomeDirectory();
initializeProperties();
initializePaths();
this.parent = createArchive();
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
home目录为loader.home系统属性如果没设置则读取环境变量${user.dir}。
private void initializeProperties() throws Exception, IOException {
List<String> configs = new ArrayList<>();
if (getProperty(CONFIG_LOCATION) != null) {
configs.add(getProperty(CONFIG_LOCATION));
}
else {
String[] names = getPropertyWithDefault(CONFIG_NAME, "loader").split(",");
for (String name : names) {
configs.add("file:" + getHomeDirectory() + "/" + name + ".properties");
configs.add("classpath:" + name + ".properties");
configs.add("classpath:BOOT-INF/classes/" + name + ".properties");
}
}
for (String config : configs) {
try (InputStream resource = getResource(config)) {
if (resource != null) {
debug("Found: " + config);
loadResource(resource);
// Load the first one we find
return;
}
else {
debug("Not found: " + config);
}
}
}
}
initializeProperties()方法的功能就是读取系统属性loader.config.location作为一个properties文件,如果该系统属性不存在则读取home目录下系统属性loader.config.name代表的文件名,如果loader.config.name属性未指定默认为loader,读取的属性文件加载到成员变量properties中供后面使用,在loadResource方法中提供了一个方便,如果设置系统属性loader.system=true,则还会将properties中的键值对设置到Java系统属性中。
initializePaths()方法读取系统属性和properties的loader.path的值逗号分隔作为归档文件或文件所在的目录。
createArchive()方法还是调用父类Launcher的,返回一个代表PropertiesLauncher所在的Jar文件或目录形式的回档文件对象JarFileArchive或ExplodedArchive。
下面还是按着Launcher的launch()方法的调用顺序,接下来是获取构成classpath的归档文件对象:
@Override
protected List<Archive> getClassPathArchives() throws Exception {
List<Archive> lib = new ArrayList<>();
for (String path : this.paths) {
for (Archive archive : getClassPathArchives(path)) {
if (archive instanceof ExplodedArchive) {
List<Archive> nested = new ArrayList<>(archive.getNestedArchives(new ArchiveEntryFilter()));
nested.add(0, archive);
lib.addAll(nested);
}
else {
lib.add(archive);
}
}
}
addNestedEntries(lib);
return lib;
}
private List<Archive> getClassPathArchives(String path) throws Exception {
String root = cleanupPath(handleUrl(path));
List<Archive> lib = new ArrayList<>();
File file = new File(root);
if (!"/".equals(root)) {
if (!isAbsolutePath(root)) {
file = new File(this.home, root);
}
if (file.isDirectory()) {
debug("Adding classpath entries from " + file);
Archive archive = new ExplodedArchive(file, false);
lib.add(archive);
}
}
Archive archive = getArchive(file);
if (archive != null) {
debug("Adding classpath entries from archive " + archive.getUrl() + root);
lib.add(archive);
}
List<Archive> nestedArchives = getNestedArchives(root);
if (nestedArchives != null) {
debug("Adding classpath entries from nested " + root);
lib.addAll(nestedArchives);
}
return lib;
}
以上方法就是从loader.path指定的目录读取归档文件,如果目录不是绝对路径形式(“/”开头或“file:///”开头或"jar:file:"开头)则从home下读取这些路径(或文件)。
下面是使用上面得到的Archive创建类加载器,在这个方法内支持自定义类加载器通过读取属性loader.classLoader。
@Override
protected ClassLoader createClassLoader(List<Archive> archives) throws Exception {
Set<URL> urls = new LinkedHashSet<>(archives.size());
for (Archive archive : archives) {
urls.add(archive.getUrl());
}
ClassLoader loader = new LaunchedURLClassLoader(urls.toArray(NO_URLS), getClass().getClassLoader());
debug("Classpath: " + urls);
String customLoaderClassName = getProperty("loader.classLoader");
if (customLoaderClassName != null) {
loader = wrapWithCustomClassLoader(loader, customLoaderClassName);
debug("Using custom class loader: " + customLoaderClassName);
}
return loader;
}
读取属性loader.main作为主类。
@Override
protected String getMainClass() throws Exception {
String mainClass = getProperty(MAIN, "Start-Class");
if (mainClass == null) {
throw new IllegalStateException("No '" + MAIN + "' or 'Start-Class' specified");
}
return mainClass;
}