Monte um pequeno test runner só com o JDK, a partir de uma anotação e de reflexão, teste código package-private com –patch-module e distribua um serviço Java como JAR modular, imagem de runtime do jlink e instalador do jpackage.
Um serviço que passa num smoke check não está pronto. Ele precisa de testes que digam exatamente o que quebrou, e de um jeito de chegar a uma máquina que talvez nem tenha Java instalado. O JDK não tem framework de testes, mas tem tudo de que um framework pequeno precisa. Para distribuir, ele tem jar, jdeps, jlink e jpackage.
Esta última parte da série adiciona um harness de testes ao serviço de tarefas da parte sobre HTTP e depois empacota esse módulo de três jeitos. Todo programa abaixo rodou no Java 25, e a saída foi colada da execução. Para rodar um dos programas de arquivo único você mesmo, salve como Main.java e rode java Main.java. As sessões de terminal vêm do projeto, e o script de verificação dele reconstrói tudo só com ferramentas do JDK.
O que um test runner faz
Um test runner encontra os métodos de teste, roda cada um e conta o que passou e o que falhou. O JUnit faz muito mais, mas esse núcleo cabe num arquivo. Uma anotação marca os testes, e a reflexão os encontra:
@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");
}
Ele imprime:
ok addsUpPrices
ok emptyCartCostsNothing
FAIL takesTenPercentOff: expected 404 but was 0
2 passed, 1 failed
O teste que falhou achou um bug de verdade: (100 - percent) / 100 é divisão inteira, e 90 / 100 dá 0. Veja o que cada parte do runner faz:
@interface Testdeclara uma anotação. Sozinha, ela não faz nada. É uma etiqueta que o runner procura.getDeclaredMethods()devolve todo método da classe, eisAnnotationPresentfica só com os etiquetados.total,withDiscountechecknão têm etiqueta, então não rodam.new CartTests()roda uma vez por teste, então nenhum teste consegue deixar estado para o próximo.invokeencapsula o que quer que o teste lance numaInvocationTargetException.getCause()é a falha real.
Os testes são ordenados por nome antes de rodar. O Javadoc de getDeclaredMethods diz que os resultados “are not sorted and are not in any particular order”, então um runner que usasse essa ordem poderia imprimir outra coisa em outra JVM. Ordenar deixa a saída igual em toda execução.
Não aparece nenhum import, porque um arquivo-fonte compacto importa todo o java.base, e isso inclui java.lang.annotation e java.lang.reflect.
Sem @Retention(RUNTIME), o runner não encontra nada
Uma anotação dura só o que a política de retenção dela manda. A política padrão, CLASS, grava a anotação no arquivo de classe mas não a carrega em tempo de execução, então a reflexão não consegue vê-la:
@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()));
}
}
Ele imprime:
first: []
second: [@Main.Kept()]
Tire a linha @Retention do primeiro programa e o runner dele encontra zero testes, imprime 0 passed, 0 failed e parece um sucesso. É por isso que o runner do projeto trata “nenhum teste encontrado” como falha.
O nome Main.Kept é o arquivo-fonte compacto aparecendo. Tudo o que é declarado no arquivo fica aninhado dentro de uma classe chamada Main que você nunca escreveu.
O harness do projeto: uma anotação, não uma lista de lambdas
Os testes do projeto ficam numa pasta nova, test, ao lado de src, nos mesmos pacotes do código que testam:
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
O design mais simples é uma lista de lambdas com nome, como Map.entry("ids start at 1", () -> ...). Não precisa de reflexão, e nenhuma política de retenção consegue esconder um teste. Mas todo teste novo exige editar a lista, e um teste que alguém esquece de adicionar nunca roda e nunca falha. Com uma anotação, escrever o método já é o registro. O JUnit fez a mesma escolha.
A anotação é a mesma do runner de arquivo único, com um @Target para que ela só vá em métodos:
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 recebe nomes de classes de teste como argumentos. Para cada classe, ele faz o que a versão de arquivo único fazia:
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) deixa o runner chamar métodos de teste e construtores que não são public. Isso é permitido porque o runner e os testes estão no mesmo módulo, e a próxima seção monta isso. Depois da última classe, o main imprime os totais e chama System.exit(1) se algo falhou ou se nada rodou.
Assert guarda as verificações. assertEquals compara com Objects.equals e põe os dois valores na mensagem. assertThrows devolve a exceção, então um teste também pode conferir a mensagem dela:
/** 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");
}
Testando código package-private com --patch-module
O Json do serviço é package-private, então só código em com.example.tasks.http, dentro do módulo, pode chamá-lo. Os testes poderiam ignorá-lo e conferir o JSON só pelas respostas HTTP. Mas aí um bug no código de escape aparece como um corpo de resposta errado, três camadas longe da causa. Então os testes chamam Json diretamente.
Compilar um teste nesse pacote, mas fora do módulo, falha. Este é o primeiro de 14 erros:
$ 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;
^
Um pacote pertence a exatamente um módulo, e com.example.tasks.http já pertence a com.example.tasks. O --patch-module adiciona a pasta de testes a esse módulo, por uma compilação e uma execução. O módulo compilado e o JAR que você distribui não mudam.
$ 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
Cada flag tem uma função:
--patch-module com.example.tasks=testcompila os fontes de teste como parte do módulo. Em tempo de execução,=out/testadiciona as classes deles.--add-reads com.example.tasks=java.net.httpdeixa o módulo lerjava.net.http, de que os testes de HTTP precisam para usarHttpClient. O própriorequiresdo módulo não o lista. Sem essa flag, o javac informapackage java.net.http is not visible, “but module com.example.tasks does not read it”.--add-modules java.net.httpcoloca esse módulo no build e na execução, para começo de conversa. Sem nenhuma das duas flags, a execução falhou comNoClassDefFoundError: java/net/http/HttpClient.
O test-errors.log recebeu dois stack traces: o serviço registrando no log as respostas 500 que um teste provoca de propósito.
Testes do store: fazendo uma condição de corrida acontecer de propósito
A maioria dos testes do store tem poucas linhas cada: os ids começam em 1, um id apagado não é reutilizado e um título null lança exceção. O teste de concorrência exige mais cuidado, porque 100 threads que começam uma depois da outra podem nunca se sobrepor:
@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());
}
O CountDownLatch é um portão de largada. Cada thread bloqueia em start.await(), e o único countDown() libera as 100 juntas, então as chamadas a create se sobrepõem o máximo que a máquina permite.
Um teste que não consegue falhar não prova nada, então quebrei o store de propósito. Troquei lastId.incrementAndGet() por uma leitura e uma escrita separadas: lastId.get() + 1, depois lastId.set(...). O teste falhou em 20 de 20 execuções, com entre 68 e 99 ids distintos em vez de 100.
O mesmo truque pegou um teste inútil. Meu primeiro listIsSortedById criava cinco tarefas, apagava uma e conferia a ordem. Apaguei a linha .sorted(...) de list(), e o teste continuou passando. Um ConcurrentHashMap põe uma chave Long pequena no bucket de número igual ao valor dela, então ids que cabem na tabela saem em ordem, com ou sem ordenação. Agora o teste mantém só os ids de 14 a 17, para o map ficar com 16 buckets, e aí os ids 16 e 17 caem nos buckets 0 e 1, antes de 14 e 15. Sem a ordenação, ele falha com expected <[14, 15, 16, 17]> but was <[16, 17, 14, 15]>.
Testes de Json, e uma falha de propósito
Os testes de Json ficam em com.example.tasks.http, então chamam métodos package-private como se fossem públicos:
/** 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ë"));
}
Outros testes leem strings, escapes e booleanos, conferem a posição em malformed JSON at character 9 e transformam títulos em JSON e de volta. Para ver como é uma falha, apaguei a linha de Json.string que escreve um tab como \t e rodei de novo os mesmos comandos javac e java. Esta é a saída, cortada nas linhas que mudaram:
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
O runner saiu com status 1. Sem aquela linha, um tab cai no ramo geral de caracteres de controle e sai como \u0009. Isso ainda é JSON válido, então whatItWritesItCanReadBack passou. Só o teste que compara a saída exata notou a mudança.
Testes de HTTP contra um servidor real, com um store quebrado
Os testes de HTTP iniciam o servidor real na porta 0, como o smoke check faz, e o chamam com HttpClient. O interessante testa o caminho do 500, que o InMemoryTaskStore nunca percorre. TaskStore é uma interface, então o teste passa um store que falha:
/** 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());
}
}
}
A mensagem da exceção cita um host e uma porta de banco de dados, e o corpo da resposta não. O erro completo vai para o log, e foi isso que parou em test-errors.log. O try com recursos para o servidor mesmo quando uma asserção lança exceção.
O que o JUnit acrescenta
Um harness escrito à mão mostra o que um test runner faz, e para por aí. Projetos Java de verdade usam o JUnit, e a API Jupiter dele é o padrão desde o JUnit 5. Veja o que ele dá e o harness não dá:
- Descoberta: ele varre pacotes, pastas e JARs em busca de testes. Não existe lista de nomes de classes para manter atualizada.
- Ciclo de vida:
@BeforeEach,@AfterEach,@BeforeAlle@TempDircuidam da preparação e da limpeza, e extensões acrescentam mais. - Testes parametrizados:
@ParameterizedTestcom@ValueSourceou@CsvSourceroda um método com muitas entradas e informa cada uma separadamente. - Falhas melhores:
assertAllinforma várias verificações que falharam de uma vez, e há timeouts,@Disabled, tags e relatórios que servidores de CI leem. - Integração: Maven, Gradle e as principais IDEs rodam testes JUnit, um de cada vez ou todos juntos, e mostram os resultados.
Um JAR modular que conhece a sua classe principal
Um JAR modular montado com --main-class grava a classe a iniciar no descritor do módulo, então o java só precisa do nome do módulo. A parte sobre módulos trata de JARs modulares em geral. Para o serviço, fica assim:
$ 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
A primeira linha do --describe-module, que traz o caminho completo do JAR, foi cortada. Apertar Ctrl+C imprimiu stopping e stopped. O JAR guarda 17 KB do seu código, e ainda precisa de um runtime Java 25 na máquina que o roda.
jdeps: quais módulos do JDK o código usa?
O jdeps lê classes compiladas e informa do que elas dependem. --print-module-deps imprime os módulos do JDK como uma lista separada por vírgulas, pronta para passar ao jlink:
$ jdeps --print-module-deps tasks.jar
java.base,jdk.httpserver
$ jdeps --jdk-internals tasks.jar
O segundo comando lista usos de APIs internas do JDK, que podem quebrar numa atualização. Ele não imprimiu nada, porque não há nenhum.
Para este serviço modular, o jlink lê as linhas requires sozinho. O jdeps mostra o seu valor com um JAR comum, sem module-info.java. Nesse caso, --print-module-deps é o único jeito de saber de quais módulos um runtime enxuto precisa.
jlink: um runtime Java só com o que o serviço precisa
O jlink monta uma imagem de runtime: uma pasta com o seu próprio bin/java e só os módulos que você nomeia, mais os módulos que eles exigem. Este é o comando para o serviço de tarefas:
$ 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
Os tamanhos são desta máquina, um pacote do OpenJDK 25.0.4 do Ubuntu 24.04 em x86-64. Os seus vão ser diferentes. As opções fazem isto:
--add-modules com.example.tasksé a raiz. Ojlinksegue osrequiresa partir dela e encontrajdk.httpserverejava.base.--launcher tasks=module/classescrevebin/tasks. A classe precisa ser nomeada, porque o módulo compilado emoutnão tem classe principal gravada. Com--module-path tasks.jar,tasks=com.example.tasksbasta.--strip-debug,--no-header-filese--no-man-pagesdescartam informação de debug, headers de C e páginas de manual. Sem elas, a imagem tinha 60M. Acrescentar--compress zip-6a levou a 42M, com algum custo no tempo de inicialização.
jwebserver e keytool vêm junto porque jdk.httpserver e java.base os incluem.
Explicado como se você tivesse dez anos
Para passar um fim de semana na casa da sua avó, você poderia levar o guarda-roupa inteiro. Tudo de que você talvez precisasse estaria lá, mas você precisaria de um caminhão.
Em vez disso, você olha o plano da viagem e arruma uma mala pequena: duas camisetas, um pijama, uma escova de dentes. A mala é leve e tem tudo de que esta viagem precisa.
O JDK é o guarda-roupa. O jlink lê a lista do que o seu módulo precisa e empacota só isso.
A versão precisa
O JDK é dividido em módulos, e neste JDK cada um também vem como um arquivo .jmod na pasta jmods. O jlink resolve o grafo de módulos a partir dos módulos raiz que você passa, usando as linhas requires de cada module-info.class. Depois ele liga as classes de todo módulo desse grafo num único arquivo lib/modules e copia as bibliotecas nativas, a JVM e os launchers de que esses módulos precisam. A imagem não consegue carregar um módulo que não está nela: image/bin/java --list-modules mostra três, e nada mais existe até onde aquele runtime sabe.
Onde a analogia falha: você pode escolher roupas para uma viagem no chute, mas o jlink não chuta. Ele empacota exatamente o que os requires dizem. Se o código carrega uma classe pelo nome em tempo de execução, por reflexão ou por uma busca de serviço, e nenhuma linha requires cita o módulo dela, o jlink deixa esse módulo de fora, e o programa falha quando chega lá. Uma mala também funciona em qualquer lugar, mas a imagem não: ela guarda código nativo Linux x86-64 ligado à glibc, então só roda num sistema compatível.
Uso de disco medido com du -sh nesta máquina, desenhado em escala. O JDK inclui todos os 69 módulos e os arquivos jmods que só o jlink lê. A imagem guarda com.example.tasks, jdk.httpserver e java.base.
O launcher do jlink não repassa o SIGTERM
O launcher que o jlink escreve é um pequeno shell script, e isso importa quando algo para o serviço com um sinal:
$ 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 "$@"
O script inicia o java como processo filho e espera. Ele não usa exec para virar o java. Quando mandei SIGTERM para o processo do script, o shell saiu com status 143, e o java continuou rodando sem pai, ainda escutando na porta. O shutdown hook nunca rodou. Mandar o SIGTERM para o processo java imprimiu stopping e stopped, como deveria.
Um gerenciador de serviços ou um container manda SIGTERM para o processo que iniciou, então nesses lugares inicie bin/java -m com.example.tasks/com.example.tasks.Main você mesmo, em vez de usar o launcher.
jpackage: uma pasta de app ou um .deb
O jpackage embrulha uma imagem de runtime e um launcher em algo que um sistema operacional instala: um .deb ou .rpm no Linux, um .msi ou .exe no Windows, e um .dmg ou .pkg no macOS. Ele só gera pacotes para o sistema em que roda. Nesta máquina, tanto uma pasta de app quanto um pacote Debian funcionaram:
$ 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]
O jpackage rodou o jlink sozinho, e a pasta de app ficou com 55M, o mesmo que a imagem. O bin/tasks dela é um programa nativo, não um script. Ele roda a JVM dentro do próprio processo, e um SIGTERM mandado para ele rodou o shutdown hook. O build do .deb usou dpkg-deb e fakeroot, que já estavam nesta máquina. Ele instalaria em /opt/tasks, o que exige root, então não o instalei. Um .rpm precisa de rpmbuild, que esta máquina não tem.
Uma imagem de container a partir da saída do jlink
Uma imagem do jlink é copiada para um container como uma única pasta, sem JDK na imagem final. Este arquivo é ilustrativo. Não o construí nem o rodei como parte deste post:
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"]
O estágio final também é baseado em glibc, porque o código nativo da imagem precisa da glibc, então uma imagem base Alpine não o rodaria. O ENTRYPOINT inicia o java diretamente, pelo motivo do SIGTERM explicado acima.
O que o run-checks.sh verifica agora
O script de verificação do projeto agora monta e roda tudo o que está neste post, e remove tudo no fim, inclusive a imagem que ele monta numa pasta temporária:
$ ./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
As verificações do JAR e da imagem iniciam o serviço na porta 0 e o param com SIGTERM, como a verificação do Main. No caso do launcher, o script sinaliza o processo java filho dele.
Para onde ir daqui
Esta série ficou dentro do JDK de propósito, para você ver cada peça. Projetos reais acrescentam ferramentas por cima:
- Maven ou Gradle para declarar dependências, baixá-las e rodar o build, em vez de um shell script.
- JUnit no lugar do harness daqui, com o mesmo tipo de método de teste.
- Jackson para ler e escrever JSON, no lugar da classe
Jsonescrita à mão. - Spring Boot, Helidon ou Micronaut para roteamento, configuração, binding de JSON e métricas num serviço maior.
- JDK Flight Recorder e JDK Mission Control para gravar e inspecionar o que uma JVM em execução faz, da coleta de lixo a locks lentos.
O que lembrar
- Um test runner é uma anotação com
@Retention(RUNTIME), reflexão para encontrar os métodos, uma instância nova por teste e uma contagem. Ordene os métodos por nome, porque a ordem da reflexão não é especificada. - Trate zero testes encontrados como falha, e quebre o código de propósito para provar que cada teste consegue falhar.
- Para testar código package-private num módulo, compile e rode os testes com
--patch-module. Um teste no mesmo pacote, fora do módulo, falha compackage exists in another module. - Use um
CountDownLatchpara liberar muitas threads de uma vez quando um teste precisa que elas se sobreponham. jar --main-classgrava a classe principal num JAR modular, entãojava -p tasks.jar -m com.example.tasksbasta.jdeps --print-module-depslista os módulos do JDK que o código usa.- O
jlinkmonta um runtime só com os módulos de que você precisa: 55M aqui, contra 331M do JDK. O launcher dele é um script que não repassa oSIGTERM. - O
jpackagetransforma o runtime numa pasta de app ou num instalador nativo, para o sistema operacional em que roda.
Teste até confiar, depois distribua só o que ele precisa.