Всем привет! В этой статье мы расскажем о том, как технология 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
EvilBeaver
Жесть какая-то. Ладно кодировки, в Java с ними в принципе все хуже чем в том же .net, но чем env-то провинился? Почему в нативе нет доступа к окружению?
Vest
Меня заинтересовал ваш вопрос, но я что-то не нашёл пока вменяемого объяснения. Только это и соответствующий ишью в ГитХабе. Я могу ошибаться, но складывается такое ощущение, что в момент сборки образа эти свойства как будто бы запекаются в исполняемом файле.
Но я могу быть не прав, конечно. Самому интересно. Можно почитать, конечно, исходник как извлекаются свойства из окружения... но это так... мысли вслух.
shelajev-oleg
Интересно, а у меня работают переменные окружения. Прокомментируйте ишшуе в гитабе с репродюсером пожалуйста — мы починим!
VasiliyKudryavtsev
Спасибо, мы обязательно попробуем как перейдем на GraalVM 21.1
fshp
Наверное речь не про переменные окружения, а про проперти. Они действительно указываются при сборке бинаря.