Эта страница может содержать автоматически переведенный текст.

Как разрабатывать приложения Native AOT в .NET

.NET 8 предоставляет полноценную поддержку компиляции ahead-of-time, также известной как Native AOT. Такие приложения обычно запускаются быстрее и потребляют меньше памяти, чем управляемые решения.

Публикация .NET AOT создает усеченные, автономные приложения. Такие приложения:

  1. Могут работать на машине, где не установлен .NET runtime.
  2. Нацелены на конкретную среду выполнения, например Windows x64.
  3. Не содержат неиспользуемый код.

Развертывание приложений .NET AOT

Мы разрабатываем библиотеку Docotic.Pdf для обработки PDF. Совместимость с AOT — один из самых популярных запросов на функциональность в 2024 году. В этой статье описан наш путь к достижению совместимости с AOT. Вы узнаете, как находить и исправлять проблемы AOT в проектах .NET.

Основы публикации .NET AOT

Чтобы опубликовать приложение Native AOT, добавьте в файл проекта следующее свойство:

<PropertyGroup>
    <PublishAot>true</PublishAot>
</PropertyGroup>

Возможно, также потребуется установить некоторые предварительные требования. Затем вы можете опубликовать приложение из Visual Studio. Или опубликовать проект из командной строки с помощью команды dotnet publish:

dotnet publish -r linux-x64 -c Release

Совместимость .NET-библиотек с AOT

Публикация Native AOT доступна в .NET 7 и .NET 8. В .NET 7 можно публиковать только консольные приложения. В .NET 8 можно также публиковать приложения ASP.NET Core.

Но основная библиотека Docotic.Pdf нацелена на .NET Standard 2.0. Можем ли мы сделать ее дружественной к AOT? Конечно.

Вы по-прежнему можете находить и исправлять проблемы совместимости с AOT в .NET-библиотеках, нацеленных на .NET Standard, .NET 5 или .NET 6. После этого приложения Native AOT могут использовать такую библиотеку.

Находим проблемы AOT

Первый запрос в поддержку о совместимости с AOT выглядит так:

Мы рассматриваем возможность использовать Docotic.Pdf, скомпилированный в WebAssembly. При компиляции в AOT он выдает предупреждения об усечении:
warning IL2104: Assembly 'BitMiracle.Docotic.Pdf' produced trim warnings. For more information see https://aka.ms/dotnet-illink/libraries

Не слишком информативно. Давайте воспроизведем проблему и получим больше сведений об этих предупреждениях об усечении.

Получаем предупреждения при публикации

Публикация тестового приложения — самый гибкий способ найти проблемы AOT в .NET. Нужно:

  1. Создать консольный проект .NET 8 с PublishAot = true
  2. Добавить ссылку на ваш проект и использовать некоторые его типы

Вот конфигурация csproj, которую мы используем для Docotic.Pdf:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <PublishAot>true</PublishAot>
        <TrimmerSingleWarn>false</TrimmerSingleWarn>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        <WarningsAsErrors />
    </PropertyGroup>

    <ItemGroup>
        <ProjectReference Include="..\Docotic\Docotic.csproj" />
    </ItemGroup>
</Project>

Параметр TrimmerSingleWarn = false позволяет получить подробные сведения о предупреждениях. Иначе вы увидите только сообщение "Assembly 'X' produced trim warnings".

А вот код C# в Program.cs:

using BitMiracle.Docotic.Pdf;

using var pdf = new PdfDocument();

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

Команда dotnet publish -r win-x64 -c Release для этого проекта вернула предупреждения об усечении и AOT. Вот сокращенная версия списка:

AOT analysis warning IL3050: Org.BouncyCastle.Utilities.Enums.GetEnumValues(Type): Using member 'System.Enum.GetValues(Type)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling.

Trim analysis warning IL2026: LogProviders.Log4NetLogProvider.Log4NetLogger.GetCreateLoggingEvent(ParameterExpression,UnaryExpression,ParameterExpression,UnaryExpression,Type): Using member 'System.Linq.Expressions.Expression.Property(Expression,String)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.

Trim analysis warning IL2057: LogProviders.LogProviderBase.FindType(String,String[]): Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String)'.

Trim analysis warning IL2070: LogProviders.NLogLogProvider.NLogLogger.GetIsEnabledDelegate(Type,String): 'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicProperties' in call to 'System.Type.GetProperty(String)'. The parameter 'loggerType' of method 'LogProviders.NLogLogProvider.NLogLogger.GetIsEnabledDelegate(Type,String)' does not have matching annotations. The source value must declare at least the same requirements as those declared on the target location it is assigned to.

Trim analysis warning IL2075: LogProviders.Log4NetLogProvider.GetOpenNdcMethod(): 'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicProperties' in call to 'System.Type.GetProperty(String)'. The return value of method 'LogProviders.LogProviderBase.FindType(String,String)' does not have matching annotations. The source value must declare at least the same requirements as those declared on the target location it is assigned to.

Мы нашли первопричину. Перед исправлением проблем давайте рассмотрим альтернативные способы поиска проблем совместимости с AOT.

Используем анализаторы совместимости с AOT

Microsoft предоставляет анализаторы Roslyn для поиска проблем AOT в проектах .NET 7 или .NET 8. Они могут подсвечивать проблемы AOT уже во время написания кода. Но публикация тестового приложения выявляет больше проблем.

Чтобы использовать статический анализ, добавьте в файл .csproj следующее свойство:

<PropertyGroup>
    <IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

Чтобы использовать такой анализ в проекте .NET Standard 2.0, нужно нацелиться на платформу .NET 7 или .NET 8. Конфигурация проекта может выглядеть так:

<PropertyGroup>
    <TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
    <IsAotCompatible Condition="'$(TargetFramework)'=='net8.0'">true</IsAotCompatible>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <WarningsAsErrors />
</PropertyGroup>

Мы применили указанную конфигурацию к нашему большому основному проекту и получили 1036 ошибок сборки.

Много ошибок сборки

Все ли они относились к совместимости с AOT? Мы пересобрали проект без свойства IsAotCompatible. И все равно — 1036 ошибок.

Все ошибки были связаны с улучшениями в .NET 8 по сравнению с .NET Standard 2.0. В нашем случае большинство проблем касалось лучшей поддержки nullable reference types. Также было много ошибок CA1500 и CA1512, связанных с новыми вспомогательными методами ArgumentOutOfRangeException.ThrowIfLessThan и ArgumentNullException.ThrowIfNull.

Мы исправили все ошибки и включили свойство IsAotCompatible. Сборка проекта завершилась без ошибок AOT. Статический анализ не обнаружил ни одного предупреждения, найденного в процессе публикации 🙁

Чтобы проверить это, мы добавили в кодовую базу следующий тестовый код:

Type t = typeof(int);
t = typeof(List<>).MakeGenericType(t);
Console.WriteLine(Activator.CreateInstance(t));

string s = Console.ReadLine() ?? string.Empty;
Type? type = Type.GetType(s);
if (type != null)
{
    foreach (var m in type.GetMethods())
        Console.WriteLine(m.Name);
}

Этот код C# на 100% несовместим с .NET AOT и усечением. И статический анализ выявил следующие ошибки:

error IL3050: Using member 'System.Type.MakeGenericType(params Type[])' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. The native code for this instantiation might not be available at runtime.

error IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String)'. It's not possible to guarantee the availability of the target type.

Итак, анализаторы Roslyn работают и могут находить некоторые проблемы. В нашем случае они ничего не нашли.

Находим ошибки во время выполнения

В статье Как сделать библиотеки совместимыми с Native AOT сформулирован следующий принцип:

Если приложение не имеет предупреждений при публикации для AOT, оно будет вести себя после AOT так же, как и без AOT.

На практике версия после AOT все равно может работать иначе. Мы исправили все предупреждения AOT и усечения. Все автоматические тесты прошли успешно. Тестовое приложение корректно обрабатывало PDF-документы. А версия AOT этого же приложения создавала некорректные документы.

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

Публикация AOT по ошибке удалила часть необходимого кода. Мы создали issue Оптимизация удаляет необходимый код. Команда .NET быстро подтвердила проблему и исправила ее. А пока исправление недоступно, мы добавили обходной путь в наш код C#.

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

Тестовое приложение Docotic.Pdf использует сжатие PDF, извлечение текста и другую ключевую функциональность. Мы используем его так:

AotCompatibility.exe compress "C:\compressed.pdf" "C:\input.pdf" "optional-password"

Ручное тестирование опубликованного приложения неудобно. Стоит автоматизировать этот процесс.

Автоматически обнаруживаем проблемы AOT

Разработка Docotic.Pdf основана на автоматических тестах. Для каждой функции или бага есть тесты. Совместимость с .NET AOT — не исключение.

Есть два способа автоматизировать тестирование приложений Native AOT.

Размещаем тесты вне приложения AOT

Здесь используется обычный проект NUnit/xUnit/MSTest. Перед сборкой этого проекта вы публикуете тестовое приложение AOT. Тесты запускают опубликованное приложение с помощью метода Process.Start.

Главное преимущество — разделение тестовой инфраструктуры и приложения AOT. Можно использовать все возможности фреймворков модульного тестирования. Любые проблемы совместимости с AOT в самом тестовом фреймворке не имеют значения. Также проще повторно использовать существующие тестовые случаи.

На GitHub доступна примерная конфигурация тестового проекта. Проект Docotic.Tests содержит автоматические тесты как для управляемой, так и для AOT-версии. AotCompatibility — это тестовое приложение для публикации AOT.

Файл AotCompatibility.csproj публикует тестовое приложение в событии после сборки в конфигурации Release. Условие '$(PublishProtocol)'=='' отключает этот шаг, когда вы используете мастер "Publish" в Visual Studio.

<Target Name="PostBuild" AfterTargets="PostBuildEvent" Condition="'$(Configuration)' == 'Release' And '$(PublishProtocol)'==''">
    <Exec Command="&quot;$(SolutionDir)Scripts\publish.bat&quot;" />
</Target>

Скрипт publish.bat прост:

@echo off

SET AOT_TEST_DIR=%~dp0\..\AotCompatibility
dotnet publish "%TRIM_TEST_DIR%\AotCompatibility.csproj" --no-build /warnaserror /p:PublishProfile="%AOT_TEST_DIR%\Properties\PublishProfiles\win-x64-installer.pubxml"

Обратите внимание: здесь используется аргумент --no-build. Проект уже собран до события после сборки. Повторная сборка не нужна.

Тесты в NativeAot.cs запускают опубликованный AotCompatibility.exe с разными аргументами. Проекты Docotic.Tests и AotCompatibility используют один и тот же тестируемый код. Это позволяет проверять управляемую и AOT-версию без дублирования кода.

Размещаем тесты внутри приложения AOT

Альтернативный подход — писать тесты внутри тестового приложения AOT. В июле 2024 года можно использовать только раннюю предварительную версию MS Test. Подробнее см. в статье Тестирование ваших приложений Native AOT.

Главная проблема в том, что любая проблема AOT в тестовом фреймворке сделает тесты ненадежными. И доступен только ограниченный набор возможностей тестового фреймворка. Поэтому мы предпочитаем выносить тестовую инфраструктуру за пределы приложения AOT.

Исправляем предупреждения AOT и усечения

Не подавляйте предупреждения при публикации. Обычно подавление — неверное решение. Когда вы подавляете предупреждения AOT или усечения, вы как бы заявляете, что ваш проект совместим с AOT. Однако первопричина предупреждения все еще остается. Любое подавленное предупреждение в итоге может привести к сбою приложения во время выполнения.

Попробуйте удалить или переписать код, связанный с каждым предупреждением. Может помочь и обновление сторонних компонентов.

Давайте разберем, как мы исправляли предупреждения AOT и усечения в Docotic.Pdf. Некоторые из них вы уже видели в разделе Получаем предупреждения при публикации.

Предупреждения об усечении из LibLog

Мы использовали библиотеку LibLog для логирования. LibLog опирается на reflection, чтобы определять и использовать популярные фреймворки логирования. По замыслу она несовместима с AOT.

В настоящее время пакет Microsoft.Extensions.Logging является стандартом для логирования в .NET. А разработка LibLog сейчас заморожена.

Учитывая это, мы полностью удалили LibLog из нашего кода. Вместо него мы выпустили надстройку Docotic.Pdf.Logging. Она зависит от интерфейсов Microsoft.Extensions.Logging.Abstractions. Это уменьшило размер основной библиотеки и исправило все связанные предупреждения об усечении.

Нет предупреждений об усечении

Отладочный код в надстройке Docotic.Layout

В надстройке layout мы используем reflection для внутренней отладки. Это привело к предупреждению IL2075 при анализе усечения.

Соответствующий код присутствует в кодовой базе, но клиенты не могут использовать его через публичный API. Решение — исключить этот код из конфигурации Release. Теперь он используется только в конфигурации Debug.

Предупреждение AOT IL3050 из BouncyCastle

Библиотека BouncyCastle помогает нам подписывать PDF-документы. Следующее предупреждение исходит из ее кода:

Org.BouncyCastle.Utilities.Enums.GetEnumValues(Type): Using member 'System.Enum.GetValues(Type)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. It might not be possible to create an array of the enum type at runtime. Use the GetValues<TEnum> overload or the GetValuesAsUnderlyingType method instead.

BouncyCastle использует метод Enum.GetValues(Type), который несовместим с AOT.

Самое простое решение — использовать перегрузку Enum.GetValues<T>(). Последняя версия BouncyCastle уже использует ее. К сожалению, эта перегрузка доступна только в .NET 5 или новее. Для .NET Standard 2.0 это не подходит.

Мы углубились в анализ кода BouncyCastle. Оказалось, что BouncyCastle использует этот метод, чтобы предотвратить обфускацию констант перечислений. Вместо этого мы отключили обфускацию соответствующих перечислений с помощью атрибута [System.Reflection.Obfuscation(Exclude = true)]. И удалили больше не нужные вызовы Enum.GetValues(Type).

Странная проблема в обфусцированной сборке

Мы используем обфускацию для защиты production-сборок. Удивительно, но развертывание .NET AOT для обфусцированной сборки возвращало похожие предупреждения:

<unknown>:0: error: symbol '__GenericLookupFromType_BitMiracle_Docotic_Pdf___4<System___Canon__System___Canon__System___Canon__Int32>_TypeHandle___System___Canon' is already defined

У таких ошибок не было связанных кодов IL30## или IL2###. Было непонятно, как определить соответствующий код.

Ошибки возникали только в обфусцированной версии. Возможно, это ошибка обфускатора. Мы обновили обфускатор, но ошибки по-прежнему оставались.

Нам нужно было сузить область проблемы. Мы использовали следующий процесс, основанный на двоичном поиске:

  1. Отключите обфускацию для половины пространств имен. Проверьте, возникают ли ошибки. Продолжайте, пока не найдете пространства имен, которые приводят к ошибкам.
  2. Отключите обфускацию для половины типов в пространстве имен из шага 1. Проверьте, возникают ли ошибки. Продолжайте, пока не найдете типы, которые приводят к ошибкам.
  3. Отключите обфускацию для половины членов в типе из шага 2. Проверьте, возникают ли ошибки. Продолжайте, пока не найдете члены, которые приводят к ошибкам.
  4. Изучите код члена из шага 3. Закомментируйте необычные части и проверьте, возникают ли ошибки.

Мы остановились на шаге 1 🙂 После отключения обфускации для всех пространств имен ошибки все равно возникали.

Мы проанализировали сгенерированную сборку с помощью ILSpy. Нашли несколько неожиданных типов, сгенерированных компилятором. ILSpy показал их использование на примере похожего кода C#:

interface X
{
    void f();
}

X obj = ..;
if (obj.f != null)
    ..

Этот странный код появился в результате миграции старого C-кода на C#. Условие if (obj.f != null) полностью избыточно. Мы удалили такие условия, и ошибки исчезли.

Не используйте GetCallingAssembly

Мы обнаружили еще одну ошибку во время выполнения. Вызов метода LicenseManager.AddLicenseData(string) после публикации AOT завершался со следующей ошибкой:

Assembly.GetCallingAssembly() throws System.PlatformNotSupportedException: Operation is not supported on this platform.

Метод Assembly.GetCallingAssembly должен быть реализован для Native AOT в .NET 9. Пока исправление не готово, мы переработали код, чтобы минимизировать негативное влияние. Теперь атрибуты вызывающей сборки используются только для проверки лицензии приложения. Другие типы лицензий совместимы с AOT.

Помечаем код специальными атрибутами

К счастью, нам удалось исправить все предупреждения AOT и усечения в основной библиотеке Docotic.Pdf и большинстве надстроек. Но вы можете не суметь переписать весь несовместимый с AOT код.

.NET предоставляет специальные атрибуты для таких случаев. Вы помечаете API атрибутами, чтобы сообщить клиентам об известных проблемах AOT. Пользователи вашего API получат предупреждение при вызове помеченного метода.

Основные атрибуты для пометки несовместимого с AOT кода .NET:

  • RequiresDynamicCode
  • RequiresUnreferencedCode
  • DynamicallyAccessedMembers

Подробнее об этих атрибутах можно узнать в официальных статьях Введение в предупреждения AOT и Подготовка .NET-библиотек к усечению.

Заключение

Развертывание Native AOT — большой шаг вперед для мира .NET. Можно писать обычный код C# и получать нативное приложение или библиотеку. Такие приложения обычно быстрее и могут работать без установленной среды выполнения .NET. Вы даже можете использовать опубликованные DLL из C, Rust или других языков программирования, не относящихся к .NET.

Опубликуйте тестовое приложение .NET 8, чтобы найти проблемы совместимости с AOT. Также есть анализаторы Roslyn, но они выявляют меньше проблем.

Основная библиотека Docotic.Pdf теперь совместима с AOT и усечением. Следующие надстройки тоже совместимы:

  • надстройка Conformance
  • надстройка Layout
  • надстройка Gdi
  • надстройка Logging

Надстройка HTML to PDF по-прежнему содержит предупреждения об усечении. Это наша следующая цель в дорожной карте Native AOT.

Не стесняйтесь задавать нам вопросы о Native AOT или обработке PDF.