title: Jar 包那些事
date: 2020/11/23 15:55
引言
公司中有一个系统用的是 Dubbo + SpringBoot 的架构,但后来发现好像并没有必要用 dubbo 的架构所以准备直接让 web 层引用 service 层的 jar 包,有如下问题:
- Spring Boot 项目打包成的 jar ,被其他项目依赖之后,总是报找不到类的错误?
- 不使用
spring-boot-maven-plugin
插件打 jar 包,那么打进去的配置文件会不会生效? -> 测试后发现生效的。
可执行 jar 包 & 依赖 jar 包
一般使用 maven clean package
打包出来的 jar 包就是依赖 jar,引入一些插件我们就可以将其打成可执行 jar,一般而言两者的目录结构没有什么区别(见附1),唯一有区别的是 MANIFEST.MF(清单文件)。
依赖 jar 包的 MANIFEST.MF
Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Built-By: x5456
Created-By: Apache Maven 3.6.3
Build-Jdk: 1.8.0_181
可执行 jar 包的 MANIFEST.MF
Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Built-By: x5456
Class-Path: lib/hutool-all-4.6.1.jar # 依赖 jar 包的路径
Created-By: Apache Maven 3.6.3
Build-Jdk: 1.8.0_181
Main-Class: cn.x5456.TestCanRunJar # 使用命令(java -jar xxx.jar)运行时的主启动类
META-INF 文件夹是干啥的,META-INF文件夹能删吗?
如果你将 Jar 中的 META-INF 文件夹删除,那么 jar 文件里边就没有 MANIFEST.MF 文件。那么,java -jar 就找不到 main class.
没有 META-INF 你仍然可以创建一个 Jar 文件。但是,当你想要执行 jar 文件的时候,这个 jar 是需要具备 META-INF/MANIFEST.MF 的。
附1:可执行 jar 与依赖 jar 打包出来的文件结构
SpringBoot 打包的奥秘
这个是我们对一个 SpringBoot 项目执行 maven clean package
命令打包出来的结构:
我们发现这里有两个文件,第一个 spring-test-0.0.1-SNAPSHOT.jar 表示打包成的可执行 jar ,第二个 spring-test-0.0.1-SNAPSHOT.jar.original 则是在打包过程中 ,被重命名的 jar,这是一个不可执行 jar,但是可以被其他项目依赖的 jar。
original:原件
为什么会有两个文件呢?
在新建 SpringBoot 项目的时候,我们引入了一个插件:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
spring-boot-maven-plugin 项目存在于 spring-boot-tools 目录中,它的默认目标(goal)就是 repackage 功能,其他功能要使用,则需要开发者显式配置。
spring-boot-maven-plugin 的 repackage 能够将 mvn package 生成的软件包,再次打包为可执行的软件包,并将 mvn package 生成的软件包重命名为 *.original。
spring-boot-maven-plugin 的 repackage 在代码层面调用了 RepackageMojo的execute 方法,而在该方法中又调用了repackage 方法,repackage 方法代码及操作解析如下:
private void repackage() throws MojoExecutionException {
// maven生成的jar,最终的命名将加上.original后缀
Artifact source = getSourceArtifact();
// 最终为可执行jar,即fat jar
File target = getTargetFile();
// 获取重新打包器,将maven生成的jar重新打包成可执行jar
Repackager repackager = getRepackager(source.getFile());
// 查找并过滤项目运行时依赖的jar
Set<Artifact> artifacts = filterDependencies(this.project.getArtifacts(), getFilters(getAdditionalFilters()));
// 将artifacts转换成libraries
Libraries libraries = new ArtifactsLibraries(artifacts, this.requiresUnpack, getLog());
try {
// 获得Spring Boot启动脚本
LaunchScript launchScript = getLaunchScript();
// 执行重新打包,生成fat jar
repackager.repackage(target, libraries, launchScript);
} catch (IOException ex) {
throw new MojoExecutionException(ex.getMessage(), ex);
}
// 将maven生成的jar更新成.original文件
updateArtifact(source, target, repackager.getBackupFile());
}
两个 jar 包比较
可执行 jar spring-test-0.0.1-SNAPSHOT.jar
解压之后,目录结构如下:
├── BOOT-INF
│ ├── classes
│ │ ├── application.properties
│ │ └── cn
│ │ └── x5456 # 应用的.class 文件目录
│ │ └── springtest
│ │ └── SpringTestApplication.class
│ └── lib # 这里存放的是应用的 Maven 依赖的jar包文件
│ ├── javax.annotation-api-1.3.2.jar
│ ├── spring-beans-5.1.8.RELEASE.jar
│ └── ...
├── META-INF
│ ├── MANIFEST.MF
│ └── maven
│ └── com.x5456
│ └── springtest
│ ├── pom.properties
│ └── pom.xml
└── org
└── springframework
└── boot
└── loader #存放的是 Spring boot loader 的 class 文件
├── ExecutableArchiveLauncher.class
├── JarLauncher.class
├── LaunchedURLClassLoader$UseFastConnectionExceptionsEnumeration.class
├── LaunchedURLClassLoader.class
├── Launcher.class
├── MainMethodRunner.class
├── PropertiesLauncher$1.class
├── PropertiesLauncher$ArchiveEntryFilter.class
├── PropertiesLauncher$PrefixMatchingArchiveFilter.class
├── PropertiesLauncher.class
├── WarLauncher.class
├── archive
│ ├── Archive$Entry.class
│ ├── ...
├── data
│ ├── RandomAccessData.class
│ ├── ...
├── jar
│ ├── AsciiBytes.class
│ ├── ...
└── util
└── SystemPropertyUtils.class
我们的代码被放在了 BOOT-INF/classes/
目录下。
META-INF
目录存放着当前 jar 包的清单文件(MANIFEST.MF)和当前 jar 包引入的 maven 依赖信息(pom.xml),我们看下 MANIFEST.MF 文件:
Manifest-Version: 1.0
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Implementation-Title: spring-test
Implementation-Version: 0.0.1-SNAPSHOT
Start-Class: cn.x5456.springtest.SpringTestApplication # 这个是 Spring 自定义的属性,存放我们的启动类的全类路径
Spring-Boot-Classes: BOOT-INF/classes/ # 这个是 Spring 自定义的属性,表示我们自己代码编译后的位置
Spring-Boot-Lib: BOOT-INF/lib/ # 这个是 Spring 自定义的属性,表示项目依赖的 jar 的位置
Build-Jdk-Spec: 1.8
Spring-Boot-Version: 2.3.2.RELEASE
Created-By: Maven Jar Plugin 3.2.0
Main-Class: org.springframework.boot.loader.JarLauncher # 当使用 java -jar xxx.jar 命令启动的时候会调用这个类的 main 方法。
依赖 jar spring-test-0.0.1-SNAPSHOT.jar.original
解压之后,目录结构如下:
依赖 jar 并没有 BOOT-INF/classes
目录,而且也没有把它所依赖的 jar 包打进来,当其他项目引用这个 jar 包的时候(通过 maven 坐标),他会去找 META-INF/maven/pom.xml
文件,将当前 jar 包所依赖的其他 jar 包从 maven 仓库拉下来。
我们顺道看下它的 META-INF/MANIFEST.MF
文件,其中没有定义启动类等。
Manifest-Version: 1.0
Implementation-Title: spring-test
Implementation-Version: 0.0.1-SNAPSHOT
Build-Jdk-Spec: 1.8
Created-By: Maven Jar Plugin 3.2.0
为什么 SpringBoot 不直接把我们的代码放到根路径,而要自定义一个 BOOT-INF 目录呢?
其他的可执行 jar 都是直接把他放在了打包的类路径下,这样就可以做到既可以执行又可以引用,那么 SpringBoot 为什么这样做呢,他的奥秘就在于 MANIFEST.MF 文件中的 Main-Class 配置的并不是我们的主启动类,而是 JarLauncher 这个类,没有放在根路径的原因可能是害怕我们创建了一个相同类路径的类将其覆盖。
Main-Class: org.springframework.boot.loader.JarLauncher # 当使用 java -jar xxx.jar 命令启动的时候会调用这个类的 main 方法。
那么 JarLauncher 是做什么的呢?
Spring-Boot启动之前做了哪些事?
其他项目引用 SpringBoot 打成的 jar 包
我们对 spring-test 这个项目执行 maven clean install
命令,将其打包到 maven 仓库。
其他项目,通过 maven 引用这个 jar 包,调用它里面的 JsonUtils 工具类:
我们发现根本就调用不了这个方法(因为 SpringBoot 打包出来的 jar 包结构和正常的不一样,他把我们的代码放到了 BOOT-INF 目录下了)。
解决:一次打包两个 jar
一般来说,Spring Boot 直接打包成可执行 jar 就可以了,不建议将 Spring Boot 作为普通的 jar 被其他的项目所依赖。如果有这种需求,建议将被依赖的部分,单独抽出来做一个普通的 Maven 项目,然后在 Spring Boot 中引用这个 Maven 项目。
如果非要将 Spring Boot 打包成一个普通 jar 被其他项目依赖,技术上来说,也是可以的,给 spring-boot-maven-plugin 插件添加如下配置:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<classifier>exec</classifier>
</configuration>
</plugin>
配置的 classifier 表示可执行 jar 的名字,配置了这个之后,在插件执行 repackage 命令时,就不会给 mvn package
所打成的 jar 重命名了,所以,打包后的 jar 如下:
我们执行 maven clean install
将其打包到仓库:
这个时候其他项目引用他就可以调用它里面的方法了:
为啥 MAIN-CLASS 是 XXXLauncher?
XXXLauncher 在调用我们自己的主类之前做了以下三件事:
- 扩展JDK默认的支持JAR对应的协议,因为Spring Boot启动不仅仅需要JDK的JAR文件,还需要BOOT-INF/lib这个目录下的文件。默认实现无法将BOOT-INF/lib这个目录当作ClassPath,故需要替换实现。
- 判断当前的介质,是java -jar启动,还是java org.springframework.boot.loader.JarLauncher启动。以便获取对应的ClassLoader。
- 获取MANIFEST.MF文件中的Start-Class属性,也就是我们自定义的主类。通过第二步获取的ClassLoader加载获取到Class文件,通过反射调用main方法,启动应用。
项目打成 war 包的结构
我们发现他打出来的结构中多了一个 org 目录,通过上面的学习我们知道这个目录里放的是启动器(Launcher)相关的类,而启动器又是在 MANIFEST.MF 文件中配置的,所以我们看下:
Manifest-Version: 1.0
Implementation-Title: spring-test
Implementation-Version: 0.0.1-SNAPSHOT
Start-Class: cn.x5456.springtest.SpringTestApplication
Spring-Boot-Classes: WEB-INF/classes/
Spring-Boot-Lib: WEB-INF/lib/
Build-Jdk-Spec: 1.8
Spring-Boot-Version: 2.3.2.RELEASE
Created-By: Maven Archiver 3.4.0
Main-Class: org.springframework.boot.loader.WarLauncher # 与打成 jar 包的相比,这里换成了 WarLauncher
Spring 官方解释这样做的目的是:打包一个又能发布于 tomcat 又能 java -jar 直接跑的war。
JarLauncher 与 WarLauncher 区别
差别仅在于,JarLauncher在构建LauncherURLClassLoader时,会搜索BOOT-INF/classes目录及BOOT-INF/lib目录下jar,WarLauncher在构建LauncherURLClassLoader时,则会搜索WEB-INFO/classes目录及WEB-INFO/lib和WEB-INFO/lib-provided两个目录下的jar
参考文章
Spring Boot 打包成的可执行 jar ,为什么不能被其他项目依赖?
聊一聊 JAR 文件和 MANIFEST.MF
Spring-Boot启动之前做了哪些事?
下面的这篇文章,等学懂了 JVM 类加载机制再回来看
springboot应用启动原理(二) 扩展URLClassLoader实现嵌套jar加载