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

Про продукт


1С:Исполнитель – наш новый продукт, предназначенный в первую очередь для администрирования информационных систем на платформе 1С:Предприятие. Содержит кроссплатформенный язык сценариев (работает в Linux и Windows, в планах – поддержка macOS), библиотеку времени исполнения и среду разработки и отладки (можно работать в IDE на базе Eclipse и в Visual Studio Code).
Продукт написан на Java.

Под катом – рассказ о специфике самого продукта 1С:Исполнитель. Кому интересно именно про Graal – могут смело переходить к следующей секции.

Про 1С:Исполнитель
Чуть подробнее о проекте, образ которого мы хотим в конечном счете получить. Исполнитель — это интерпретатор для кроссплатформенного языка сценариев, и сам язык сценариев к нему. Подобно тому, как cmd.exe исполняет bat-скрипты или bash исполняет bash-скрипты, Исполнитель работает со своими скриптами. Если вы, например, решили внедрить практику CI в разработку приложения на базе платформы 1С:Предприятие, то использование Исполнителя будет подходящим выбором, потому что Исполнитель содержит необходимый функционал для такой работы, а также позволяет избавить администратора от ограничений платформо-зависимых решений в пользу фокусировки на самой задаче. Кроме того, язык содержит объекты для работы с кластером серверов 1С и базами данных 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]
;

Запускать из консоли можно вот так:

image

Все эти сущности, которые отправляют email, читают xml и т.п. называются объектами языка (или просто объектами).

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

image

Мы сами, в частности, активно используем 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. Нет объектов языка Исполнителя. Это те самые объекты, которые мы видели в разделе «Про 1С:Исполнитель» — объекты отправки почты, File и так далее. Все они лежат отдельно в jar-никах, мы их подгружаем в коде при старте Исполнителя в рантайме.
  2. Не работает часть функциональности, которая должна обеспечивать саму работу Исполнителя (даже без этих объектов). Например, интерфейс командной строки. Так, задание пути до скрипта с портом для дебага ($executor -d <port> -s <script_path>), или получение версии ($executor -v) не работает. Сами аргументы не разбираются по заданному правилу в одной из библиотек.
  3. Не отображаются тексты ошибок компиляции скрипта. Да и в целом тексты ошибок по всему проекту не отображаются.

Так, например, работал только простой скрипт с выводом информации в консоль, потому что данный объект описывается не отдельно, а непосредственно в коде Исполнителя.

Проблема в том, что в библиотеках, которые задействованы в нашем проекте, используется 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
    • 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. В общем, пока нам хватит первого приближения для решения этой задачи.

image

После этих действий мы получили относительно нормально работающий нативный образ Исполнителя.

Однако перед нами возникла следующая проблема: логи из Исполнителя пишутся прямо в консоль (даже уровня 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 не поддерживает вообще получение каких-либо переменных из окружения. Значит, получить локаль просто так не получится.
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 локализацией какие-то временные трудности.

Промежуточный итог и общий процесс сборки


Итак, мы получили относительно корректно работающий нативный образ Исполнителя. Посмотрим на общий процесс сборки:

  1. Собираем в fat-jar Исполнитель.
  2. Собираем стандартную поставку Исполнителя (см пункт «Про 1С:Исполнитель»)/
  3. Специальной утилитой собираем все локализованные сообщения и заполняем конфигурационные файлы.
  4. После этого на стандартной поставке запускается исполнение джавы с native-image-agent.
  5. Сборка нативного образа Исполнителя (как раз-таки мы получили из всех шагов выше конфигурационные файлы которые нам позволят построить правильный образ).

Получившийся образ работает быстро, а чтобы не быть голословными — перейдём к цифрам. Нативный образ можно собирать и на 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 уже «прогрета»).

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

image

Еще на языке Исполнителя был реализован алгоритм решета Эратосфена (без оптимизаций и т.п., так как нам нужно сравнить Исполнители).

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 не рассчитан на работу с большим объемом памяти.

image

Суммарный вес образа Исполнителя стал 100мб, что на самом деле мало, потому что мы должны получить классы из стандартной поставки Java, кроме того мы должны включить в этот образ SubstrateVM и код Исполнителя и библиотек объектов (в обычном Исполнителе 40 Мб). Это отличный результат для вещи, которая работает изолированно.

Таким образом, мы выполнили те задачи, которые перед собой ставили:

  1. Мы избавили пользователя от скачивания Java (Исполнитель запускается как обычное приложение для Windows/Linux).
  2. Мы уменьшили время запуска Исполнителя.

Планы


Планы развития:

  1. Добавить логирование, вроде бы у новой версии грааля должно быть с этим получше (на момент подготовки статьи мы пользовались версией 20.1, а сейчас уже доступна версия 21.1).
  2. Полная локализация. Мы будем пробовать два варианта: отдельная сборка под разные языки, либо же общий образ под разные языки сообщений.
  3. Посмотреть, как можно еще ускорить нативный образ Исполнителя.

Библиотеки, которые работают в нативном образе Исполнителя


Далее перечислены библиотеки, которые в итоге заработали в нативном образе Исполнителя, с их версиями:

  • 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 никогда не меняется с момента компиляции, оставаясь одним из выбранных вариантов бандлов, который использовался во время сборки образа.

Ссылки