
Привет! Я Дмитрий Сипаков, ведущий разработчик в Госсервисах Т-Банка. Расскажу про относительно новую фичу .NET — Interceptors: как они работают вместе со Source Generators и как с их помощью можно избавиться от бойлерплейта без IL-инъекций и сторонней магии.
Задача: автоматизировать шаги в Allure
Разберем кейс. Допустим, мы пишем автотесты и хотим формировать отчет для Allure TestOps. Помимо тестовых методов, в отчет нужно добавлять промежуточные шаги для отладки или передачи дополнительной информации.
Allure TestOps — Test Management System (TMS) для управления тестами приложения. Отчеты по тестам строятся на основании Allure reports. Основные возможности:
управление тест-кейсами;
планирование тестирования;
анализ результатов и отчетов (allure reports);
интеграция с CI/CD;
выявление flaky-тестов.
Если решать задачу в лоб, код выглядит примерно так:
[Fact] public async Task Test() { await Step1(123); } public async Task Step1(int i) { var parameters = new List<Allure.Net.Commons.Parameter>(); parameters.Add(new Allure.Net.Commons.Parameter { name = "defined", value = i.ToString() }); Allure.Net.Commons.ExtendedApi.StartStep("Step1", step => step.parameters = parameters); try { await Task.Yield(); Allure.Net.Commons.ExtendedApi.PassStep(); } catch(Exception) { Allure.Net.Commons.ExtendedApi.FailStep(); throw; } }
На небольшом примере — терпимо. Но если шагов становится много, копировать одинаковый код в каждый метод уже неудобно.
Почему в задаче напрашивается AOP
Иногда приходится многократно повторять один и тот же шаблон: начать шаг, собрать параметры, обработать успех, обработать ошибку. Это классический случай для аспектно-ориентированного программирования.
Аспектно-ориентированное программирование (АОП) — парадигма программирования, основная идея которой — разделение функциональности для улучшения разбиения программы на модули.
Аспект — это модуль, который реализует сквозную функциональность (в нашем примере это логирование шага в отчет), не затрагивая основную логику.
Идея проста: мы хотим повесить на метод атрибут вроде AllureStep, а дальше чтобы код автоматически «оборачивался» в нужное логирование и отправлялся в TestOps.
Посмотрим на пример:
public class AsyncStepsTest { [Fact] public async Task Test() { await Step1(123); } [AllureStep("Step1")] private static async Task Step1([Name("defined")] int i) { await Task.Yield(); } }
В отчете мы хотим увидеть шаг Step1 с параметром defined=123. Пример фрагмента JSON может выглядеть так:
{ "name": "Step1", "status": "passed", //...остальные поля "stage": "finished", "steps": [], "attachments": [], "parameters": [ { "name": "defined", "value": "123", "excluded": false } ], "start": 1771570531445, "stop": 1771570531446 }
Мы хотим, чтобы при компиляции AllureStep оборачивал метод в готовую обертку с логированием шага. Это уже очень похоже на AOP.
Почему IL-инъекции могут быть неудобны
Когда говорим о реализации АОП в разрезе .NET, то можно выделить две библиотеки: AspectInjector и PostSharp, они основаны на подходе IL-инъекций.
IL (Intermediate Language) Injection — низкоуровневая техника внедрения логики программы в скомпилированный IL-код. Мы собираем приложение и после этого в IL внедряется необходимый аспект.
К примеру, в PostSharp есть аспект OnMethodBoundaryAspect, который имеет методы OnEntry и OnExit.
Создаем атрибут аспекта:
[Serializable] public class LogAttribute : OnMethodBoundaryAspect { public override void OnEntry(MethodExecutionArgs args) { Console.WriteLine($"Вход в метод: {args.Method.Name}"); } public override void OnExit(MethodExecutionArgs args) { Console.WriteLine($"Выход из метода: {args.Method.Name}"); } }
Затем навешиваем атрибут на метод:
class Program { [Log] static void DoWork() { Console.WriteLine("Выполняется работа..."); } static void Main(string[] args) { DoWork(); } }
Если скомпилировать проект и открыть сборку через ILSpy, увидим, что вызовы OnEntry и OnExit внедрены в IL. Именно это и называется IL-инъекцией.
Проблема в том, что такой код не виден в исходниках и появляется только после компиляции. Это усложняет сопровождение и диагностику.
Недостатки IL-инъекций:
внедренный код не видно в исходниках, только после компиляции;
стек вызовов может быть искажен;
ReSharper, Roslyn, SonarQube не видят внедренный код и могут пропустить уязвимости или ошибки;
сложность с поддержкой новых версий .NET или с сохранением обратной совместимости;
проблемы с JIT-оптимизациями.
Source Generators решают часть проблем
Чтобы уйти от перечисленных ограничений, в .NET 5 появились Source Generators. В отличие от IL-инъекций, Source Generators создают код на этапе компиляции. После этого сгенерированные файлы можно открыть, отрефакторить и проверить их поведение. Они видны в IDE и доступны всем инструментам анализа кода.
Эта технология стала очень популярной в экосистеме .NET. На ней строят решения не только Microsoft, но и множество независимых разработчиков. Многие проекты уходят от Reflection в сторону Source Generators, что помогает повысить производительность.
В статье я не буду акцентировать внимание на том, как сделать свой Source Generator, сосредоточимся на решении проблемы: как сделать аспект.
Source Generator по-настоящему стала важной фичей, но она имеет один существенный недостаток, который кроется в самом названии: мы не можем менять существующий код.
Разберем на примере, что это значит. У нас есть исходный код:
public class AsyncStepsTest { [Fact] public async Task Test() { await Step1(123); } [AllureStep("Step1")] private static async Task Step1([Name("defined")] int i) { await Task.Yield(); } }
А вот что мог бы сгенерировать Source Generator:
public static async Task Step1([Name("defined")] int i) { var parameters = new List<Allure.Net.Commons.Parameter>(); parameters.Add(new Allure.Net.Commons.Parameter { name = "defined", value = i.ToString() }); Allure.Net.Commons.ExtendedApi.StartStep("Step1", step => step.parameters = parameters); try { await Task.Yield(); Allure.Net.Commons.ExtendedApi.PassStep(); } catch(Exception) { Allure.Net.Commons.ExtendedApi.FailStep(); throw; } }
Логика аспекта сгенерирована, но оригинальный метод остался нетронутым. И дальше возникает ключевой вопрос: как заставить тесты вызывать именно сгенерированный код, а не оригинал?
На первый взгляд может показаться, что поможет partial class. Но это не решает проблему. Метод нельзя просто «перекрыть» через partial так, чтобы компилятор начал подставлять новую реализацию вместо старой. Оригинальный и сгенерированный метод начнут конфликтовать.
Interceptors: недостающий элемент
Получается, что Source Generator не АОП, возвращаться к IL-инъекциям больно. И вот здесь появляются Interceptors. В .NET 8 эта возможность пришла как preview, а в .NET 9 стала полноценной частью платформы.
Идея Interceptors простая: можно указать, какой метод должен быть перехвачен другим методом. В результате компилятор перепишет вызов так, чтобы вместо оригинала вызывался нужный interceptor. Это уже очень похоже на то, что нам нужно.
В .NET 8 фича была доступна в preview, поэтому в проекте нужно было явно включить соответствующий флаг:
<PropertyGroup> <TargetFramework>net8.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <IsPackable>false</IsPackable> <Features>InterceptorsPreview</InterceptorsNamespaces> </PropertyGroup>
Дальше нужно создать атрибут перехвата:
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] sealed class InterceptsLocationAttribute(string filePath, int line, int column) : Attribute { }
Здесь filePath указывает на файл, где расположен перехватываемый метод, а line и column задают его координаты в файле. Затем создается static class с методом-перехватчиком:
static class Interception { [InterceptsLocation(@"C:\ConsoleApp\TestProject\UnitTest1.cs", line: 4, column: 5)] public static void Step1([Name("defined")] int i) { await Task.Yield(); // здесь пишем уже наш аспект } }
При сборке компилятор подставит вызов перехватчика вместо оригинального метода. Но здесь есть важный нюанс: сигнатура interceptor-метода должна полностью совпадать с сигнатурой перехватываемого метода. Кроме того, перехватчик должен находиться в правильном namespace.
Если изменить оригинальный файл, например добавить отступы, переносы строк или новые строки, координаты могут съехать, и все сломается. Это одна из причин, почему в .NET 9 API был переработан.
В .NET 9 Interceptors вышли из preview, и способ подключения стал удобнее. Теперь в проекте достаточно указать namespace, где находятся методы, которые мы хотим перехватывать:
<PropertyGroup> <TargetFramework>net9.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <IsPackable>false</IsPackable> <InterceptorsNamespaces>$(InterceptorsNamespaces);TestProject1</InterceptorsNamespaces> </PropertyGroup>
Теперь нам нужен Source Generator, который будет искать методы с атрибутом AllureStep, определять их местоположение и генерировать перехватчики автоматически.
Как генератор создает атрибут перехвата
Сначала сгенерируем сам атрибут перехватчика:
[global::System.Diagnostics.Conditional("DEBUG")] [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)] sealed file class InterceptsLocationAttribute : global::System.Attribute { public InterceptsLocationAttribute(int version, string data) { _ = version; _ = data; } }
Сигнатура упростилась: появились version и data. Не нужно вычислять их вручную — это делает сам генератор. Дальше генератор ищет все вызовы нужного метода в дереве синтаксиса.
private List<InvocationExpressionSyntax> GetInvocationsForMethod( Compilation compilation, IMethodSymbol methodSymbol, CancellationToken ct) { var result = new List<InvocationExpressionSyntax>(); foreach (var tree in compilation.SyntaxTrees) { var model = compilation.GetSemanticModel(tree); foreach (var node in tree.GetRoot(ct).DescendantNodes()) { if (node is InvocationExpressionSyntax invocation) { var invokedSymbol = model.GetSymbolInfo(invocation, ct).Symbol as IMethodSymbol; if (invokedSymbol != null && SymbolEqualityComparer.Default.Equals(invokedSymbol, methodSymbol)) { result.Add(invocation); } } } } return result; }
Из Compilation мы получаем все места вызова метода. Затем берем список invocation-выражений и строим обертку:
var invocationExpressions = GetInvocationsForMethod(compilation, method, context.CancellationToken); var model = compilation.GetSemanticModel(methodSyntax.SyntaxTree); foreach (var invocation in invocationExpressions) { // Получаем позицию вызова var location = model.GetInterceptableLocation(invocation); if (location != null) { var wrapped = WrapMethod(methodSyntax, method, location.Version, location.Data); methodsBuilder.AppendLine(wrapped); } }
Метод GetInterceptableLocation возвращает полную информацию о месте вызова, включая Data и Version. Именно эти значения и используются в атрибуте перехвата. Если вызов не может быть перехвачен, метод вернет null, поэтому проверка обязательна.
Внутри WrapMethod добавляем атрибут к сгенерированному методу:
return $@" {attributes} [System.Runtime.CompilerServices.InterceptsLocation({version}, ""{locationData}"")] public static {returnType} {method.Identifier.Text}({classSignatureOriginal}{methodParameters}) {{ {newBody} }}";
Метод-перехватчик должен полностью повторять сигнатуру оригинального метода, поэтому мы сохраняем все параметры и передаем их в генератор.
Что получилось в итоге и ограничения подхода
Исходный код:
public class AsyncStepsTest { [Fact] public async Task Test() { await Step1(123); } [AllureStep("Step1")] private static async Task Step1([Name("defined")] int i) { await Task.Yield(); } }
Сгенерированный код:
namespace System.Runtime.CompilerServices { [global::System.Diagnostics.Conditional("DEBUG")] [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)] sealed file class InterceptsLocationAttribute : global::System.Attribute { public InterceptsLocationAttribute(int version, string data) { _ = version; _ = data; } } } namespace TestProject1 { public static class AsyncStepsTestGenerated { [AllureStep("Step1")] [System.Runtime.CompilerServices.InterceptsLocation(1, "E8F6MzL5PEiXqlbd94aMNh0BAABVbml0VGVzdDEuY3M=")] public static async Task Step1([Name("defined")] int i) { var parameters = new List<Allure.Net.Commons.Parameter>(); parameters.Add(new Allure.Net.Commons.Parameter { name = "defined", value = i.ToString() }); Allure.Net.Commons.ExtendedApi.StartStep("Step1", step => step.parameters = parameters); try { await Task.Yield(); Allure.Net.Commons.ExtendedApi.PassStep(); } catch(Exception) { Allure.Net.Commons.ExtendedApi.FailStep(); throw; } } } }
После запуска тестов в allure-results мы увидим шаг Step1 с параметрами. Из примеров видно, что в атрибуте InterceptsLocationAttribute мы указываем AllowMultiple = true — это нужно для множественных перехватов, если у вас Step1 вызывается в нескольких местах.
Если посмотреть на результат через ILSpy, будет видно, что сгенерированный код почти не изменился, а вот в оригинальном классе вызов был подменен:
public static class AsyncStepsTestGenerated { [AllureStep("Step1")] [<AsyncStepsTest_AllureGenerated>F6685BD4543E2AFCFA3025AAB3D6BC3426E6F4A6FDC825606995182F7112E28E8__InterceptsLocation(1, "E8F6MzL5PEiXqlbd94aMNh0BAABVbml0VGVzdDEuY3M=")] public static async Task Step1([Name("defined")] int i) { List<Parameter> parameters = new List<Parameter>(); parameters.Add(new Parameter { name = "defined", value = i.ToString() }); ExtendedApi.StartStep("Step1", delegate(StepResult step) { step.parameters = parameters; }); try { await Task.Yield(); ExtendedApi.PassStep(); } catch (Exception) { ExtendedApi.FailStep(); throw; } } }
public class UnitTest1 { public class AsyncStepsTest { [Fact] public async Task Test() { await AsyncStepsTestGenerated.Step1(123); } [AllureStep("Step1")] private static async Task Step1([Name("defined")] int i) { await Task.Yield(); } } }
То есть компилятор подставляет перехватчик в Test, а не вызывает оригинальный метод напрямую. Это возможно потому, что класс с перехватчиками должен быть static.
Ограничения подхода:
Можно перехватывать только код, который виден на этапе компиляции. Методы из зависимых библиотек таким способом не перехватить, потому что это все еще генерация кода, а не runtime-подмена.
Если метод вообще не вызывается внутри проекта, он не будет перехвачен.
Например, такой код не сработает, и шаги не попадут в отчет:
public class UnitTest1 { public class AsyncStepsTest { [Fact] [Allure("Step1")] public async Task Test() { await Task.Yield(); } [Fact] [AllureStep("Step2")] public async Task Test2() { await Task.Yield(); } } }
Итоги
Несмотря на ограничения, Interceptors оказались для меня очень полезными. Они позволяют реализовать аспектное поведение без IL-инъекций, без сторонних runtime-хаков и без потери контроля над кодом.
Source Generators отвечают за генерацию логики, а Interceptors — за перенаправление вызова. Вместе они дают именно тот уровень гибкости, который нужен для аккуратной автоматизации шагов в отчетах.
Надеюсь, статья была полезной и помогла лучше понять, как Interceptors и Source Generators решают задачу, которую раньше приходилось закрывать через IL-инъекции.