Spring Boot JarLauncher

前面在 WebMvc 的部分咱有解析过,打war包运行,使用外部Servlet容器启动 SpringBoot 应用时,需要一个 ServletInitializer 来引导启动 SpringBoot 应用。那在使用jar包启动时,咱只是知道会走主启动类的 main 方法,但那是在开发时直接指定走主启动类的 main 方法,在jar包启动时是另一种方式。咱这最后一篇就来看看jar包启动 SpringBoot 应用的原理。

翻开打好的jar包,会发现3个文件夹:

  • BOOT-INF:存放自己编写并编译好的 .class 文件和静态资源文件、配置文件等

  • META-INF:有一个 MANIFEST.MF 的文件

  • orgspring-boot-loader 的一些 .class 文件

其中,org.springframework.boot.loader 里开始能找到 .class 文件了。

翻看 META-INF 下面的 MANIFEST.MF 文件,发现里面的内容如下:

Manifest-Version: 1.0
Implementation-Title: demo
Implementation-Version: 0.0.1-SNAPSHOT
Start-Class: com.example.demo.DemoApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Build-Jdk-Spec: 1.8
Spring-Boot-Version: 2.1.9.RELEASE
Created-By: Maven Archiver 3.4.0
Main-Class:org.springframework.boot.loader.JarLauncher

这个文件中有两个值得关注的:

  • Start-Class 中注明了 SpringBoot 的主启动类

  • Main-Class 中注明了一个类: JarLauncher

如果能靠 SpringBoot 的主启动类完成应用的启动,那为什么还要标注下面的那个 JarLauncher 呢?

1. 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);
    }

}

发现有个 main 方法!而且上面定义了两个常量,恰好就是在jar包中 BOOT-INF 里面的两个部分:自己的源码,和第三方jar包。

2. 测试直接启动两个带main方法的类

2.1 SpringBootApplication

错误: 找不到或无法加载主类 **com.example.demo.DemoApplication**

发现启动失败,根本就找不到这个类。

2.2 JarLauncher

能正常启动,打印 Banner 等。

3. 【拓展】主启动类无法正常引导启动的原理

用正常的指令启动时,java 指令没有指定 classpath,而当前 SpringBoot 应用依赖的jar包均放在 BOOT-INF/lib 下,这部分无法被识别。

3.1 标准jar包的启动规范

在可执行jar包中,有一个规范:被标记为Main-Class的类必须连同自己的包,直接放在jar包的最外层(没有额外的文件夹包含)。

  • SpringBootApplication 的位置:"BOOT-INF/classes/com.example.demo.DemoApplication.class"

  • JarLauncher 的位置:"org.springframework.boot.loader.JarLauncher"

所以 JarLauncher 能引导成功,而直接运行主启动类却无法成功启动。

这也解释了另外一个现象:

SpringBoot 在打jar包时,没有直接将 spring-boot-loader 包直接依赖到lib目录,而是将这个包下面的所有 .class 文件都复制到要打的jar包中。

3.2 标准jar包的内嵌jar规范

可执行jar包中还有一个规范:jar包中原则上不允许嵌套jar包。

传统的打jar包的方式是将所有依赖的jar包都复制到一个新的jar包中。这样会出现一个致命问题:

如果两个不同的jar包中有一个全限定类名相同的文件,会出现覆盖现象。

SpringBoot 使用自定义的 ClassLoader,可以解决这个问题,具体的部分要剖析源码才能看到实现机制。

4. JarLauncher的main方法都做了什么

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

只有这一句,而且从上面的源码中可以发现,调用的空参数构造方法没有任何实际作用,也没有调父类的构造方法。

那一切的功能都在 launch 方法中。launch 方法不在 JarLauncher 里,在父类的 Launcher 内有定义:

// 这个方法是一个入口点,且应该被一个public static void main(String[] args)调用
protected void launch(String[] args) throws Exception {
    //注册URL协议并清除应用缓存
    JarFile.registerUrlProtocolHandler();
    //设置类加载路径
    ClassLoader classLoader = createClassLoader(getClassPathArchives());
    //执行main方法
    launch(args, getMainClass(), classLoader);
}

从文档注释可以发现,这个方法必须被 main 方法调用,这跟上面的 JarLauncher 中 main 方法直接调用一致。

4.1 registerUrlProtocolHandler

private static final String MANIFEST_NAME = "META-INF/MANIFEST.MF";

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();
}

// 重置任何缓存的处理程序,以防万一已经使用了jar协议。
// 我们通过尝试设置null URLStreamHandlerFactory来重置处理程序,除了清除处理程序缓存之外,它应该没有任何效果。
private static void resetCachedUrlHandlers() {
    try {
        URL.setURLStreamHandlerFactory(null);
    }
    catch (Error ex) {
        // Ignore
    }
}

先设置当前系统的一个变量 **java.protocol.handler.pkgs**,而这个变量的作用,是设置 URLStreamHandler 实现类的包路径。

之后要重置缓存,目的是清除之前启动的残留(文档注释已标明)。

4.2 createClassLoader

它要来创建 ClassLoader,而创建之前先调了 getClassPathArchives 方法来取一些 Archive 对象。

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]));
}

4.2.1 getClassPathArchives

protected List<Archive> getClassPathArchives() throws Exception {
    List<Archive> archives = new ArrayList<>(this.archive.getNestedArchives(this::isNestedArchive));
    postProcessClassPathArchives(archives);
    return archives;
}

从最后看起,isNestedArchive 方法在调用时要传入 Archive.Entry,而这个参数的来源尚不明确,先搁置一边。

往前看,有一个 this.archive,而这个 archive 的成员属性是在这个类创建时被调用的。

private final 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;
    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 对象最终创建在下面的 createArchive 方法。

File root = new File 之前的部分,这段代码都是在找当前类的所在jar包的绝对路径

之后下面把这个文件创建出来,并以此创建一个 JarFileArchive 对象。

而这个 JarFileArchiveArchive 的子类,这个 Archive 就可以被 Launcher 启动。文档注释和类定义:

/**
* An archive that can be launched by the Launcher
*/
public interface Archive extends Iterable<Archive.Entry>

恰巧从 Archive 中得到一个意外收获:Archive 里的 Archive.Entry 可以被迭代!

跟前面的那个方法引用刚好能对应上了。

isNestedArchive 方法传入的参数就是 archive 对象中的那一组 Entry 对象(一个 Entry 相当于一个 "File")。

4.2.2 getNestedArchives

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 对象要执行 getNestedArchives 时,会传入一个 EntryFilter,以此来获取一组被嵌套的 Archive

而这个 EntryFilter 的工作机制就是上面的 isNestedArchive 方法,在 JarLauncher 中也有定义:

protected boolean isNestedArchive(Archive.Entry entry) {
    if (entry.isDirectory()) {
        return entry.getName().equals(BOOT_INF_CLASSES);
    }
    return entry.getName().startsWith(BOOT_INF_LIB);
}

很明显,看看是不是 BOOT-INF/lib 开头的jar包,如果不是,看看是不是 BOOT-INF/classes 文件夹。

这部分的意义正好跟前面测试主启动类与 JarLauncher 的启动相呼应:

位于 BOOT-INF/classes 的启动类需要后续被扫描到,才能被处理

由此可见,得到的 archives 集合就是 **BOOT-INF/classes****BOOT-INF/lib** 下面的所有文件。

4.2.3 postProcessClassPathArchives

// 在使用之前调用后处理存档条目。实现可以添加和删除Entry。
protected void postProcessClassPathArchives(List<Archive> archives) throws Exception {
}

这个后置处理的方法是空的,且没有子类重写,说明默认就是拿 **BOOT-INF/classes****BOOT-INF/lib** 下面的文件了。

4.3 createClassLoader

// 为指定的归档文件创建一个类加载器
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());
}

上面部分的源码很容易可以看出是将每个 Archive 的绝对路径保存到一个 List 中,之后调用下面的 createClassLoader 方法。

下面直接创建了一个 LaunchedURLClassLoader,传入的 ClassLoader 很明显是默认的,也就是 AppClassLoader

4.3.1 构造方法

public LaunchedURLClassLoader(URL[] urls, ClassLoader parent) {
    super(urls, parent);
}

很简单,直接调用父类的构造方法(指定 ClassLoader 是双亲委托机制)

LaunchedURLClassLoader 的父类是: java.net.URLClassLoader ,是jdk内部的 ClassLoader,不再深入描述。

4.4 launch

launch(args, getMainClass(), classLoader);

调用 launch 之前会先调用 getMainClass 方法获取主启动类。

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;
}

它要从 Launcher 类的成员 archive 中获取 Manifest 文件,这个文件就是之前在 META-INF 下面的 MANIFEST.MF 文件。

之后从这个文件中取出 Start-Class 的值,这个值就是主启动类的全限定类名。

之后调用 launch 方法:

// 根据一个Archive文件和一个完全配置好的ClassLoader启动应用。
protected void launch(String[] args, String mainClass, ClassLoader classLoader) throws Exception {
    Thread.currentThread().setContextClassLoader(classLoader);
    createMainMethodRunner(mainClass, args, classLoader).run();
}

先设置当前线程的上下文类加载器为新的类加载器,也就是 LaunchedURLClassLoader (默认为 AppClassLoader)。

之后要开始创建 main 方法的运行器,并运行。

4.5 mainMethodRunner.run

protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) {
    return new MainMethodRunner(mainClass, args);
}

简单的创建了 MainMethodRunner 的对象,之后上面会调用 run 方法。

MainMethodRunner 的结构:

// 用于Launcher调用main方法的辅助类。使用当前线程上下文类加载器加载包含main方法的类。
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 });
    }

}

核心是 run 方法。

先拿到当前线程的上下文类加载器,就是 LaunchedURLClassLoader

之后用这个 ClassLoader 加载主启动类,之后运行 main 方法。

这也解释了为什么 SpringBoot 应用在开发期间只需要写 main 方法,引导启动即可。

最后更新于