В этот раз упакуем то же самое приложение в exe, но уже с помощью GraalVM. Этот инструмент позволяет создать нативный образ приложения со всеми зависимостями и модулями из JRE в виде байт-кода.
Hibernate в этот раз использовать не буду совсем, вместо этого сделаю заглушки слоя работы с данными. Сделаю я так по одной причине: GraalVM не переваривает javafx и hibernate в одном месте, по отдельности запросто, но вместе никак, совсем.
Поэтому для решение той же задачи — приложение, которое работает с данными — я применю архитектурный подход: разделю приложение на две независимые части, где exe c интерфейсом приложения будет обращаться к exe со слоем работы с базой данных.
В этой статье будет первый exe с интерфейсом приложения.
Версия java 21.0.8 и остальные зависимости и плагины аналогично той версии, которая будет работать с этой версией Java.
Подготовка POM.
Для начала удалим все лишние плагины и другие настройки
- jpackage-maven-plugin и секцию с профилями
- javafx-maven-plugin, поскольку запуск буду делать из IDE и сборка jlink образа не нужна
- maven-dependency-plugin, поскольку он собирал зависимости для jpackage, а graalvm будет делать это по-своему.
- maven-antrun-plugin, в нем теперь нет особой нужды, поскольку ничего кроме target не будет создаваться во время сборки, а значить чистку можно делать стандартным ‘mvn clean’.
- maven-jar-plugin, поскольку он не умеет создавать толстые исполняемые jar файлы, а именно это понадобится, поскольку приложение не модульное.
Остается только maven-compiler-plugin для решения проблем с lombok.
Добавляем GraalVM
- Установить GraalVm той же версии, на которой разрабатывается приложение.
- Лучшее решение использовать GraalVM для разработки, тогда путь к ней нужно указать в JAVA_HOME
- Тоже рабочее решение — это разрабатывать на обычной java, а собирать нативный образ с помощью GraalVM, в этом случае нужно задать переменную GRAALVM_HOME и указать в ней путь к папке с разархивированными исходниками скачанными с официального сайта GraalVM.
- Также не забыть обновить переменную PATH указав в ней пути до папок bin java и graalVm соответственно.
- Проверить так:
native-image --version
Main class
Поскольку GraalVM native-image tool не любит (не умеет?) главные классы, которые наследуются от чего-то другого, например, от javafx.application.Application. При сборке образа он то не может найти главный класс приложения, то его класс родитель, даже не смотря на различные аргументы сборки.
В общем применим хитрый ход: сделаем еще один главный класс который будет вызывать уже тот другой главный класс. Получится такая точка входа.

Дополняем POM
Для удобства добавляем свойства
<properties>
<main.class>ru.makushimo.javafxexe.Main</main.class>
<lombok.version>1.18.34</lombok.version>
<javafx.lib.path>${project.build.directory}/libs</javafx.lib.path>
<javafx.version>21.0.8</javafx.version>
</properties>
Привычный набор зависимостей
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>${javafx.version}</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-fxml</artifactId>
<version>${javafx.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.17</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.18</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>compile</scope>
</dependency>
Для сборки толстого исполняемого jar приложения использую maven-shade-plugin с настройками из документации к этому плагину.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${main.class}</mainClass>
</transformer>
</transformers>
<createDependencyReducedPom>false</createDependencyReducedPom>
</configuration>
</execution>
</executions>
</plugin>
Проверим, что исполняемый jar все таки собирается и внезапно еще и запускается без танцев с бубном.
mvn clean package
java -jar target/javafx-exe-1.0.0.jar
Если все сработало тогда запустится наше приложение

Теперь усложним его до вида todo-list.
Task model.
Создадим модель для отображения строки в таблице задач в интерфейсе приложения

Task Service
Для работы с данными приложения создадим сервис-заглушку. Для проверки работы интерфейса этого достаточно, а потом переделаем его для работы с внешним сервисом слоя работы с базой данных (второй exe).

Task Controller
Класс, который будет предоставлять всю закадровую логику работы интерфейса. Он будет брать и отдавать данные через task service
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
public class TaskController {
@FXML private TableView<Task> taskTable;
@FXML private TableColumn<Task, String> nameColumn;
@FXML private TableColumn<Task, Boolean> doneColumn;
@FXML private TextField taskInput;
private final TaskService taskService = new TaskService();
private ObservableList<Task> tasks = FXCollections.observableArrayList();
@FXML
public void initialize() {
nameColumn.setCellValueFactory(cellData -> new SimpleStringProperty(cellData.getValue().getName()));
doneColumn.setCellValueFactory(cellData -> new SimpleBooleanProperty(cellData.getValue().isDone()));
taskTable.setItems(tasks);
loadTasks();
}
private void loadTasks() {
tasks.clear();
tasks.addAll(taskService.getAllTasks());
}
@FXML
private void handleAdd() {
String name = taskInput.getText();
if (name.isEmpty()) {
return;
};
Task task = new Task();
task.setName(name);
task.setDone(false);
taskService.addTask(task);
taskInput.clear();
loadTasks();
}
@FXML
private void handleDelete() {
Task selected = taskTable.getSelectionModel().getSelectedItem();
if (selected == null) {
return;
};
taskService.deleteTask(selected);
loadTasks();
}
@FXML
private void handleToggle() {
Task selected = taskTable.getSelectionModel().getSelectedItem();
if (selected == null) {
return;
};
selected.setDone(!selected.isDone());
taskService.updateTask(selected);
loadTasks();
}
}
Запускаем и проверяем, что оно работает.

Теперь повторно проверяем толстый Jar
mvn clean package
java -jar target/javafx-exe-1.0.0.jar
GraalVM native-agent
По инструкции перед запуском сборки нативного образа нужно подготовить специальные конфигурационные файлы описывающие ресурсы, рефлексивные вызовы, JNI вызовы и т.к., которые помогут правильно собрать приложение в бинарный код. Все эти удобные штуки типа рефлексии или AOP вообще не работают в виде собранного бинарного exe, поэтому GraalVM все подобные ситуации сначала переделывает в виде обычного кода, а конфиги ему в этом помогают.
Обычно фреймворки типа Spring или Quarkus предоставляют такие файлы для целей сборки нативного образа, но в данный момент их в проекте нет и придется делать все вручную.
GraalVM toolset предоставляет так называемый tracing agent, это java агент, с которым нужно запустить приложение и протыкать в нем все сценарии в интерфейсе, чтобы код пробежался по всем маршрутам, а агент смог их засечь и правильно записать в конфиг файлы.
Для использования агента нужно создать в проекте следующий каталог src/main/resources/META-INF/native-agent. В него потом сложим полученные json файлы.
Добавим POM профиль для запуска приложения в режиме агента трассировки.
<profile>
<id>native-agent</id>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.1</version>
<!-- Запуск приложения -->
<executions>
<execution>
<id>java-agent</id>
<goals>
<goal>exec</goal>
</goals>
<phase>none</phase>
<configuration>
<executable>java</executable>
<workingDirectory>${project.build.directory}</workingDirectory>
<arguments>
<argument>-agentlib:native-image-agent=config-output-dir=${project.build.directory}/native-image</argument>
<argument>-classpath</argument>
<classpath/>
<argument>${main.class}</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
Затем выполним команду для запуска приложения.
mvn clean package -Pnative-agent -DskipTests exec:exec@java-agent
Она соберет толстый jar и запустит его. Затем протыкаем все сценарии и закроем приложение. в папке target/native-image появилось несколько json файлов. Скопируем их не глядя в src/main/resources/META-INF/native-image.
Далее по мере развития приложения агента нужно будет запускать опять и затем объединять полученные json файлы с уже существующими. Тогда новый функционал правильно попадет в нативный образ.
GraalVM Native image
Для сборки нативного образа добавим новый профиь с плагином native-image-plugin. При этом он будет собирать образ из толстого jar проекта. Срабатывать он будет после фазы package общей для всех профилей. Никаких специальных настроек для того, чтобы сборка начиналась именно с этого jar нет, плагин сам находит его в target по имени проекта и его версии.
<profile>
<id>native-jar</id>
<build>
<plugins>
<!-- Скопировать зависимости в отдельную папку -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.8.1</version>
<executions>
<execution>
<id>copy-dlls</id>
<phase>prepare-package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${javafx.lib.path}</outputDirectory>
<includeScope>runtime</includeScope>
<includeArtifactIds>javafx-graphics,javafx-controls</includeArtifactIds>
<includeGroupIds>org.openjfx</includeGroupIds>
<stripVersion>true</stripVersion>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<id>build-native</id>
<goals>
<goal>compile-no-fork</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<mainClass>${main.class}</mainClass>
<imageName>${project.artifactId}</imageName>
<skipNativeTests>true</skipNativeTests>
<fallback>false</fallback>
<verbose>true</verbose>
<resourceIncludedPatterns>
<pattern>.*\.fxml$</pattern>
<pattern>.*\.css$</pattern>
<pattern>.*\.bss$</pattern>
<pattern>.*\.obj$</pattern>
<pattern>.*\.dll$</pattern>
<pattern>.*\.icu$</pattern>
</resourceIncludedPatterns>
<buildArgs>
<!-- путь к файлам конфигурации -->
<buildArg>-H:ConfigurationFileDirectories=src/main/resources/META-INF/native-image/</buildArg>
<!-- Выводить подробно ошибки при сборке-->
<buildArg>-H:+ReportExceptionStackTraces</buildArg>
<!-- включить протоколы -->
<buildArg>-H:+UnlockExperimentalVMOptions</buildArg>
<buildArg>--enable-http</buildArg>
<buildArg>--enable-https</buildArg>
<!-- Путь к javafx файлам зависимостей -->
<buildArg>-Djava.library.path=${javafx.lib.path}</buildArg>
<!-- Указание на то чтобы инициализировать эти модули при запуске а не при сборке-->
<buildArg>--initialize-at-run-time=javafx,com.sun.javafx,com.sun.glass,com.sun.prism,org.slf4j,ch.qos.logback,org.jboss.logging</buildArg>
</buildArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
Кроме этого также надо отредактировать resource-config.json, собранный агентом. Как оказалось агент не шибко то и умный и может поймать примерно 80% всех настроек. Остальные нужно добавлять методом проб и ошибок. Посмотреть ошибку сборки, понять ее (или нагуглить) и внести недостающее в json. Даже если образ соберется, это вообще не гарантия того, что приложение запустится. читаем полученные ошибки, пытаемся понять чего в супе не хватает (или гуглим) и вносим изменения. Например такой простой пример как у меня запускался с пустым окном, потому что в образе не добавились ресурсы типа modena.css и прочие dll утилиты для графического интерфейса.
Вот что мне пришлось добавить и изменить:
resource-config.json
- все вхождения для объявления ресурсов (dll, css, xml и т.д.), которые начинаются с префиксов «javafx.graphics:» или «javafx.controls:» заменяем на такую же строку но без префикса, например было «javafx.controls:\\Qapi-ms-win-crt-private-l1-1-0.dll\\E», стало «\\Qapi-ms-win-crt-private-l1-1-0.dll\\E».
По идее агент все указал правильно, нашел нужные ресурсы внутри jar зависимостей, которые тоже нашел, однако сборщик образа такие записи не может правильно обработать, даже если эти зависимости лежат прамо в толстом jar. После замены путей к ресурсам далее нужно явно указать где именно лежат jar файлы javafx, чтобы сборщик не заблудился. - Добавить эти ресурсы в список
{
"pattern":"\\\\Qcom/sun/javafx/scene/control/skin/modena/modena.bss\\\\E"
},{
"pattern":"com/sun/prism/d3d/hlsl/.*\\.obj"
}, {
"pattern":"com/sun/scenario/effect/impl/hw/d3d/hlsl/.*\\.obj"
}
3. Добавить ресурсы из проекта, которых пока нет, но они будут, этим сэкономим себе кучу нервов в будущем.
{
"pattern":"ru/makushimo/javafxexe/.*\\.png"
},{
"pattern":"ru/makushimo/javafxexe/.*\\.jpg"
},{
"pattern":"ru/makushimo/javafxexe/.*\\.svg"
}, {
"pattern":"ru/makushimo/javafxexe/.*\\.css"
}, {
"pattern":"ru/makushimo/javafxexe/.*\\.fxml"
}
reflect-config.json
Добавить это, хз что это, но на него ругается приложение при запуске, если его нет:
{
"name":"com.sun.prism.shader.FillPgram_Color_Loader",
"allDeclaredFields":true,
"allPublicFields":true,
"allDeclaredMethods":true,
"allPublicMethods":true,
"allDeclaredConstructors":true,
"allPublicConstructors":true,
"methods":[{"name":"loadShader","parameterTypes":["com.sun.prism.ps.ShaderFactory","java.io.InputStream"] }]
},
{
"name":"com.sun.prism.shader.FillPgram_ImagePattern_Loader",
"allDeclaredFields":true,
"allPublicFields":true,
"allDeclaredMethods":true,
"allPublicMethods":true,
"allDeclaredConstructors":true,
"allPublicConstructors":true,
"methods":[{"name":"loadShader","parameterTypes":["com.sun.prism.ps.ShaderFactory","java.io.InputStream"] }]
}
maven-dependency-plugin
Последний штрих, в профиль «native-jar» добавим плагин для сборка javafx jar-файлов в одно место.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.8.1</version>
<executions>
<execution>
<id>copy-dlls</id>
<phase>prepare-package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${javafx.lib.path}</outputDirectory>
<includeScope>runtime</includeScope>
<includeArtifactIds>javafx-graphics,javafx-controls</includeArtifactIds>
<includeGroupIds>org.openjfx</includeGroupIds>
<stripVersion>true</stripVersion>
</configuration>
</execution>
</executions>
</plugin>
А в конфигурацию плагина native-image выше добавили аргумент, указывающий на эту папку /libs.
<buildArg>-Djava.library.path=${javafx.lib.path}</buildArg>
Вот теперь приложение не только собирается в exe, но еще и запускается и даже работает.
Заключение
Как показал этот опыт, GraalVM еще безнадежно сырой, поскольку даже с встроенной поддержкой javafx в сборке нативных образов все не работает из коробки. Пока что доверять сложным инструментам нельзя. Мануалы на официальном сайте не сходятся с тем что видишь у себя в консоли вывода логов, мало того, что оно как есть не работает, так документация еще и путает, показывает настройки, которых просто нет в плагине последней версии, на которые логи трассировки указывают как на устаревшие и так далее. Как говорится, используй на свой страх и риск.
Код на этот успешный вариант тут.