System Internals Concurrency

Source Generators: Compile-Time Code Generation

Roslyn Source Generators для генерації C# коду на етапі компіляції. IIncrementalGenerator API, syntax receivers, трансформації, debugging, та production use cases.

Source Generators: Compile-Time Code Generation

Вступ: Наступний Рівень Продуктивності

У попередньому розділі ми навчилися замінювати рефлексію на compiled expressions для отримання швидкості прямого виклику. Але що, якщо ми можемо зовсім уникнути runtime overhead?

Source Generators (Генератори Вихідного Коду) — це compile-time технологія, введена в .NET 5 та C# 9, що дозволяє генерувати C# код під час компіляції на основі аналізу існуючого коду.

Loading diagram...
graph LR
    A[Your Code] -->|Compilation| B[Roslyn Compiler]
    B --> C[Source Generator]
    C -->|Analyzes| A
    C -->|Generates| D[New Code]
    D --> B
    B -->|Output| E[DLL/EXE]

    style A fill:#64748b,stroke:#334155,color:#ffffff
    style B fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
    style D fill:#10b981,stroke:#059669,color:#ffffff
    style E fill:#64748b,stroke:#334155,color:#ffffff

Порівняння Підходів

ПідхідКоли виконуєтьсяOverheadТипобезпекаIDE Support
ReflectionRuntime50-100x
Expression TreesRuntime (компіляція 1 раз)1.2x післяPartial
Source GeneratorsCompile-Time0x✅ Full
Золота Ідея: Source Generators створюють звичайний C# код, який компілюється разом з вашим проєктом. Жодного runtime overhead, повний IntelliSense, debugger працює як для звичайного коду.

Що Таке Source Generator?

Source Generator — це спеціальний .NET компонент (NuGet пакет), що:

  1. Аналізує ваш код під час компіляції через Roslyn API
  2. Генерує новий C# код (додаткові класи, методи)
  3. Додає згенерований код до компіляції
  4. НЕ модифікує існуючий код (тільки додає новий)

Реальні Приклади

ФреймворкЩо генеруєНавіщо
System.Text.JsonСеріалізатори для типівШвидша серіалізація без рефлексії
ASP.NET Core Minimal APIsEndpoint handlersОптимізація request processing
Entity Framework CoreCompiled modelsШвидший startup без runtime model building
MVVM ToolkitProperty/Command boilerplateМенше рукописного коду
MapperlyObject-to-object mappersAutoMapper без runtime overhead
ви вже використовуєте Source Generators: Якщо ви працюєте з .NET 6+, багато фреймворків вже генерують код для вас "під капотом"!

Roslyn API: Компілятор як Бібліотека

Roslyn — це компілятор C#/VB.NET, написаний на C#, який надає API для аналізу та модифікації коду.

Основні Концепції

Loading diagram...
graph TD
    A[Source Code Text] -->|Parse| B[SyntaxTree]
    B -->|Analyze| C[Compilation]
    C --> D[SemanticModel]
    C --> E[Symbols]

    B -->|Syntax Nodes| F[ClassDeclarationSyntax]
    B -->|Syntax Nodes| G[MethodDeclarationSyntax]

    D -->|Type Info| H[ITypeSymbol]
    E -->|Metadata| I[IMethodSymbol]

    style A fill:#64748b,stroke:#334155,color:#ffffff
    style B fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
    style D fill:#10b981,stroke:#059669,color:#ffffff

Ієрархія:

  1. SyntaxTree — дерево синтаксису (парсинг коду як текст)
  2. Compilation — вся інформація про проєкт (збірки, референси)
  3. SemanticModel — семантична інформація (типи, символи)
  4. Symbols — ITypeSymbol, IMethodSymbol (метадані про типи/методи)

Різниця з Expression Trees:

  • Expression Trees: Runtime API для виразів
  • Roslyn: Compile-time API для всього коду

Створення Першого Source Generator

Крок 1: Створення Проєкту

Генератор має бути окремою бібліотекою типу netstandard2.0:

dotnet new classlib -n MyGenerator
cd MyGenerator
dotnet add package Microsoft.CodeAnalysis.CSharp
dotnet add package Microsoft.CodeAnalysis.Analyzers

Важливо: У .csproj додати:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <IsRoslynComponent>true</IsRoslynComponent>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
  </ItemGroup>
</Project>

Пояснення:

  • Рядок 3: netstandard2.0 — вимога Roslyn
  • Рядок 5: Позначає це як Roslyn компонент
  • Рядок 10: PrivateAssets="all" — не передавати залежності споживачам

Крок 2: Простий Генератор "Hello World"

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using System.Text;

namespace MyGenerator;

[Generator]
public class HelloWorldGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)
    {
        // Опціонально: реєстрація callbacks
    }

    public void Execute(GeneratorExecutionContext context)
    {
        // Генеруємо код
        string sourceCode = @"
namespace Generated
{
    public static class HelloWorld
    {
        public static string GetMessage() => ""Hello from Source Generator!"";
    }
}";

        context.AddSource("HelloWorld.g.cs", SourceText.From(sourceCode, Encoding.UTF8));
    }
}

Пояснення:

  • Рядок 7: [Generator] — атрибут для позначення генератора
  • Рядок 8: Реалізація ISourceGenerator (застарілий, але простіший інтерфейс)
  • Рядок 29: AddSource — додає згенерований файл до компіляції

Крок 3: Використання в Проєкті

У вашому основному проєкті:

<ItemGroup>
  <ProjectReference Include="..\MyGenerator\MyGenerator.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>
using Generated;

Console.WriteLine(HelloWorld.GetMessage());
// Output: "Hello from Source Generator!"
Debugging: Додайте до Execute:
System.Diagnostics.Debugger.Launch(); // Attach debugger

IIncrementalGenerator: Сучасний Підхід

.NET 6+ ввів IIncrementalGenerator — більш ефективну та композитну альтернативу ISourceGenerator.

Чому Incremental?

Проблема: При зміні одного файлу компілятор запускає генератор знову. Якщо генератор аналізує весь проєкт → повільно.

Рішення: Incremental generators кешують результати та перевираховують тільки змінені частини.

Loading diagram...
graph LR
    A[File A Changed] --> B{Incremental Pipeline}
    C[File B Unchanged] --> B

    B -->|Cached| D[Result for B]
    B -->|Recompute| E[New Result for A]

    D --> F[Final Output]
    E --> F

    style A fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#64748b,stroke:#334155,color:#ffffff
    style B fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style F fill:#10b981,stroke:#059669,color:#ffffff

Структура IIncrementalGenerator

using Microsoft.CodeAnalysis;

[Generator]
public class MyIncrementalGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 1. Створити pipeline для аналізу коду
        var classDeclarations = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: (node, _) => node is ClassDeclarationSyntax,
                transform: (ctx, _) => (ClassDeclarationSyntax)ctx.Node
            );

        // 2. Зареєструвати generation step
        context.RegisterSourceOutput(classDeclarations, Execute);
    }

    private void Execute(SourceProductionContext context, ClassDeclarationSyntax classDecl)
    {
        // 3. Генерувати код для кожного класу
        string className = classDecl.Identifier.Text;
        string code = $"// Generated for {className}";
        context.AddSource($"{className}.g.cs", code);
    }
}

Pipeline Explanation:

  1. CreateSyntaxProvider — фільтрує syntax nodes (тут: всі класи)
  2. RegisterSourceOutput — викликає Execute для кожного відфільтрованого node
  3. Incremental кешування відбувається автоматично

Практичний Приклад: ToString Generator

Створимо генератор, що створює ToString() для класів з атрибутом [GenerateToString].

Marker Attribute

Спочатку створюємо атрибут (в основному проєкті або згенерований):

namespace MyAttributes;

[AttributeUsage(AttributeTargets.Class)]
public class GenerateToStringAttribute : Attribute { }

Generator Implementation

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Linq;
using System.Text;

namespace MyGenerator;

[Generator]
public class ToStringGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Фільтруємо класи з атрибутом [GenerateToString]
        var classesWithAttribute = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "MyAttributes.GenerateToStringAttribute",
                predicate: (node, _) => node is ClassDeclarationSyntax,
                transform: GetClassInfo
            )
            .Where(info => info is not null);

        context.RegisterSourceOutput(classesWithAttribute, Execute!);
    }

    private static ClassInfo? GetClassInfo(
        GeneratorAttributeSyntaxContext context,
        CancellationToken ct)
    {
        if (context.TargetNode is not ClassDeclarationSyntax classDecl)
            return null;

        var symbol = context.TargetSymbol as INamedTypeSymbol;
        if (symbol is null) return null;

        // Отримати всі публічні властивості
        var properties = symbol.GetMembers()
            .OfType<IPropertySymbol>()
            .Where(p => p.DeclaredAccessibility == Accessibility.Public)
            .Select(p => p.Name)
            .ToImmutableArray();

        return new ClassInfo(
            symbol.ContainingNamespace.ToDisplayString(),
            symbol.Name,
            properties
        );
    }

    private void Execute(SourceProductionContext context, ClassInfo classInfo)
    {
        var sb = new StringBuilder();

        sb.AppendLine("// <auto-generated/>");
        sb.AppendLine($"namespace {classInfo.Namespace}");
        sb.AppendLine("{");
        sb.AppendLine($"    partial class {classInfo.ClassName}");
        sb.AppendLine("    {");
        sb.AppendLine("        public override string ToString()");
        sb.AppendLine("        {");

        // Генерувати інтерполяцію
        sb.Append($"            return $\"{classInfo.ClassName} {{ ");

        var propStrings = classInfo.Properties
            .Select(p => $"{p} = {{{p}}}");

        sb.Append(string.Join(", ", propStrings));
        sb.AppendLine(" }\";");

        sb.AppendLine("        }");
        sb.AppendLine("    }");
        sb.AppendLine("}");

        context.AddSource($"{classInfo.ClassName}.ToString.g.cs", sb.ToString());
    }

    private record ClassInfo(
        string Namespace,
        string ClassName,
        ImmutableArray<string> Properties
    );
}

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

using MyAttributes;

[GenerateToString]
public partial class User
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
}

// Згенерований код (User.ToString.g.cs):
// partial class User
// {
//     public override string ToString()
//     {
//         return $"User { Id = {Id}, Name = {Name} }";
//     }
// }

User user = new() { Id = 1, Name = "Alice" };
Console.WriteLine(user); // "User { Id = 1, Name = Alice }"

Ключові моменти:

  • Рядок 4: partial class — необхідно для генераторів
  • Генератор: Додає метод ToString до існуючого класу
  • Результат: Compile-time генерація, IntelliSense працює

Syntax Analysis: Пошук Цільових Типів

context.SyntaxProvider.ForAttributeWithMetadataName(
    fullyQualifiedMetadataName: "MyNamespace.MyAttribute",
    predicate: (node, _) => node is ClassDeclarationSyntax,
    transform: (ctx, ct) => /* ... */
);

Переваги:

  • Оптимізовано компілятором
  • Automatic incremental caching
  • Не потрібно вручну перевіряти атрибути

CreateSyntaxProvider (Manual Filtering)

var targetNodes = context.SyntaxProvider.CreateSyntaxProvider(
    predicate: (node, _) =>
    {
        // Фільтр на рівні синтаксису (швидко)
        return node is ClassDeclarationSyntax cls &&
               cls.AttributeLists.Count > 0;
    },
    transform: (ctx, _) =>
    {
        // Семантичний аналіз (повільно, але кешується)
        var classDecl = (ClassDeclarationSyntax)ctx.Node;
        var symbol = ctx.SemanticModel.GetDeclaredSymbol(classDecl);

        return symbol?.GetAttributes()
            .Any(a => a.AttributeClass?.Name == "MyAttribute") == true
            ? symbol
            : null;
    }
).Where(s => s is not null);

Predicate vs Transform:

  • Predicate: Швидкий синтаксичний фільтр (без типів)
  • Transform: Повільний семантичний аналіз (з типами)
Best Practice: Фільтруйте максимум в predicate, щоб мінімізувати дорогі transform виклики.

Генерація Складного Коду

StringBuilder vs Roslyn SyntaxFactory

Підхід 1: StringBuilder (простіше):

var sb = new StringBuilder();
sb.AppendLine("public class MyClass");
sb.AppendLine("{");
sb.AppendLine("    public int Id { get; set; }");
sb.AppendLine("}");

Підхід 2: SyntaxFactory (типобезпечніше):

using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;

ClassDeclarationSyntax classDecl = ClassDeclaration("MyClass")
    .AddModifiers(Token(SyntaxKind.PublicKeyword))
    .AddMembers(
        PropertyDeclaration(
            PredefinedType(Token(SyntaxKind.IntKeyword)),
            "Id"
        )
        .AddModifiers(Token(SyntaxKind.PublicKeyword))
        .AddAccessorListAccessors(
            AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
                .WithSemicolonToken(Token(SyntaxKind.SemicolonToken)),
            AccessorDeclaration(SyntaxKind.SetAccessorDeclaration)
                .WithSemicolonToken(Token(SyntaxKind.SemicolonToken))
        )
    );

string code = classDecl.NormalizeWhitespace().ToFullString();

Порівняння:

МетодПлюсиМінуси
StringBuilderПростіше, читабельнішеTypo-prone, no validation
SyntaxFactoryТипобезпека, валідаціяVerbosity, складніший API
Рекомендація: Для простих генераторів використовуйте StringBuilder з raw string literals ("""). Для складних — SyntaxFactory або Template Engines (Scriban, T4).

Debugging Source Generators

Метод 1: EmitCompilerGeneratedFiles

У .csproj:

<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

Згенеровані файли з'являться в obj/Generated/.

Метод 2: Debugger.Launch()

public void Execute(...)
{
    if (!System.Diagnostics.Debugger.IsAttached)
    {
        System.Diagnostics.Debugger.Launch();
    }
    // ...
}

При компіляції відкриється вікно для attach debugger.

Метод 3: Unit Testing

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Xunit;

public class GeneratorTests
{
    [Fact]
    public void GeneratesToString_ForMarkedClass()
    {
        string source = @"
using MyAttributes;

[GenerateToString]
public partial class User
{
    public string Name { get; set; }
}";

        var compilation = CreateCompilation(source);
        var generator = new ToStringGenerator();

        GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
        driver = driver.RunGenerators(compilation);

        var result = driver.GetRunResult();

        Assert.Single(result.GeneratedTrees);
        Assert.Contains("public override string ToString()",
            result.GeneratedTrees[0].ToString());
    }

    private static Compilation CreateCompilation(string source)
    {
        var syntaxTree = CSharpSyntaxTree.ParseText(source);
        var references = AppDomain.CurrentDomain.GetAssemblies()
            .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
            .Select(a => MetadataReference.CreateFromFile(a.Location));

        return CSharpCompilation.Create("TestAssembly",
            new[] { syntaxTree },
            references,
            new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
    }
}

Real-World Use Cases

1. Minimal API Endpoint Registration

[GenerateEndpoint("/api/users", HttpMethod.Get)]
public static IResult GetUsers(IUserService service)
{
    return Results.Ok(service.GetAll());
}

// Генератор створює:
// app.MapGet("/api/users", GetUsers);

2. AutoMapper без Runtime

[GenerateMapper(typeof(UserDto))]
public partial class User
{
    public int Id { get; set; }
    public string Name { get; set; }

    // Генерується:
    // public UserDto ToDto() => new() { Id = this.Id, Name = this.Name };
}

3. Dependency Injection Registration

[Service(ServiceLifetime.Scoped)]
public class UserRepository : IUserRepository
{
    // ...
}

// Генератор створює в статичному класі:
// services.AddScoped<IUserRepository, UserRepository>();

Best Practices

1. Partial Classes

Генеруйте код у partial класах, щоб користувач міг додавати власну логіку.

2. Namespaces

Зберігайте namespace оригінального типу у згенерованому коді.

3. Error Reporting

Використовуйте context.ReportDiagnostic() для інформативних помилок.

4. Incremental

Завжди використовуйте IIncrementalGenerator для performance.

Error Reporting Example

private void Execute(SourceProductionContext context, ClassInfo classInfo)
{
    if (!classInfo.IsPartial)
    {
        var diagnostic = Diagnostic.Create(
            new DiagnosticDescriptor(
                id: "GEN001",
                title: "Class must be partial",
                messageFormat: "Class '{0}' must be declared as partial",
                category: "Generator",
                DiagnosticSeverity.Error,
                isEnabledByDefault: true
            ),
            Location.None,
            classInfo.ClassName
        );

        context.ReportDiagnostic(diagnostic);
        return;
    }

    // Генерувати код...
}

Обмеження Source Generators

Що НЕ можна:❌ Модифікувати існуючий код користувача
❌ Видаляти код
❌ Читати файли поза проєктом (обмежений доступ до файлової системи)
❌ Виконувати async операції
❌ Мати залежності від NuGet пакетів (тільки netstandard2.0 compatible)Що можна:✅ Додавати нові файли
✅ Розширювати partial типи
✅ Аналізувати syntax trees
✅ Читати додаткові файли (AdditionalFiles)

Практичні Завдання

Рівень 1: Property Changed Generator

Створіть генератор для INotifyPropertyChanged:

[GenerateNotifyPropertyChanged]
public partial class ViewModel
{
    private string _name;
    // Генератор створить:
    // public string Name
    // {
    //     get => _name;
    //     set { _name = value; OnPropertyChanged(); }
    // }
}

Рівень 2: Builder Pattern Generator

[GenerateBuilder]
public partial class User
{
    public int Id { get; init; }
    public string Name { get; init; }
}

// Генерується UserBuilder:
// var user = new UserBuilder()
//     .WithId(1)
//     .WithName("Alice")
//     .Build();

Рівень 3: Validation Generator

public partial class RegisterDto
{
    [Required]
    [EmailAddress]
    public string Email { get; set; }

    // Генерується метод:
    // public IEnumerable<ValidationError> Validate() { ... }
}

Порівняння з Іншими Підходами

ПідхідCompile-TimeRuntime OverheadIDE SupportUse Case
T4 TemplatesNone❌ PoorLegacy projects
ReflectionVery HighPartialFlexibility needed
Expression TreesLow (after compile)PartialRuntime mapping
Source GeneratorsNone✅ FullModern .NET

Резюме

Source Generators Compile-time технологія для генерації C# коду на основі Roslyn API. Нульовий runtime overhead.
IIncrementalGenerator Сучасний інтерфейс з automatic caching. Перевираховує тільки змінені частини для швидшої компіляції.
Use Cases Серіалізація, DI реєстрація, маппінг, boilerplate reduction (INotifyPropertyChanged, ToString, Builders).
Limitations Тільки додавання файлів, не модифікація. Обмежені залежності. Синхронний API.
Коли використовувати:
  • Boilerplate код, що повторюється (property changed, builders)
  • Performance-critical сценарії (серіалізація, маппінг)
  • Framework-level features (DI, endpoints)
Коли НЕ використовувати:
  • Одноразова генерація (краще T4 templates)
  • Динамічна логіка (краще рефлексія/expression trees)
  • Прості задачі (snippet expansion достатньо)

Додаткові Ресурси


Попередня тема: Expression Trees: Швидка Альтернатива Рефлексії

Наступна тема: Multithreading (Low Level) (розділ 6.3)