Start Debugging

Что такое атрибут DynamicallyAccessedMembers?

DynamicallyAccessedMembers сообщает триммеру .NET и AOT-компилятору, к каким членам типа Type вы обращаетесь через рефлексию, чтобы они были сохранены, а не удалены при тримминге. Он превращает молчаливое исключение MissingMethodException во время выполнения в предупреждение IL2070 на этапе сборки. Вот что делает этот атрибут, как работает лежащий в его основе анализ потока данных и как правильно аннотировать параметры, поля и параметры обобщённых типов.

[DynamicallyAccessedMembers] — это атрибут, который вы ставите на Type (или на имя типа в виде string), чтобы сообщить триммеру .NET и компилятору Native AOT: “я собираюсь обращаться к этим членам через рефлексию, поэтому не удаляй их”. Это контракт, который позволяет статическому анализу следовать за рефлексией, которую он иначе не может увидеть. Без него триммер удаляет любой член, достижимость которого он не может доказать, и ваш type.GetMethod("Run").Invoke(...) выбрасывает MissingMethodException во время выполнения в опубликованном приложении, хотя в dotnet run всё работало. С ним тот же код либо компилируется чисто, либо выдаёт точное предупреждение на этапе сборки (IL2070, IL2075, IL2077 и им подобные), указывающее на то самое место, где неаннотированный Type попадает в вызов рефлексии. Атрибут находится в System.Diagnostics.CodeAnalysis, поставляется в System.Runtime.dll и остаётся стабильным начиная с .NET 5. Всё нижеизложенное ориентировано на .NET 11 SDK (11.0.100) и C# 14, но сама механика применима начиная с .NET 6, где впервые стали выдаваться предупреждения анализа тримминга.

Зачем триммеру вообще нужна подсказка

Тримминг и сборка Native AOT, которая его требует, работают за счёт анализа достижимости. Триммер начинает с вашей точки входа, обходит каждый вызов метода, обращение к полю и ссылку на тип, которые он может увидеть статически, помечает всё, к чему прикоснулся, как “сохраняемое” и удаляет остальное. Именно так самодостаточное приложение уменьшается с 70 МБ до 15 МБ: фреймворк огромен, а ваше приложение использует лишь его малую часть.

Рефлексия ломает этот обход. Когда вы пишете type.GetMethods(), триммер не имеет представления о том, какой тип хранит type во время выполнения, поэтому он не может знать, какие методы сохранять. У него есть два варианта: сохранить каждый метод каждого типа, который потенциально может попасть в этот вызов (что полностью сводит на нет тримминг), либо не сохранять ничего и дать вам узнать об этом во время выполнения. Он не делает ни того, ни другого. Вместо этого методы BCL, которые используют рефлексию, сами аннотированы, и триммер требует, чтобы переданный им Type нёс соответствующее обещание. [DynamicallyAccessedMembers] — это то, как вы даёте это обещание.

Посмотрите на сигнатуру Type.GetMethods() в исходниках .NET, и вы увидите, что этот метод экземпляра фактически аннотирован так, чтобы требовать PublicMethods для this. Поэтому этот безобидный вспомогательный метод выдаёт предупреждение:

// .NET 11, C# 14. Compiled with <PublishTrimmed>true</PublishTrimmed>.
static void UseMethods(Type type)
{
    // warning IL2070: 'this' argument does not satisfy
    // 'DynamicallyAccessedMemberTypes.PublicMethods' in call to
    // 'System.Type.GetMethods()'. The parameter 'type' of method
    // 'UseMethods(Type)' does not have matching annotations.
    foreach (var method in type.GetMethods())
    {
        // ...
    }
}

Триммер словно говорит: я вот-вот позволю тебе перечислить публичные методы того, чем бы ни был type, но никто не пообещал, что эти методы переживут тримминг. Аннотируй источник этого Type, чтобы обещание было закреплено в системе типов.

Решение: укажите требование на параметре

Решение состоит в том, чтобы скопировать требование на параметр, который поставляет Type:

// .NET 11, C# 14.
using System.Diagnostics.CodeAnalysis;

static void UseMethods(
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
    foreach (var method in type.GetMethods()) // no warning
    {
        // ...
    }
}

Теперь UseMethods внутренне удовлетворён, но требование не исчезает. Оно перемещается к вызывающим. Любой, кто вызывает UseMethods, должен передать ему Type, про который само известно, что он сохраняет свои публичные методы. В этом и состоит вся модель: атрибут сам по себе ничего не сохраняет. Это аннотация потока, которая проталкивает обязательство вверх по цепочке вызовов, пока оно не достигнет точки, где конкретный тип известен.

Где обязательство действительно заканчивается

Требование перестаёт распространяться в одном из двух мест. Первое — это typeof. Когда вы пишете typeof(Customer), триммер знает точный тип и может сохранить всё, что требует место назначения:

// .NET 11. typeof gives the trimmer a concrete type, so it keeps
// Customer's public methods and the call is clean.
UseMethods(typeof(Customer));

Второе — это граница публичного API. Если Type приходит из параметра, поля или возвращаемого значения метода, вы аннотируете и это место, и цепочка продолжается наружу. Вот случай с полем, который как раз и упускают:

// .NET 11, C# 14.
static Type _type = typeof(Customer);

static void UseMethodsHelper()
{
    // warning IL2077: 'type' argument does not satisfy
    // 'DynamicallyAccessedMemberTypes.PublicMethods' in call to 'UseMethods(Type)'.
    // The field '_type' does not have matching annotations.
    UseMethods(_type);
}

Поле не несёт никакого обещания, поэтому передача его в аннотированный параметр вызывает предупреждение. Аннотируйте поле, и теперь триммер будет вместо этого обеспечивать выполнение обещания при каждом присваивании в это поле:

// .NET 11, C# 14.
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)]
static Type _type = typeof(Customer); // assignment of a concrete type: clean

static void UseMethodsHelper() => UseMethods(_type); // clean

Присвойте этому аннотированному полю что-то, о чём триммер не может рассуждать (неаннотированный параметр Type, Type.GetType("…") из строки времени выполнения), и вы получите предупреждение в месте присваивания. Теперь обещание несёт нагрузку в обоих направлениях.

Флаги DynamicallyAccessedMemberTypes

Единственный аргумент конструктора — это перечисление с атрибутом [Flags], поэтому вы комбинируете значения через |, чтобы сохранить ровно то, к чему обращается ваша рефлексия, и ничего больше. Основные значения и их числовые флаги:

ЗначениеЧто сохраняется
NoneНичего.
PublicParameterlessConstructorПубличный конструктор по умолчанию (то, что нужно Activator.CreateInstance(type)).
PublicConstructorsВсе публичные конструкторы.
NonPublicConstructorsВсе непубличные конструкторы.
PublicMethods / NonPublicMethodsПубличные / непубличные методы.
PublicFields / NonPublicFieldsПубличные / непубличные поля.
PublicProperties / NonPublicPropertiesПубличные / непубличные свойства.
PublicEvents / NonPublicEventsПубличные / непубличные события.
PublicNestedTypes / NonPublicNestedTypesВложенные типы.
InterfacesИнтерфейсы, которые реализует тип.
AllВсё (значение -1).

Правило таково: запрашивать минимум. Если всё, что вы делаете, — это Activator.CreateInstance(type), запрашивайте PublicParameterlessConstructor, а не All. Каждый добавленный вами флаг — это члены, которые триммеру запрещено удалять, а это размер бинарного файла, за который вы платите в итоговом приложении. All — это ленивый ответ, который тихо сводит на нет большую часть выгоды от тримминга для этого типа.

В .NET 9 и 10 добавилось второе семейство флагов ...WithInherited и All..., например AllMethods, AllConstructors и NonPublicMethodsWithInherited. Обычный флаг PublicMethods уже включает унаследованные публичные методы, потому что они являются частью публичной поверхности типа, но непубличные варианты исторически не обходили базовые классы. Флаги WithInherited закрывают этот пробел, когда вы используете рефлексию над унаследованными приватными или защищёнными членами. Прибегайте к ним только тогда, когда ваша рефлексия действительно пересекает границу наследования.

Аннотирование параметров обобщённых типов и возвращаемых значений

Атрибут не ограничивается параметрами и полями. Его AttributeUsage охватывает параметры, поля, свойства, возвращаемые значения, обобщённые параметры, классы, интерфейсы, структуры и методы. Два из них стоит выделить.

Параметр обобщённого типа может нести требование, и именно так вы пишете фабрику на основе рефлексии, которая остаётся безопасной для тримминга:

// .NET 11, C# 14.
using System.Diagnostics.CodeAnalysis;

static T Create<[DynamicallyAccessedMembers(
    DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>()
{
    return Activator.CreateInstance<T>(); // clean
}

// The constraint flows to the call site. typeof is implicit in the type argument.
var c = Create<Customer>(); // trimmer keeps Customer's parameterless ctor

Применение атрибута к методу — это документированный особый случай: он трактуется как применённый к параметру this, поэтому имеет смысл только на методах экземпляра типа, приводимого к Type. Цель в виде возвращаемого значения позволяет методу дать обещание о Type, который он возвращает:

// .NET 11, C# 14. The caller can reflect over public methods of the returned Type.
[return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)]
static Type ResolveHandler(string name) =>
    name == "customer" ? typeof(Customer) : typeof(Order);

Когда шаблон по-настоящему нельзя аннотировать

Иногда поток данных реален и корректен, но анализатор не может за ним проследить, например Type[], где вы по построению знаете, что каждый элемент сохраняет свой конструктор, но триммер не может увидеть этот инвариант. Для таких случаев [UnconditionalSuppressMessage] подавляет конкретное предупреждение и, в отличие от [SuppressMessage], сохраняется в IL, поэтому анализ тримминга его учитывает:

// .NET 11, C# 14.
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2063",
    Justification = "The array only ever holds types stored through the annotated setter.")]
get => _types[i];

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

Другой запасной выход — это [DynamicDependency], который сохраняет именованные члены, но не информирует анализ. Это крайнее средство для шаблонов, которые даже [DynamicallyAccessedMembers] не может выразить, например загрузка члена по строке из отдельной сборки:

// .NET 11, C# 14.
[DynamicDependency("Helper", "MyType", "MyAssembly")]
static void RunHelper()
{
    var helper = Assembly.Load("MyAssembly").GetType("MyType").GetMethod("Helper");
    helper.Invoke(null, null);
}

Если вы обнаруживаете, что прибегаете к любому из этих запасных выходов во множестве мест вызова, это сигнал того, что API по своей сути имеет форму рефлексии и должен быть заменён генерацией кода на этапе компиляции. Сериализаторы и мапперы на основе рефлексии — канонический пример: вместо того чтобы аннотациями обходить обходы GetProperties(), вы переходите на генератор исходного кода, который выдаёт код доступа на этапе сборки. Если этот термин для вас новый, что такое генератор исходного кода и когда он вам нужен — это вводная статья, и именно по этой причине существует System.Text.Json с генерацией исходного кода.

Как это связано с RequiresUnreferencedCode

Легко спутать [DynamicallyAccessedMembers] с [RequiresUnreferencedCode]. Они решают смежные проблемы. [DynamicallyAccessedMembers] — для анализируемой рефлексии: вы точно знаете, к каким членам Type обращаетесь, поэтому вы это указываете, и триммер их сохраняет. [RequiresUnreferencedCode] — для рефлексии, которую вообще нельзя сделать безопасной для тримминга, признание того, что метод делает нечто, что триммер не может смоделировать. Аннотирование метода им ничего не исправляет; оно распространяет предупреждение IL2026 на каждого вызывающего, точно так же, как [DynamicallyAccessedMembers] распространяет своё требование, пока предупреждение не достигнет публичного API, где автор библиотеки сможет задокументировать ограничение. Используйте [DynamicallyAccessedMembers], когда вы можете выразить требование, и прибегайте к [RequiresUnreferencedCode] только тогда, когда вы по-настоящему не можете.

Практический рабочий процесс тот же, что управляет всей историей тримминга и AOT: включите анализатор, относитесь к каждому предупреждению IL2xxx как к настоящему дефекту, а не к шуму, и сведите их количество к нулю, прежде чем выпускать продукт. Установите <IsTrimmable>true</IsTrimmable> для библиотеки, чтобы получать предупреждения, ограниченные этим проектом, или соберите небольшое тестовое приложение для тримминга с <PublishTrimmed>true</PublishTrimmed> и записью TrimmerRootAssembly, чтобы увидеть все предупреждения по всему графу зависимостей. Чистая сборка — это контракт. Когда несовместимый с AOT вызов проскальзывает мимо анализатора и падает только во время выполнения, вы снова оказываетесь за отладкой PlatformNotSupportedException в Native AOT, а это именно то молчаливое падение, которое эти аннотации и призваны предотвратить. А если вы настраиваете это для реального веб-сервиса, как использовать Native AOT с минимальными API ASP.NET Core проводит по рецепту чистой сборки от начала до конца.

Ментальная модель, благодаря которой всё это становится на свои места: [DynamicallyAccessedMembers] не сохраняет код. Он распространяет требование вдоль потока значения Type, от вызова рефлексии обратно туда, где этот Type родился, и сохранение происходит только в typeof или конкретном присваивании, где требование наконец приземляется. Сделайте этот поток правильно, и тримминг перестанет быть источником загадочных падений только в продакшене и станет возможностью компилятора, которой вы действительно сможете доверять.

Связанное

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад