Всем привет! В этой статье мы расскажем о том, как технология GraalVM Native Image помогла нам решить ряд задач в одном из наших новых продуктов, написанном на Java, расскажем о проблемах, с которыми столкнулись в ходе применения этой технологии, и о том, как эти проблемы решали.

1С:Исполнитель – наш новый продукт, предназначенный в первую очередь для администрирования информационных систем на платформе 1С:Предприятие. Содержит кроссплатформенный язык сценариев (работает в Linux и Windows, в планах – поддержка macOS), библиотеку времени исполнения и среду разработки и отладки (можно работать в IDE на базе Eclipse и в Visual Studio Code).
Продукт написан на Java.
Под катом – рассказ о специфике самого продукта 1С:Исполнитель. Кому интересно именно про Graal – могут смело переходить к следующей секции.
В ходе использования 1С:Исполнителя и мы, и наши пользователи столкнулись с двумя проблемами:
Для улучшения ситуации мы решили сделать специальную поставку Исполнителя, не требующую установленной Java и представляющую собой нативное (исполняемое непосредственно в среде ОС) приложение. Чтобы пользователю было проще различать версии и реализации, решено было назвать исходный Исполнитель — Исполнитель-U (от слова Universal — универсальный), а его нативный образ — Исполнитель-X (eXecutable). О том, как мы создавали Исполнитель-X, и пойдёт речь ниже. А для его создания мы решили использовать технологию GraalVM Native Image.
GraalVM Native Image — это технология, которая позволяет скомпилировать Java (и не только Java) приложение в нативный образ, то есть AOT компиляция из Java идет сразу в машинный код. Также можно компилировать в shared library или в статически связанный образ. Это может помочь сократить время запуска и уменьшить объем используемой памяти, так как не нужно будет держать мета информацию о классах. С технологией можно ознакомиться на сайте Грааля, подробный мануал. В этом же блоке затронем значимые вещи для использования в проектах.
У Native Image есть ряд ограничений, перечислим основные.
Ограничения, которые можно обойти конфигурированием:
Однако есть и жесткие ограничения (jar-файлы, например, вообще нельзя подгружать в рантайме), почитать об этом можно тут.
Основная вещь, которая работает по-другому в нативном образе в сравнении с привычным Java-миром — это, пожалуй, инициализация классов. В большинстве случаев инициализация классов происходит во время компиляции. Для нас данный факт, в частности, означает, что в статических переменных лучше не хранить значения, зависящие от конкретной машины или окружения (потому что с момента компиляции такие значение не поменяются). Кроме того, нельзя динамически подгружать библиотеки (из jar).
Давайте посмотрим, как мы создавали нативный образ Исполнителя (Исполнитель-Х). Если вы внимательно прочитали про ограничения выше и в мануале, то понимаете, что сходу собрать нативный образ большого приложения не получится. И у нас тоже была такая ситуация.
Вот как мы пошагово внедряли технологию в продукт.
В проекте нами используется система сборки Maven. Для того чтобы собрать приложение в нативный образ есть специальный плагин. Поэтому первым делом нужно подключить native-image-maven-plugin. Прошу обратить внимание, что в нем есть аргументы сборки, мы будем ими активно пользоваться. Ведь с их помощью можно конфигурировать процесс компиляции вашего образа и дальнейшие действия с ним.
Аргумент --no-fallback сообщает компилятору, что образ надо собирать такой, чтобы он работал без JVM (как раз то, что нам нужно). --allow-incomplete-classpath в свою очередь разрешает сборку даже если компилятор не может найти некоторые классы (включить их в образ). В нашем случае, если мы отключали эту опцию, то получали ошибку компиляции из-за попыток сослаться на классы, которые в 1С:Исполнителе даже не используются. Нужно помнить, что если во время сборки эти классы были недоступны, то и во время исполнения они доступными не будут, поэтому при попытках обратиться по их classpath будет выброшено исключение.
Так мы принялись впервые собирать и тестировать нативный образ Исполнителя. Однако же после того как мы получили образ, были обнаружены следующие проблемы:
Так, например, работал только простой скрипт с выводом информации в консоль, потому что данный объект описывается не отдельно, а непосредственно в коде Исполнителя.
Проблема в том, что в библиотеках, которые задействованы в нашем проекте, используется reflection, динамические прокси и динамическая загрузка классов. Значит, нам нужно создать конфигурационные файлы, которые будут участвовать при сборке и сообщать компилятору как и где используется, например, reflection. Для обработки нужно выписать classpath и флаги в такой файл в нужном формате. Но для этого нужно знать, где у нас этот reflection используется. Учесть все случаи использования в нашем случае вручную нереально. И вообще довольно трудно по всему проекту искать reflection, не говоря уже про библиотеки, код которых мы не контролируем. Тут на помощь приходит native-image-agent. Это специальная утилита к GraalVM, которая поможет нам найти reflection, динамический прокси и т.д. во всём проекте. Как это работает? Вы запускаете ваше Java-приложение вместе с аргументом agentlib:native-image-agent. Во время исполнения утилита выписывает в нужном формате reflection, proxy в конфигурационные файлы, которые уже потом будут использоваться при сборке нативного образа. То есть на этом шаге ваша задача определить сценарии работы приложения и прогнать их с агентом, потому что просто глядя на код GraalVM не сможет разобраться с ограничениями.
Поскольку необходимо именно исполнять приложение для отработки сценариев, в нашем случае мы написали код, который запускает Исполнитель вместе с выбранными скриптами и с разными аргументами. Эти скрипты заранее описаны, код для них взят из тестов на объекты, где мы стараемся отрабатывать крайние случаи и уж точно вызываем все методы (что, в конечном счете, для этих прогонов и нужно). Эти танцы с бубном проводятся для того, чтобы получившийся в итоге образ работал правильно.
На самом деле, мы еще никак не решили проблему, связанную с сообщениями об ошибках, потому что сообщения у нас расположены по всему проекту и этот код может даже и не вызываться при прогонах скриптов. Чтобы пользователи могли получать сообщения на разных языках, нами используется собственная библиотека локализации. Сообщения должны быть описаны на двух языках: русском и английском. Внутри компании существует регламент по использованию этой библиотеки: текст на русском языке с помощью аннотаций описывается в интерфейсах с именем IMessageList, есть привычные бандлы ресурсов, в которых сообщения уже на английском описываются в формате <имя метода из интерфейса>=<сообщение>. Чтобы лучше понять вышенаписанное, можно ознакомится со структурой файлов и их содержимым ниже.
При запусках приложения с native-image-agent часть файлов для сообщений, конечно, попадет в конфигурационные файлы, но далеко не все. Потому что покрыть абсолютно все вызовы сообщений невозможно (ведь тестовые прогоны могут не задействовать классы специфических ошибок). То есть нам для решения проблемы с сообщениями уже не подходят прогоны с агентом.
Поэтому в данном случае мы использовали отдельное приложение, которое на вход принимает fat-jar Исполнителя, открывая его как обычный zip-файл, и находит классы для локализованных сообщений (содержат в имени IMessageList.class). После этого остается просто выписать classpath в нужном формате в файлы конфигурации для reflection и proxy. Далее эти файлы дополняются выводом из агента и на этой основе собирается нативный образ.
Самые внимательные могут спросить, почему поиск идет по имени, ведь это не столь надежно, а лучше бы искать по аннотации. Да, можно, однако потребовалось бы больше времени на разбор всех файлов во всех jar. В общем, пока нам хватит первого приближения для решения этой задачи.

После этих действий мы получили относительно нормально работающий нативный образ Исполнителя.
Однако перед нами возникла следующая проблема: логи из Исполнителя пишутся прямо в консоль (даже уровня debug), такого быть не должно. Более того файлы для логов не создаются. То есть у нас проблемы в целом с логированием во всём проекте.
Почему может не работать логирование? Мы помним, что классы инициализируются, как правило, при построении нативного образа. А тем более при построении инициализируются статические поля классов. Для нативного образа статическое поле значит, что оно меняться во время использования не будет. Поэтому одна из возможных причин поломки логирования – это использование логгеров в статических полях классов. То есть мы открываем файлы в статическом коде и с этими файлами работаем.
Вообще иметь в статических полях классов машинозависимые значения не рекомендуется (потому что пользователь при использовании вашего приложения обнаружит, что с момента компиляции образа значения не изменились).
После всех проб мы решили вообще на время отключить логи в Исполнителе, а еще мы позволили себе инициализировать все классы для логирования в buildtime, что в теории даст нам еще больший прирост скорости запуска.
Тем временем, мы приближаемся к корректно работающему Исполнителю.
Но обнаруживается, что у нас есть нестыковки с кодировкой. В Linux кодировка вывода в консоль – UTF-8, здесь всё понятно и вопросов не вызывает. В Windows же за это отвечает код страницы (посмотреть его можно выполнив команду chcp). Код страницы для разных языков свой, например, 866 для кириллицы, 437 для латиницы. А в чём у нас проблема? При выводе на консоль кириллицы отображается либо какие-то кракозябры, либо знаки вопроса.
Простейший пример для воспроизведения: github.com/oracle/graal/issues/2492
Путем проб и ошибок было установлено, что в аргументы при сборке надо добавить следующее:
Добавили все кодировки и передали образу Java-аргумент на установку выбранной кодировки. Также прописать кодировку надо и при запуске самого образа. Если кодировки при сборке образа и при его запуске будет отличаться, то мы опять получим кракозябры.
Однако, что мы получаем для нативного образа Исполнителя в Windows, что у нас кодировка вывода всегда будет одна и та же, 866, и эта кодировка жестко прибита в образе? К сожалению, да, здесь уже как-то побороть или придумать другое решение мы не смогли. Если Вы его знаете, пожалуйста, напишите в комментариях. Если что, про chcp 65001 (UTF-8 в windows консоли) мы в курсе, попробовав собрать образ, по��учили, что ввод из stdin, содержащий кириллицу, трансформируется в кракозябры.
Опять-таки после этого у нас получился нативный бинарник Исполнителя ещё ближе к тому, что задумывалось. Однако мы столкнулись с ещё одной проблемой, вернее, с особенностью технологии. GraalVM Native Image не поддерживает вообще получение каких-либо переменных из окружения. Значит, получить локаль просто так не получится.

Замечание: вообще, получить переменные можно, только если передать проперти аргументом в бинарник специальным образом.
Это ещё не всё, из комментариев к issues на гитхабе и вообще в целом из документации мы сделали вывод, что одновременно хранить в нативном образе один бандл ресурсов с разными локалями нельзя (а при компиляции выбирается только один). Что это значит? А то, что мы не можем выбрать язык, на котором выводить сообщение пользователю в рантайме. А хотелось бы хотя бы для справки (-h) иметь два варианта: на русском и английском. Нам пришлось держать 2 бандла ресурсов и уже определять в коде, на каком языке выводить сообщения.
Для выбора языка, кстати, в итоге ввели специальный параметр в CLI. Короче говоря, у GraalVM Native Image c локализацией какие-то временные трудности.
Итак, мы получили относительно корректно работающий нативный образ Исполнителя. Посмотрим на общий процесс сборки:
Получившийся образ работает быстро, а чтобы не быть голословными — перейдём к цифрам. Нативный образ можно собирать и на CI, потому что написан скрипт для прогонов и запуска сборки. Такой подход позволит держать образ актуальным и получать его сразу для Windows и для Linux (если завести два варианта машинок на CI).
Тестирование быстродействия велось на таком оборудовании:
На самом деле диск практически никак не влияет на результаты, в любом случае наша задача — сравнить скорость работы нативного образа Исполнителя и стандартного. Замечу лишь, что оборудование не мощное, такой выбор сделан специально
Исполнение простейшего скрипта («Hello world») для нативного образа Исполнителя занимает в разы меньше времени: 0,3с для нативного и 1,9с для стандартного. Надо заметить, что нативный образ вызывался ранее, но и обычная поставка также была вызвана несколько раз до этого (т.е. JVM ��же «прогрета»).

Рассмотрим скрипт посложнее; в этом разбираются большое количество JSON-ов и из них получаются объекты и наоборот (примерно 1000 строк), кроме того есть много сравнений строк. Первый запуск образа занимал 1,9 с, для стандартного же — 3с, последующие запуски нативного образа занимали 0.5 секунд, а в стандартном Исполнителе 2,8 с. Разница по ощущениям для пользователя довольно большая (особенно если работать в паттерне «поменял что-то — сразу запустил»).

Еще на языке Исполнителя был реализован алгоритм решета Эратосфена (без оптимизаций и т.п., так как нам нужно сравнить Исполнители).
Ниже представлены результаты в зависимости от разных границ, до которой считаем простые:
Для N = 10^7 видно, что нативный образ выигрывает (50с против 110с) у стандартной поставки. Однако для N = 10^8 время уже сравнимое (900c и 1100c) — значит, мы где-то близко к условной границе оптимальной применимости образа. Действительно, для N = 3 * 10^8 нативный образ исполняет скрипт с решетом за 4200с, когда обычный — за 3300 с.
Тут мы видим JIT-компиляцию во всей её красе. А еще то, что, SubstrateVM не рассчитан на работу с большим объемом памяти.

Суммарный вес образа Исполнителя стал 100мб, что на самом деле мало, потому что мы должны получить классы из стандартной поставки Java, кроме того мы должны включить в этот образ SubstrateVM и код Исполнителя и библиотек объектов (в обычном Исполнителе 40 Мб). Это отличный результат для вещи, которая работает изолированно.
Таким образом, мы выполнили те задачи, которые перед собой ставили:
Планы развития:
Далее перечислены библиотеки, которые в итоге заработали в нативном образе Исполнителя, с их версиями:
Даже зная про функционал, который работает по-другому в нативном образе, можно наткнуться на неожиданное поведение. Например, в нашем случае это произошло в коде для вывода сообщений из кода в консоль.
В первом мы подключили два бандла ресурсов с одинаковыми сообщениями, но на разном языке (почему мы так сделали — описано выше). Каждый раз, когда нам нужен определенный бандл, дергается метод getResourceBundle(), который уже выдает нам нужный файл с сообщениями.
Учитывая, что локаль в рантайме у нас не поменяется, не слишком рациональный код, не правда ли (ну хотя бы работает)? Что ж, перепишем!
Получим примерно такой код. Тут мы храним нужный нам бандл в не статическом и не константном поле класса. В конструкторе же определяем нужный бандл.
Однако во втором коде значение currentBundle никогда не меняется с момента компиляции, оставаясь одним из выбранных вариантов бандлов, который использовался во время сборки образа.

Про продукт
1С:Исполнитель – наш новый продукт, предназначенный в первую очередь для администрирования информационных систем на платформе 1С:Предприятие. Содержит кроссплатформенный язык сценариев (работает в Linux и Windows, в планах – поддержка macOS), библиотеку времени исполнения и среду разработки и отладки (можно работать в IDE на базе Eclipse и в Visual Studio Code).
Продукт написан на Java.
Под катом – рассказ о специфике самого продукта 1С:Исполнитель. Кому интересно именно про Graal – могут смело переходить к следующей секции.
Про 1С:Исполнитель
Чуть подробнее о проекте, образ которого мы хотим в конечном счете получить. Исполнитель — это интерпретатор для кроссплатформенного языка сценариев, и сам язык сценариев к нему. Подобно тому, как cmd.exe исполняет bat-скрипты или bash исполняет bash-скрипты, Исполнитель работает со своими скриптами. Если вы, например, решили внедрить практику CI в разработку приложения на базе платформы 1С:Предприятие, то использование Исполнителя будет подходящим выбором, потому что Исполнитель содержит необходимый функционал для такой работы, а также позволяет избавить администратора от ограничений платформо-зависимых решений в пользу фокусировки на самой задаче. Кроме того, язык содержит объекты для работы с кластером серверов 1С и базами данных 1С:Предприятия, использование этих объектов может сильно облегчить администрирование платформы, еще в Исполнителе есть встроенные объекты для полноценной работы со системой взаимодействия. Посмотрим на примере, как Исполнитель может управлять кластером:
Еще один пример простого скрипта на языке Исполнителя. Допустим, задача такая: получить ipconfig, сохранить его в файл, добавить в архив и отправить архив по почте (логин-пароль для smtp считать из xml). Для этого нужно использовать соответствующие объекты языка. Код будет выглядеть примерно вот так:
Запускать из консоли можно вот так:

Все эти сущности, которые отправляют email, читают xml и т.п. называются объектами языка (или просто объектами).
Стандартная поставка Исполнителя включает в себя скрипт с командами запуска (executor.cmd) и набор jar-ников, в которых содержатся используемые библиотеки, а также код описания и работы объектов:

Мы сами, в частности, активно используем 1С:Исполнитель для задач администрирования и автоматизации в наших высоконагруженных облачных сервисах 1cFresh и 1С:Готовое Рабочее Место (ГРМ).
const Address = "127.0.0.1" const Port = 1545 const AgentAdminName = "AdminOfThisExample" const AgentAdminPassword = "123456" const ClusterAdminName = "ClusterAdmin" const ClusterAdminPassword = "654321" const Dbms = "PostgreSQL" const DatabaseServer = "127.0.0.1" const DatabaseAdminName = "postgres" const DatabaseAdminPassword = "" /* 1. Поднимаем кластер и ждем пока запустится процесс, выполняя проверку 2. Поднимаем дополнительный рабочий сервер в новом кластере 3. Поднимаем информационную базу в новом кластере (с созданием БД) */ method Script() try use Agent = new AdministrationServer(Address, Port) Agent.Authenticate(AgentAdminName, AgentAdminPassword) // Поднимаем кластер с указанными параметрами: // Name: ClusterName01 // ИмяКомпьютера: 127.0.0.1 // Порт: 10500 var ClusterId = AddCluster(Agent, "ClusterName01", "127.0.0.1", 10501) var Cluster = Agent.GetCluster(ClusterId) Cluster.Authenticate(ClusterAdminName, ClusterAdminPassword) // Cluster поднимается долго, для поднятия инфобазы нужны рабочие процессы while (Cluster.GetWorkProcesses().Empty()) // ждем ; // Поднимаем рабочий сервер в кластере с UUID = ClusterId с указанными параметрами: // Компьютер: 10.70.4.50 // Порт: 20541 var WorkingServerId = AddWorkingServer(Cluster, "10.70.4.50", 20541) // Поднимаем инфобазу на кластере var InfoBaseId = AddInfoBase(Cluster, "TestInfobaseName", "TestInfobaseDBMSName") var InfoBase = Cluster.GetInfoBase(InfoBaseId) var InfoBaseConnections = InfoBase.GetConnections() var Connections = Cluster.GetConnections() for Connection in Connections if (Connection.ApplicationName == "RAS") Connection.Disconnect() ; ; catch E: AdministrationClusterException fail("Error: " + E.Description) ; ; method AddCluster(Agent: AdministrationServer, Name: String, ComputerName: String, ClusterPort: Number): UUID var Cluster = Agent.CreateCluster() Cluster.Name = Name Cluster.ComputerName = ComputerName Cluster.ProcessRestartPeriod = 3600 Cluster.Port = ClusterPort Cluster.LoadBalancingMode = AdministrationProcessChoicePriority.ByMemory Cluster.ConnectionSecurityLevel = AdministrationConnectionSecurityLevel.Unsecure return Cluster.Write() ; method AddInfoBase(Cluster: AdministrationCluster, InfobaseName: String, DbName: String): UUID var NewIB = Cluster.CreateInfoBase() NewIB.LockScheduledJobs = false NewIB.SessionsLockEnabled = false NewIB.Name = InfobaseName NewIB.DataBaseName = DbName NewIB.SessionStartPermissionCode = "Session start permission code" NewIB.Dbms = Dbms NewIB.Locale = "ru" NewIB.ExternalManagementRequired = false NewIB.Description = "Infobase with name <" + InfobaseName + ">" NewIB.LockParameter = "Lock params" NewIB.DatabaseServer = DatabaseServer NewIB.DatabaseUser = DatabaseAdminName NewIB.DatabaseUserPassword = DatabaseAdminPassword NewIB.DateOffset = 0 NewIB.CreateDatabase = true NewIB.LockMessage = "Lock message" NewIB.ExternalSessionManagementConnectionString = "This is a string of parameters" return NewIB.Write() ; method AddWorkingServer(Cluster: AdministrationCluster, WorkingServerAddress: String, WorkingServerPort: Number): UUID var NewServer = Cluster.CreateWorkServer() NewServer.ComputerName = WorkingServerAddress NewServer.SingleProcessConnectionsNumber = 10 NewServer.CreateManagerForEachService = true NewServer.Port = WorkingServerPort NewServer.MainServer = false // Дальнейшая настройка параметров // ... return NewServer.Write() ;
Еще один пример простого скрипта на языке Исполнителя. Допустим, задача такая: получить ipconfig, сохранить его в файл, добавить в архив и отправить архив по почте (логин-пароль для smtp считать из xml). Для этого нужно использовать соответствующие объекты языка. Код будет выглядеть примерно вот так:
method GetIpConfig(): String Console.Write("Getting ip config") // Вернем результат работы ipconfig var Process = new OsProcess("cmd.exe", ["/c", "ipconfig"]) Process.Start() var Output = Process.GetOutputStream().ReadAsText("cp866") Console.Write("Process with ip config finished") return Output ; method Zip() var Zip = new ZipFile("example.zip", "my_secret_password") var FolderToZip = new File("C:\\executor-examples/folder_to_zip") // Запишем результат ipconfig в файл в папку на архивацию var IpConfigOutputFile = new File("ipconfig_output.txt", FolderToZip) if (not IpConfigOutputFile.Exists()) Files.Create(IpConfigOutputFile) ; use OutputStream = IpConfigOutputFile.OpenWritableStream() OutputStream.Write(GetIpConfig()) Console.Write("Ip config has written to file") // Добавим в архив папку Console.Write("Zipping folder") Zip.Add(FolderToZip.Path) // Запишем коллекцию из элементов архива for e in Zip.Entries() Console.Write("- Archive element: " + e.PathInArchive) ; var EmailMessage = new OutgoingEmailMessage("example.@1c.ru", "example2@1c.ru", "This is archive") EmailMessage.Text = "Just an example, dont care" EmailMessage.AttachFile("example.zip", "very_important_zf") var Auth = ReadAuthXml(new File("C:/prog/examples/scripts/auth.xml")) var Params = new SmtpConnectionParameters("smtp.gmail.com", 465, new EmailAuthentication(Auth.Get(0), Auth.Get(1))) Console.Write("Sending email") SmtpClient.Send(Params, EmailMessage) Console.Write("Email has been sent") Files.Delete("example.zip", True) ; method ReadAuthXml(XmFile: File): Array Console.Write("Reading xml file") var Xml = new XmlReader(XmFile.OpenReadableStream()) var User = "" var Password = "" while (Xml.Next()) if (Xml.Name == "user" and User == "") Xml.Next() User = Xml.Value ; if (Xml.Name == "password" and Password == "") Xml.Next() Password = Xml.Value ; ; Console.Write("Xml file has been read") return [User, Password] ;
Запускать из консоли можно вот так:

Все эти сущности, которые отправляют email, читают xml и т.п. называются объектами языка (или просто объектами).
Стандартная поставка Исполнителя включает в себя скрипт с командами запуска (executor.cmd) и набор jar-ников, в которых содержатся используемые библиотеки, а также код описания и работы объектов:

Мы сами, в частности, активно используем 1С:Исполнитель для задач администрирования и автоматизации в наших высоконагруженных облачных сервисах 1cFresh и 1С:Готовое Рабочее Место (ГРМ).
Постановка задачи
В ходе использования 1С:Исполнителя и мы, и наши пользователи столкнулись с двумя проблемами:
- Недостаточно быстрый запуск продукта из-за инициализации Java на старте.
- В ряде компаний ИТ-политики не разрешают установку Java, что делает применение 1С:Исполнителя невозможным.
Для улучшения ситуации мы решили сделать специальную поставку Исполнителя, не требующую установленной Java и представляющую собой нативное (исполняемое непосредственно в среде ОС) приложение. Чтобы пользователю было проще различать версии и реализации, решено было назвать исходный Исполнитель — Исполнитель-U (от слова Universal — универсальный), а его нативный образ — Исполнитель-X (eXecutable). О том, как мы создавали Исполнитель-X, и пойдёт речь ниже. А для его создания мы решили использовать технологию GraalVM Native Image.
Про технологию
GraalVM Native Image — это технология, которая позволяет скомпилировать Java (и не только Java) приложение в нативный образ, то есть AOT компиляция из Java идет сразу в машинный код. Также можно компилировать в shared library или в статически связанный образ. Это может помочь сократить время запуска и уменьшить объем используемой памяти, так как не нужно будет держать мета информацию о классах. С технологией можно ознакомиться на сайте Грааля, подробный мануал. В этом же блоке затронем значимые вещи для использования в проектах.
Ограничения технологии
У Native Image есть ряд ограничений, перечислим основные.
Ограничения, которые можно обойти конфигурированием:
- Динамическая загрузка классов.
- Рефлексия.
- Динамические прокси.
Однако есть и жесткие ограничения (jar-файлы, например, вообще нельзя подгружать в рантайме), почитать об этом можно тут.
Что работает по-другому
Основная вещь, которая работает по-другому в нативном образе в сравнении с привычным Java-миром — это, пожалуй, инициализация классов. В большинстве случаев инициализация классов происходит во время компиляции. Для нас данный факт, в частности, означает, что в статических переменных лучше не хранить значения, зависящие от конкретной машины или окружения (потому что с момента компиляции такие значение не поменяются). Кроме того, нельзя динамически подгружать библиотеки (из jar).
Native-Image Исполнителя
Давайте посмотрим, как мы создавали нативный образ Исполнителя (Исполнитель-Х). Если вы внимательно прочитали про ограничения выше и в мануале, то понимаете, что сходу собрать нативный образ большого приложения не получится. И у нас тоже была такая ситуация.
Вот как мы пошагово внедряли технологию в продукт.
В проекте нами используется система сборки Maven. Для того чтобы собрать приложение в нативный образ есть специальный плагин. Поэтому первым делом нужно подключить native-image-maven-plugin. Прошу обратить внимание, что в нем есть аргументы сборки, мы будем ими активно пользоваться. Ведь с их помощью можно конфигурировать процесс компиляции вашего образа и дальнейшие действия с ним.
Аргумент --no-fallback сообщает компилятору, что образ надо собирать такой, чтобы он работал без JVM (как раз то, что нам нужно). --allow-incomplete-classpath в свою очередь разрешает сборку даже если компилятор не может найти некоторые классы (включить их в образ). В нашем случае, если мы отключали эту опцию, то получали ошибку компиляции из-за попыток сослаться на классы, которые в 1С:Исполнителе даже не используются. Нужно помнить, что если во время сборки эти классы были недоступны, то и во время исполнения они доступными не будут, поэтому при попытках обратиться по их classpath будет выброшено исключение.
<plugin> <groupId>org.graalvm.nativeimage</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>executor-native-image</imageName> <!-- Имя бинарника на выходе --> <buildArgs> <!-- Аргументы сборки ---> <!-- В нашем случае заполним вот так:--> --no-fallback --allow-incomplete-classpath </buildArgs> <mainClass>com.e1c.g5rt.executor.boot.ExecutorBootstrap</mainClass> </configuration> </plugin>
Так мы принялись впервые собирать и тестировать нативный образ Исполнителя. Однако же после того как мы получили образ, были обнаружены следующие проблемы:
- Нет объектов языка Исполнителя. Это те самые объекты, которые мы видели в разделе «Про 1С:Исполнитель» — объекты отправки почты, File и так далее. Все они лежат отдельно в jar-никах, мы их подгружаем в коде при старте Исполнителя в рантайме.
- Не работает часть функциональности, которая должна обеспечивать саму работу Исполнителя (даже без этих объектов). Например, интерфейс командной строки. Так, задание пути до скрипта с портом для дебага ($executor -d <port> -s <script_path>), или получение версии ($executor -v) не работает. Сами аргументы не разбираются по заданному правилу в одной из библиотек.
- Не отображаются тексты ошибок компиляции скрипта. Да и в целом тексты ошибок по всему проекту не отображаются.
Так, например, работал только простой скрипт с выводом информации в консоль, потому что данный объект описывается не отдельно, а непосредственно в коде Исполнителя.
Проблема в том, что в библиотеках, которые задействованы в нашем проекте, используется reflection, динамические прокси и динамическая загрузка классов. Значит, нам нужно создать конфигурационные файлы, которые будут участвовать при сборке и сообщать компилятору как и где используется, например, reflection. Для обработки нужно выписать classpath и флаги в такой файл в нужном формате. Но для этого нужно знать, где у нас этот reflection используется. Учесть все случаи использования в нашем случае вручную нереально. И вообще довольно трудно по всему проекту искать reflection, не говоря уже про библиотеки, код которых мы не контролируем. Тут на помощь приходит native-image-agent. Это специальная утилита к GraalVM, которая поможет нам найти reflection, динамический прокси и т.д. во всём проекте. Как это работает? Вы запускаете ваше Java-приложение вместе с аргументом agentlib:native-image-agent. Во время исполнения утилита выписывает в нужном формате reflection, proxy в конфигурационные файлы, которые уже потом будут использоваться при сборке нативного образа. То есть на этом шаге ваша задача определить сценарии работы приложения и прогнать их с агентом, потому что просто глядя на код GraalVM не сможет разобраться с ограничениями.
$ java -agentlib:native-image-agent=config-merge-dir=<папка с конфиг файлами> -jar <jar-ник Исполнителя>.jar <аргументы запуска> $ ls <папка с конфиг файлами> jni-config.json proxy-config.json reflect-config.json resource-config.json
Поскольку необходимо именно исполнять приложение для отработки сценариев, в нашем случае мы написали код, который запускает Исполнитель вместе с выбранными скриптами и с разными аргументами. Эти скрипты заранее описаны, код для них взят из тестов на объекты, где мы стараемся отрабатывать крайние случаи и уж точно вызываем все методы (что, в конечном счете, для этих прогонов и нужно). Эти танцы с бубном проводятся для того, чтобы получившийся в итоге образ работал правильно.
На самом деле, мы еще никак не решили проблему, связанную с сообщениями об ошибках, потому что сообщения у нас расположены по всему проекту и этот код может даже и не вызываться при прогонах скриптов. Чтобы пользователи могли получать сообщения на разных языках, нами используется собственная библиотека локализации. Сообщения должны быть описаны на двух языках: русском и английском. Внутри компании существует регламент по использованию этой библиотеки: текст на русском языке с помощью аннотаций описывается в интерфейсах с именем IMessageList, есть привычные бандлы ресурсов, в которых сообщения уже на английском описываются в формате <имя метода из интерфейса>=<сообщение>. Чтобы лучше понять вышенаписанное, можно ознакомится со структурой файлов и их содержимым ниже.
Пример
Структура файлов:
Java файл выглядит так:
property файл тогда должен выглядеть вот так:
- java
- IMessageList.java
- resources
- IMessageList_en.propeties
Java файл выглядит так:
@Localizable public interface IMessageList { IMessageList Messages = LocalizableFactory.create(IMessageList.class); @RuString("Ошибка, и это ее сообщение.") String some_error(); }
property файл тогда должен выглядеть вот так:
some_error=Error, and this is a message.
При запусках приложения с native-image-agent часть файлов для сообщений, конечно, попадет в конфигурационные файлы, но далеко не все. Потому что покрыть абсолютно все вызовы сообщений невозможно (ведь тестовые прогоны могут не задействовать классы специфических ошибок). То есть нам для решения проблемы с сообщениями уже не подходят прогоны с агентом.
// Так выглядит описание одного интерфейса сообщений в reflect-config { "name":"com.e1c.g5rt.executor.client.IMessageList", "allPublicMethods":true }
// Так выглядит описание одного интерфейса сообщений в proxy-config ["com.e1c.g5rt.executor.client.IMessageList"]
Поэтому в данном случае мы использовали отдельное приложение, которое на вход принимает fat-jar Исполнителя, открывая его как обычный zip-файл, и находит классы для локализованных сообщений (содержат в имени IMessageList.class). После этого остается просто выписать classpath в нужном формате в файлы конфигурации для reflection и proxy. Далее эти файлы дополняются выводом из агента и на этой основе собирается нативный образ.
Самые внимательные могут спросить, почему поиск идет по имени, ведь это не столь надежно, а лучше бы искать по аннотации. Да, можно, однако потребовалось бы больше времени на разбор всех файлов во всех jar. В общем, пока нам хватит первого приближения для решения этой задачи.

После этих действий мы получили относительно нормально работающий нативный образ Исполнителя.
Однако перед нами возникла следующая проблема: логи из Исполнителя пишутся прямо в консоль (даже уровня debug), такого быть не должно. Более того файлы для логов не создаются. То есть у нас проблемы в целом с логированием во всём проекте.
Почему может не работать логирование? Мы помним, что классы инициализируются, как правило, при построении нативного образа. А тем более при построении инициализируются статические поля классов. Для нативного образа статическое поле значит, что оно меняться во время использования не будет. Поэтому одна из возможных причин поломки логирования – это использование логгеров в статических полях классов. То есть мы открываем файлы в статическом коде и с этими файлами работаем.
Вообще иметь в статических полях классов машинозависимые значения не рекомендуется (потому что пользователь при использовании вашего приложения обнаружит, что с момента компиляции образа значения не изменились).
После всех проб мы решили вообще на время отключить логи в Исполнителе, а еще мы позволили себе инициализировать все классы для логирования в buildtime, что в теории даст нам еще больший прирост скорости запуска.
<plugin> <groupId>org.graalvm.nativeimage</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>executor-native-image</imageName> <buildArgs> --no-server --no-fallback --allow-incomplete-classpath --report-unsupported-elements-at-runtime <!-- Отключаем логирование пустым конфиг файлом --> -J-Dlogback.configurationFile=${project.basedir}/config/logback-ni.xml <!-- Конфигурационные файлы для native-image, сформированные niconfiger-ом и agent-ом--> -H:ConfigurationFileDirectories=${project.basedir}/src/main/resources/META-INF/native-image/ ... ... ... <!-- Загрузка классов логгирования --> --initialize-at-build-time=org.slf4j.LoggerFactory --initialize-at-build-time=org.slf4j.impl.StaticLoggerBinder --initialize-at-build-time=org.apache.log4j.Logger --initialize-at-build-time=org.apache.log4j.Category --initialize-at-build-time=org.slf4j.MDC <!-- Почти весь logback должен инициализируется во время сборки--> --initialize-at-build-time=ch.qos.logback.classic.joran.action.ConsolePluginAction --initialize-at-build-time=ch.qos.logback.core.util.Loader --initialize-at-build-time=ch.qos.logback.classic.Level --initialize-at-build-time=ch.qos.logback.core.status.InfoStatus --initialize-at-build-time=ch.qos.logback.classic.spi.ThrowableProxy --initialize-at-build-time=ch.qos.logback.core.util.StatusPrinter --initialize-at-build-time=ch.qos.logback.core.util.Duration --initialize-at-build-time=ch.qos.logback.core.status.WarnStatus --initialize-at-build-time=ch.qos.logback.core.status.StatusBase --initialize-at-build-time=ch.qos.logback.classic.Logger </buildArgs> <mainClass>com.e1c.g5rt.executor.niboot.NativeImageExecutorBootstrap</mainClass> </configuration> </plugin>
Тем временем, мы приближаемся к корректно работающему Исполнителю.
Но обнаруживается, что у нас есть нестыковки с кодировкой. В Linux кодировка вывода в консоль – UTF-8, здесь всё понятно и вопросов не вызывает. В Windows же за это отвечает код страницы (посмотреть его можно выполнив команду chcp). Код страницы для разных языков свой, например, 866 для кириллицы, 437 для латиницы. А в чём у нас проблема? При выводе на консоль кириллицы отображается либо какие-то кракозябры, либо знаки вопроса.
Простейший пример для воспроизведения: github.com/oracle/graal/issues/2492
Путем проб и ошибок было установлено, что в аргументы при сборке надо добавить следующее:
<!-- Исправляем вывод кириллицы в консоль (в runtime должны тоже подать кодировку) --> -H:-AddAllCharsets -J-Dfile.encoding=cp866
Добавили все кодировки и передали образу Java-аргумент на установку выбранной кодировки. Также прописать кодировку надо и при запуске самого образа. Если кодировки при сборке образа и при его запуске будет отличаться, то мы опять получим кракозябры.
Однако, что мы получаем для нативного образа Исполнителя в Windows, что у нас кодировка вывода всегда будет одна и та же, 866, и эта кодировка жестко прибита в образе? К сожалению, да, здесь уже как-то побороть или придумать другое решение мы не смогли. Если Вы его знаете, пожалуйста, напишите в комментариях. Если что, про chcp 65001 (UTF-8 в windows консоли) мы в курсе, попробовав собрать образ, по��учили, что ввод из stdin, содержащий кириллицу, трансформируется в кракозябры.
Опять-таки после этого у нас получился нативный бинарник Исполнителя ещё ближе к тому, что задумывалось. Однако мы столкнулись с ещё одной проблемой, вернее, с особенностью технологии. GraalVM Native Image не поддерживает вообще получение каких-либо переменных из окружения. Значит, получить локаль просто так не получится.

Замечание: вообще, получить переменные можно, только если передать проперти аргументом в бинарник специальным образом.
Это ещё не всё, из комментариев к issues на гитхабе и вообще в целом из документации мы сделали вывод, что одновременно хранить в нативном образе один бандл ресурсов с разными локалями нельзя (а при компиляции выбирается только один). Что это значит? А то, что мы не можем выбрать язык, на котором выводить сообщение пользователю в рантайме. А хотелось бы хотя бы для справки (-h) иметь два варианта: на русском и английском. Нам пришлось держать 2 бандла ресурсов и уже определять в коде, на каком языке выводить сообщения.
static final ResourceBundle defaultResourceBundle = PropertyResourceBundle.getBundle("native-image-messages"); static final ResourceBundle ruResourceBundle = PropertyResourceBundle.getBundle("native-image-messages-ru"); ResourceManager() { ...
Для выбора языка, кстати, в итоге ввели специальный параметр в CLI. Короче говоря, у GraalVM Native Image c локализацией какие-то временные трудности.
Промежуточный итог и общий процесс сборки
Итак, мы получили относительно корректно работающий нативный образ Исполнителя. Посмотрим на общий процесс сборки:
- Собираем в fat-jar Исполнитель.
- Собираем стандартную поставку Исполнителя (см пункт «Про 1С:Исполнитель»)/
- Специальной утилитой собираем все локализованные сообщения и заполняем конфигурационные файлы.
- После этого на стандартной поставке запускается исполнение джавы с native-image-agent.
- Сборка нативного образа Исполнителя (как раз-таки мы получили из всех шагов выше конфигурационные файлы которые нам позволят построить правильный образ).
Получившийся образ работает быстро, а чтобы не быть голословными — перейдём к цифрам. Нативный образ можно собирать и на CI, потому что написан скрипт для прогонов и запуска сборки. Такой подход позволит держать образ актуальным и получать его сразу для Windows и для Linux (если завести два варианта машинок на CI).
Результаты
Тестирование быстродействия велось на таком оборудовании:
- Оперативная память — 16 Гб.
- Процессор — Intel Core i5-3550 CPU 3.30 GHz x 4.
- Операционная система — Windows 10.
- Диск — SSD Samsung evo 850 EMT03B6Q, 250GB.
На самом деле диск практически никак не влияет на результаты, в любом случае наша задача — сравнить скорость работы нативного образа Исполнителя и стандартного. Замечу лишь, что оборудование не мощное, такой выбор сделан специально
Исполнение простейшего скрипта («Hello world») для нативного образа Исполнителя занимает в разы меньше времени: 0,3с для нативного и 1,9с для стандартного. Надо заметить, что нативный образ вызывался ранее, но и обычная поставка также была вызвана несколько раз до этого (т.е. JVM ��же «прогрета»).

Рассмотрим скрипт посложнее; в этом разбираются большое количество JSON-ов и из них получаются объекты и наоборот (примерно 1000 строк), кроме того есть много сравнений строк. Первый запуск образа занимал 1,9 с, для стандартного же — 3с, последующие запуски нативного образа занимали 0.5 секунд, а в стандартном Исполнителе 2,8 с. Разница по ощущениям для пользователя довольно большая (особенно если работать в паттерне «поменял что-то — сразу запустил»).

Еще на языке Исполнителя был реализован алгоритм решета Эратосфена (без оптимизаций и т.п., так как нам нужно сравнить Исполнители).
method Script() var n = 300000000 var prime = new Array() for i=0 to n + 1 prime.Add(True) ; prime[0] = False prime[1] = False for i=2 to n + 1 if (prime[i]) if (i * i <= n) var j = i * i while(j <= n) prime[j] = False j += i ; ; ; ; ;
Ниже представлены результаты в зависимости от разных границ, до которой считаем простые:
Для N = 10^7 видно, что нативный образ выигрывает (50с против 110с) у стандартной поставки. Однако для N = 10^8 время уже сравнимое (900c и 1100c) — значит, мы где-то близко к условной границе оптимальной применимости образа. Действительно, для N = 3 * 10^8 нативный образ исполняет скрипт с решетом за 4200с, когда обычный — за 3300 с.
Тут мы видим JIT-компиляцию во всей её красе. А еще то, что, SubstrateVM не рассчитан на работу с большим объемом памяти.

Суммарный вес образа Исполнителя стал 100мб, что на самом деле мало, потому что мы должны получить классы из стандартной поставки Java, кроме того мы должны включить в этот образ SubstrateVM и код Исполнителя и библиотек объектов (в обычном Исполнителе 40 Мб). Это отличный результат для вещи, которая работает изолированно.
Таким образом, мы выполнили те задачи, которые перед собой ставили:
- Мы избавили пользователя от скачивания Java (Исполнитель запускается как обычное приложение для Windows/Linux).
- Мы уменьшили время запуска Исполнителя.
Планы
Планы развития:
- Добавить логирование, вроде бы у новой версии грааля должно быть с этим получше (на момент подготовки статьи мы пользовались версией 20.1, а сейчас уже доступна версия 21.1).
- Полная локализация. Мы будем пробовать два варианта: отдельная сборка под разные языки, либо же общий образ под разные языки сообщений.
- Посмотреть, как можно еще ускорить нативный образ Исполнителя.
Библиотеки, которые работают в нативном образе Исполнителя
Далее перечислены библиотеки, которые в итоге заработали в нативном образе Исполнителя, с их версиями:
- Guice (v 5.0.1)
- Guava (v 28.1)
- Netty (v 4.1.43)
- Jackson (v 2.10.4)
- Gson (v 2.8.2)
- Apache http client (v 4.4.1)
- zip4j (v 2.6.4)
- Threeten (v 1.4.0)
- Antlr (v 3.2)
- EMF (v 2.15.0)
- jsch (v 0.1.55)
- com.sun.mail.android-mail (v 1.5.6)
- woodstox (v 5.0.3)
- Streamsupport (v 1.7.2)
- Java-WebSocket (v 1.3.9)
- Библиотеки 1С для работы с кластером V8
Интересные факты и примеры
Даже зная про функционал, который работает по-другому в нативном образе, можно наткнуться на неожиданное поведение. Например, в нашем случае это произошло в коде для вывода сообщений из кода в консоль.
В первом мы подключили два бандла ресурсов с одинаковыми сообщениями, но на разном языке (почему мы так сделали — описано выше). Каждый раз, когда нам нужен определенный бандл, дергается метод getResourceBundle(), который уже выдает нам нужный файл с сообщениями.
class ResourceManager { static final ResourceBundle defaultResourceBundle = PropertyResourceBundle.getBundle("native-image-messages"); static final ResourceBundle ruResourceBundle = PropertyResourceBundle.getBundle("native-image-messages-ru"); ResourceManager() { } method1() { //Из Locale.getDefault(), которую ранее установили на нужную, получаем нужный нам bundle (по getResourceBundle()) } private ResourceBundle getResourceBundle() { if (!NativeImageExecutorBootstrap.ruLocale.equals(Locale.getDefault())) return defaultResourceBundle; return ruResourceBundle; } }
Учитывая, что локаль в рантайме у нас не поменяется, не слишком рациональный код, не правда ли (ну хотя бы работает)? Что ж, перепишем!
Получим примерно такой код. Тут мы храним нужный нам бандл в не статическом и не константном поле класса. В конструкторе же определяем нужный бандл.
class ResourceManager { static final ResourceBundle defaultResourceBundle = PropertyResourceBundle.getBundle("native-image-messages"); static final ResourceBundle ruResourceBundle = PropertyResourceBundle.getBundle("native-image-messages-ru"); ResourceBundle currentBundle; //НЕ СТАТИЧЕСКОЕ И НЕ КОНСТАНТНОЕ ПОЛЕ ResourceManager() { //Из Locale.getDefault(), которую ранее установили на нужную, получаем нужный нам bundle (логика такая же, как и у getResourceBundle() выше) currentBundle = ruResourceBundle; if (!NativeImageExecutorBootstrap.ruLocale.equals(Locale.getDefault())) currentBundle = defaultResourceBundle; } method1() { //используем currentBundle } private ResourceBundle getResourceBundle() { f (!NativeImageExecutorBootstrap.ruLocale.equals(Locale.getDefault())) return defaultResourceBundle; return ruResourceBundle; }
Однако во втором коде значение currentBundle никогда не меняется с момента компиляции, оставаясь одним из выбранных вариантов бандлов, который использовался во время сборки образа.
Ссылки
- GraalVM www.graalvm.org
- Github GraalVM github.com/oracle/graal
- Выступление евангелиста GraalVM на тему native image: youtu.be/tPezgDSD1Bk
- Хорошая статья по теме: habr.com/ru/post/454790
