Что такое trim-safe код и как его писать?
Trim-safe код -- это код, который триммер .NET может статически доказать как достижимый, поэтому он выживает при удалении неиспользуемого кода из self-contained приложения. Это практическое руководство: включите анализатор, доведите каждое предупреждение IL2xxx до нуля, аннотируйте рефлексию с помощью DynamicallyAccessedMembers, распространите RequiresUnreferencedCode на публичные API и замените неанализируемые паттерны генераторами исходного кода.
Trim-safe код — это код, который триммер .NET может статически доказать как достижимый, поэтому он не удаляется при публикации обрезанного (trimmed) self-contained приложения, а также код, который не ломает приложение молча, когда удаляются неиспользуемые члены вокруг него. Конкретно это означает, что ваш проект компилируется чисто под trim-анализом: ноль IL2026, IL3050, IL2070 и им подобных. Вы делаете код trim-safe, включая анализатор, а затем прорабатывая список предупреждений до нуля: аннотируйте рефлексию, которую можете выразить ([DynamicallyAccessedMembers]), изолируйте рефлексию, которую не можете, за [RequiresUnreferencedCode] и продвиньте её вверх к публичному API, а паттерны, сопротивляющиеся обоим подходам, замените генератором исходного кода. Чистая сборка анализа — это контракт; если вы не можете дойти до нуля, приложение не является trim-safe, и обрезка приведёт к падениям, возникающим только в продакшене, которых никогда не было видно в dotnet run.
Всё здесь ориентировано на .NET 11 SDK (11.0.100) и C# 14, но предупреждения trim-анализа существуют с .NET 6, поэтому механика применима начиная с net6.0. .NET 8 SDK или новее — это практический минимум для работы над библиотеками, потому что именно там стабилизировались анализатор и совместимость с AOT.
Почему триммеру нужно, чтобы ваш код сотрудничал
Обрезка работает через анализ достижимости. Триммер (внутренне ILLink) начинает с точки входа, обходит каждый вызов метода, доступ к полю и ссылку на тип, которые видит статически, помечает всё, чего касается, как сохраняемое, а всё остальное удаляет. Именно так self-contained приложение сжимается с десятков мегабайт до малой доли: фреймворк огромен, ваше приложение использует лишь тонкий срез, а остальное уходит. Начиная с .NET 6 гранулярность по умолчанию — на уровне членов (TrimMode=full), а не старое поведение уровня сборки copyused, поэтому триммер удаляет отдельные неиспользуемые методы и типы, а не только целые сборки, на которые нет ссылок. Это более агрессивно и более вероятно приведёт к удалению чего-то, чего вы на самом деле достигаете способом, который анализ не смог увидеть.
То, что анализ не может увидеть, — это рефлексия. Когда вы пишете type.GetMethods() или Activator.CreateInstance(someType), триммер понятия не имеет, какой конкретный тип попадёт туда во время выполнения, поэтому не может знать, какие члены сохранить. У него два плохих варианта: сохранить всё, что могло бы туда попасть (что сводит на нет обрезку), или не сохранить ничего и позволить вам узнать об этом во время выполнения через MissingMethodException. Он не делает ни того, ни другого. Вместо этого он требует, чтобы рефлективный код нёс аннотации, точно описывающие, чего он касается, и предупреждает везде, где неаннотированное значение попадает в вызов рефлексии. Trim-safe код — это код, который удовлетворяет этим требованиям. Те же правила управляют сборкой Native AOT, которая делает обрезку обязательной, поэтому всё, что ниже, — это также плата за вход для AOT.
Шаг первый: включите анализатор
Вы не можете исправить предупреждения, которых не видите, а по умолчанию обычная сборка не показывает ни одного. Есть два способа их обнаружить, и документация рекомендует использовать оба, потому что они ловят разные вещи.
Для приложения, которое вы собираетесь публиковать обрезанным, установите PublishTrimmed в файле проекта (не только в командной строке, чтобы это применялось также во время dotnet build):
<!-- .NET 11, C# 14. App project. Turns on the trimmer at publish and
the trim-compat Roslyn analyzer during every build. -->
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>
Для библиотеки вы её не публикуете, поэтому вместо этого включаете анализ на уровне проекта. <IsTrimmable>true</IsTrimmable> помечает сборку как совместимую с обрезкой и включает предупреждения для этого проекта:
<!-- .NET 11, C# 14. Library project. Marks the assembly trimmable and
emits trim warnings for this project's own code. -->
<PropertyGroup>
<IsTrimmable>true</IsTrimmable>
</PropertyGroup>
Здесь важны два нюанса. Во-первых, если вам нужны предупреждения без обещания, что сборка является trim-safe (скажем, пока вы ещё прорабатываете их), используйте <EnableTrimAnalyzer>true</EnableTrimAnalyzer> вместо IsTrimmable. Он выдаёт тот же анализ, не помечая сборку как обрезаемую. Во-вторых, IsTrimmable по умолчанию равно true, когда вы устанавливаете <IsAotCompatible>true</IsAotCompatible>, поэтому AOT-совместимая библиотека получает trim-анализ, просили вы его отдельно или нет.
Загвоздка с анализом библиотеки на уровне проекта в том, что он видит только ваш код плюс reference-сборки ваших зависимостей, а reference-сборки не несут достаточно информации, чтобы триммер мог судить о них. Чтобы увидеть каждое предупреждение, включая те, что происходят из того, как ваша библиотека использует свои зависимости, соберите небольшое тестовое приложение для обрезки: консольный проект, который ссылается на вашу библиотеку, устанавливает <PublishTrimmed>true</PublishTrimmed> и корневит вашу библиотеку так, чтобы вся она была проанализирована:
<!-- .NET 11. Trimming test app. Roots the library so ILLink walks every
code path in it, not just the ones the console Main reaches. -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MyLibrary\MyLibrary.csproj" />
<TrimmerRootAssembly Include="MyLibrary" />
</ItemGroup>
</Project>
Затем dotnet publish -c Release -r linux-x64 и читайте предупреждения. TrimmerRootAssembly — это то, что создаёт разницу: без него триммер анализирует только вызовы, которых ваш Main на самом деле достигает; с ним он трактует всю библиотеку как корень и обходит каждый путь.
Чтение предупреждений, означающих “ещё не trim-safe”
Четыре семейства предупреждений покрывают почти всё, с чем вы столкнётесь. Знание того, какое из них какое, подсказывает вам исправление.
IL2026 (RequiresUnreferencedCode) означает, что вы вызвали метод, который кто-то уже объявил несовместимым с обрезкой. IL3050 (RequiresDynamicCode) — это AOT-специфичный собрат: методу нужна генерация кода во время выполнения, которую предоставил бы JIT, но AOT не может. IL2070, IL2075, IL2077 и остальная часть диапазона IL207x — это предупреждения потока данных: неаннотированный Type (из параметра, поля, возвращаемого значения) попал в вызов рефлексии, требующий обещания [DynamicallyAccessedMembers], а его никто не дал. Относитесь к каждому из них как к настоящему дефекту, а не к шуму анализатора. Весь смысл анализатора в том, чтобы преобразовать молчаливое, возникающее только при публикации, иногда только в продакшене MissingMethodException в предупреждение времени сборки, указывающее на точную строку.
Один совет по чтению. В .NET 6+ триммер сворачивает все внутренние предупреждения от сборки PackageReference в единое предупреждение на сборку, чтобы вы не утонули в шуме от зависимости, которую не контролируете. Когда вы действительно хотите увидеть их все (вы пытаетесь решить, можно ли починить зависимость), установите <TrimmerSingleWarn>false</TrimmerSingleWarn>, чтобы развернуть их.
Исправление IL207x: аннотируйте рефлексию, которую можете выразить
Предупреждения потока данных — это те, что вы исправляете в собственном коде, и исправление всегда одной и той же формы: заявите требование в месте, откуда приходит Type, чтобы обещание жило в системе типов. Возьмите этот вспомогательный метод, который предупреждает, потому что GetMethods() требует, чтобы тип сохранил свои публичные методы, а параметр ничего не обещает:
// .NET 11, C# 14. Warns IL2070: 'type' has no matching annotation.
using System.Diagnostics.CodeAnalysis;
static void UseMethods(Type type)
{
foreach (var m in type.GetMethods()) { /* ... */ }
}
Скопируйте требование на параметр:
// .NET 11, C# 14. Clean: the parameter now carries the promise.
static void UseMethods(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
foreach (var m in type.GetMethods()) { /* ... */ }
}
Требование не исчезает, оно перемещается к вызывающим и продолжает распространяться наружу, пока не приземлится на typeof(Customer) или конкретный аргумент типа, где триммер наконец точно узнает, что сохранить. Просите минимум: если всё, что вы делаете, — это Activator.CreateInstance(type), запрашивайте PublicParameterlessConstructor, а не All. Каждый флаг, который вы добавляете, — это члены, которые триммеру запрещено удалять, а это размер бинарника, за который вы платите. Полная модель потока, включая то, как аннотировать поля, обобщённые параметры типа и возвращаемые значения, рассмотрена в подробном разборе DynamicallyAccessedMembers; краткое изложение в одну строку: сам по себе атрибут ничего не сохраняет, он распространяет обязательство туда, где рождается конкретный тип.
Исправление IL2026: изолируйте рефлексию, которую не можете выразить
Иногда рефлексия действительно неанализируема. Вы загружаете тип по строке из конфигурации или обходите GetProperties() над объектом, тип которого неизвестен до времени выполнения. Вы не можете аннотировать себя из этого, поэтому объявляете метод несовместимым с обрезкой через [RequiresUnreferencedCode] и позволяете предупреждению распространяться:
// .NET 11, C# 14. Honest: this method reflects in a way trimming can break.
[RequiresUnreferencedCode("Serializes arbitrary types via reflection over their properties.")]
static string Serialize(object value)
{
var sb = new StringBuilder();
foreach (var p in value.GetType().GetProperties())
sb.Append(p.Name).Append('=').Append(p.GetValue(value)).Append(';');
return sb.ToString();
}
Атрибут ничего не исправляет. Он перемещает предупреждение IL2026 к каждому вызывающему Serialize, так же как [DynamicallyAccessedMembers] перемещает своё требование, пока оно не достигнет границы публичного API, где автор библиотеки документирует ограничение и останавливает распространение. Это правильный исход для кода, который действительно не является trim-safe: вместо молчаливого сбоя во время выполнения вызывающий получает предупреждение времени компиляции, сообщающее ему, что этот путь небезопасен при обрезке, и он может решить, стоит ли его избегать. Именно поэтому важно чистое распространение до публичных API, поскольку это подавляет общее IL2104: Assembly produced trim warnings и даёт потребителям точные сигналы по каждому методу.
Если паттерн в основном состоит из рефлексии, не аннотируйте себя вокруг него вызов за вызовом. Это сигнал заменить его генерацией кода во время компиляции. Сериализаторы и мапперы на основе рефлексии — канонический пример: вместо аннотирования обхода GetProperties() вы принимаете генератор исходного кода, который выдаёт код доступа во время сборки, и именно поэтому существует сгенерированный из исходного кода System.Text.Json и почему это рекомендуемый путь для AOT.
Запасные выходы и когда они становятся ловушкой
Два атрибута позволяют заглушить предупреждения, не меняя код, и оба остры.
[UnconditionalSuppressMessage] заглушает одно конкретное предупреждение и, в отличие от обычного [SuppressMessage], сохраняется в IL, поэтому trim-анализ его учитывает. Используйте его только когда поток данных реален и корректен, но анализатор не может за ним проследить, например Type[], каждый элемент которого, как вы знаете, по построению сохраняет свой конструктор:
// .NET 11, C# 14. Valid only because the setter guarantees the invariant.
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2063",
Justification = "The array only holds types stored through the annotated setter.")]
get => _types[i];
Документация прямолинейна насчёт режима отказа: подавлять допустимо только когда члены, над которыми выполняется рефлексия, являются настоящими целями рефлексии где-то ещё в программе. Член, который используется нерефлективно (обычным вызовом метода), не является допустимой целью подавления, потому что оптимизатор волен встроить его, переименовать или переместить, и тогда ваша подавленная рефлексия сломается без всякого предупреждения. Подавление “свойство используется приложением, значит оно должно быть там” — это в точности недопустимый паттерн, о котором предупреждает документация.
[DynamicDependency] — это последнее средство. Он сохраняет именованные члены, но не информирует анализ, поэтому сам по себе не заглушает предупреждения и используется вместе с подавлением. Прибегайте к нему только для паттернов, которые не может выразить даже [DynamicallyAccessedMembers], например загрузка члена по строке из отдельной сборки:
// .NET 11, C# 14. Last resort. Keeps Helper so the reflection below finds it.
[DynamicDependency("Helper", "MyType", "MyAssembly")]
static void RunHelper()
{
var helper = Assembly.Load("MyAssembly").GetType("MyType").GetMethod("Helper");
helper.Invoke(null, null);
}
Если вы ловите себя на том, что рассыпаете любой из этих атрибутов по многим местам вызова, это не исправление trim-безопасности, это запах дизайна, говорящий вам, что API фундаментально имеет форму рефлексии и вместо этого должен генерироваться во время сборки.
Переключатели возможностей фреймворка: обрезка кода, который вы никогда не вызываете
Не вся trim-безопасность касается вашей рефлексии. Часть фреймворка поставляется с директивами триммера за переключателями возможностей (feature switches), поэтому вы можете сказать триммеру удалить целые области функциональности, которые не используете. Они служат одновременно конфигурацией времени выполнения и инструкциями обрезки. Несколько, о которых стоит знать: <EventSourceSupport>false</EventSourceSupport> убирает обвязку EventSource, <UseSystemResourceKeys>true</UseSystemResourceKeys> урезает полный текст сообщений об исключениях из сборок System.* до идентификаторов ресурсов, <InvariantGlobalization>true</InvariantGlobalization> удаляет данные глобализации, а <StackTraceSupport>false</StackTraceSupport> (.NET 8+) удаляет генерацию трассировки стека во время выполнения. .NET 10 добавил <UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>, который меняет часть пропускной способности LINQ на меньший размер вывода и является значением по умолчанию под PublishAot. Это не о том, чтобы сделать ваш код анализируемым; это о том, чтобы не платить размером бинарника за возможности фреймворка, которых вы никогда не касаетесь. Включайте их осознанно, потому что каждый из них удаляет реальное поведение.
Рабочий процесс, который действительно доводит вас до trim-safe
Ментальная модель, которая делает всё это связным: trim-безопасность — это не переключатель, это состояние сборки, которое вы защищаете. Включите анализатор (PublishTrimmed для приложений, IsTrimmable плюс корневое тестовое приложение для библиотек). Прочитайте предупреждения и рассортируйте их: IL207x вы исправляете, аннотируя источник Type минимальными флагами DynamicallyAccessedMembers; IL2026/IL3050 вы либо аннотируете через [RequiresUnreferencedCode] и распространяете до публичного API, либо заменяете рефлексию генератором исходного кода; два запасных выхода вы используете только когда инвариант реален, а анализатор действительно не может его увидеть. Доведите счётчик до нуля, а затем удерживайте его там, потому что появление нового trim-предупреждения — это ломающее изменение для всех, кто публикует вашу библиотеку обрезанной, а обновление зависимости может вновь ввести его без всякого изменения API с вашей стороны. Когда небезопасный вызов проскальзывает мимо анализатора и падает только во время выполнения, вы возвращаетесь к отладке PlatformNotSupportedException в Native AOT, а это именно тот сбой, который призвана предотвратить сборка без предупреждений. А если целью является настоящий веб-сервис, пошаговое руководство по minimal API на Native AOT показывает рецепт чистой сборки от начала до конца. Trim-safe код — это не особый диалект C#; это обычный C#, который держит анализ достижимости триммера честным, а анализатор — это инструмент, который сообщает вам во время сборки, удалось ли вам это.
Похожие статьи
- Что такое Native AOT и чего он вам стоит? объясняет, почему обрезка обязательна под AOT и чем ещё вы жертвуете.
- Что такое атрибут DynamicallyAccessedMembers? — это подробный разбор аннотации, которая исправляет предупреждения потока данных IL207x.
- Что такое генератор исходного кода и когда он мне нужен? рассматривает генерацию кода во время компиляции, которая заменяет рефлексию, которую вы не можете аннотировать.
- Как использовать Native AOT с minimal API в ASP.NET Core — это пошаговое руководство по чистой сборке, где эти предупреждения появляются в реальном приложении.
- Исправление: PlatformNotSupportedException в Native AOT — это сбой во время выполнения, который предотвращает trim-сборка без предупреждений.
Источники
- Prepare .NET libraries for trimming, MS Learn (IsTrimmable, EnableTrimAnalyzer, the test-app pattern, RequiresUnreferencedCode, DynamicallyAccessedMembers, UnconditionalSuppressMessage, DynamicDependency).
- Trimming options, MS Learn (PublishTrimmed, TrimmerSingleWarn, ILLinkTreatWarningsAsErrors, the feature-switch table).
- Trim self-contained deployments and executables, MS Learn (TrimMode granularity and how reachability trimming works).
- Introduction to trim warnings, MS Learn (the IL2026/IL3050/IL207x warning catalog and the data-flow model).
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.