Source Generators: Compile-Time Code Generation
Source Generators: Compile-Time Code Generation
Вступ: Наступний Рівень Продуктивності
У попередньому розділі ми навчилися замінювати рефлексію на compiled expressions для отримання швидкості прямого виклику. Але що, якщо ми можемо зовсім уникнути runtime overhead?
Source Generators (Генератори Вихідного Коду) — це compile-time технологія, введена в .NET 5 та C# 9, що дозволяє генерувати C# код під час компіляції на основі аналізу існуючого коду.
Порівняння Підходів
| Підхід | Коли виконується | Overhead | Типобезпека | IDE Support |
|---|---|---|---|---|
| Reflection | Runtime | 50-100x | ❌ | ❌ |
| Expression Trees | Runtime (компіляція 1 раз) | 1.2x після | ✅ | Partial |
| Source Generators | Compile-Time | 0x | ✅ | ✅ Full |
Що Таке Source Generator?
Source Generator — це спеціальний .NET компонент (NuGet пакет), що:
- Аналізує ваш код під час компіляції через Roslyn API
- Генерує новий C# код (додаткові класи, методи)
- Додає згенерований код до компіляції
- НЕ модифікує існуючий код (тільки додає новий)
Реальні Приклади
| Фреймворк | Що генерує | Навіщо |
|---|---|---|
| System.Text.Json | Серіалізатори для типів | Швидша серіалізація без рефлексії |
| ASP.NET Core Minimal APIs | Endpoint handlers | Оптимізація request processing |
| Entity Framework Core | Compiled models | Швидший startup без runtime model building |
| MVVM Toolkit | Property/Command boilerplate | Менше рукописного коду |
| Mapperly | Object-to-object mappers | AutoMapper без runtime overhead |
Roslyn API: Компілятор як Бібліотека
Roslyn — це компілятор C#/VB.NET, написаний на C#, який надає API для аналізу та модифікації коду.
Основні Концепції
Ієрархія:
- SyntaxTree — дерево синтаксису (парсинг коду як текст)
- Compilation — вся інформація про проєкт (збірки, референси)
- SemanticModel — семантична інформація (типи, символи)
- 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!"
Execute:System.Diagnostics.Debugger.Launch(); // Attach debugger
IIncrementalGenerator: Сучасний Підхід
.NET 6+ ввів IIncrementalGenerator — більш ефективну та композитну альтернативу ISourceGenerator.
Чому Incremental?
Проблема: При зміні одного файлу компілятор запускає генератор знову. Якщо генератор аналізує весь проєкт → повільно.
Рішення: Incremental generators кешують результати та перевираховують тільки змінені частини.
Структура 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:
- CreateSyntaxProvider — фільтрує syntax nodes (тут: всі класи)
- RegisterSourceOutput — викликає
Executeдля кожного відфільтрованого node - 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: Пошук Цільових Типів
ForAttributeWithMetadataName (Recommended)
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: Повільний семантичний аналіз (з типами)
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 |
"""). Для складних — 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
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-Time | Runtime Overhead | IDE Support | Use Case |
|---|---|---|---|---|
| T4 Templates | ✅ | None | ❌ Poor | Legacy projects |
| Reflection | ❌ | Very High | Partial | Flexibility needed |
| Expression Trees | ❌ | Low (after compile) | Partial | Runtime mapping |
| Source Generators | ✅ | None | ✅ Full | Modern .NET |
Резюме
- Boilerplate код, що повторюється (property changed, builders)
- Performance-critical сценарії (серіалізація, маппінг)
- Framework-level features (DI, endpoints)
- Одноразова генерація (краще T4 templates)
- Динамічна логіка (краще рефлексія/expression trees)
- Прості задачі (snippet expansion достатньо)
Додаткові Ресурси
- Офіційна документація Source Generators
- Source Generator Cookbook
- Roslyn API Docs
- Community Toolkit MVVM Generators - Production example
- Mapperly - AutoMapper альтернатива на Source Generators
- Source Generator Playground - Online IDE для експериментів
Попередня тема: Expression Trees: Швидка Альтернатива Рефлексії
Наступна тема: Multithreading (Low Level) (розділ 6.3)
Expression Trees: Швидка Альтернатива Рефлексії
Expression Trees та компільовані вирази як high-performance заміна рефлексії. System.Linq.Expressions API, побудова виразів програмно, компіляція в делегати, та практичні кейси.
Multithreading Fundamentals
Глибокий розбір класу Thread, життєвого циклу потоків, пріоритетів та основ багатопотокового програмування в .NET