Привет! Я Дмитрий Сипаков, ведущий разработчик в Госсервисах Т-Банка. Расскажу про относительно новую фичу .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-инъекции. 

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