Привет, Хабр! Открываете JIT‑логи свежезапущенного ASP.NET Core сервиса и видите, что внушительная часть тиков тратится на построение метаданных через рефлексию: типы пробегаются по GetType, свойства собираются через GetProperties, делегаты компилируются через Expression.Compile.

Это работает давно и для большинства сценариев работает быстро после прогрева. Проблема в том, что в современных условиях прогрев перестал быть допустимой роскошью, и от рефлексии в горячем пути приходится постепенно избавляться.

За последние пять с лишним лет, начиная с.NET 5 и появления source generators в C# 9, стандартная библиотека постепенно переходит на кодогенерацию во время компиляции вместо рефлексии в runtime. К моменту выхода .NET 10 в ноябре 2025 source generators проникли в JSON‑сериализацию, логирование, regex, конфигурацию, минимальные API, EF Core и десяток других мест.

Разберём, почему так получилось, как это устроено внутри и какие изменения в подходе к коду это влечёт за собой.

Чем source generator отличается от рефлексии

Рефлексия работает во время исполнения программы. Когда JsonSerializer.Serialize(obj) встречает незнакомый тип, runtime обращается к метаданным сборки, читает список свойств, для каждого создаёт делегат‑аксессор, кэширует результат и применяет его к объекту. Первый вызов медленный, последующие быстрые за счёт кэша. Стоимость: время на построение кэша, память на хранение делегатов, невозможность статического анализа.

Source generator работает во время компиляции. Это специальный класс, который реализует интерфейс IIncrementalGenerator, регистрируется в проекте через [Generator] и получает на вход синтаксическое дерево всего проекта. Генератор анализирует код, находит интересующие его конструкции (например, классы с атрибутом [JsonSerializable]) и выдаёт дополнительные C#‑файлы, которые компилируются вместе с исходными. На выходе получается обычный код, без рефлексии, без runtime‑кэшей и без задержки на первый вызов.

Принципиальная разница в том, что рефлексия откладывает работу до момента исполнения и платит за гибкость производительностью, source generator выполняет работу заранее и платит за производительность ограничением: типы должны быть известны во время компиляции. Для подавляющего большинства сценариев в реальном коде это ограничение не критично.

Как System.Text.Json избавился от рефлексии

Сериализация JSON долгие годы оставалась эталонным случаем оправданной рефлексии. Универсальный сериализатор должен уметь работать с произвольными типами, и без чтения метаданных типов в runtime сделать это нельзя. В.NET 6 Microsoft выпустил source‑generated подход к сериализации, и теперь его рекомендуют как основной для всех новых проектов.

Старый подход:

public record Order(int Id, string Customer, decimal Total);

string json = JsonSerializer.Serialize(new Order(1, "Alice", 99.99m));

При первом вызове JsonSerializer.Serialize runtime рефлексией собирает информацию о свойствах Order, строит конвертеры и сериализаторы, всё кэширует в JsonSerializerOptions. Прогретый путь быстрый, холодный старт ощутимый.

Новый подход через source generator:

public record Order(int Id, string Customer, decimal Total);

[JsonSerializable(typeof(Order))]
public partial class AppJsonContext : JsonSerializerContext
{
}

string json = JsonSerializer.Serialize(
    new Order(1, "Alice", 99.99m),
    AppJsonContext.Default.Order
);

При компиляции source generator System.Text.Json находит атрибут [JsonSerializable], генерирует partial‑расширение для AppJsonContext, в котором лежат готовые сериализаторы для Order и всех типов, на которые он ссылается. Никакой рефлексии в runtime, никакой инициализации кэша, никаких задержек на первый вызов. Сгенерированный код можно открыть прямо в IDE: в Visual Studio и Rider есть навигация в сгенерированные файлы через Go to Definition.

На самых базовых сценариях REST API сериализация через source generator проходит на двадцать‑сорок процентов быстрее по холодному пути, потребляет меньше памяти и совместима с Native AOT. Последнее особенно важно: рефлексия в AOT‑режиме либо не работает, либо требует громоздких хинтов в проектном файле, source generator работает без оговорок.

В.NET 10 эта связка стала рекомендуемой по умолчанию для новых проектов, а в шаблонах ASP.NET Core минимальные API теперь генерируют JsonSerializerContext автоматически.

LoggerMessage attribute и производительность логирования

Стандартный код логирования выглядит так:

logger.LogInformation("Processed order {OrderId} for {Customer} with total {Total:C}", 
    order.Id, order.Customer, order.Total);

Под капотом LogInformation принимает массив object[] для аргументов, проверяет уровень логирования, при необходимости боксит value‑типы (order.Id и order.Total), парсит шаблон сообщения, форматирует. Если уровень логирования отключён, бокс всё равно происходит до проверки. Старый совет был обворачивать вызовы в if (logger.IsEnabled(...)), но это засоряло код.

В.NET 6 появился source generator для логирования через атрибут [LoggerMessage]:

public partial class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger) => _logger = logger;

    [LoggerMessage(
        EventId = 1001,
        Level = LogLevel.Information,
        Message = "Processed order {OrderId} for {Customer} with total {Total:C}")]
    public partial void LogOrderProcessed(int orderId, string customer, decimal total);

    public void ProcessOrder(Order order)
    {
        LogOrderProcessed(order.Id, order.Customer, order.Total);
    }
}

Source generator видит [LoggerMessage] на partial‑методе и генерирует реализацию: проверка IsEnabled происходит первой, бокса аргументов нет (строго типизированные параметры передаются напрямую), шаблон сообщения парсится во время компиляции, форматирование оптимизировано под конкретный набор аргументов. На горячих путях логирования разница достигает десятков наносекунд на вызов, что в сервисах с миллионами запросов в секунду превращается в проценты CPU.

Дополнительная фича в том, что статический анализатор подсвечивает несоответствие между параметрами шаблона и аргументами метода прямо в IDE. Если в шаблоне {OrderId}, а в параметре order_id (с подчёркиванием), анализатор это поймает на этапе сборки, а не в проде через месяц.

Regex compiles to code

Регулярные выражения в.NET долгое время предлагали выбор: интерпретация или compiled‑режим. Интерпретация быстро стартует, медленно работает на больших объёмах. Compiled генерирует IL во время первого вызова, что даёт ускорение, но добавляет паузу на компиляцию и не работает с AOT.

В.NET 7 появился source generator для regex:

public partial class EmailValidator
{
    [GeneratedRegex(@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")]
    public static partial Regex Email();

    public bool IsValid(string input) => Email().IsMatch(input);
}

Во время компиляции генератор парсит регулярное выражение, строит из него специализированный код на C# и подставляет в partial‑метод. Получается obvious‑код без интерпретатора и без отложенной IL‑компиляции. Стартует мгновенно, работает быстрее compiled‑режима в большинстве случаев, прекрасно работает с Native AOT.

Если регулярка некорректна, ошибка появляется во время сборки с указанием места проблемы в строке шаблона, а не в runtime при первом срабатывании. Это редкая, но приятная мелочь.

Биндинг конфигурации без рефлексии

IConfiguration.Get<MyOptions>() исторически использовал рефлексию для построения биндинга свойств из ключей конфигурации. В.NET 8 появился source generator для конфигурации, в.NET 10 он стал стабильнее и применяется в шаблонах по дефолту.

public sealed class DatabaseOptions
{
    public string ConnectionString { get; init; } = "";
    public int CommandTimeout { get; init; } = 30;
    public bool EnableRetry { get; init; } = true;
}

// В Program.cs
builder.Services.Configure<DatabaseOptions>(
    builder.Configuration.GetSection("Database"));

С включённым EnableConfigurationBindingGenerator в проекте source generator анализирует все вызовы Configure<T>, GetSection().Get<T>() и генерирует код биндинга специально для конкретных типов. Никакой рефлексии, никакой динамической диспетчеризации, полная совместимость с AOT.

Включается это одним флагом в csproj:

<PropertyGroup>
  <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>

После этого веб‑приложение стартует на пятнадцать‑двадцать миллисекунд быстрее за счёт устранения работы рефлексии при инициализации опций.

Что нового в.NET 10

В.NET 10 source generators получили два важных обновления.

Первое: новый API AddEmbeddedAttributeDefinition() для авторов генераторов. Раньше при создании source generator с собственным marker‑атрибутом возникала проблема: атрибут нужен и в проекте пользователя для маркировки, и в самом генераторе для распознавания, а делиться сборками между генератором (netstandard2.0) и пользовательским проектом сложно. Стандартное решение состояло в том, чтобы сам генератор добавлял исходник атрибута в компиляцию пользователя. Это работало, но усложняло код. Новый API решает задачу одной строкой и снимает целый пласт boilerplate‑кода в собственных генераторах.

Второе: partial events. C# 14 разрешает разделять объявление и реализацию event так же, как это давно работало для методов и свойств. Это открывает дорогу source generators, которые генерируют реализации event‑ов, что раньше требовало гораздо более громоздкого workaround через partial‑методы. Применений много: автоматические event‑агрегаторы, model‑binding события для UI‑фреймворков, mediator‑паттерны.

В.NET 10 также появился новый тестовый фреймворк TUnit, построенный полностью на source generators. Он обнаруживает тесты во время компиляции, исключая рефлексию из этапа discovery, что критично для AOT‑сценариев и просто заметно ускоряет старт тестового прогона. Для команд, начинающих новые проекты на.NET 10, TUnit становится одной из реальных альтернатив xUnit и NUnit.

Когда писать свой source generator

Большинство разработчиков никогда не напишет свой source generator, и это нормально. Но иногда смысл появляется: повторяющийся boilerplate, который хочется генерировать, маппинг между типами, валидация на основе атрибутов, генерация API‑клиентов из OpenAPI, генерация INPC‑кода для view‑моделей в WPF и MAUI.

Минимальный каскад генератора:

[Generator]
public class MyGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var classesWithAttribute = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "MyLib.MyAttribute",
                predicate: static (node, _) => node is ClassDeclarationSyntax,
                transform: static (ctx, _) => GetClassInfo(ctx))
            .Where(static info => info is not null);

        context.RegisterSourceOutput(classesWithAttribute, (ctx, info) =>
        {
            var source = GenerateSource(info!);
            ctx.AddSource($"{info.ClassName}.g.cs", source);
        });
    }
}

Главные правила: использовать IIncrementalGenerator, а не устаревший ISourceGenerator, потому что инкрементальный API не пересобирает всё дерево при каждом изменении файла.

Когда source generator не подходит

Не всегда замена рефлексии на генератор имеет смысл. Сценарии, где рефлексия остаётся уместной:

  • Плагинные системы с динамической загрузкой сборок: типы становятся известны только в runtime, генератор тут просто не сможет их увидеть.

  • Инструменты интроспекции работающего приложения: профайлеры, отладчики, какие‑нибудь системы динамического конфигурирования.

  • Библиотечный код, который должен работать с произвольными user‑defined типами: например, общий контейнер DI, который принимает любые типы потребителей. Заставлять каждого пользователя писать source generator под свои типы непрактично, поэтому популярные DI‑контейнеры всё равно используют compiled expressions.

  • Маленькие редкие операции, где сложность Roslyn‑генератора не окупается ни производительностью, ни читаемостью кода.


Source generators за пять лет прошли путь от эксперимента до базового инструмента.NET, и в 2026 году это уже не «фича для энтузиастов», а стандарт того, как стандартная библиотека генерирует то, что раньше собирала рефлексией в runtime. JSON, логи, regex, конфигурация: везде кодогенерация во время компиляции стала приоритетной.

Если в проекте до сих пор JsonSerializer.Serialize без контекста и logger.LogInformation без [LoggerMessage], переход на source generators обычно занимает пару дней работы, окупается холодным стартом и снимает целый класс проблем при будущем переходе на Native AOT. Сама рефлексия никуда не делась и не денется, она остаётся правильным инструментом для динамических сценариев и плагинных систем, но для статически известных типов её место уже занято кодогенерацией.

А как у вас в проектах с source generators? Уже мигрировали на JsonSerializerContext и [LoggerMessage], или всё ещё откладываете? Делитесь в комментах, особенно если упирались в нетривиальные ограничения генераторов на сложных типах.

Если хотите увереннее чувствовать себя в C# и лучше понимать, как работают его базовые механизмы на практике, приходите на бесплатные уроки от преподавателей курсов OTUS:

  • 2 июля в 20:00 — «Методы, их перегрузка и расширения». Записаться
    Разберем, как устроены методы в C#, когда использовать перегрузку и чем полезны методы расширения.

  • 16 июля в 20:00 — «Коллекции и структуры данных на C#». Записаться
    Покажем, как правильно выбирать структуры данных и какие решения помогают избежать проблем с производительностью.

Полный список бесплатных уроков июня смотрите в дайджесте.