Java:使用 Gradle 将源码打包为 jar


#Java 工具#


在 build.gradle 中应用 java 插件后,默认会有一个 jar 任务用于打包。在 Gradle 项目根目录执行下面命令即可:

$ gradle jar

但是,该任务默认不会将依赖打包进去(也就是不会打包成 fat jar)。如果要将依赖打包进去,可以修改 jar 任务的配置:

jar {
    manifest {
        attributes 'Main-Class': 'com.example.App' // 启动类全路径,需要根据项目自定义,或者不配置
    }
    from {
        configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
    }
}

或者自定义一个 fatJar 任务:

task fatJar(type: Jar) {
    manifest {
        attributes 'Main-Class': 'com.example.App'
    }
    from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } }
    with jar
}

测试示例1

Java 版本:1.8 Gradle 版本: 5.2

项目结构:

.
├── build.gradle
├── settings.gradle
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── example
    │   │           ├── App.java
    │   │           └── Utils.java
    │   └── resources
    └── test
        ├── java
        └── resources

settings.gradle :

rootProject.name = 'test-gradle-jar-01'

build.gradle :

plugins {
    id 'java'
}

group 'com.example'
version '1.0-SNAPSHOT'

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    
}

Utils.java :

package com.example;

public class Utils {

    public static int add(int a, int b) {
        return a + b;
    }

}

App.java :

package com.example;

public class App {

    public static void main(String[] args) {
        System.out.println("1 + 1 = " + Utils.add(1, 1));
    }
}

在项目根目录执行 gradle jar 命令,然后可以在 build/libs目录中找到打包好的 jar :

$ ls build/libs 
test-gradle-jar-01-1.0-SNAPSHOT.jar

# 查看 jar 中文件
$ jar tf build/libs/test-gradle-jar-01-1.0-SNAPSHOT.jar
META-INF/
META-INF/MANIFEST.MF
com/
com/example/
com/example/App.class
com/example/Utils.class

# 执行
$ java -jar build/libs/test-gradle-jar-01-1.0-SNAPSHOT.jar 
build/libs/test-gradle-jar-01-1.0-SNAPSHOT.jar中没有主清单属性

# 上面的执行报错了,原因是 MANIFEST.MF 没指定主类,
$ unzip -q -c build/libs/test-gradle-jar-01-1.0-SNAPSHOT.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0

# 执行
$ java -cp build/libs/test-gradle-jar-01-1.0-SNAPSHOT.jar com.example.App 
1 + 1 = 2

测试示例2

项目结构:

.
├── build.gradle
├── settings.gradle
└── src
    └── main
        ├── java
        │   └── com
        │       └── example
        │           └── App.java
        └── resources
            └── test.txt

settings.gradle :

rootProject.name = 'test-gradle-jar-02'

build.gradle :

plugins {
    id 'java'
}

group 'com.example'
version '1.0-SNAPSHOT'

sourceCompatibility = 1.8

repositories {
    maven { url 'http://mirrors.cloud.tencent.com/nexus/repository/maven-public/' }
    mavenLocal()
    mavenCentral()
}

dependencies {
    compile group: 'com.google.guava', name: 'guava', version: '28.2-jre'
}

test.txt :

Hello World

App.java :

package com.example;

import com.google.common.base.Charsets;
import com.google.common.io.Resources;

import java.io.IOException;
import java.net.URL;

public class App {

    public static void main(String[] args) throws IOException {
        URL url = Resources.getResource("test.txt");
        String content = Resources.toString(url, Charsets.UTF_8);
        System.out.println(content);
    }
}

使用 gradle jar 打包后,执行 App 类会失败。

$ java -cp build/libs/test-gradle-jar-02-1.0-SNAPSHOT.jar com.example.App 
Exception in thread "main" java.lang.NoClassDefFoundError: com/google/common/io/Resources
        at com.example.App.main(App.java:12)
Caused by: java.lang.ClassNotFoundException: com.google.common.io.Resources
        at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
        at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:338)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
        ... 1 more

没找到 guava 中的 Resources 类,所以报错了。

解决方法1:在 classpath 中指定依赖路径

在 build.gradle 中增加下面的任务:

task getDeps(type: Copy) {
    from sourceSets.main.runtimeClasspath
    into 'runtime/'
}

执行 gradle getDeps 下载依赖到 runtime 目录。

运行 App 类:

# 方式1
$ java -cp build/libs/test-gradle-jar-02-1.0-SNAPSHOT.jar:runtime/guava-28.2-jre.jar com.example.App 
Hello World

# 方式2
$ java -cp build/libs/test-gradle-jar-02-1.0-SNAPSHOT.jar:"runtime/*" com.example.App 
Hello World

解决方法2:构造 fat jar

将 build.gradle 修改如下:

plugins {
    id 'java'
}

group 'com.example'
version '1.0-SNAPSHOT'

sourceCompatibility = 1.8

repositories {
    maven { url 'http://mirrors.cloud.tencent.com/nexus/repository/maven-public/' }
    mavenLocal()
    mavenCentral()
}

task getDeps(type: Copy) {
    from sourceSets.main.runtimeClasspath
    into 'runtime/'
}

// 构建 fat jar
task fatJar(type: Jar) {
    manifest {
        attributes 'Main-Class': 'com.example.App'
    }
    baseName = project.name + '-fatJar'
    from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } }
    with jar
}

dependencies {
    compile group: 'com.google.guava', name: 'guava', version: '28.2-jre'
}

执行 gradle fatJar,在 build/libs 目录会生成test-gradle-jar-02-fatJar-1.0-SNAPSHOT.jar

执行 App 类:

$ java -cp build/libs/test-gradle-jar-02-fatJar-1.0-SNAPSHOT.jar com.example.App 
Hello World

因为在清单文件中指定了主类,也可以用下面的方式执行:

$ java -jar build/libs/test-gradle-jar-02-fatJar-1.0-SNAPSHOT.jar
Hello World

fatJar 长什么样子 ?

$ jar tf build/libs/test-gradle-jar-02-fatJar-1.0-SNAPSHOT.jar
META-INF/MANIFEST.MF
com/google/common/util/concurrent/Striped.class
... 省略部分内容
com/google/j2objc/annotations/Weak.class
com/example/
com/example/App.class
test.txt

看一下 META-INF/MANIFEST.MF 的内容:

$ unzip -q -c build/libs/test-gradle-jar-02-fatJar-1.0-SNAPSHOT.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Main-Class: com.example.App

测试示例3

这个一个多模块的项目。

项目结构:

.
├── build.gradle
├── settings.gradle
├── src
│   └── main
│       ├── java
│       │   └── com
│       │       └── example
│       │           └── App.java
│       └── resources
│           └── test.txt
└── sub-project
    └── src
        └── main
            ├── java
            │   └── com
            │       └── example
            │           └── sub
            │               └── Utils.java
            └── resources
                ├── test.txt
                └── test2.txt

settings.gradle :

rootProject.name = 'test-gradle-jar-03'
include 'sub-project'

build.gradle :

plugins {
    id 'java'
}

group 'com.example'
version '1.0-SNAPSHOT'

sourceCompatibility = 1.8

allprojects {
    apply plugin: 'java'

    repositories {
        maven { url 'http://mirrors.cloud.tencent.com/nexus/repository/maven-public/' }
        mavenLocal()
        mavenCentral()
    }
}


jar {
    manifest {
        attributes 'Main-Class': 'com.example.App'
    }
    from {
        configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
    }
}

project(":sub-project") {
    dependencies {
        compile group: 'com.google.guava', name: 'guava', version: '28.2-jre'
    }
}


dependencies {
    compile project(":sub-project")
}

sub-project/src 目录下的 test.txt :

子项目: 你好

sub-project/src 目录下的 test2.txt :

子项目: 你好2

sub-project/src 目录下的 Utils.java :

package com.example.sub;

import com.google.common.base.Charsets;
import com.google.common.io.Resources;

import java.io.IOException;
import java.net.URL;


public class Utils {

    public static int add(int a, int b) {
        return a + b;
    }

    public static String readResourceFileContent(String path) {
        try {
            URL url = Resources.getResource(path);
            return Resources.toString(url, Charsets.UTF_8);
        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }
    }

}

src 目录下的 test.txt :

Hello World

src 目录下的 App.java :

package com.example;

import com.example.sub.Utils;

public class App {

    public static void main(String[] args)  {
        System.out.println("test.txt 内容: " + Utils.readResourceFileContent("test.txt"));
        System.out.println("test2.txt 内容: " + Utils.readResourceFileContent("test2.txt"));
        System.out.println("1+1 =  " + Utils.add(1, 1));
    }

}

执行 gradle jar 打包。

执行 jar:

$ java -jar build/libs/test-gradle-jar-03-1.0-SNAPSHOT.jar
test.txt 内容: 子项目: 你好
test2.txt 内容: 子项目: 你好2
1+1 =  2

题外话:jar 中出现同路径、同名文件怎么办

上面的代码中,有两个 test.txt 文件。

在执行jar时,发现 test.txt 的内容是 sub-project 下的文件内容:

$ java -jar build/libs/test-gradle-jar-03-1.0-SNAPSHOT.jar
test.txt 内容: 子项目: 你好
test2.txt 内容: 子项目: 你好2
1+1 =  2

这并不意味着只有 sub-project 下的 test.txt 被放到了 jar 中,实际上两个 test.txt 都被打包进去了。

$ jar tf  build/libs/test-gradle-jar-03-1.0-SNAPSHOT.jar | grep "test"
test.txt
test.txt
test2.txt

为什么 ? jar 其实使用 zip 打包,而zip内部支持同路径、同名文件出现多次。不过我们的操作系统只允许一次,所以讲jar解压后,只会保留一个 test.txt 。java 在运行时也只会保留一个。

guava 的 Resources.getResource 底层用的 ClassLoader 下的 getResource 方法,该方法只会返回一个 URL。而 ClassLoader 下的 getResources 可以返回多个 URL 。我们用 getResources 测试下。

增加 App2.java 类:

package com.example;

import com.google.common.base.Charsets;
import com.google.common.io.Resources;

import java.io.IOException;
import java.net.URL;
import java.util.Enumeration;

public class App2 {

    public static void main(String[] args) throws IOException {
        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();

        // 符合要求的文件可能不止一个
        Enumeration<URL> urlList = classLoader.getResources("test.txt");

        while (urlList.hasMoreElements()) {
            URL url = urlList.nextElement();
            System.out.printf("%s 内容: %s\n", url.getPath(), Resources.toString(url, Charsets.UTF_8));
        }

    }
}

使用 gradle jar 打包后执行 App2 :

$ java -cp build/libs/test-gradle-jar-03-1.0-SNAPSHOT.jar com.example.App2
file:/path/to/build/libs/test-gradle-jar-03-1.0-SNAPSHOT.jar!/test.txt 内容: 子项目: 你好

但是,在 Intellij IDEA 中执行 App2.java ,会输出两个:

/path/to/out/production/resources/test.txt 内容: Hello World
/path/to/sub-project/out/production/resources/test.txt 内容: 子项目: 你好

这是IDE的运行机制不同导致的,IDEA 在执行 App2.java 的 java -classpath 参数中, 有下面一段内容:

/path/to/out/production/classes:/path/to/out/production/resources:/path/to/sub-project/out/production/classes:/path/to/sub-project/out/production/resources:

这意味着同一个jar、同一个目录下只能有一个同名文件生效。

我们再测试下。

build/libs 目录中创建 test.txt ,内容是:

窗前明月光

再次执行 jar 中的 App2,但 classpath 中增加该目录:

$ java -cp build/libs/test-gradle-jar-03-1.0-SNAPSHOT.jar:build/libs/ com.example.App2
file:/path/to/build/libs/test-gradle-jar-03-1.0-SNAPSHOT.jar!/test.txt 内容: 子项目: 你好
/path/to/build/libs/test.txt 内容: 窗前明月光

如果再把 test.txt 打包到test.jar中,再执行 App2 :

$ java -cp build/libs/test-gradle-jar-03-1.0-SNAPSHOT.jar:build/libs/:build/libs/test.jar com.example.App2
file:/path/to/build/libs/test-gradle-jar-03-1.0-SNAPSHOT.jar!/test.txt 内容: 子项目: 你好
/path/to/build/libs/test.txt 内容: 窗前明月光
file:/path/to/build/libs/test.jar!/test.txt 内容: 窗前明月光

( 本文完 )