Java Native Image: проверка возможности использования +18




Не так давно Oracle выпустил первый релиз проекта GraalVM (https://www.graalvm.org/). Релизу был сразу присвоен номер 19.0.0, видимо, для того чтобы убедить, что проект зрелый и готов к использованию в серьезных приложениях. Одна из частей этого проекта: Substrate VM — фреймворк, который позволяет превращать Java-приложения в нативные исполняемые файлы (а также нативные библиотеки, которые можно подключать в приложениях, написанных, например, на С/С++). Эта возможность пока объявлена экспериментальной. Также стоит отметить, что у нативных приложений на Java есть некоторые ограничения: необходимо перечислять все используемые ресурсы, чтобы включить их в нативную программу; нужно перечислить все классы, которые будут использоваться с помощью reflection и другие ограничения. Полный список указан тут Native Image Java Limitations. Изучив этот список, в принципе понятно, что ограничения не такие значительные, чтобы нельзя было разработать более сложные приложения, чем хелловорлды. Мною была поставлена такая цель: разработка небольшой программы, которая имеет встроенный web-сервер, использует базу данных (через ORM библиотеку) и компилируется в нативный бинарник, который может запускаться на системах без установленной Java машины.

Экспериментировать я буду на Ubuntu 19.04 (Intel Core i3-6100 CPU @ 3.70GHz ? 4).

Установка GraalVM


Установку GraalVM удобно производить с помощью SDKMAN. Команда установки GraalVM:

sdk install java 19.0.0-grl

Выполнится установка OpenJDK GraalVM CE 19.0.0, CE — это Community Edition. Есть еще Enterprise Edition (EE), но эту редакцию нужно скачивать с Oracle Technology Network, ссылка находятся на странице GraalVM Downloads.

После установки GraalVM, уже с помощью менеджера обновления компонентов gu из GraalVM, я установил поддержку компиляции в нативный бинарник —

gu install native-image

Все, рабочие инструменты готовы, теперь можно начать разрабатывать приложения.

Простое нативное приложение


В качестве системы сборки я использую Maven. Для создания нативных бинарников есть maven plugin:

native-image-maven-plugin
<build>
    <plugins>
        <plugin>
            <groupId>com.oracle.substratevm</groupId>
            <artifactId>native-image-maven-plugin</artifactId>
            <version>${graal.version}</version>
            <executions>
                <execution>
                    <goals>
                        <goal>native-image</goal>
                    </goals>
                    <phase>package</phase>
                </execution>
            </executions>
            <configuration>
                <imageName>nativej</imageName>
                <buildArgs>
                    --no-server
                </buildArgs>
            </configuration>
        </plugin>
    </plugins>
</build>


Еще требуется задать main класс приложения. Это можно сделать как в native-image-maven-plugin, так и традиционным способом, через:

maven-jar-plugin
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <version>2.4</version>
    <configuration>
        <archive>
            <manifest>
                <mainClass>nativej.Startup</mainClass>
            </manifest>
        </archive>
    </configuration>
</plugin>


Создадим main класс:

Startup.java
public class Startup {
    public static void main(String[] args) {
        System.out.println("Hello world!");
    }
}


Теперь можно выполнить команду maven для сборки приложения:

mvn clean package

Сборка нативного бинарника на моей машине занимает 35 секунд. В результате в каталоге target получается бинарный файл размером 2.5 MB. Программа не требует установленной Java машины и запускается на машинах, где отсутствует Java.

Ссылка на репозиторий: Github: native-java-helloworld-demo.

JDBC драйвер Postgres


И так, простое приложение работает, выводит «Hello world». Решения каких-то проблем не потребовалось. Попробую перейти на уровень выше: подключу драйвер JDBC Postgres для запроса данных из базы данных. В Issues на гитхабе GraalVM попадаются баги, связанные с драйвером Postgres, но на релиз-кандидаты GraalVM. Все они отмечены как исправленные.

Подключаю зависимость postgresql:

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.2.5</version>
</dependency>

Пишу код извлечения данных из базы данных (была создана простейшая табличка users):

Startup.java
public class Startup {
    public static void main(String[] args) SQLException {
        final PGSimpleDataSource ds = new PGSimpleDataSource();
        ds.setUrl("jdbc:postgresql://localhost/demo_nativem");
        ds.setUser("test");
        ds.setPassword("test");

        try (
                Connection conn = ds.getConnection();
                Statement stmt = conn.createStatement();
                ResultSet rs = stmt.executeQuery("SELECT * FROM \"public\".\"user\"");
        ) {
            while(rs.next()){
                System.out.print("ID: " + rs.getLong("id"));
                System.out.println(", Name: " + rs.getString("name"));
            }
        }
    }
}


Собираю нативный бинарник и сразу получаю ошибку сборки:

Error: No instances are allowed in the image heap for a class that is initialized or reinitialized at image runtime: org.postgresql.Driver. Try marking this class for build-time initialization with --initialize-at-build-time=org.postgresql.Driver


Дело в том, что сборщик нативного приложения инициализирует все static поля в процессе сборки (если не указано иначе), причем он делает это, исследуя зависимости классов. Мой код не ссылается на org.postgresql.Driver, поэтому сборщик не знает как его лучше инициализировать (при сборке, либо при старте приложения) и предлагает зарегистрировать его для инициализации при сборке. Это можно сделать, добавив его в аргументы maven плагина native-image-maven-plugin, как указано в описании ошибки. После добавления Driver получаю еще такую же ошибку, связанную с org.postgresql.util.SharedTimer. Снова собираю и сталкиваюсь с такой ошибкой сборки:

Error: Class initialization failed: org.postgresql.sspi.SSPIClient


Тут уже нет рекомендаций по исправлению. Но, посмотрев на исходник класса, понятно, что он относится к выполнению кода под Windows. На Linux его инициализация (которая происходит при сборке) падает с ошибкой. Есть возможность отложить его инициализацию на старт приложения: --initialize-at-run-time=org.postgresql.sspi.SSPIClient. Инициализация на Linux не будет происходить и ошибок, связанных с этим классом мы больше не получим. Аргументы сборки:

<buildArgs>
    --no-server
    --no-fallback
    --initialize-at-build-time=org.postgresql.Driver
    --initialize-at-build-time=org.postgresql.util.SharedTimer
    --initialize-at-run-time=org.postgresql.sspi.SSPIClient
</buildArgs>

Сборка стала занимать уже 1 минуту 20 секунд и файл распух до 11 MB. Я добавил дополнительный флаг для сборки бинарника: --no-fallback запрещает генерить нативный бинарник, который требует установленной Java машины. Такой бинарник создается, если сборщик обнаруживает использование фич языка, которые либо не поддерживаются в Substrate VM, либо требуют настройки, но настройка пока отсутствует. В моем случае сборщик обнаружил потенциальное использование рефлекшена в драйвере JDBC. Но это только потенциальное использование, в моей программе оно не требуется и поэтому и не требуется дополнительная настройка (как ее делать будет показано дальше). Существует еще флаг --static, который заставляет генератор статически линковать libc. Но если его использовать, то программа падает с segmentation fault при попытке разрешить сетевое имя в IP адрес. Я поискал какие-либо решения этой проблемы, но не нашел ничего подходящего, поэтому оставил зависимость программы на libc.

Запускаю получившийся бинарник и получаю следующую ошибку:

Exception in thread "main" org.postgresql.util.PSQLException: Could not find a java cryptographic algorithm: TLS SSLContext not available.


После некоторых исследований была выявлена причина ошибки: Postgres по умолчанию устанавливает TLS соединение с использованием Elliptic Curve. В SubstrateVM не входит реализация таких алгоритмов для TLS, вот соответствующий открытый issue — Single-binary ECC (ECDSA/ECDHE) TLS support for SubstrateVM. Вариантов решения несколько: положить рядом с приложением библиотеку из поставки GraalVM: libsunec.so, на сервере Postgres настроить список алгоритмов, исключив Elliptic Curve алгоритмы или просто отключить установку TLS соединения в драйвере Postgres (этот вариант и был выбран):

dataSource.setSslMode(SslMode.DISABLE.value);

Устранив ошибку создания соединения с Postgres, запускаю нативное приложение, оно выполняется и выводит данные из базы данных.

Ссылка на репозиторий: Github: native-java-postgres-demo.

DI framework и встроенный web-сервер


При разработке сложного приложения на Java обычно используют какой-нибудь framework, например, Spring Boot. Но судя по этой статье GraalVM native image support, работу Spring Boot в native image «из коробки» нам обещают только в версии Spring Boot 5.3.

Но есть замечательный framework Micronaut, в котором заявлена работа в GraalVM native image. В целом подключение Micronaut к приложению, которое будет собираться в бинарник, не требует каких-то специальных настроек и решения проблем. Действительно, многие настройки использования рефлекшена и подключения ресурсов для Substrate VM уже сделаны внутри Micronaut. Кстати, такие же настройки можно разместить и внутри своего приложения в файле настроек META-INF/native-image/${groupId}/${artifactId}/native-image.properties (такой путь для файла настроек рекомендует Substrate VM), вот типичное содержание файла:

native-image.properties
Args =   -H:+ReportUnsupportedElementsAtRuntime   -H:ResourceConfigurationResources=${.}/resource-config.json   -H:ReflectionConfigurationResources=${.}/reflect-config.json   -H:DynamicProxyConfigurationResources=${.}/proxy-config.json   --initialize-at-build-time=org.postgresql.Driver   --initialize-at-build-time=org.postgresql.util.SharedTimer   --initialize-at-run-time=org.postgresql.sspi.SSPIClient


Файлы resource-config.json, reflect-config.json, proxy-config.json содержат настройки подключения ресурсов, рефлекшена и использованных прокси (Proxy.newProxyInstance). Эти файлы можно создать вручную или получить, используя agentlib:native-image-agent. В случае использования native-image-agent нужно запустить обычный jar (а не нативный бинарник) с использованием агента:


java -agentlib:native-image-agent=config-output-dir=output -jar my.jar

где output — каталог, в котором разместятся файлы, описанные выше. При этом программу нужно не просто запустить, но и исполнить сценарии в программе, потому что в файлы записываются настройки по мере использования рефлекшена, открытия ресурсов, создания прокси. Эти файлы можно поместить META-INF/native-image/${groupId}/${artifactId} и сослаться на них в native-image.properties.

Я решил подключить логирование с помощью logback: добавил зависимость на библиотеку logback-classic и файл logback.xml. После этого собрал обычный jar и запустил его с использованием native-image-agent. При завершении программы нужные файлы настроек. Если посмотреть их содержимое, можно увидеть, что агент зарегистрировал использование logback.xml, чтобы вкомпилить в бинарник. Также, в файл reflect-config.json попали все случаи использования рефлекшена: для заданных классов в бинарник попадет мета-информация.

Затем я добавил зависимость на библиотеку micronaut-http-server-netty для использования встроенного web-сервера на основе netty и создал контроллер:

Startup.java
@Controller("/hello")
public class HelloController {
    @Get("/{name}")
    @Produces(MediaType.TEXT_PLAIN)
    public HttpResponse<String> hello(String name) {
        return HttpResponse.ok("Hello " + name);
    }
}


И main class:

HelloController.java
public class Startup {
    public static void main(String[] args) {
        Signal.handle(new Signal("INT"), sig -> System.exit(0));

        Micronaut.run(Startup.class, args);
    }
}


Теперь можно попробовать собрать нативный бинарник. У меня сборка заняла 4 минуты. Если его запустить и перейти по адресу http://localhost:8080/hello/user то выпадает ошибка:

{"_links":{"self":{"href":"/hello/user","templated":false}},"message":"More than 1 route matched the incoming request. The following routes matched /hello/user: GET - /hello/user, GET - /hello/user"}

Честно говоря не совсем ясно, почему так происходит, но после исследования методом тыка я обнаружил, что ошибка исчезает, если из файла resource-config.json (который был создан агентом) убрать следующие строки:

    {"pattern":"META-INF/services/com.fasterxml.jackson.databind.Module"}, 
    {"pattern":"META-INF/services/io.micronaut.context.env.PropertySourceLoader"}, 
    {"pattern":"META-INF/services/io.micronaut.http.HttpResponseFactory"}, 
    {"pattern":"META-INF/services/io.micronaut.inject.BeanConfiguration"}, 
    {"pattern":"META-INF/services/io.micronaut.inject.BeanDefinitionReference"}, 

Эти ресурсы регистрирует Micronaut и похоже, что повторная регистрация приводит к двойной регистрации моего контроллера и ошибке. Если после исправления файла пересобрать бинарник и запустить, то ошибок уже не будет, по адресу http://localhost:8080/hello/user выведется текст «Hello user».

Хочу обратить внимание на использование в main классе следующей строчки:

Signal.handle(new Signal("INT"), sig -> System.exit(0));

Ее нужно вставлять для корректного завершения Micronaut. Несмотря на то, что Micronaut вешает хук на завершение работы, он не срабатывает в нативном бинарнике. Есть соответствующий issue: Shutdownhook not firing with native. Он отмечен как исправленный, но, по факту, в нем только обходное решение с использованием класса Signal.

Ссылка на репозиторий: Github: native-java-postgres-micronaut-demo.

Подключение ORM


JDBC это хорошо, но утомляет повторяющимся кодом, бесконечными SELECT и UPDATE. Попробую облегчить (или усложнить, смотря с какой стороны смотреть) себе жизнь, подключив какой-нибудь ORM.

Hibernate


Сначала я решил попробовать Hibernate, так как он один из самых распространенных ORM для Java. Но у меня не получилось собрать native image с использованием Hibernate из-за ошибки сборки:

Error: Field java.lang.reflect.Method.defaultValue is not present on type java.lang.reflect.Constructor. Error encountered while analysing java.lang.reflect.Method.getDefaultValue() 
Parsing context:
	parsing org.hibernate.annotations.common.annotationfactory.AnnotationProxy.getAnnotationValues(AnnotationProxy.java:63)
	parsing org.hibernate.annotations.common.annotationfactory.AnnotationProxy(AnnotationProxy.java:52)
...

Есть соответствующий открытый issue: [native-image] Micronaut + Hibernate results in Error encountered while analysing java.lang.reflect.Method.getDefaultValue().

jOOQ


Дальше я решил попробовать jOOQ. Мне удалось собрать нативный бинарник, правда при этом пришлось сделать много настроек: указание, какие классы, когда инициализировать (buildtime, runtime), возиться с рефлекшеном. В итоге все уперлось в то, что при запуске приложения jOOQ инициализирует прокси org.jooq.impl.ParserImpl$Ignore как статический член класса org.jooq.impl.Tools. А данный прокси использует MethodHandle, которые Substrate VM пока не поддерживает. Вот похожий открытый issue: [native-image] Micronaut + Kafka fails to build native image with MethodHandle argument could not be reduced to at most a single call.

Apache Cayenne


Apache Cayenne менее распространен, но выглядит достаточно функциональным. Попробую его подключить. Я создал XML файлы описания схемы базы данных, их можно создать как вручную, так и с помощью CayenneModeler GUI tool, либо на основе уже существующей базы данных. С помощью cayenne-maven-plugin в pom файле будет осуществляться кодогенерация классов, которые соответствуют таблицам базы данных:

cayenne-maven-plugin
<plugin>
    <groupId>org.apache.cayenne.plugins</groupId>
    <artifactId>cayenne-maven-plugin</artifactId>
    <version>${cayenne.version}</version>
    <configuration>
        <map>src/main/resources/db/datamap.map.xml</map>
        <destDir>${project.build.directory}/generated-sources/cayenne</destDir>
    </configuration>
    <executions>
        <execution>
            <goals>
                <goal>cgen</goal>
            </goals>
        </execution>
    </executions>
</plugin>


Затем я добавил класс CayenneRuntimeFactory для инициализации фабрики контекстов работы с БД:

CayenneRuntimeFactory.java
@Factory
public class CayenneRuntimeFactory {

    private final DataSource dataSource;

    public CayenneRuntimeFactory(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Bean
    @Singleton
    public ServerRuntime cayenneRuntime() {
        return ServerRuntime.builder()
                .dataSource(dataSource)
                .addConfig("db/cayenne-test.xml")
                .build();
    }
}


Контроллер HelloController:

HelloController.java
@Controller("/hello")
public class HelloController {

    private final ServerRuntime cayenneRuntime;

    public HelloController(ServerRuntime cayenneRuntime) {
        this.cayenneRuntime = cayenneRuntime;
    }

    @Get("/{name}")
    @Produces(MediaType.TEXT_PLAIN)
    public HttpResponse<String> hello(String name) {
        final ObjectContext context = cayenneRuntime.newContext();

        final List<User> result = ObjectSelect.query(User.class).select(context);
        if (result.size() > 0) {
            result.get(0).setName(name);
        }

        context.commitChanges();

        return HttpResponse.ok(result.stream()
                .map(x -> MessageFormat.format("{0}.{1}", x.getObjectId(), x.getName()))
                .collect(Collectors.joining(",")));
    }
}


Потом запустил программу как обычный jar, с использованием агента agentlib:native-image-agent, для сбора информации об использованных ресурсах и рефлекшене.

Собрал нативный бинарник, запускаю, перехожу по адресу http://localhost:8080/hello/user и получаю ошибку:

{"message":"Internal Server Error: Provider com.sun.org.apache.xerces.internal.jaxp.SAXParserFactoryImpl not found"}

оказывается agentlib:native-image-agent не обнаружил использования этого класса в рефлекшене.

Вручную добавил его в файл reflect-config.json:

{
  "name":"com.sun.org.apache.xerces.internal.jaxp.SAXParserFactoryImpl",
  "allDeclaredConstructors":true
}

Снова собираю бинарник, запускаю, обновляю web-страничку и получаю другую ошибку:

Caused by: java.util.MissingResourceException: Resource bundle not found org.apache.cayenne.cayenne-strings. Register the resource bundle using the option -H:IncludeResourceBundles=org.apache.cayenne.cayenne-strings.

Тут все понятно, добавляю настройку, как указано в предлагаемом решении. Опять собираю бинарник (это 5 минут времени), снова запускаю и снова ошибка, другая:

No DataMap found, can't route query org.apache.cayenne.query.SelectQuery@2af96966[root=class name.voyachek.demos.nativemcp.db.User,name=]"}

С этой ошибкой пришлось повозиться, после многочисленных тестов, изучения исходников стало понятно что причина ошибки кроется в этой строчке из класса org.apache.cayenne.resource.URLResource:

return new URLResource(new URL(url, relativePath));

Как оказалось, Substrate VM загружает ресурс по url, который указывается в качестве базового, а не по url, который должен формироваться на основе базового и relativePath. О чем был мною зарегистрирован следующий issue: Invalid resource content when using new URL(URL context, String spec).

Ошибка определена, теперь нужно искать варианты обхода. К счастью Apache Cayenne оказался достаточно настраиваемой штукой. Нужно было зарегистрировать свой собственный загрузчик ресурсов:

ServerRuntime.builder()
                .dataSource(dataSource)
                .addConfig("db/cayenne-test.xml")
                .addModule(binder -> {
                    binder.bind(ResourceLocator.class).to(ClassLoaderResourceLocatorFix.class);
                    binder.bind(Key.get(ResourceLocator.class, Constants.SERVER_RESOURCE_LOCATOR)).to(ClassLoaderResourceLocatorFix.class);
                })
                .build();

Вот его код:

ClassLoaderResourceLocatorFix.java
public class ClassLoaderResourceLocatorFix implements ResourceLocator {

    private ClassLoaderManager classLoaderManager;

    public ClassLoaderResourceLocatorFix(@Inject ClassLoaderManager classLoaderManager) {
        this.classLoaderManager = classLoaderManager;
    }

    @Override
    public Collection<Resource> findResources(String name) {
        final Collection<Resource> resources = new ArrayList<>(3);

        final Enumeration<URL> urls;
        try {
            urls = classLoaderManager.getClassLoader(name).getResources(name);
        } catch (IOException e) {
            throw new ConfigurationException("Error getting resources for ");
        }

        while (urls.hasMoreElements()) {
            resources.add(new URLResourceFix(urls.nextElement()));
        }

        return resources;
    }

    private class URLResourceFix extends URLResource {

        URLResourceFix(URL url) {
            super(url);
        }

        @Override
        public Resource getRelativeResource(String relativePath) {
            try {
                String url = getURL().toString();
                url = url.substring(0, url.lastIndexOf("/") + 1) + relativePath;

                return new URLResource(new URI(url).toURL());
            } catch (MalformedURLException | URISyntaxException e) {
                throw new CayenneRuntimeException(
                        "Error creating relative resource '%s' : '%s'",
                        e,
                        getURL(),
                        relativePath);
            }
        }
    }
}


В нем строчка

return new URLResource(new URL(url, relativePath));

заменена на:

String url = getURL().toString();
url = url.substring(0, url.lastIndexOf("/") + 1) + relativePath;
return new URLResource(new URI(url).toURL());

Собираю бинарник (70 МБ), запускаю, перехожу на http://localhost:8080/hello/user и все работает, на страницу выводятся данные из базы данных.

Ссылка на репозиторий: Github: native-micronaut-cayenne-demo.

Выводы


Поставленная цель достигнута: разработано простое web приложение с доступом к базе данных с помощью ORM. Приложение собрано в нативный бинарник и может запускаться на системах без установленной Java машины. Несмотря на многочисленные проблемы, мною было найдена комбинация фреймворков, настроек и обходных путей, которая позволила получить работающую программу.

Да, возможность собирать обычные бинарники из исходного кода Java пока еще находится в экспериментальном статусе. Это видно по обилию проблем, необходимости искать обходные пути. Но в итоге все равно получилось добиться желаемого результата. Что же я получил?

  • Единственный самодостаточный файл (почти, есть зависимости на библиотеки такие как libc), способный запускаться на системах без Java машины.
  • Время старта в среднем 40 миллисекунд против 2 секунд при запуске обычного jar.

Из недостатков хочется отметить большое время компиляции нативного бинарника. У меня оно занимает в среднем пять минут, и скорее всего, будет увеличиваться при написании кода и подключении библиотек. Поэтому создавать бинарники имеет смысл уже на основе полностью отлаженного кода. К тому же отладочная информация для нативных бинарников доступна только в коммерческой редакции Graal VM — Enterprise Edition.




К сожалению, не доступен сервер mySQL