Мне хотелось, чтобы знакомство с HydraScript выглядело просто: скачал интерпретатор, запустил скрипт. Сомнительно предлагать человеку сначала установить подходящий .NET Runtime ради моего языка программирования.

Переход на Native AOT поначалу казался правкой .csproj. Включил настройку, опубликовал бинарник, пошёл дальше. На практике пришлось переписать всё. Досталось даже интеграционным тестам. А в процессе рефакторинга ещё и пропали логи.

Интерпретатор тоже можно скомпилировать заранее

HydraScript написан на C#. Парсер, статический анализ, генератор инструкций и виртуальную машину можно собрать в нативное приложение. Сам скрипт останется входными данными:

hydrascript sample.js

Новый скрипт не требует пересобирать интерпретатор. В готовом приложении уже есть всё для его разбора и исполнения, включая управление памятью, которым пользуется C#-код.

Так я получал нужный способ поставки: бинарник под конкретную ОС и архитектуру без требования отдельно устанавливать .NET Runtime. Зависимости от системных библиотек и совместимость с платформой при этом остаются; они описаны в документации Microsoft по Native AOT.

Ради этого я был готов принять больший размер исполняемого файла по сравнению с framework-dependent сборкой. Размер важен, но количество действий до запуска первого скрипта тоже чего-то стоит.

Чтобы получить такой бинарник, пришлось разобраться с решениями, которые до этого спокойно работали в обычном managed-приложении.

Подготовка приложения к AOT

Я начал рассматривать Native AOT только на .NET 8 — тогда это был свежий LTS-релиз. Чтобы получить от перехода максимум пользы, заодно стал смотреть, где пригодится генерация исходного кода. Для AOT это естественный подход: если поведение известно заранее, генератор может подготовить его C#-реализацию при сборке, а нативному компилятору останется её скомпилировать.

Одним из кандидатов стало логирование. Скрипты HydraScript могут активно печатать в консоль, поэтому путь вывода тоже заслуживал внимания. В предложении перейти на source-generated logging я хотел сократить накладные расходы этих вызовов, переделав запись вывода.

В результате у LoggingWriter методы WriteLine и WriteError стали частичными и получили [LoggerMessage]. По заданным уровням и шаблонам сообщений генератор логирования создаёт их реализации. Абстракция вывода осталась прежней, а вызовы логгера под ней взял на себя сгенерированный код. Такую оптимизацию мне хотелось получить вместе с переходом.

internal partial class LoggingWriter(ILogger<LoggingWriter> logger) : IOutputWriter
{
    [LoggerMessage(
        EventId = 0,
        Level = LogLevel.Information,
        Message = "{obj}")]
    public partial void WriteLine(object? obj);

    [LoggerMessage(
        EventId = 1,
        Level = LogLevel.Error,
        Message = "{message}")]
    public partial void WriteError(Exception e, string message);
}

Ещё одна возможность нашлась в лексере. Раньше он во время работы приложения собирал один большой паттерн из определений токенов и передавал его в Regex:

Regex = new Regex(
    string.Join(
        '|',
        types
            .Where(t => !t.EndOfProgram())
            .Select(t => t.GetNamedRegex())
            .ToList()
    )
);

Тогда я впервые заменил это построение на [GeneratedRegex]. Объединённый паттерн стал строковым литералом в атрибуте над частичным методом GetRegex(). Правила токенов известны ещё до запуска программы, поэтому собирать тот же паттерн при инициализации лексера незачем. Именно такую работу мне хотелось переносить в сборку, готовя HydraScript к AOT.

Удалил System.CommandLine.Hosting

В командной строке препятствием оказался NamingConventionBinder, который подтягивался через System.CommandLine.Hosting. Сам System.CommandLine я сохранил и обновил, а от интеграции с хостингом отказался.

Раньше разбор аргументов и запуск интерпретатора связывал хост:

var builder = new CommandLineBuilder(Command)
    .UseHost(Host.CreateDefaultBuilder, configureHost);

Интеграция регистрировала сервисы с учётом разобранных аргументов и подключала к команде ExecuteCommandHandler. Удобно, пока подходит приложению. Когда она помешала переходу, проще оказалось написать эту связку явно.

Первая замена выглядела так:

command.SetAction(parseResult =>
{
    var fileInfo = parseResult.GetValue(command.PathArgument)!;
    var dump = parseResult.GetValue(command.DumpOption);
    var serviceProvider = GetServiceProvider(fileInfo, dump);
    var executor = serviceProvider.GetRequiredService<Executor>();
    return executor.Invoke();
});

Исполнение интерпретатора переехало в Executor с методом Invoke() без параметров. Заодно выяснилось, насколько фикстура интеграционных тестов завязана на библиотеку командной строки. Тесты передавали имитацию аргументов в её парсер.

После рефакторинга появился типизированный record Options:

  • имя файла

  • режим dump

  • подмена файловой системы

  • необязательный скрипт в памяти.

Фикстура переиспользовала Program.GetServiceProvider, а для скриптов в памяти можно было напрямую подменить ISourceCodeProvider.

Потом напомнило о себе завершение работы. В примере выше service provider создаётся, но никто его не освобождает. Раньше это делал хост. Без него процесс мог завершиться до того, как отработает асинхронный вывод логов в консоль.

Open Source комьюнити исправило пропущенный Dispose:

using var serviceProvider = GetServiceProvider(fileInfo, dump);

Фабрика стала возвращать ServiceProvider с поддержкой освобождения ресурсов, а test runner тоже получил disposal. Теперь за это отвечает код, вызывающий интерпретатор.

Именно здесь я бы был внимательнее при удалении хоста из другого приложения. Запустить те же сервисы — половина замены. Кому-то ещё нужно корректно завершить их работу и освободить ресурсы.

Декораторы без Scrutor

В HydraScript есть режим dump, позволяющий посмотреть промежуточные результаты. Для этого лексер, парсер и виртуальная машина оборачиваются в декораторы. Со Scrutor регистрации были короткими:

services.Decorate<ILexer, DumpingLexer>();
services.Decorate<IParser, DumpingParser>();
services.Decorate<IVirtualMachine, DumpingVirtualMachine>();

Scrutor оказался ещё одной зависимостью, которая мешала моей попытке перейти на AOT. При этом сами декораторы были обычными классами, и отказываться от них не хотелось. Оставалось дать декоратору зависимость с тем же интерфейсом, который реализует он сам.

Если зарегистрировать исходный лексер, а затем декоратор как обычные ILexer, запрос оборачиваемого лексера снова приведёт к DumpingLexer. Keyed services позволяют контейнеру различать эти две реализации:

services.AddKeyedSingleton<ILexer, RegexLexer>(DecoratorKey.Value);
services.AddSingleton<ILexer, DumpingLexer>();

Параметр конструктора, принимающий исходный лексер, получает ключ:

[FromKeyedServices(DecoratorKey.Value)]

Остальной код по-прежнему запрашивает ILexer и получает поведение с дампом. Знать об отдельной регистрации исходной реализации нужно только декоратору. С парсером и виртуальной машиной работает та же схема; целиком её можно посмотреть в регистрациях сервисов и DumpingLexer.

Ради трёх известных декораторов мне вполне подошло написать эти регистрации вручную. Появился ключ, за которым надо следить, зато связь видна с обеих сторон. Этого хватило, чтобы убрать зависимость и сохранить поведение режима dump.

Source Generated JSON

Разумеется, даже в собственном языке программирования мне пришлось перекладывать JSON.

Операция as string сериализует в строку значения HydraScript. До AOT она передавала значение и JsonSerializerOptions сериализатору, который изучал входной тип данных через рефлексию. Для компиляции заранее пришлось явно указать CLR-типы, которые попадают в сериализатор.

На первый взгляд, неудобное требование для языка, где пользователь сам придумывает структуру объектов:

let payload = {
    name: "HydraScript";
    values: [1, 2, 3];
    nested: { ready: true; };
}
>>> payload as string

Но разнообразие объектов в скриптах не требует такого же разнообразия CLR-типов. Объект HydraScript представлен словарём, массив — списком. Имена name и nested остаются данными внутри этих коллекций.

Сериализатору достаточно понимать это представление. Генерировать C#-тип под каждый объект из будущих скриптов ему не требуется.

AsString.Convert использует сгенерированный контракт:

protected override string Convert(object? value) =>
    JsonSerializer.Serialize(value, AsStringSerializationContext.Default.Object);

Вот список типов из его контекста сериализации:

[JsonSerializable(typeof(List<object>))]
[JsonSerializable(typeof(Dictionary<string, object>))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(double))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(int))]
private sealed partial class AsStringSerializationContext : JsonSerializerContext

Контракт для object не избавляет от этого списка. У значений, доступных через object, всё равно есть конкретные типы, и сгенерированному сериализатору нужны их контракты. Если я добавлю новое CLR-представление в интерпретатор, придётся вернуться и к этому контексту. Для членов, объявленных как object, System.Text.Json описывает такое же требование.

Переделка затронула и TokenTypesProvider, и уже существовавший генератор лексера, который тогда тоже десериализовал JSON. Они также получили сгенерированные контракты. Эти изменения остались в проекте, даже когда первая попытка перейти на AOT остановилась из-за других зависимостей.

Ещё я отключил сериализацию через рефлексию по умолчанию:

<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>

Настройка действует и в обычных managed-сборках. Если случайно добавить сериализацию без сгенерированного контракта, ошибка может проявиться уже во время разработки.

За уменьшение релиза можно заплатить диагностикой

Когда код удалось опубликовать, оставалось решить, каким будет готовое приложение. В Release-конфигурации HydraScript есть такие настройки:

<PropertyGroup Condition="'$(Configuration)' == 'Release'">
    <DebugType>none</DebugType>
    <DebugSymbols>false</DebugSymbols>
    <OptimizationPreference>Size</OptimizationPreference>
    <InvariantGlobalization>true</InvariantGlobalization>
    <StackTraceSupport>false</StackTraceSupport>
    <UseSystemResourceKeys>true</UseSystemResourceKeys>
</PropertyGroup>

Я бы не переносил эту группу в другой проект, не разобравшись с потерями. Без символов и стектрейсов сложнее расследовать ошибки. Инвариантная глобализация должна подходить правилам разбора и форматирования языка. А ключи ресурсов менее полезны человеку, читающему сообщения об ошибках фреймворка.

OptimizationPreference=Size задаёт приоритет нативному компилятору; в документации Microsoft описан компромисс с производительностью. По одной настройке нельзя сказать, насколько HydraScript стал меньше или медленнее.

Как доставить результат пользователю

После рефакторинга стало возможно заняться тем, ради чего всё затевалось. HydraScript собирается для win-x64, linux-x64 и osx-arm64, а модель упаковки утилит в .NET 10 позволяет спрятать эти варианты за одним именем команды.

В конфигурации проекта перечислены нативные платформы и managed fallback:

<PublishAot>true</PublishAot>
<ToolPackageRuntimeIdentifiers>any;linux-x64;win-x64;osx-arm64;</ToolPackageRuntimeIdentifiers>
<PackageId>HydraScript</PackageId>
<PackAsTool>true</PackAsTool>
<ToolCommandName>hydrascript</ToolCommandName>

За одним именем стоят пять NuGet-пакетов:

Package ID

Содержимое

HydraScript

Входной пакет со ссылками на доступные платформенные пакеты.

HydraScript.win-x64

Нативная сборка для Windows x64.

HydraScript.linux-x64

Нативная сборка для Linux x64.

HydraScript.osx-arm64

Нативная сборка для macOS Arm64.

HydraScript.any

Framework-dependent managed-сборка.

Установщик выбирает пакет под целевую платформу. Вариант any оставляет managed-путь, для которого нужен совместимый .NET Runtime. Как входной пакет описывает эти варианты, разобрано в документации по RID-specific tools.

Разделение отразилось и на релизном workflow. Джобы GitHub Actions работают на Windows, Linux и macOS: Native AOT не поддерживает кросс-ОС компиляцию. Каждая job создаёт самостоятельный исполняемый файл и платформенный пакет. Отдельная job собирает входной пакет и managed fallback.

Для fallback нужно переопределить AOT-настройку проекта:

dotnet pack ./src/HydraScript/HydraScript.csproj -c Release -r any -p:PublishAot=false

У всех пакетов одна версия, а пакеты со сборками публикуются раньше входного. Иначе установщик может найти ссылку на платформенный пакет, который ещё не успел появиться в NuGet.

Конечно, установка через dotnet tool всё ещё требует SDK с поддержкой такой модели пакетов. Однако пользователь может скачать самостоятельный нативный бинарник со страницы релизов GitHub и обойтись без отдельно установленного .NET SDK.

Попробовать пакет локально

Теперь для установки утилиты достаточно написать:

dotnet tool install hydrascript -g

Я провёл эту работу, чтобы HydraScript было проще установить. Заодно в приложении стало меньше ненужных зависимостей. А казалось смогу всего лишь поменять флаг!

Ещё я веду Telegram канал StepOne, куда выкладываю много интересного контента о программировании на C#, даю карьерные советы, рассказываю истории из личного опыта и раскрываю все тайны IT‑индустрии!

Комментарии (0)