文章

jar与maven打包

jar是Java ARchive的缩写,将Java文件归档,其实和zip的原理类似,只不过加了一些java执行独有的功能。

  1. zip
  2. view
  3. manifest - META-INF/MANIFEST.MF
    1. entry point - Main-Class
    2. other jar - Class-Path
  4. 冷知识:Jar-related API
  5. maven打jar包
    1. maven-jar-plugin - dependency jar
    2. maven-assembly-plugin - executable jar
    3. maven-shaed-plugin - executable jar
    4. spring-boot-maven-plugin - executable jar
      1. Start-Class
      2. Spring-Boot-Classes & Spring-Boot-Lib
      3. Spring-Boot-Classpath-Index
    5. 总结
  6. Ref

zip

JAR files are packaged with the ZIP file format.所以jar的选项和zip基本一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ jar                                                                                                                            ⏎
Usage: jar {ctxui}[vfmn0PMe] [jar-file] [manifest-file] [entry-point] [-C dir] files ...
Options:
    -c  create new archive
    -t  list table of contents for archive
    -x  extract named (or all) files from archive
    -u  update existing archive
    -v  generate verbose output on standard output
    -f  specify archive file name
    -m  include manifest information from specified manifest file
    -n  perform Pack200 normalization after creating a new archive
    -e  specify application entry point for stand-alone application
        bundled into an executable jar file
    -0  store only; use no ZIP compression
    -P  preserve leading '/' (absolute path) and ".." (parent directory) components from file names
    -M  do not create a manifest file for the entries
    -i  generate index information for the specified jar files
    -C  change to the specified directory and include the following file
If any file is a directory then it is processed recursively.
The manifest file name, the archive file name and the entry point name are
specified in the same order as the 'm', 'f' and 'e' flags.

Example 1: to archive two class files into an archive called classes.jar:
       jar cvf classes.jar Foo.class Bar.class
Example 2: use an existing manifest file 'mymanifest' and archive all the
           files in the foo/ directory into 'classes.jar':
       jar cvfm classes.jar mymanifest -C foo/ .

xvf、c、-0(不压缩只存储),和zip都一毛一样。

C是打包的时候不打包directory,直接进到directory里,把里面的内容打进jar包,所以看起来就和这些文件没有在文件夹里一样。这个功能是模仿的tar。

所以jar打包时,也在做压缩,除非明确指定不压缩,否则都是压缩的。比如:

1
2
3
4
5
6
7
8
9
10
11
12
$ jar cvf TicTacToe.jar TicTacToe.class audio images

adding: TicTacToe.class (in=3825) (out=2222) (deflated 41%)
adding: audio/ (in=0) (out=0) (stored 0%)
adding: audio/beep.au (in=4032) (out=3572) (deflated 11%)
adding: audio/ding.au (in=2566) (out=2055) (deflated 19%)
adding: audio/return.au (in=6558) (out=4401) (deflated 32%)
adding: audio/yahoo1.au (in=7834) (out=6985) (deflated 10%)
adding: audio/yahoo2.au (in=7463) (out=4607) (deflated 38%)
adding: images/ (in=0) (out=0) (stored 0%)
adding: images/cross.gif (in=157) (out=160) (deflated -1%)
adding: images/not.gif (in=158) (out=161) (deflated -1%)

打包进去的class文件和普通文件都会被压缩。

view

显示jar内的文件 - t,也是模仿的tar:

1
jar tf xxx.jar

manifest - META-INF/MANIFEST.MF

m/M选项比较特殊,和manifest相关。默认jar是会自动生成一个manifest的:META-INF/MANIFEST.MF

或者手动添加自己写的manifest:

1
jar cmf <jar-file-name> <existing-manifest> <input-files>

m选项是在merge manifest,而不是replace原有的manifest

manifest是jar的metadata的集合,因为它,jar才丰富多彩。默认manifest类似:

1
2
Manifest-Version: 1.0
Created-By: 1.7.0_06 (Oracle Corporation)

manifest规范定义了很多kv对。用户也可以自定义kv对,下面的spring-boot manifest会说到。

entry point - Main-Class

一个能启动的jar被称为executable jar,它的manifest必须使用Main-Class表明整个application的entry point

1
Main-Class <some-class-name>

当然,该class必须包含public static void main(String[] args),要不然也没法启动。

启动jar:

1
java -jar <jar-file>

除了上述手动merge manifest设定entry point,还可以使用e选项(entrypoint):

1
jar cef <jar-file-name> <entrypoint-class-name> <input-files>

文件最后一行不被解析,所以最后一行要加一个回车。

  • https://docs.oracle.com/javase/tutorial/deployment/jar/appman.html

other jar - Class-Path

如果jar里的类想要引用其他jar,需要在manifest里指定其他jar的路径:

1
Class-Path: xx/xxx.jar

但是引用的jar只能是本地的jar,不能是internet上的jar,也不能是jar里嵌套的jar。使用Class-Path唯一的好处就是,在命令行里执行jar的时候,不需要使用-classpath指定jar里的类引用的jar了。

其实这个特性用处不大,毕竟没有把被引用的jar打包到这个jar里,所以他们之间是一种很松散的引用关系,一旦文件位置移动,这种关系就会被破坏。所以Class-Path其实很鸡肋,基本没见它被用到过。

  • https://docs.oracle.com/javase/tutorial/deployment/jar/downman.html

如果想引用jar里嵌套的jar,必须自己写代码来实现这套逻辑。就像spring-boot-loader做的一样:

  • https://stackoverflow.com/questions/66023001/does-jar-manifest-support-class-path-why-use-spring-boot-loader-instead

冷知识:Jar-related API

java有一套关于jar的这套逻辑的代码实现,比如:

  • 关于jar的java.util.jar
    • java.util.jar.Manifest;
    • java.util.jar.JarEntry;
  • 关于jar远程访问的URLConnection:java.net.JarURLConnection

如果有兴趣,可以看看一个demo:

  • https://docs.oracle.com/javase/tutorial/deployment/jar/apiindex.html
  1. 根据url,开启一个JarURLConnection,远程获取jar;
  2. 根据manife的Main-Class获取entry class;
  3. 使用classloader加载该类,反射获取main方法;
  4. invoke main;

当需要自定义一些jar相关的功能的时候,就会用到这些类。比如下面将要介绍的spring-boot-loader。

maven打jar包

(这是一个热知识)

从上面可以知道,jar的manifest最核心的配置项是:

  • Main-Class:entry point;

现在基本不会有用jar命令手动打包的场景了,一般用maven打包。

maven-jar-plugin - dependency jar

编译一个工程后,maven会把${basedir}/src/main/resources下的资源直接扔到target/classes下,也会把${basedir}/src/main/java下的类编译后的class文件扔到target/classes下。所以资源和class文件是在同一个文件下的。

打包的时候,直接把target/classes下的内容打成jar包。然后按照jar的规范,会额外生成一个META-INF/MANIFEST.MF

假设生成的jar为target.jar,这样的jar并不能使用java -jar target.jar执行,因为MANIFESE.MF里不含Main-Class文件

所以,只能使用:

1
java -cp target.jar <entry-class>

把jar放在classpath下,java自然会去classpath的jar里寻找entry class,和其他需要的类。

但是未必能执行成功。如果引入了第三方依赖,maven的jar插件并不会将第三方jar包也打进来,程序执行的时候就会找不到相应的类,挂掉。除非把第三方的jar也放到classpath下:

1
java -cp target.jar:dependency1.jar:dependency2.jar <entry-class>

意义:jar plugin不是为了打一个可执行的jar包,而是为了打一个包,让别人依赖这个包,因为这样打包只会打该工程自己的class文件,不会把第三方依赖的class也打进来。

maven-assembly-plugin - executable jar

assembly插件就更全面一些:

  1. 可以指定main class,给manifest加上Main-Class指定entry class;
  2. 会把第三方依赖打进来,但不是以内嵌jar的形式,而是将所有的第三方jar unzip成一个个的class,把这些class和自己写的class一起打包进jar

assembly plugin之所以这么做,当然是因为jar本身不支持嵌套jar读取,把第三方jar全都拆开,再打包进jar,简单粗暴又有效。

assembly插件打的包可以直接java -jar执行。当然也可以使用java -cp target.jar <entry-class>的方式执行

意义:assembly插件就是为了打一个可执行jar包,简单粗暴有效。

maven-shaed-plugin - executable jar

assembly插件是很方便,但是有一个致命问题:假设工程依赖了某依赖A的1.0版,另一个依赖B依赖了A的2.0版,而A的1.0和2.0并不兼容,必须同时存在。且A的1.0和2.0的类名一样,那assembly打包的时候就会类名冲突,相互覆盖了。(其实程序执行的时候也可能在找1.0的某个类时错找为2.0的类,导致某个方法not found)。

shade插件比assembly强的地方在于,用户打jar包的时候可以按照意愿,将一些依赖class所在的package改名(修改字节码)。这样修改后的类和原来的类就不会同包名了,可以做到同时存在。具体操作见class relocation

意义:和assembly一样打executable jar,同时可以通过改包名处理一下依赖冲突的问题。

spring-boot-maven-plugin - executable jar

spring-boot + maven 开发的时候,可以直接使用这个插件,将包打成一个带依赖的可执行jar包。而且并不会像assembly/shade插件一样把依赖unzip,而是以jar的形式打到jar包里的

  • 优点:第三方以来以jar包的形式嵌入到jar里,看起来相当清晰;
  • 缺点:需要实现一套从jar包里load嵌套的jar里的class的逻辑;

观察boot打包后的jar结构(只显示两层目录):

1
2
3
4
5
6
7
8
9
10
11
$ tree -L 2
.
├── BOOT-INF
│   ├── classes
│   ├── classpath.idx
│   └── lib
├── META-INF
│   ├── MANIFEST.MF
│   └── maven
└── org
    └── springframework

分成三部分:

  1. META-INF/MANIFEST.MF:jar规范,manifest文件;
  2. BOOT-INF,它里面又分为两部分:
    1. BOOT-INF/classes:这个目录的内容基本等同于maven jar插件打包后的内容,就是自己写的类 + resources;
    2. BOOT-INF/lib:所有引入的第三方jar;
  3. org.springframework.loader包,这一部分是spring boot自己塞进去的;

首先看manifest的内容:

1
2
3
4
5
6
7
8
9
10
11
Manifest-Version: 1.0
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Implementation-Title: restful
Implementation-Version: 1.0-SNAPSHOT
Start-Class: com.puppylpg.server.Application
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
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

最重要的自然是入口类:Main-Class: org.springframework.boot.loader.JarLauncher

这个类并不是我们写的,而是spring-boot-loader包的内容,可以通过以下maven配置获取其内容:

1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-loader</artifactId>
</dependency>

显然,spring boot自己把该包打了进来,并使用其中的JarLauncher作为整个程序的入口。

再看几个不在manifest规范里,看起来有很重要的项:

1
2
3
4
Start-Class: com.puppylpg.server.Application
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx

JarLauncher的实现里,可以找到上述配置。

Start-Class

spring boot构建的app启动类。平时在IDE里运行程序的时候,一般我们都是直接run这个类,整个服务就起来了。

1
2
3
4
5
6
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

这个来也是有public static void main方法的,所以在不打jar包的时候,直接通过这个类就可以启动这个spring boot工程了。

打成jar包之后,JarLauncher是整个jar包的启动入口,它会根据Start-Class找到该类,然后反射调用其main方法,以启动整个spring boot工程。

Spring-Boot-Classes & Spring-Boot-Lib

启动spring boot启动类的是JarLauncher,所以它会在启动启动类之前,把整个spring boot工程依赖的类(自己写的、第三方依赖)都找到。JarLauncher通过自己实现的逻辑,使用classloader加载Spring-Boot-Classes下的类,加载Spring-Boot-Lib下的jar包里的类,这样整个工程的类该classloader都能找到,最后再用这个classloader加载Start-Class就行了。

至于Start-Class,也就是spring boot的启动类,是怎么启动整个工程的,那就是spring和spring boot相关的内容了。

Spring-Boot-Classpath-Index

这个其实就是BOOT-INF/lib下所有jar的名字,都写在了这个文件里。这些jar都会被classloader load,所以这个文件相当于起到了classpath的作用——为classloader指明要load的内容。

一开始我不是很理解,为什么一定要搞这个文件,直接看一下BOOT-INF/lib下有哪些jar不就可以了吗?后来想了想,可执行jar包在执行的时候,并没有unzip,所以没办法扫描BOOT-INF/lib下有哪些内容。有了Spring-Boot-Classpath-Index指定的文件,根据文件找出每一个jar的名字,可以直接读取它的内容。

有一个classloader专门负责从directory里load class,从jar file里load class,它就是URLClassLoader。所以spring-boot-loader里的classloader是基于URLClassLoader实现的:public class LaunchedURLClassLoader extends URLClassLoader。所有的class都由同一个classloader LaunchedURLClassLoader加载,spring boot启动类也被它加载,所以spring boot启动类需要依赖的其他类也可以通过该classloader找到。

关于这点,结合Java - classloader里对URLClassLoader一节的介绍,理解会更深刻。

  • https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-executable-jar-format.html

总结

  • jar plugin:把工程打为依赖;
  • assembly plugin:把工程以纯class文件的形式打成可执行jar包;
  • shade plugin:同assembly,可以修改类的package name,解决类冲突;
  • spring-boot-maven-plugin:把工程代码和第三方以来分类打成可执行jar包。为此,自己实现了从jar内的jar里load class的逻辑。

Ref

  • https://docs.oracle.com/javase/tutorial/deployment/jar/index.html
本文由作者按照 CC BY 4.0 进行授权