springboot如何构建独立可执行的jar包

1,995 阅读12分钟

背景

最近有一个需求要往公司的生产数据库中导入一批数据,因为python用的不熟练,所以最终还是决定用老本行java来导数据到db中。使用maven来构建管理项目,同时为了方便操作JDBC、处理数据库表和JAVA对象的映射、SQL语句的生成,使用了Mybatis。但是,在将项目代码以及依赖的jar包打包成一个可执行的jar包,并部署到服务器上之后,执行该jar包却报属于依赖包中的某个class文件找不到。于是我就围绕“如何构建一个可执行的jar包”这个问题,展开了对一下知识点的学习:

  1. java -jar命令执行一个jar包的执行过程。
  2. ClassLoader的双亲委派机制与自定义ClassLoader。
  3. Springboot是如何构建一个包含了依赖包的可执行jar包。

项目结构

演示说明用的项目,就三个文件

image-20200807205001724

pom文件👇

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.example</groupId>
    <artifactId>jar-load-dependency</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.72</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- 使用maven-assembly-plugin将依赖包打入到项目的jar包内-->
            <plugin>
                <groupId>org.apache.maven.pl根据指定的配置文件来进行打包ugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <configuration>
                    <finalName>jar-with-dependency</finalName>
                    <appendAssemblyId>false</appendAssemblyId>
                    <descriptors>
                        <!-- maven-assembly-plugin根据指定的配置文件来进行打包-->
                        <descriptor>assembly.xml</descriptor>
                    </descriptors>
                    <archive>
                        <manifest>
                            <mainClass>Test</mainClass>
                        </manifest>
                    </archive>
                    <archiverConfig>
                        <compress>false</compress>
                    </archiverConfig>
                </configuration>
                <executions>
                    <execution>
                        <id>package-jar-with-dependencies</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>

        </plugins>
    </build>

</project>

assembly.xml文件👇

<assembly>
   <id>package</id>
   <formats>
       <format>jar</format><!--打包的文件格式,也可以有:war zip-->
   </formats>
   <!--压缩包下是否生成和项目名相同的根目录-->
   <includeBaseDirectory>false</includeBaseDirectory>
   <dependencySets>
       <dependencySet>
            <!--是否把本项目添加到依赖文件夹下-->
            <useProjectArtifact>false</useProjectArtifact>
            <!-- 存放依赖包的目录名-->
            <outputDirectory>lib</outputDirectory>
        </dependencySet>
    </dependencySets>
    <fileSets>
        <fileSet>
            <directory>${project.build.directory}/classes/</directory>
            <outputDirectory>/</outputDirectory>
            <filtered>false</filtered>
        </fileSet>
    </fileSets>
</assembly>

Test.java类

import com.alibaba.fastjson.JSONObject;

public class Test {

    public static void main(String[] args) throws Exception {
	   // Test类依赖了fastjson包中的JSONObject类
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("hello", "world");
        System.out.println( jsonObject.toJSONString());

    }

}

用 mvn package将上述项目打包成jar包,查看jar包内的结构:

image-20200807205611468

META-INF下的MANIFEST文件的配置如下👇(jar文件中MANIFEST文件的作用以及配置,请查看oracle的文档[JAR File Specification](docs.oracle.com/javase/8/do…

image-20200807205658337

jar包的入口是Test类的main方法,我们使用java -jar 命令来启动我们项目:

image-20200809115405121

woops,提示找不到com.alibaba.fastjson.JSONObject。奇怪了,命名已经将fastjson的依赖jar包,放入到我们项目的jar包中了,为什么执行的时候,还是找不到fastjson中的类呢?java -jar 命令的执行机制是如何的呢?

"java -jar ${文件名}"执行过程

众所周知,我们的java程序被编译打包成class文件的形式,运行在JVM上,JVM通过ClassLoader来读取加载程序运行过程中需要用到的class。通常如果我们在项目中没有额外设置ClassLoader的话,那么运行的时候默认会有这三个ClassLoader:BootstrapClassLoader、ExtClassLoader、AppClassLoader。

以我们的项目为例子,执行java -jar的过程大致如下(简单的针对classloader来描述下java -jar的执行过程,实际过程肯定没这么简单的,jvm是做了很多工作的,而且用户也可以通过java命令的各种参数来影响这个启动的过程):

  1. JVM会设置好上述三个ClassLoader加载class文件的路径,BootstrapClassLoader负责加载 ${JAVA_HOME}/jre/lib内的包,ExtClassLoader 负责加载 ${JAVA_HOME}/jre/lib/ext 下面的 jar 包,AppClassLoader负载加载jar包内的所有class文件,以及用户自定义的classpath(可以通过java -classpath或者MANIFEST.MF文件中的Class-Path配置classpath)下的第三方jar包。
  2. JVM读取MANIFEST.MF中的Class-Path和java -classpath,查看用户有无设置项目的classpath,如果有,则将自定义的classpath的路径交给AppClassLoader,后续加载这些目录下的jar文件内的class文件,就交给AppClassLoader来查找。
  3. JVM读取MANIFEST.MF中的Main-Class配置,知道jar包的执行入口是Test.class的main方法,遂执行之。

OK,这下我们知道了,java -jar命令并不会去让JVM的ClassLoader加载被执行的jar包内的jar文件。那我们将项目的依赖jar包都解压到项目内,再将项目打包成可执行jar包,不就行了,而且maven-assembly-plugin插件也是支持这个操作的,我们修改下pom文件:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.example</groupId>
    <artifactId>jar-load-dependency</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.72</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- 使用maven-assembly-plugin将依赖包打入到项目的jar包内-->
            <plugin>
                <groupId>org.apache.maven.pl根据指定的配置文件来进行打包ugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <configuration>
                    <finalName>jar-with-dependency</finalName>
                    <appendAssemblyId>false</appendAssemblyId>
<!--                 <descriptors>
                        &lt;!&ndash; maven-assembly-plugin根据指定的配置文件来进行打包&ndash;&gt;
                        <descriptor>assembly.xml</descriptor>
                    </descriptors>-->
                    <!-- 将👆的descriptors注释掉,添加descriptorRefs直接引用内置的描述符"jar-with-dependencies"
					   它会帮助我们将项目的依赖jar包都解压到项目内-->
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <archive>
                        <manifest>
                            <mainClass>Test</mainClass>
                        </manifest>
                    </archive>
                    <archiverConfig>
                        <compress>false</compress>
                    </archiverConfig>
                </configuration>
                <executions>
                    <execution>
                        <id>package-jar-with-dependencies</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>

        </plugins>
    </build>

</project>

执行mvn clean package命令打包我们的项目,打包后的项目如下,你看,我们项目唯一依赖的fastjson jar包,已经被解压,所有的class文件都在我们项目的jar包内:

image-20200809124026397

使用java -jar 命令来启动我们的项目,bingo,成功执行,不会报依赖jar包的类找不到了,JVM已经将我们jar包内的所有class文件都交给AppClassLoader来加载了:

image-20200809124205101

不过,将第三方依赖包的所有class文件都解压到项目的jar包内,会有3个问题:

  1. jar包内结构不清晰。如果项目依赖了很多jar包,都被解压到项目的jar内,就会找到我们项目的class文件和依赖包的class文件放在一起,对于我们查看jar包里的内容造成了干扰。
  2. 如果两个不同的依赖jar包,它们之中有相同路径相同名字但是文件内容不同的class文件,就会导致冲突,其中一个class文件将会被覆盖。
  3. 项目打包过程慢。一个jar包内有很多class文件,如果项目依赖了很多jar包,将会有很多class文件需要被解压到项目内,这么多小文件的读写,速度上肯定会比只是拷贝几个jar包慢,所以会导致我们项目的打包过程变慢(虽然每次打包的时间不会很夸张的慢,不过现在都讲Devops,很多公司都有在开展使用持续集成,自动化构建测试,来提高效率。如果一次打包的时间慢了10s,一天里面自动化构建了100次,就总共慢了1000s≈16m,嗯还是挺慢的)。

所以将依赖jar包的class文件都解压到项目内,也不是一个很优雅的做法。那有没有什么方式能够让项目jar包内的依赖jar包被添加到classloader中呢?答案当然是有的,我们可以自定义自己的ClassLoader(集成自ClassLoader类或者它的子类),来实现这一过程,具体怎么自定义ClassLoader在这里就不多说了。

大名鼎鼎的Springboot,搞java的大家肯定都知道,springboot项目可以被打包成一个可执行的jar包,项目的依赖都以jar文件的形式存在在项目的jar包内,启动一个Springboot项目,直接java -jar就完事了。其实Springboot也是通过自定义ClassLoader,来加载项目jar包内的依赖jar包。下面我们就来看看Springboot是如何实现的。

Springboot实现可执行jar包

官方文档说明

可以先查看下Springboot官网中的这篇文档Creating an Executable Jar

image-20200809134129554

大致意思如下:

Springboot项目会被构建成一个独立的可执行的jar文件,仅仅只需要这个jar文件就可以运行我们的项目。可执行jar文件中,包含了这个项目已经被编译过的class文件,以及项目代码所需要的所有依赖jar包。

Java的JDK没有提供一个标准的方式去加载嵌套的jar文件(嵌套jar文件指定的是包含在某个jar文件中的jar文件),比如说A.jar文件里面包含了B.jar文件,B.jar就是嵌套jar文件,JDK提供的"java.util.jar.JarFile",只提供了获取A.jar里面的文件的api,只能获取B.jar文件,但是没有提供api来获取B.jar中的文件。这将会成为你去发布一个独立(这里所谓的独立,指的是发布一个应用只需要一个文件就行了,不用在发布应用的时候,还要额外准备其他应用所依赖的文件(JDK不算,哈哈哈))应用时的问题。

为了解决这个问题,很多开发者使用 “uber” jar文件。uber jar文件指定就是将项目所依赖的所有jar包内的class文件全部打包进项目的jar包内。这种方式将会带来两个问题,1:通过项目的jar包很难知道项目依赖了那些文件,2:如果两个不同的依赖jar包,它们之中有相同路径相同名字但是文件内容不同的class文件,就会导致冲突,其中一个class文件将会被覆盖。

针对上述问题,Springboot提供了另一种方式,能够让你在项目的jar包内直接嵌套jar包。如果你是maven项目,只需要使用Springboot提供的spring-boot-maven-plugin插件来打包构建你的项目即可。

使用spring-boot-maven-plugin

wow,只要使用spring-boot-maven-plugin的这个插件就能解决我们的问题,实在是太秀了吧,赶紧在项目中配置上来搞一把,修改后的pom文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.example</groupId>
    <artifactId>jar-load-dependency</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.72</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>2.3.2.RELEASE</version>
                <executions>
                    <execution>
                        <goals>
                            <!-- 如果项目的pom文件没有设置“org.springframework.boot:spring-boot-starter-parent”为parent
                                 需要自己配置将spring-boot-maven-plugin和maven构建深生命周期内的repackage绑定,
                                 这样子,在repackage的时候才会使用spring-boot-maven-plugin插件-->
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
                <!-- 如果项目中有多个包含了main方法的类,需要在这里配置指定应用程序的入口  -->
                <configuration>
                    <mainClass>Test</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

将我们的项目打包构建并使用java -jar执行,Test的main方法成功执行,并没有报找不到fastjson。Springboot也太棒了吧~

spring-boot-maven-plugin原理

查看上一小节中用spring-boot-maven-plugin插件打包出来的项目jar的结构:

image-20200809140950617

spring-boot-maven-plugin将jar包内的文件总共分为三部分:

  1. BOOT-INF下放着项目的class文件以及依赖jar包
  2. META-INF下放着和jar包执行相关的的MANIFEST.MF,还放着maven的pom文件。
  3. 最后一个部分,也是最重要的一个部分,org/spring/boot文件夹内放着Springboot用来加载项目第三方依赖jar的代码(编译后的class文件),这些代码来自于Springboot的这个jar包:org.springframework.boot:spring-boot-loader

查看spring-boot-maven-plugin为我们项目生成的META-INF/MANIFEST.MF文件:

Manifest-Version: 1.0
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Archiver-Version: Plexus Archiver
Built-By: XPS
Start-Class: Test
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Version: 2.3.2.RELEASE
Created-By: Apache Maven 3.5.0
Build-Jdk: 1.8.0_92
Main-Class: org.springframework.boot.loader.JarLauncher

几个关键配置说明下:

  1. Spring-Boot-Classpath-Index:一个索引文件,记录了项目所有的依赖jar包:

    image-20200809142205599

  2. Spring-Boot-Classes:项目代码编译后的class文件所在的目录

  3. Spring-Boot-Lib:项目第三方依赖包所在的目录

  4. Main-Class:jar包的入口类

  5. Start-Class:项目的入口类

想知道Springboot的jar包是如何加载项目jar内的依赖jar包到ClassLoader中,我们只要从入口,也就是“Main-Class”配置项中配置的org.springframework.boot.loader.JarLauncher的main方法入手即可。

首先将spring-boot-loader添加到pom文件的依赖中

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-loader</artifactId>
    <version>2.3.2.RELEASE</version>
</dependency>

然后在IDE中打开spring-boot-loader中的JarLauncher类源码:

image-20200809143256878

接下来我们就可以从JarLauncher的main方法开始debug,来一探究竟了,看看Springboot是如何加载第三方依赖jar包的。

具体分析源码的过程,本文就不写了,可以查看这两篇博客,写的还是挺挺细致的:

你了解 SpringBoot java -jar 的启动原理吗?

彻底透析SpringBoot jar可执行原理

除此之外debug源码的时候,结合我自己的经验,给大家伙儿提几个建议:

  1. debug一定要有明确的目标,明确要查看哪个功能点。一开始对项目不熟悉的时候,这个目标的粒度最好比较小,在debug的过程中与目标无关的代码直接F6跳过就完事了。不然每一行代码都看过去,最后debug完,脑子🧠还是一片浆糊。

  2. debug的时候要关注各个类的继承和接口实现,否则,debug的过程中,从 “实现类/子类”和“接口/父类”之间跳来跳去,脑子要被跳晕掉。

  3. 第一次debug某个功能的时候,先粗略的debug一遍,只关注整个功能的框架流程,脑子里先搭好这个功能的框架。有了框架之后,debug的时候再关注具体的代码实现。就比如说这里debug JarLuancher的过程,第一遍debug的时候,不要深究每一行代码,只要知道大概的过程就行:

    1. 创建JarFileArchive读取当前项目的jar包。
    2. 创建自定义ClassLoader,LaunchedURLClassLoader的实例(继承自URLClassLoader),将项目内的依赖jar包的URL传入进去LaunchedURLClassLoader
    3. 将LaunchedURLClassLoader设置为当前线程的ClassLoader(Thread.currentThread().setContextClassLoader(LaunchedURLClassLoader)),并将AppClassLoader设置为LaunchedURLClassLoader的parent classloader
    4. 通过MINIFEST.MF中的Start-Class配置以及java的反射机制,运行项目的真正入口类的main方法,来启动项目。
    5. 项目中有用到依赖jar包内的class文件的时候,通过LaunchedURLClassLoader来查找。

    知道了JarLauncher加载依赖jar的大概过程之后,接下来debug的时候,我们再深入细节去debug相关的代码,比如LaunchedURLClassLoader是如何读取dao 依赖包的jar文件中的class文件:

    1. 因为依赖jar包包含在项目的jar包中,是嵌套jar,java的JDK的java.util.JarFile不能读取嵌套jar中的文件,所以Spring这里自己基于java.util.JarFile实现了org.springframework.boot.loader.jar.JarFile,可以通过文件流的方式读取嵌套jar包内的class文件。
    2. 当程序需要一个依赖包内的class文件的时候,先询问LaunchedURLClassLoader中是否已经加载了这个文件,如果没有,则询问它的parent classloader,即AppClassLoader,如果也没有加载,则一层层询问上去,直到最底层的BootStrapClassLoader。如果这些classloader都没有已经加载这个class文件的话,则从最底层的BootStrapClassLoader开始,向上一层层的尝试去查找读取这个class文件,最终,LaunchedURLClassLoader从嵌套的依赖jar包内找到了这个class文件,并将它加载到LaunchedURLClassLoader中。(这个就是类加载的双亲委派机制,对于ClassLoader是十分主要的,不问就不详细描述了,可以参考这篇文章学习下 “面试官:java双亲委派机制及作用”,我们自己在自定义ClassLoader的时候,也要注意尽量不要override父类的一些方法导致违反双亲委派机制。)
  4. debug知名的开源项目的时候,最好储备些“设计模式”的知识。因为这些知名开源库的代码,肯定是符合“6大这几原则”,各种设计模式用的飞起。比如说mybatis的一级缓存机制(BaseExecutor(三个实现类SimpleExecutor,ReuseExecutor,BatchExecutor)),就用了装饰者模式,它的二级缓存机制CachingExecutor用了责任链模式:

    image-20200809153825364

    所以了解些设计模式,对于我们debug开源项目是很有帮助的,能让我们快速明白要debug的代码的逻辑结构。

参考

Creating an Executable Jar

Launches a Java application.

JAR File Specification

Java类加载器--默认的三种类加载器(AppClassLoader/ExtClassLoader/BootstrapClassLoader)

面试官:java双亲委派机制及作用

彻底透析SpringBoot jar可执行原理

你了解 SpringBoot java -jar 的启动原理吗?

彻底透析SpringBoot jar可执行原理