Публікація

Source Generators в C# - від теорії до практики

Source Generators в C# - від теорії до практики

Source Generators були представлені в .NET 5 як інструмент для генерації коду під час компіляції, це інструмент для метапрограмування під час компіляції в C#. До їх появи розробники використовували різні підходи для генерації коду:

  • T4 Templates
  • PostSharp та інші AOP-фреймворки
  • Рефлексія під час виконання
  • Roslyn Analyzers

Кожен з цих підходів мав свої обмеження:

  • T4 Templates генерують код до компіляції
  • PostSharp модифікує IL код після компіляції
  • Рефлексія має overhead під час виконання
  • Roslyn Analyzers призначені більше для аналізу, ніж для генерації

Source Generators вирішують ці проблеми, яка дозволяє нам аналізувати код проєкту та генерувати додатковий код під час компіляції, з повним доступом до семантичної моделі коду. Це дає нам можливість:

  • Автоматизувати рутинні задачі
  • Покращити продуктивність, уникаючи рефлексії
  • Зменшити кількість бойлерплейт коду

Практичне використання


У цій статті ми розглянемо створення автоматичного маппера моделей - доволі частої задачі в сучасній розробці ПЗ.

Наш мапер буде:

  • Генерувати код під час компіляції
  • Працювати без рефлексії
  • Підтримувати базовий маппінг властивостей за іменами
  • Підтримувати типобезпечність

Налаштування проєкту


Для початку нам потрібно створити новий Solution з двома проєктами.

1
2
3
4
5
dotnet new sln -n Mapping
dotnet new classlib -o Mapping.SourceGenerators
dotnet new console -o Mapping.Consumer
dotnet sln add Mapping.SourceGenerators
dotnet sln add Mapping.Consumer

Налаштування Generator Project (Mapping.SourceGenerators.csproj)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        
        <IsPackable>false</IsPackable>
        <Nullable>enable</Nullable>
        <LangVersion>latest</LangVersion>
        
        <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
        <IsRoslynComponent>true</IsRoslynComponent>
        
        <RootNamespace>Mapping.SourceGenerators</RootNamespace>
        <PackageId>Mapping.SourceGenerators</PackageId>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0">
            <PrivateAssets>all</PrivateAssets>
            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
        </PackageReference>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0"/>
    </ItemGroup>

</Project>

Важливі налаштування та їх призначення:

1
<TargetFramework>netstandard2.0</TargetFramework>

Source Generators повинні бути сумісні з .NET Standard 2.0

Це забезпечує широку сумісність з різними версіями .NET

1
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>

Вмикає додаткові правила перевірки для аналізаторів

Допомагає виявити потенційні проблеми з продуктивністю та сумісністю

Рекомендується для всіх нових Source Generators

1
<IsRoslynComponent>true</IsRoslynComponent>

Позначає проект як компонент компілятора Roslyn

Активує специфічні оптимізації для Source Generators

Впливає на процес завантаження та виконання генератора

Важливі NuGet пакети для Source Generators

Microsoft.CodeAnalysis.Analyzers - це пакет, який містить набір аналізаторів для розробки компіляторних розширень, включаючи Source Generators.

Він забезпечує:

  • Правила та рекомендації для написання ефективних генераторів
  • Перевірки на типові помилки
  • Оптимізацію продуктивності

Microsoft.CodeAnalysis.CSharp - надає доступ до Roslyn Compiler API, що дозволяє:

  • Аналізувати C# код
  • Працювати з синтаксичним деревом
  • Отримувати семантичну модель

Налаштування Consumer Project (Mapping.Consumer)

1
2
3
4
5
<ItemGroup>
    <ProjectReference Include="..\Mapping.SourceGenerators\Mapping.SourceGenerators.csproj"
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false"/>
</ItemGroup>

Ключові параметри:

  • OutputItemType="Analyzer"
    • Вказує, що проект є аналізатором коду
    • Інтегрує генератор в процес компіляції
    • Дозволяє MSBuild правильно обробляти генератор
  • ReferenceOutputAssembly="false"
    • Запобігає включенню збірки генератора в результуючий проєкт
    • Важливо для уникнення конфліктів типів
    • Генератор використовується тільки під час компіляції

Реалізація Source Generator


IIncrementalGenerator vs ISourceGenerator

У прикладі ми використовуємо IIncrementalGenerator замість старішого ISourceGenerator.

Ключові переваги:

  • Інкрементальна генерація:
    • Обробляє тільки змінені файли
    • Кешує результати між компіляціями
    • Підтримує паралельне виконання
  • Кращий контроль над життєвим циклом:
    • Чіткіший API
    • Краща продуктивність
    • Менше споживання пам’яті

Детальний розбір коду

Базова структура та атрибути

1
2
3
4
5
6
7
[Generator]
public class MappingSourceGenerator : IIncrementalGenerator
{
    // Константи для конфігурації
    private const string Namespace = "Generators";
    private const string AttributeName = "MapFromAttribute";
}

Атрибут Generator - маркує клас як Source Generator для компілятора і також використовуємо IIncrementalGenerator для кращої продуктивності в порівнянні із ISourceGenerator

Генерація атрибута

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private const string AttributeSourceCode = $@"
namespace {Namespace}
{{
    [System.AttributeUsage(System.AttributeTargets.Class)]
    public class {AttributeName} : System.Attribute
    {{
        public System.Type SourceType {{ get; }}

        public {AttributeName}(System.Type sourceType)
        {{
            SourceType = sourceType;
        }}
    }}
}}";

Цей код генерує атрибут, який буде використовуватися для маркування класів, що потребують маппінгу.

Ініціалізація генератора

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void Initialize(IncrementalGeneratorInitializationContext context)
{
    // Реєструємо атрибут
    context.RegisterPostInitializationOutput(ctx => ctx.AddSource(
        $"{AttributeName}.g.cs",
        SourceText.From(AttributeSourceCode, Encoding.UTF8)));

    // Налаштовуємо пайплайн обробки
    var provider = context.SyntaxProvider
        .CreateSyntaxProvider(
            // Швидка перевірка: чи є нод класом?
            (s, _) => s is ClassDeclarationSyntax,
            // Детальний аналіз: перевірка атрибутів
            (ctx, _) => GetClassDeclarationForSourceGen(ctx))
        // Фільтруємо тільки класи з нашим атрибутом
        .Where(t => t.mapFromAttributeFound)
        .Select((t, _) => t.classDeclaration);

    // Реєструємо генерацію коду
    context.RegisterSourceOutput(
        context.CompilationProvider.Combine(provider.Collect()),
        (ctx, t) => GenerateCode(ctx, t.Left, t.Right));
}

Аналіз синтаксису та пошук атрибутів

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private static (ClassDeclarationSyntax classDeclaration, bool mapFromAttributeFound)
    GetClassDeclarationForSourceGen(GeneratorSyntaxContext context)
{
    // Отримуємо синтаксичне дерево класу
    var classDeclarationSyntax = (ClassDeclarationSyntax)context.Node;

    // Перебираємо всі атрибути класу
    foreach (var attributeSyntax in classDeclarationSyntax.AttributeLists
        .SelectMany(syntax => syntax.Attributes))
    {
        // Отримуємо інформацію про символ атрибута
        if (context.SemanticModel.GetSymbolInfo(attributeSyntax).Symbol 
            is not IMethodSymbol attributeSymbol)
        {
            continue;
        }

        // Перевіряємо чи це наш атрибут
        string attributeName = attributeSymbol.ContainingType.ToDisplayString();
        if (attributeName == $"{Namespace}.{AttributeName}")
            return (classDeclarationSyntax, true);
    }

    return (classDeclarationSyntax, false);
}

Генерація коду маппера

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private static void GenerateCode(SourceProductionContext context,
    Compilation compilation,
    ImmutableArray<ClassDeclarationSyntax> classDeclarations)
{
    foreach (var classDeclaration in classDeclarations)
    {
        // Отримуємо семантичну модель
        var semanticModel = compilation.GetSemanticModel(classDeclaration.SyntaxTree);
        var classSymbol = semanticModel.GetDeclaredSymbol(classDeclaration) as INamedTypeSymbol;

        if (classSymbol == null) continue;

        // Знаходимо атрибут та отримуємо тип-джерело
        var attribute = classSymbol.GetAttributes()
            .FirstOrDefault(a => a.AttributeClass?.Name == AttributeName);

        if (attribute?.ConstructorArguments[0].Value is not INamedTypeSymbol sourceType) 
            continue;

        // Генеруємо код маппера
        string mappingCode = GenerateMappingCode(sourceType, classSymbol);
        context.AddSource(
            $"{sourceType.Name}To{classSymbol.Name}Mapper.g.cs",
            SourceText.From(mappingCode, Encoding.UTF8));
    }
}

Генерація логіки маппінгу

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
private static string GenerateMappingCode(
  INamedTypeSymbol sourceType, 
  INamedTypeSymbol targetType
)
{
    // Отримуємо всі публічні властивості
    var sourceProperties = sourceType.GetMembers()
        .OfType<IPropertySymbol>()
        .Where(p => p.DeclaredAccessibility == Accessibility.Public)
        .ToList();

    var targetProperties = targetType.GetMembers()
        .OfType<IPropertySymbol>()
        .Where(p => p.DeclaredAccessibility == Accessibility.Public)
        .ToList();

    // Генеруємо код маппінгу властивостей
    var propertyMappings = new StringBuilder();
    foreach (var sourceProp in sourceProperties)
    {
        // Шукаємо відповідну властивість за іменем та типом
        var targetProp = targetProperties.FirstOrDefault(x =>
            x.Name == sourceProp.Name &&
            SymbolEqualityComparer.Default.Equals(x.Type, sourceProp.Type));

        if (targetProp != null)
        {
            propertyMappings.AppendLine(
                $"target.{targetProp.Name} = source.{sourceProp.Name};");
        }
    }

    // Генеруємо кінцевий код маппера
    return $@"
using System;

namespace {Namespace}
{{
    public static class {sourceType.Name}Extensions
    {{
        public static {targetType.Name} MapTo{targetType.Name}(
            this {sourceType.Name} source)
        {{
            if (source == null)
            {{    
                throw new ArgumentNullException(nameof(source));
            }}

            var target = new {targetType.Name}();
            {propertyMappings}
            return target;
        }}
    }}
}}";
}

Використання


Для використання нам потрібно створити два класи між якими ми хочемо мапити дані та викликати метод розширення який буде згенеровано автоматично за наступним патерном MapTo{targetType.Name}:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Модель даних
public class UserDto
{
    public int Id { get; set; }
    
    public string Name { get; set; }

    public string Surname { get; set; }
}

// Цільова модель з атрибутом для генерації мапера
[MapFrom(typeof(UserDto))]
public class UserViewModel
{
    public int Id { get; set; }

    public string Name { get; set; }

    public string Surname { get; set; }
}

// Використання згенерованого коду
var dto = new UserDto { Id = 1, Name = "Taras", Surname = "Kovalenko" };
var viewModel = dto.MapToUserViewModel();

Якщо все вірно зроблено то після збірки рішення ви повинні побачити згенерований код для мапінгу і також атрибут по якому і відбувається пошук моделей

sg-output

Переваги Використання Source Generators

  • Продуктивність:
    • Нульовий overhead під час виконання
    • Код генерується один раз під час компіляції
    • Немає затримок на рефлексію
  • Типобезпека:
    • Помилки виявляються на етапі компіляції
    • Повна підтримка IntelliSense
    • Легке рефакторинг
  • Підтримка:
    • Згенерований код можна переглядати та дебажити
    • Легко розширювати функціональність
    • Простіше тестування

Висновок


Source Generators - це потужний інструмент для автоматизації рутинних задач в .NET розробці.

Вони надають:

  • Високу продуктивність завдяки генерації під час компіляції
  • Типобезпеку та відмінну інтеграцію з IDE
  • Гнучкість у розширенні та модифікації

У порівнянні з традиційними підходами, Source Generators пропонують кращий баланс між продуктивністю, безпекою та зручністю використання.

Публікація захищена ліцензією CC BY 4.0 .