Source generators и Roslyn (как в analytics_generator)
↑ 2.1 C♯: продвинутый язык · ← Предыдущая · → Следующая
Зачем это на собесе
source generators заменяют рефлексию генерацией кода при компиляции: быстрее, AOT-совместимо, ошибки видны сразу. В проекте есть свой генератор (
analytics_generator), который по атрибутам профилей создаёт маппинг ресурсов в строки ClickHouse, реестр и диспетчер. Это сильная тема для рассказа на собесе.
Объяснение
Roslyn
Компилятор C# с открытым API: синтаксические деревья, семантическая модель (символы, типы). На нём построены анализаторы (warnings в IDE), code fixes и source generators.
Source generator
Компонент, который во время компиляции анализирует код проекта и добавляет новые файлы C# в компиляцию. Существующий код менять не может, только дополнять (поэтому связка с partial).
Современный API — IIncrementalGenerator: конвейер с кэшированием, пересчитываются только изменившиеся части. Это важно, чтобы IDE не тормозила.
Как устроен генератор в проекте (упрощённо)
[Generator(LanguageNames.CSharp)]
public sealed class AnalyticsRowGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// маркерный файл: доказывает, что генератор вообще загрузился
context.RegisterPostInitializationOutput(ctx => ctx.AddSource("__Loaded.g.cs", "// loaded"));
// найти классы с атрибутом [AnalyticsProfile(...)] — быстрый путь через ForAttributeWithMetadataName
var profiles = context.SyntaxProvider.ForAttributeWithMetadataName(
"analytics_models.AnalyticsProfileAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => Build(ctx, ct)) // семантическая модель → своя модель данных
.Where(static m => m is not null)
.Select(static (m, _) => m!);
// на каждый профиль — свой файл с маппингом
context.RegisterSourceOutput(profiles, static (spc, model) =>
spc.AddSource($"{model.TypeName}AnalyticsMap.g.cs", EmitMap(model)));
// по всем профилям вместе — реестр и диспетчер + диагностика дубликатов ключей
context.RegisterSourceOutput(profiles.Collect(), static (spc, all) =>
{
foreach (var d in FindDuplicateKeys(all)) spc.ReportDiagnostic(d);
spc.AddSource("AnalyticsRegistry.g.cs", EmitRegistry(all));
spc.AddSource("AnalyticsDispatcher.g.cs", EmitDispatcher(all));
});
}
}Что это даёт: при добавлении нового профиля (класс с атрибутами [AnalyticsColumn], [AnalyticsIgnore], [AnalyticsTemporal]) маппинг в колонки ClickHouse и регистрация в диспетчере появляются автоматически, без рефлексии в рантайме и без ручного кода. Ошибки (дубликат ключа) видны как ошибки компиляции.
Проект генератора
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework> <!-- требование: генератор грузится в компилятор -->
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IncludeBuildOutput>false</IncludeBuildOutput>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
</ItemGroup>
<!-- подключение в потребителе -->
<ProjectReference Include="..\analytics_generator\analytics_generator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />Посмотреть сгенерированный код: <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> → папка obj/.../generated.
Встроенные генераторы в .NET
System.Text.Json:[JsonSerializable(typeof(PatientDto))] partial class AppJsonContext : JsonSerializerContext— сериализация без рефлексии.[GeneratedRegex]— регулярка компилируется в код.[LoggerMessage]— высокопроизводительное логирование без боксинга.- Minimal API Request Delegate Generator, Configuration Binder Generator (для AOT).
- gRPC (
Grpc.Tools) — генерация клиентов и серверов из.proto(формально MSBuild-кодоген, но идея та же).
public static partial class Log
{
[LoggerMessage(EventId = 1001, Level = LogLevel.Information, Message = "Report {FormCode} built for {PatientId} in {Elapsed} ms")]
public static partial void ReportBuilt(ILogger logger, string formCode, Guid patientId, long elapsed);
}Нюансы и подводные камни
- Генератор, который молча «ничего не сгенерировал», — самая неприятная ошибка. Отсюда приём с маркерным файлом в
RegisterPostInitializationOutputи собственныеDiagnostic. - Модель данных конвейера должна быть сравнимой (records,
EquatableArray): иначе кэш инкрементального генератора не работает, и IDE тормозит. Нельзя тащить в модельISymbolиSyntaxNode. - Генератор выполняется внутри компилятора:
netstandard2.0, никаких тяжёлых зависимостей, исключение генератора = предупреждение и пустой вывод. - Отлаживать сложно: юнит-тесты генератора через
CSharpGeneratorDriverи снапшоты (Verify.SourceGenerators).
Тестирование
[Fact]
public Task Generates_map_for_profile()
{
var source = """
[AnalyticsProfile("encounters", "Encounter", "profile-url")]
public partial class EncounterProfile { [AnalyticsColumn("status")] public string Status { get; set; } }
""";
var compilation = CSharpCompilation.Create("test", [CSharpSyntaxTree.ParseText(source)], References);
var driver = CSharpGeneratorDriver.Create(new AnalyticsRowGenerator()).RunGenerators(compilation);
return Verify(driver); // snapshot сгенерированных файлов
}Вопросы с ответами
Что такое source generator и чем он лучше рефлексии?
Компонент Roslyn, который при компиляции анализирует код и добавляет новые исходники. В отличие от рефлексии, работа делается на этапе сборки: нет затрат в рантайме, совместимо с trimming и AOT, ошибки видны при компиляции.
Чем IIncrementalGenerator лучше старого ISourceGenerator?
Конвейер с кэшированием промежуточных результатов: пересчитываются только изменившиеся части. Это критично для производительности IDE при каждом нажатии клавиши.
Почему генератор должен таргетировать netstandard2.0?
Он загружается в процесс компилятора (включая Visual Studio на .NET Framework и MSBuild), которому нужна совместимая сборка.
Расскажите о генераторе в своём проекте.
Генератор находит классы-профили аналитики по атрибуту, строит модель колонок по атрибутам свойств и генерирует для каждого профиля маппинг ресурса в строку ClickHouse, а по всем профилям — реестр и диспетчер. Дубликаты ключей выдаются как ошибки компиляции. Результат: новый профиль подключается без ручного кода и без рефлексии.
Связанные темы
- Предыдущая: Атрибуты и рефлексия · Следующая: Сериализация: System.Text.Json и Newtonsoft
- Атрибуты и рефлексия: Атрибуты и рефлексия
- partial: Модификаторы доступа, static, sealed, partial
- ClickHouse: вставка данных: Вставка данных: батчи, async insert, почему нельзя по одной строке
- Snapshot-тесты (Verify): Snapshot-тесты: Verify
- Кодоген GraphQL на фронте (та же идея): graphql-codegen и graphql-request