1 需求的提出
原来的项目中的jar包大约50MB,但是jar包是分散在不同客户方使用的。每次版本升级jar都需要下载50MB,导致每次升级下载都很慢,所以体验很差。
所以呢,既然大多数依赖包都不会被修改,那么能否缩减一下jar的容量,使得每次下载只下载修改的那部分jar呢?这样升级下载速度会有很大提升。
2 解决
胖jar:fat jar,指spring boot打包出的可执行jar,包含很多依赖,所以包比较胖大
瘦jar:thin jar,指spring boot打包时,可以考虑不打包依赖,把依赖从外部加载,这样打出的jar就比较小,称为瘦jar
2.1 解决思路
- 将外部依赖排除掉,只需要打包需要的依赖(比如容易改动的公司内部模块)
- 将需要外部引用的依赖提取出来
- 调整启动逻辑,使得启动的时候能够读取外部依赖,也能读取jar包中的依赖
2.2 解决步骤
2.2.1 调整spring-boot-maven-plugin
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- 指定SpringBoot主类 -->
<mainClass>com.xxx.XXXApplication</mainClass>
<!-- 设置布局 -->
<layout>ZIP</layout>
<!--
指定需要包含的依赖,其它的依赖都不会包含。
下面的都是项目内部模块的依赖,所以全部进行引入 。
-->
<includes>
<include>
<groupId>com.xxx.xxx</groupId>
<artifactId>gsp-xxx1</artifactId>
</include>
<include>
<groupId>com.xxx.xxx</groupId>
<artifactId>gsp-xxx2</artifactId>
</include>
<include>
<groupId>com.xxx.xxx</groupId>
<artifactId>gsp-xxx2</artifactId>
</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
LAYOUT的可选值如下:
- JAR: 可执行jar
- WAR: 可执行war, provided依赖放在WEB-INF/lib-provided目录中,以防止war部署在servlet容器会崩溃
- ZIP (alias to DIR): 和JAR布局相似,不同的是可以使用外部配置,而不是JAR布局中的默认配置。
- NONE: 打包所有依赖和项目资源,不打包启动加载器(指spring boot loader代码)
通过这种配置方式,原有的jar大小58MB,修改后的jar仅为579KB。
打包后包含的依赖包:
如需排除所有依赖jar,则使用:
<groupId>nothing</groupId>
<artifactId>nothing</artifactId>
2.2.2 提取外部依赖
现在把外部依赖都提取出来,放到与当前执行jar包相同目录的lib文件夹下。那么如何提取依赖呢?使用maven依赖插件的copy-dependencies目标来实现。
# outputDirectory:表示拷贝的依赖放在当前目录的lib文件夹中
# includeScope:需要拷贝的依赖的范围是runtime
# excludeGroupIds:需要排除的依赖,其实就上面引入的那些项目内部依赖
mvn dependency:copy-dependencies -DoutputDirectory=lib -DincludeScope=runtime -DexcludeGroupIds=com.xxx.xxx
2.2.3 添加loader.properties
既然现在已经打的小jar包有了,外部依赖也有了。那么就剩下如何加载外部依赖包了。
SpringBoot默认的依赖包加载加载路径是当前jar包中的BOOT-INF/lib/目录,而我们现在还需要从lib文件夹中读取。所以只需要在loader.properties(放在classpath下,比如src/main/resources)中配置加载路径即可:
# 表示优先从当前jar目录的lib文件夹中读取,然后再从BOOT-INF/lib/中读取
# 注意:loader.path也可以在命令行启动的时候设置,即添加-Dloader.path=lib/,BOOT-INF/lib/
loader.path=lib/,BOOT-INF/lib/
打包后的loader.properties文件位置如下:
注意:逗号分隔的顺序好像有问题,如果把lib/放在后面,启动就会报错,报错看到貌似没有正常读取到默认的yml配置。
2.2.4 项目运行
直接按照以前启动的jar的方式启动即可:
java -jar gsp-client-service.jar
3 扩展学习
3.1 可执行jar是靠什么支撑实现的?
使用spring-boot-loader模块使得Spring Boot支持可执行jar和war。
如果在项目中使用Maven(spring-boot-maven-plugin)或者Gradle插件,那么可执行jar将会自动生成。
怎么知道spring-boot-loader会影响可执行jar?在可执行jar中可以发现spring-boot-loader的源码
3.2 什么是嵌套jar(nested jar)?
嵌套jar,指的是jar中包含jar。Java本身并没有提供一种标准方式来加载内嵌的jar文件。所以就有这样的问题,很难直接通过命令行直接运行一个自给的应用。
为了解决这个问题,很多开发者使用shaded jar,这种jar会把所有jar中的class都打包到一个uber jar中。这样也会有问题,很难知道哪些代码是自己写的代码,哪些是依赖代码。当然还有可能会文件名冲突(多个jar中的文件名称一样的情况)。Spring Boot则采取了另外一种方式,可以让我们直接内嵌jar。
3.3 可执行jar的工作原理
3.3.1 核心启动类Launcher
可执行jar的核心类是org.springframework.boot.loader.Launcher,这是一个特别的启动类,作为可执行jar的主入口。其有三个子类:
- JarLauncher:启动可执行jar,从BOOT-INF/lib/读取外部依赖
- WarLauncher:启动可执行war,从WEB-INF/lib/和WEB-INF/lib-provided/读取外部依赖
- PropertiesLauncher:和JarLauncher类似,从配置中读取主类和classpath。默认从BOOT-INF/lib/读取外部依赖,也可以指定配置路径来加载外部依赖(使用环境变量LOADER_PATH 、命令行指定loader.path、loader.properties(放在classpath下)中指定loader.path方式均可指定外部依赖位置)。
3.3.2 可执行jar/war文件结构
上面就知道要从哪些地方加载依赖,现在看下可执行jar/war的文件结构:
# 可执行jar
# 自定义代码:BOOT-INF/classes
# 第三方依赖:BOOT-INF/lib
example.jar
|
+-META-INF
| +-MANIFEST.MF
+-org
| +-springframework
| +-boot
| +-loader
| +-<spring boot loader classes>
+-BOOT-INF
+-classes
| +-mycompany
| +-project
| +-YourClasses.class
+-lib
+-dependency1.jar
+-dependency2.jar
# 可执行war
# 第三方依赖:WEB-INF/lib
# 运行时依赖:WEB-INF/lib-provider,比如servlet-api.jar。
example.war
|
+-META-INF
| +-MANIFEST.MF
+-org
| +-springframework
| +-boot
| +-loader
| +-<spring boot loader classes>
+-WEB-INF
+-classes
| +-com
| +-mycompany
| +-project
| +-YourClasses.class
+-lib
| +-dependency1.jar
| +-dependency2.jar
+-lib-provided
+-servlet-api.jar
+-dependency3.jar
3.3.3 启动原理
java -jar XXX.jar启动原理:通过启动XXX.jar中META-INF/MANIFEST.MF文件中的Main-Class指定的包含main()方法的类来实现的。
Spring Boot Loader也做了对应的处理,其Main-Class会指定为JarLauncher、WarLauncher和PropertiesLauncher,jar中实际要执行的主类使用Start-Class来指定。典型的MANIFEST.MF文件如下:
# 可执行jar
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.mycompany.project.MyApplication
# 可执行war
Main-Class: org.springframework.boot.loader.WarLauncher
Start-Class: com.mycompany.project.MyApplication
# PropertiesLauncher
Main-Class: org.springframework.boot.loader.PropertiesLauncher
Start-Class: com.example.demo.DemoApplication
# PropertiesLauncher真实案例
Manifest-Version: 1.0
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Implementation-Title: demo
Implementation-Version: 0.0.1-SNAPSHOT
Spring-Boot-Layers-Index: BOOT-INF/layers.idx
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.5.0
Created-By: Maven Jar Plugin 3.2.0
Main-Class: org.springframework.boot.loader.PropertiesLauncher
3.3.4 内嵌jar中的class是如何加载的?通过Spring Boot Loader的JarFile类
支持加载内嵌jar的核心类是org.springframework.boot.loader.jar.JarFile。当第一次加载完后,每一个JarEntry的位置都会映射到外部jar物理文件的偏移量上,就像下面这样:
myapp.jar
+-------------------+-------------------------+
| /BOOT-INF/classes | /BOOT-INF/lib/mylib.jar |
|+-----------------+||+-----------+----------+|
|| A.class ||| B.class | C.class ||
|+-----------------+||+-----------+----------+|
+-------------------+-------------------------+
^ ^ ^
0063 3452 3980
A.class是通过位置0063找到的,B.class是通过3452,C.class位于3980。通过这个信息,就可以在内前加中找到相应的类,且不需要解压这个jar,也不需要把jar中的所有内容都读入内存。
org.springframework.boot.loader.jar.JarFile继承自java.util.jar.JarFile,保持了与已有的代码的兼容性。getURL()方法返回的URL打开的连接与java.net.JarURLConnection兼容并能被Java的URLClassLoader所使用。
3.3.5 PropertiesLauncher的相关配置属性
PropertiesLauncher 有一些特别的特性,可以通过设置外部属性(系统属性、环境变量、manifest entries或者loader.properties)来启用。相关属性如下:
Key |
Manifest Entry |
环境变量 |
作用 |
loader.path |
Loader-Path |
LOADER_PATH |
逗号分隔的Classpath,比如lib,${HOME}/app/lib。前面的优先加载,这里lib就会先加载,${HOME}/app/lib其后加载。 |
loader.home |
Loader-Home |
LOADER_HOME |
用于解析loader.path的home位置。比如loader.path=lib,则classpath路径就是${loader.home}/lib。 |
loader.args |
Loader-Args |
LOADER-ARGS |
给main方法的默认参数,空格分隔 |
loader.main |
Start-Class |
LOADER_MAIN |
要启动的main类,比如com.app.Application |
loader.config.name |
- |
- |
启动加载的配置文件名,默认是loader。所以加载的是loader.properties。 |
loader.config.location |
Loader-Config-Location |
LOADER_CONFIG_LOCATION |
配置文件的路径位置,默认是loader.properties。 |
loader.system |
Loader-System |
LOADER_SYSTEM |
一个Boolean标记位,标记是否所有的属性都会被添加到系统属性中去。默认false。 |
使用插件构建胖jar时会自动将Main-Class属性值移动到Start-Class上去。也就是说,Main-Class上原本是启动类,然后移动到了Start-Class,而Main-Class值被Spring Boot Loader中的启动器类所代替。
其它说明:
- loader.properties的查找顺序:先loader.home里找,再classpath的根目录,再classpath:/BOOT-INF/classed
- 仅当loader.config.location没有指定时,loader.home才会生效
- loader.path的值:目录(会递归扫描jar和zip文件)、压缩文件路径(可以是相对于loader.home的路径或者以以jar:file:为前缀的文件系统路径)、jar包中的目录(比如xxx.jar!/lib)、通配符正则表达式(默认JVM的行为)
- PropertiesLauncher和JarLauncher的区别:如果loader.path配置为空,那么就使用默认值BOOT-INF/lib。则此时PropertiesLauncher和JarLauncher是等价的
- loader.path不能用于配置loader.properyties的位置
- 所有placeholder的替换会在值使用前做好替换
- 查找属性的顺序:环境变量、系统属性、loader.properties、解压后的manifest文件、和压缩包中的manifest文件
3.3.6 可执行jar有什么限制?
有如下限制:
- Zip Entry压缩问题:对于内嵌jar必须使用ZipEntry.STORED方法保存,这样就能直接在内嵌jar中查询内容。
- System ClassLoader:启动的应用去加载字节码时应该使用Thread.getContextClassLoader()(大多数包和框架默认用的都是这个)。如果使用ClassLoader.getSystemClassLoader()加载内嵌jar中的class文件,就会失败,java.util.Logging用的就是这个类加载器,所以你可能需要换一种日志实现。
考虑到SpringBoot可执行jar有这样的限制,还可以考虑使用如下替代方案:
3.3.7 解压后运行可执行程序
有的PaaS服务会先解压jar,然后再运行。比如:
unzip -q myapp.jar
java org.springframework.boot.loader.JarLauncher
参考
1.[spring boot可执行jar](https://docs.spring.io/spring-boot/docs/2.1.3.RELEASE/reference/htmlsingle/#executable-jar)
2.[把SpringBoot项目从18.18M瘦身到0.18M,部署起来真省事!](https://zhuanlan.zhihu.com/p/136645768)
3.[给你的SpringBoot工程打的jar包瘦瘦身](https://blog.****.net/w1014074794/article/details/106445145/)
4.[maven依赖插件](http://maven.apache.org/plugins/maven-dependency-plugin/copy-dependencies-mojo.html)
5.[spring-boot-maven-plugin](https://docs.spring.io/spring-boot/docs/current/maven-plugin/reference/htmlsingle/)
6.[spring-boot-maven-plugin的用法](https://docs.spring.io/spring-boot/docs/2.1.3.RELEASE/maven-plugin/usage.html)