Blog

不用构建工具测试和发布 Java:jlink 与 jpackage

只用 JDK,靠一个注解加反射写出小型测试运行器,用 –patch-module 测试包私有代码,再把 Java 服务发布成模块化 JAR、jlink 运行时镜像和 jpackage 安装包。

通过冒烟检查的服务还不算完工。它需要能准确指出哪里坏了的测试,还需要一种办法装到可能没有 Java 的机器上。JDK 没有测试框架,但写一个小框架需要的东西它都有。发布方面,它有 jarjdepsjlinkjpackage

作为本系列的最后一篇,本文给 HTTP 那一篇里的 tasks 服务加上测试工具,然后用三种方式打包这个模块。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行其中的单文件程序,就把代码存成 Main.java,再执行 java Main.java。终端会话来自这个项目,它的检查脚本只用 JDK 工具就能把这一切重新构建一遍。

测试运行器做什么

测试运行器找到测试方法,逐个运行,再统计通过和失败的数量。JUnit 做的事多得多,但核心部分一个文件就装得下。用注解标记测试,用反射找到它们:

@Retention(RetentionPolicy.RUNTIME)
@interface Test {}

static class CartTests {
    int total(List<Integer> prices) {
        return prices.stream().mapToInt(Integer::intValue).sum();
    }

    int withDiscount(int total, int percent) {
        return total * ((100 - percent) / 100);
    }

    void check(boolean ok, String message) {
        if (!ok) {
            throw new AssertionError(message);
        }
    }

    @Test
    void emptyCartCostsNothing() {
        check(total(List.of()) == 0, "an empty cart should cost 0");
    }

    @Test
    void addsUpPrices() {
        check(total(List.of(250, 199)) == 449, "250 + 199 should be 449");
    }

    @Test
    void takesTenPercentOff() {
        int price = withDiscount(449, 10);
        check(price == 404, "expected 404 but was " + price);
    }
}

void main() throws ReflectiveOperationException {
    List<Method> tests = Arrays.stream(CartTests.class.getDeclaredMethods())
            .filter(m -> m.isAnnotationPresent(Test.class))
            .sorted(Comparator.comparing(Method::getName))
            .toList();

    int passed = 0;
    int failed = 0;
    for (Method test : tests) {
        var instance = new CartTests();
        try {
            test.invoke(instance);
            passed++;
            IO.println("ok    " + test.getName());
        } catch (InvocationTargetException e) {
            failed++;
            IO.println("FAIL  " + test.getName() + ": " + e.getCause().getMessage());
        }
    }
    IO.println(passed + " passed, " + failed + " failed");
}

输出:

ok    addsUpPrices
ok    emptyCartCostsNothing
FAIL  takesTenPercentOff: expected 404 but was 0
2 passed, 1 failed

失败的测试找到了一个真 bug:(100 - percent) / 100 是整数除法,而 90 / 100 等于 0。运行器各部分的作用如下:

  • @interface Test 声明一个注解。它本身什么也不做,只是运行器要找的一个标签。
  • getDeclaredMethods() 返回类里的所有方法,isAnnotationPresent 只留下带标签的那些。totalwithDiscountcheck 没有标签,所以不会运行。
  • new CartTests() 每个测试执行一次,所以任何测试都没法给下一个测试留下状态。
  • invoke 会把测试抛出的任何东西包进 InvocationTargetExceptiongetCause() 才是真正的失败原因。

测试在运行前按名字排序。getDeclaredMethods 的 Javadoc 说它返回的结果“没有排序,也没有任何特定顺序”,所以直接用这个顺序的运行器,换一个 JVM 输出就可能不一样。排序之后,每次运行的输出都相同。

代码里没有任何导入,因为紧凑源文件会导入整个 java.base,其中就包括 java.lang.annotationjava.lang.reflect

没有 @Retention(RUNTIME),运行器什么也找不到

注解能存活多久,由它的保留策略决定。默认策略 CLASS 会把注解写进 class 文件,但运行时不加载,所以反射看不到它:

@interface Forgotten {}

@Retention(RetentionPolicy.RUNTIME)
@interface Kept {}

static class Checks {
    @Forgotten
    void first() {
    }

    @Kept
    void second() {
    }
}

void main() throws NoSuchMethodException {
    for (String name : List.of("first", "second")) {
        Method method = Checks.class.getDeclaredMethod(name);
        IO.println(name + ": " + Arrays.toString(method.getAnnotations()));
    }
}

输出:

first: []
second: [@Main.Kept()]

把第一个程序里的 @Retention 那一行去掉,它的运行器会找到零个测试,输出 0 passed, 0 failed,看起来还像是成功了。所以项目里的运行器把“没找到测试”当作失败。

Main.Kept 这个名字暴露了紧凑源文件的底细。文件里声明的所有东西,都嵌套在一个叫 Main 的类里,而这个类你从没写过。

项目的测试工具:用注解,而不是 lambda 列表

项目的测试放在 src 旁边新建的 test 文件夹里,和被测代码位于同一个包:

19-tasks-service/
├── run-checks.sh
├── checks/
│   └── SmokeCheck.java
├── src/
│   └── com.example.tasks/ ...
└── test/
    └── com/example/tasks/
        ├── testing/
        │   ├── Assert.java
        │   ├── Test.java
        │   └── TestRunner.java
        ├── store/
        │   └── InMemoryTaskStoreTest.java
        └── http/
            ├── JsonTest.java
            └── TaskServerTest.java

更简单的设计是一个带名字的 lambda 表达式列表,比如 Map.entry("ids start at 1", () -> ...)。它不需要反射,也不会有保留策略把测试藏起来。但每加一个测试都得改这个列表,而忘了加进去的测试永远不会运行,也永远不会失败。用注解的话,写下方法就等于完成了注册。JUnit 做的也是同样的选择。

这个注解就是单文件运行器里那个,只是加了 @Target,让它只能用在方法上:

package com.example.tasks.testing;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/** Marks a method as a test. The method takes no arguments and returns nothing. */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}

TestRunner 以测试类名作为参数。对每个类,它做的事和单文件版本一样:

    private void runClass(Class<?> testClass) throws ReflectiveOperationException {
        IO.println(testClass.getName());
        Constructor<?> constructor = testClass.getDeclaredConstructor();
        constructor.setAccessible(true);
        for (Method method : testMethods(testClass)) {
            // A new instance per test, so no test sees another test's fields.
            Object instance = constructor.newInstance();
            try {
                method.invoke(instance);
                passed++;
                IO.println("  ok    " + method.getName());
            } catch (InvocationTargetException e) {
                failed++;
                IO.println("  FAIL  " + method.getName() + ": " + describe(e.getCause()));
            }
        }
    }

    /** The @Test methods, sorted by name: getDeclaredMethods has no fixed order. */
    private static List<Method> testMethods(Class<?> testClass) {
        var methods = Arrays.stream(testClass.getDeclaredMethods())
                .filter(m -> m.isAnnotationPresent(Test.class))
                .sorted(Comparator.comparing(Method::getName))
                .toList();
        for (Method m : methods) {
            if (m.getParameterCount() != 0 || Modifier.isStatic(m.getModifiers())) {
                throw new IllegalStateException(m + ": a test takes no arguments and isn't static");
            }
            m.setAccessible(true);
        }
        return methods;
    }

setAccessible(true) 让运行器能调用非 public 的测试方法和构造器。之所以允许这样做,是因为运行器和测试在同一个模块里,下一节会搭好这一点。最后一个类跑完后,main 输出总数;如果有测试失败,或者一个都没运行,就调用 System.exit(1)

Assert 里放着各种检查。assertEqualsObjects.equals 比较,并把两个值都写进消息。assertThrows 返回抛出的异常,所以测试还可以检查异常的消息:

    /** Runs code, and returns what it threw if that is an instance of type. */
    public static <T extends Throwable> T assertThrows(Class<T> type, Code code) {
        try {
            code.run();
        } catch (Throwable thrown) {
            if (type.isInstance(thrown)) {
                return type.cast(thrown);
            }
            throw new AssertionError("expected " + type.getSimpleName() + " but "
                    + thrown.getClass().getSimpleName() + " was thrown", thrown);
        }
        throw new AssertionError("expected " + type.getSimpleName() + " but nothing was thrown");
    }

--patch-module 测试包私有代码

服务里的 Json 是包私有的,所以只有模块内部 com.example.tasks.http 包里的代码能调用它。测试可以绕开它,只通过 HTTP 响应来检查 JSON。但这样一来,转义代码里的 bug 会表现为响应体不对,离真正的原因隔了三层。所以测试直接调用 Json

在这个包里、但在模块之外编译测试,会失败。下面是 14 个错误里的第一个:

$ javac -p out --add-modules com.example.tasks -d out/test test/com/example/tasks/http/JsonTest.java test/com/example/tasks/testing/*.java
test/com/example/tasks/http/JsonTest.java:1: error: package exists in another module: com.example.tasks
package com.example.tasks.http;
^

一个包只能属于一个模块,而 com.example.tasks.http 已经属于 com.example.tasks--patch-module 把测试文件夹加进这个模块,只在这一次编译和这一次运行中生效。编译好的模块和你发布的 JAR 都不会变。

$ javac -Xlint:all -Werror --release 25 -d out --module-source-path src -m com.example.tasks
$ javac -Xlint:all -Werror --release 25 -d out/test -p out \
    --patch-module com.example.tasks=test \
    --add-modules java.net.http --add-reads com.example.tasks=java.net.http \
    $(find test -name '*.java')
$ java -p out --patch-module com.example.tasks=out/test \
    --add-modules java.net.http --add-reads com.example.tasks=java.net.http \
    -m com.example.tasks/com.example.tasks.testing.TestRunner \
    com.example.tasks.store.InMemoryTaskStoreTest \
    com.example.tasks.http.JsonTest \
    com.example.tasks.http.TaskServerTest 2> test-errors.log
com.example.tasks.store.InMemoryTaskStoreTest
  ok    concurrentCreatesGetDistinctIds
  ok    deleteRemovesOnlyThatTask
  ok    idsAreNotReusedAfterDelete
  ok    idsStartAtOneAndGoUp
  ok    listIsSortedById
  ok    titleMustNotBeNull
com.example.tasks.http.JsonTest
  ok    escapesQuotesBackslashesAndControlCharacters
  ok    readsEscapes
  ok    readsStringsAndBooleans
  ok    rejectsARepeatedField
  ok    rejectsMalformedJsonWithItsPosition
  ok    rejectsNumbers
  ok    whatItWritesItCanReadBack
  ok    writesATask
  ok    writesAnEmptyListAsBrackets
com.example.tasks.http.TaskServerTest
  ok    brokenStoreIs500WithoutDetails
  ok    createThenFollowLocation
  ok    wrongMethodIs405WithAllow
18 passed, 0 failed

每个参数各管一件事:

  • --patch-module com.example.tasks=test 把测试源码当作模块的一部分来编译。运行时,=out/test 把它们的类加进来。
  • --add-reads com.example.tasks=java.net.http 让模块可以读取 java.net.http,HTTP 测试要用其中的 HttpClient。模块自己的 requires 里没有列出它。没有这个参数,javac 会报 package java.net.http is not visible,“but module com.example.tasks does not read it”。
  • --add-modules java.net.http 让这个模块真正进入构建和运行。两个参数都不加时,运行失败,报 NoClassDefFoundError: java/net/http/HttpClient

test-errors.log 里有两段栈跟踪:那是服务在记录某个测试故意触发的 500 响应。

存储测试:故意制造竞态

大多数存储测试每个只有几行:id 从 1 开始,删掉的 id 不会被重用,null 标题会抛异常。并发测试需要多花点心思,因为一个接一个启动的 100 个线程,可能根本不会重叠:

    @Test
    void concurrentCreatesGetDistinctIds() throws InterruptedException {
        int threads = 100;
        var start = new CountDownLatch(1);
        Set<Long> ids = ConcurrentHashMap.newKeySet();
        var workers = new ArrayList<Thread>();
        for (int i = 0; i < threads; i++) {
            workers.add(Thread.ofPlatform().start(() -> {
                try {
                    start.await();   // every thread waits here until countDown below
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
                ids.add(store.create("task", false).id());
            }));
        }
        start.countDown();           // release all 100 threads at once
        for (Thread worker : workers) {
            worker.join();
        }

        assertEquals(threads, ids.size());
        assertEquals(threads, store.list().size());
        assertEquals(100L, store.list().getLast().id());
    }

CountDownLatch 就是一道起跑闸门。每个线程都阻塞在 start.await(),唯一的那次 countDown() 把 100 个线程同时放出去,于是它们对 create 的调用在机器允许的范围内尽量重叠。

不可能失败的测试什么也证明不了,所以我故意把存储改坏。我把 lastId.incrementAndGet() 换成分开的读和写:先 lastId.get() + 1,再 lastId.set(...)。测试 20 次运行 20 次失败,得到的不同 id 在 68 到 99 个之间,而不是 100 个。

同样的办法还揪出了一个没用的测试。我最初写的 listIsSortedById 创建五个任务,删掉一个,再检查顺序。我把 list() 里的 .sorted(...) 那一行删了,测试照样通过。ConcurrentHashMap 会把较小的 Long 键放进编号等于其值的桶里,所以只要 id 放得进表,不管排不排序,出来都是有序的。现在这个测试只保留 id 14 到 17,这样映射保持 16 个桶,id 16 和 17 落进 0 号和 1 号桶,排在 14 和 15 前面。去掉排序后,测试失败,报 expected <[14, 15, 16, 17]> but was <[16, 17, 14, 15]>

Json 测试,以及一次故意的失败

Json 测试位于 com.example.tasks.http,所以调用包私有方法就像调用公有方法一样:

/** Json is package-private, so this test lives in the same package, inside the module. */
class JsonTest {
    @Test
    void writesATask() {
        assertEquals("{\"id\":7,\"title\":\"Buy milk\",\"done\":true}",
                Json.task(new Task(7, "Buy milk", true)));
    }

    @Test
    void writesAnEmptyListAsBrackets() {
        assertEquals("[]", Json.tasks(List.of()));
    }

    @Test
    void escapesQuotesBackslashesAndControlCharacters() {
        assertEquals("\"say \\\"hi\\\" \\\\ tab\\t bell\\u0007 Zoë\"",
                Json.string("say \"hi\" \\ tab\t bell\u0007 Zoë"));
    }

其他测试解析字符串、转义和布尔值,检查 malformed JSON at character 9 里的位置,还把标题转成 JSON 再转回来。为了看看失败是什么样子,我删掉了 Json.string 里把制表符写成 \t 的那一行,然后重新执行同样的 javacjava 命令。下面是输出,只保留了变化的行:

com.example.tasks.http.JsonTest
  FAIL  escapesQuotesBackslashesAndControlCharacters: expected <"say \"hi\" \\ tab\t bell\u0007 Zoë"> but was <"say \"hi\" \\ tab\u0009 bell\u0007 Zoë">
...
17 passed, 1 failed

运行器以状态码 1 退出。没有那一行,制表符会落到通用的控制字符分支,输出成 \u0009。这仍然是合法的 JSON,所以 whatItWritesItCanReadBack 通过了。只有比较精确输出的那个测试发现了变化。

用坏掉的存储测试真实的 HTTP 服务器

HTTP 测试和冒烟检查一样,在端口 0 上启动真正的服务器,再用 HttpClient 调用它。最有意思的一个测的是 500 路径,InMemoryTaskStore 从来不会走到这条路。TaskStore 是接口,所以测试可以传进一个会失败的存储:

    /** A store that fails on every call, the way a store with a dead database would. */
    private static final class BrokenStore implements TaskStore {
        @Override
        public List<Task> list() {
            throw new IllegalStateException("connection to db.internal:5432 refused");
        }

        @Override
        public Optional<Task> get(long id) {
            return Optional.of(list().getFirst());
        }

        @Override
        public Task create(String title, boolean done) {
            return list().getFirst();
        }

        @Override
        public boolean delete(long id) {
            return !list().isEmpty();
        }
    }
    @Test
    void brokenStoreIs500WithoutDetails() throws Exception {
        try (var server = TaskServer.start(new BrokenStore(), 0)) {
            for (String method : List.of("GET", "DELETE")) {
                var response = send(server, method, "/tasks/1", null);
                assertEquals(500, response.statusCode());
                assertEquals("{\"error\":\"internal server error\"}\n", response.body());
            }
        }
    }

异常消息里有数据库的主机名和端口,响应体里没有。完整的错误写进日志,也就是 test-errors.log 里的内容。即使断言抛出异常,try-with-resources 也会停掉服务器。

JUnit 多给了什么

手写的测试工具能让你看清测试运行器做什么,但也就到此为止。真实的 Java 项目用 JUnit,从 JUnit 5 开始,它的 Jupiter API 就是标准。下面是它有而这个测试工具没有的东西:

  • 发现测试: 它扫描包、文件夹和 JAR 来查找测试,不需要维护类名列表。
  • 生命周期: @BeforeEach@AfterEach@BeforeAll@TempDir 负责准备和清理,扩展还能加更多功能。
  • 参数化测试: @ParameterizedTest 配合 @ValueSource@CsvSource,用多组输入运行同一个方法,并分别报告每一组。
  • 更好的失败信息: assertAll 一次报告多个失败的检查,还有超时、@Disabled、标签,以及 CI 服务器能读取的报告。
  • 集成: Maven、Gradle 和主流 IDE 都能运行 JUnit 测试,单个运行或全部运行都行,并显示结果。

知道自己主类的模块化 JAR

--main-class 构建的模块化 JAR 会把要启动的类记录在它的模块描述符里,所以 java 只需要模块名。模块那一篇讲过模块化 JAR 的一般用法。对这个服务来说,是这样的:

$ jar --create --file tasks.jar --main-class com.example.tasks.Main -C out/com.example.tasks .
$ jar --describe-module --file tasks.jar | tail -n +2
exports com.example.tasks.http
exports com.example.tasks.store
requires java.base mandated
requires jdk.httpserver
contains com.example.tasks
main-class com.example.tasks.Main
$ java -p tasks.jar -m com.example.tasks
listening on port 8080

--describe-module 输出的第一行包含 JAR 的完整路径,这里剪掉了。按下 Ctrl+C 后输出了 stoppingstopped。这个 JAR 里是 17 KB 的你自己的代码,运行它的机器上仍然需要 Java 25 运行时。

jdeps:代码用了哪些 JDK 模块?

jdeps 读取编译好的类,报告它们依赖什么。--print-module-deps 把 JDK 模块输出成逗号分隔的列表,可以直接交给 jlink

$ jdeps --print-module-deps tasks.jar
java.base,jdk.httpserver
$ jdeps --jdk-internals tasks.jar

第二条命令列出对 JDK 内部 API 的使用,这些用法升级时可能出问题。它什么也没输出,因为一处都没有。

对这个模块化服务,jlink 自己会读 requires 行。jdeps 真正派上用场的是没有 module-info.java 的普通 JAR。那种情况下,--print-module-deps 是得知精简运行时需要哪些模块的唯一办法。

jlink:只含服务所需内容的 Java 运行时

jlink 构建运行时镜像:一个文件夹,里面有自己的 bin/java,只包含你指定的模块,外加这些模块依赖的模块。下面是 tasks 服务用的命令:

$ jlink --add-modules com.example.tasks --module-path out \
    --launcher tasks=com.example.tasks/com.example.tasks.Main \
    --strip-debug --no-header-files --no-man-pages --output image
$ ls image/bin
java
jwebserver
keytool
tasks
$ image/bin/java --list-modules
com.example.tasks
java.base@25.0.4
jdk.httpserver@25.0.4
$ du -sh image /usr/lib/jvm/java-25-openjdk-amd64
55M	image
331M	/usr/lib/jvm/java-25-openjdk-amd64
$ image/bin/tasks
listening on port 8080

这些大小来自这台机器,是 x86-64 上 Ubuntu 24.04 打包的 OpenJDK 25.0.4。你的结果会不一样。各个选项的作用如下:

  • --add-modules com.example.tasks 是根模块。jlink 从这里顺着 requires 找下去,找到 jdk.httpserverjava.base
  • --launcher tasks=module/class 生成 bin/tasks。这里必须写出类名,因为 out 里编译好的模块没有记录主类。如果用 --module-path tasks.jar,写 tasks=com.example.tasks 就够了。
  • --strip-debug--no-header-files--no-man-pages 去掉调试信息、C 头文件和手册页。不加它们,镜像是 60M。再加上 --compress zip-6,镜像降到 42M,代价是启动时间稍长。

jwebserverkeytool 也跟着进来了,因为 jdk.httpserverjava.base 包含它们。

用十岁孩子能懂的话说

去奶奶家过周末,你可以把整个衣柜都带上。可能用到的东西全都有,但你得开一辆卡车。

换个做法,你看看这趟出行的安排,收拾一个小行李箱:两件 T 恤、一套睡衣、一把牙刷。箱子很轻,这趟需要的东西一样不缺。

JDK 就是那个衣柜。jlink 读你的模块列出的需求清单,只把这些打包。

准确的说法

JDK 被拆分成多个模块,在这个 JDK 里,每个模块还以 .jmod 文件的形式放在 jmods 文件夹中。jlink 从你传入的根模块出发,依据每个 module-info.class 里的 requires 行解析出模块图。然后它把图中每个模块的类链接进同一个 lib/modules 文件,并复制这些模块需要的本地库、JVM 和启动器。镜像无法加载它不包含的模块:image/bin/java --list-modules 显示三个模块,对这个运行时来说,其他模块根本不存在。

这个比喻的局限: 出门带什么衣服可以靠猜,但 jlink 不猜。它严格按 requires 打包。如果代码在运行时按名字加载类,比如通过反射或服务查找,而没有任何 requires 行写到它所在的模块,jlink 就会漏掉那个模块,程序运行到那里就会失败。另外,行李箱到哪儿都能用,镜像却不行:它包含针对 glibc 链接的 Linux x86-64 本地代码,只能在匹配的系统上运行。

完整 JDK,69 个模块 lib: 243M jmods: 86M 331M jlink 镜像,3 个模块 55M jlink 镜像 + –compress zip-6 42M

磁盘占用在这台机器上用 du -sh 测得,按比例绘制。JDK 包含全部 69 个模块,以及只有 jlink 才读取的 jmods 文件。镜像里只有 com.example.tasks、jdk.httpserver 和 java.base。

jlink 生成的启动器不转发 SIGTERM

jlink 生成的启动器是一个小 shell 脚本。当有东西用信号停止服务时,这一点就很要紧:

$ cat image/bin/tasks
#!/bin/sh
JLINK_VM_OPTIONS=
DIR=`dirname $0`
$DIR/java $JLINK_VM_OPTIONS -m com.example.tasks/com.example.tasks.Main "$@"

这个脚本把 java 作为子进程启动,然后等待。它没有用 exec 把自己替换成 java。我向脚本的进程发送 SIGTERM 后,shell 以状态码 143 退出,而 java 没了父进程,却还在运行,仍然监听着端口。关闭钩子始终没有运行。改为向 java 进程发送 SIGTERM,就如预期那样输出了 stoppingstopped

服务管理器或容器会把 SIGTERM 发给它启动的那个进程。所以在这些场合,请自己启动 bin/java -m com.example.tasks/com.example.tasks.Main,不要用这个启动器。

jpackage:应用文件夹或 .deb

jpackage 把运行时镜像和启动器包装成操作系统能安装的东西:Linux 上是 .deb.rpm,Windows 上是 .msi.exe,macOS 上是 .dmg.pkg。它只能为自己所在的系统构建安装包。在这台机器上,应用文件夹和 Debian 包都成功了:

$ jpackage --type app-image --name tasks --module-path out \
    --module com.example.tasks/com.example.tasks.Main --dest dist
$ ls dist/tasks/bin dist/tasks/lib
dist/tasks/bin:
tasks

dist/tasks/lib:
app
libapplauncher.so
runtime
tasks.png
$ jpackage --type deb --name tasks --app-version 1.0.0 --module-path out \
    --module com.example.tasks/com.example.tasks.Main --dest dist
$ dpkg-deb --field dist/tasks_1.0.0_amd64.deb Package Version Depends Installed-Size
Package: tasks
Version: 1.0.0
Depends: libc6, libgcc-s1, libstdc++6, zlib1g
Installed-Size: 56077
$ ls -lh dist/*.deb | awk '{print $5, $9}'
14M dist/tasks_1.0.0_amd64.deb
$ jpackage --type rpm --name tasks --module-path out \
    --module com.example.tasks/com.example.tasks.Main --dest dist
Error: Invalid or unsupported type: [rpm]

jpackage 自己调用了 jlink,应用文件夹是 55M,和镜像一样大。它的 bin/tasks 是本地程序,不是脚本。它在自己的进程里运行 JVM,发给它的 SIGTERM 会执行关闭钩子。.deb 的构建用到了 dpkg-debfakeroot,这台机器上已经装好。它会安装到 /opt/tasks,需要 root 权限,所以我没有安装。.rpm 需要 rpmbuild,这台机器上没有。

jlink 的输出做容器镜像

jlink 镜像作为一个文件夹复制进容器,最终镜像里不需要 JDK。下面这个文件只是示意,我没有在写这篇文章时构建或运行它:

FROM ubuntu:24.04 AS build
RUN apt-get update && apt-get install -y --no-install-recommends openjdk-25-jdk-headless
WORKDIR /src
COPY src src
RUN javac -d out --module-source-path src -m com.example.tasks \
 && jlink --add-modules com.example.tasks --module-path out \
      --strip-debug --no-header-files --no-man-pages --output /opt/tasks

FROM ubuntu:24.04
COPY --from=build /opt/tasks /opt/tasks
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/opt/tasks/bin/java", "-m", "com.example.tasks/com.example.tasks.Main"]

最后一个阶段同样基于 glibc,因为镜像里的本地代码需要 glibc,所以 Alpine 基础镜像跑不了它。出于上面讲的 SIGTERM 原因,ENTRYPOINT 直接启动 java

run-checks.sh 现在检查什么

项目的检查脚本现在会构建并运行本文里的所有内容,最后再把它们全部删掉,包括它在临时文件夹里构建的镜像:

$ ./run-checks.sh
ok  compiled com.example.tasks
ok  smoke check: 45 checks passed
ok  Main starts on port 0 and stops cleanly on SIGTERM
ok  a bad port prints usage and exits 2
ok  tests: 18 passed, 0 failed
ok  java -p tasks.jar -m com.example.tasks starts and stops
ok  jlink image with com.example.tasks,java.base,jdk.httpserver starts and stops
all checks passed

JAR 和镜像的检查都在端口 0 上启动服务,再用 SIGTERM 停止它,和 Main 的检查一样。对启动器,脚本向它的子进程 java 发送信号。

接下来学什么

本系列刻意只用 JDK,好让你看清每一个部件。真实的项目会在它之上加工具:

  • Maven 或 Gradle:声明依赖、下载依赖并运行构建,取代 shell 脚本。
  • JUnit:取代这里的测试工具,测试方法的写法差不多。
  • Jackson:读写 JSON,取代手写的 Json 类。
  • Spring Boot、Helidon 或 Micronaut:在更大的服务里负责路由、配置、JSON 绑定和指标。
  • JDK Flight Recorder 和 JDK Mission Control:记录并查看运行中的 JVM 在做什么,从垃圾回收到慢锁都能看到。

要点

  • 测试运行器就是:一个带 @Retention(RUNTIME) 的注解、用反射找到方法、每个测试一个新实例,再加一个计数。要按名字给方法排序,因为反射返回的顺序没有规定。
  • 把找到零个测试当作失败,并故意把代码改坏,证明每个测试都能失败。
  • 要测试模块里的包私有代码,就用 --patch-module 编译和运行测试。在模块外、同一个包里的测试会失败,报 package exists in another module
  • 测试需要很多线程重叠时,用 CountDownLatch 把它们同时放出去。
  • jar --main-class 把主类记录进模块化 JAR,所以 java -p tasks.jar -m com.example.tasks 就够了。jdeps --print-module-deps 列出代码用到的 JDK 模块。
  • jlink 构建只含所需模块的运行时:这里是 55M,而 JDK 是 331M。它生成的启动器是脚本,不转发 SIGTERM
  • jpackage 把运行时做成应用文件夹或本地安装包,只针对它所运行的操作系统。

测到你信得过它,再只发布它需要的东西。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。