Start Debugging

Переход от интерполяции строк в ILogger к шаблонам сообщений структурированного журналирования в .NET 11

Пошаговое руководство по переводу вызовов ILogger с $-интерполяцией на шаблоны сообщений и методы, сгенерированные через [LoggerMessage], в .NET 11: что ломается, как пройтись по кодовой базе с CA2254, как проверить состояние JSON и как откатиться.

Каждый _logger.LogInformation($"Order {orderId} failed for {customerId}") в вашей кодовой базе выбрасывает ровно те два поля, которые понадобятся вам, когда сработает оповещение. Это руководство переводит кодовую базу .NET 11 (SDK 11.0.100-preview.6, C# 14) с интерполированных вызовов журналирования на шаблоны сообщений, а затем переводит горячие пути на методы, сгенерированные через [LoggerMessage]. В сервисе среднего размера проход по шаблонам занимает полдня почти механических правок, которыми управляет CA2254, а проход с генератором исходного кода занимает ещё день, если делать его как следует. Ничего рискованного здесь нет: исправление не ломает совместимость, каждый шаг откатывается независимо, а выигрыш в том, что ваш backend журналов наконец может фильтровать по OrderId, а не искать grep-ом отрендеренные фразы.

Почему интерполяция теряет данные, которые вам нужны

Если вы ещё не решили, куда идут журналы, разберитесь с этим сначала. Структурированное журналирование с Serilog и Seq и OpenTelemetry с .NET 11 и бесплатным backend оба предполагают, что шаблоны из этого руководства уже корректны.

Что на самом деле выдают две формы

Вот минимальное воспроизведение. Одно и то же намерение, два стиля вызова, пропущенные через форматтер JsonConsole в .NET 11.

// .NET 11 preview 6, C# 14
int orderId = 4711;
string customerId = "acme-inc";

// Interpolated: the template IS the rendered sentence.
_logger.LogInformation($"Order {orderId} failed for {customerId}");

// Message template: placeholders survive as named properties.
_logger.LogInformation("Order {OrderId} failed for {CustomerId}", orderId, customerId);

Первый вызов выдаёт состояние с единственной бесполезной записью:

{
  "LogLevel": "Information",
  "Message": "Order 4711 failed for acme-inc",
  "State": {
    "Message": "Order 4711 failed for acme-inc",
    "{OriginalFormat}": "Order 4711 failed for acme-inc"
  }
}

Второй вызов выдаёт поля:

{
  "LogLevel": "Information",
  "Message": "Order 4711 failed for acme-inc",
  "State": {
    "Message": "Order 4711 failed for acme-inc",
    "OrderId": 4711,
    "CustomerId": "acme-inc",
    "{OriginalFormat}": "Order {OrderId} failed for {CustomerId}"
  }
}

Отрендеренное Message одинаково. Всё, что делает журнал пригодным для запросов, живёт в разнице.

Что ломается

ОбластьИзменениеСерьёзность
Точки вызова с $"..."Должны стать константным шаблоном плюс аргументывысокая (по объёму, не по риску)
Запросы и панели журналовСохранённые поиски по отрендеренному тексту продолжают работать; новые фильтры по свойствам надо строитьсредняя
Правила оповещений на {OriginalFormat}Строка шаблона меняется, поэтому правила точного совпадения со старым отрендеренным текстом перестают срабатыватьсредняя
Конкатенация строк в шаблонах"Order " + id + " failed" это тот же дефект, и его ловит то же правилосредняя
Переход на [LoggerMessage]Содержащий класс и метод должны стать partial; метод должен возвращать voidнизкая
Значения EventIdДублирующиеся идентификаторы внутри сборки порождают предупреждения генераторанизкая
Деструктуризация @ в SerilogСемантика {@Order} отличается от перечисления состояния в Microsoft.Extensions.Loggingнизкая

Ничто из этого не является ломающим изменением во время выполнения. Правило Roslyn, которое ведёт весь проход, CA2254, явно задокументировано как неломающее исправление.

Подготовительный чек-лист

Шаги миграции

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

  1. Сделайте CA2254 ошибкой сборки. Добавьте правило в .editorconfig сначала как warning, чтобы увидеть масштаб, и поднимите до error, когда счётчик дойдёт до нуля. Проверка: dotnet build сообщает ненулевое число CA2254 на первом прогоне.
  2. Переведите интерполированные и конкатенированные вызовы на шаблоны сообщений. Вынесите каждое значение из строки в аргумент, с именем заполнителя в PascalCase. Проверка: dotnet build сообщает ноль диагностик CA2254.
  3. Исправьте порядок аргументов, потому что связывание позиционное. LoggerExtensions связывает аргументы с заполнителями слева направо, а не по имени. Проверка: запустите приложение и убедитесь, что каждое свойство в состоянии JSON содержит то значение, которое обещает его имя.
  4. Добавьте методы [LoggerMessage] для горячих путей. Переведите вызовы журналирования на каждый запрос и на каждый элемент в методы partial внутри класса partial, чтобы шаблон разбирался один раз во время компиляции. Проверка: dotnet build чист, а сгенерированный файл появляется в obj/**/Microsoft.Extensions.Logging.Generators/.
  5. Назначьте стабильный EventId каждому сообщению и держите их уникальными. Проверка: в журнале сборки нет предупреждений SYSLIB о дублирующихся идентификаторах событий.
  6. Используйте SkipEnabledCheck плюс ручную проверку там, где вычисление аргументов дорого. Проверка: поставьте категорию на Information и убедитесь, что дорогой вызов не выполняется.
  7. Раскрывайте объекты через [LogProperties], а не через ToString(). Проверка: публичные свойства объекта появляются отдельными записями в состоянии журнала, а не одной плоской строкой.

1. Сделайте CA2254 ошибкой сборки

CA2254 начиная с .NET 10 по умолчанию включено как подсказка, а значит в CI оно невидимо. Поднимите его:

# .editorconfig -- .NET 11, analyzers at latest
[*.{cs,vb}]

# CA2254: Template should be a static expression
dotnet_diagnostic.CA2254.severity = warning

Соберите и посчитайте, с чем имеете дело:

dotnet build -warnaserror:CA2254 --no-incremental

Пока не включайте CA1848. Это правило срабатывает на каждом вызове LogInformation в кодовой базе, включая корректные, и похоронит сигнал CA2254. Оно вернётся на шаге 4.

2. Переведите на шаблоны сообщений

Механическое преобразование в трёх типичных формах:

// .NET 11, C# 14 -- before
_logger.LogInformation($"Order {order.Id} failed for {order.CustomerId}");
_logger.LogWarning("Retry " + attempt + " of " + maxAttempts);
_logger.LogError(ex, $"Import of {file.Name} aborted after {sw.ElapsedMilliseconds} ms");

// after
_logger.LogInformation("Order {OrderId} failed for {CustomerId}", order.Id, order.CustomerId);
_logger.LogWarning("Retry {Attempt} of {MaxAttempts}", attempt, maxAttempts);
_logger.LogError(ex, "Import of {FileName} aborted after {ElapsedMs} ms", file.Name, sw.ElapsedMilliseconds);

Три правила именования, которые окупаются позже:

3. Связывание аргументов позиционное, а не по имени

Это единственная ошибка, которую способен занести такой проход, и CA2254 её не поймает:

// .NET 11 -- compiles, no analyzer warning, WRONG
_logger.LogInformation("Order {OrderId} for {CustomerId}", customerId, orderId);

Microsoft.Extensions.Logging сопоставляет заполнители с аргументами по порядку. Имена это ярлыки для получающихся свойств, а не ключ связывания. Строка журнала отрисует идентификатор клиента под именем OrderId, и никто этого не заметит, пока через три недели запрос не вернёт бессмыслицу. Прочитайте каждую переведённую строку один раз, держа в голове именно этот отказ, и лучше переводите метод целиком, чем принимайте результат массовой замены.

У генератора [LoggerMessage] из шага 4 такой проблемы нет: он сопоставляет заполнители шаблона с именами параметров без учёта регистра, так что порядок параметров там не имеет значения.

4. Добавьте [LoggerMessage] на горячих путях

Шаблоны сообщений починили структуру. Стоимость одного вызова они не починили: LoggerExtensions.LogInformation по-прежнему упаковывает значимые типы в object, выделяет params object?[] и заново разбирает шаблон на каждом вызове. Генератор исходного кода [LoggerMessage] убирает все три пункта, выпуская во время компиляции строго типизированную обёртку над LoggerMessage.Define.

// .NET 11 preview 6, C# 14
using Microsoft.Extensions.Logging;

public partial class OrderProcessor(ILogger<OrderProcessor> logger, OrderPipeline pipeline)
{
    public async Task ProcessAsync(Order order, CancellationToken ct)
    {
        try
        {
            await pipeline.RunAsync(order, ct);
            OrderProcessed(order.Id, order.CustomerId);
        }
        catch (PaymentDeclinedException ex)
        {
            OrderFailed(ex, order.Id, order.CustomerId);
        }
    }

    [LoggerMessage(
        EventId = 1001,
        Level = LogLevel.Information,
        Message = "Order {OrderId} processed for {CustomerId}")]
    private partial void OrderProcessed(int orderId, string customerId);

    [LoggerMessage(
        EventId = 1002,
        Level = LogLevel.Warning,
        Message = "Order {OrderId} failed for {CustomerId}")]
    private partial void OrderFailed(Exception ex, int orderId, string customerId);
}

Начиная с .NET 9 генератор берёт ILogger и из параметра первичного конструктора, поэтому в примере выше нет явного поля _logger. Если есть и поле, и параметр первичного конструктора, побеждает поле.

Ограничения, которые стоит запомнить, согласно документации по генерации исходного кода: методы должны быть partial и возвращать void, ни имена методов, ни имена параметров не должны начинаться с подчёркивания, а параметры не могут использовать params, scoped или out и не могут быть типами ref struct. Статические методы обязаны принимать ILogger параметром; добавьте this, чтобы сделать из них методы расширения.

Теперь включите CA1848 для тех проектов, которые вы уже перевели, ограничив область, чтобы не залить остальное:

# .editorconfig, in the hot-path project folder only
[*.cs]
# CA1848: Use the LoggerMessage delegates
dotnet_diagnostic.CA1848.severity = warning

CA1848 не включено по умолчанию даже в .NET 10 и новее и намеренно агрессивно: оно помечает каждый вызов в стиле LogInformation. Включайте его по проектам, а не на всё решение, если только вы действительно не собираетесь генерировать все сообщения.

5. Держите идентификаторы событий стабильными и уникальными

EventId это стабильная идентичность сообщения журнала. Она переживает переформулировку шаблона, что делает её правильной опорой для правил оповещений. Держите идентификаторы в одном месте на сборку, чтобы коллизии были очевидны:

// .NET 11 -- one file, one range per subsystem
internal static class LogEvents
{
    public const int OrderProcessed = 1001;
    public const int OrderFailed    = 1002;
    public const int PaymentRetried = 1003;
}

Генератор предупреждает о дублирующихся идентификаторах событий внутри класса. Между классами он не предупреждает, так что файл с константами делает реальную работу.

6. SkipEnabledCheck для дорогих аргументов

По умолчанию сгенерированный метод сначала вызывает ILogger.IsEnabled, так что отключённый уровень стоит одного виртуального вызова. Чего он не может, так это помешать вызывающему коду вычислить аргументы. Когда аргумент дорогой, поднимите проверку наверх:

// .NET 11, C# 14
[LoggerMessage(
    EventId = 2001,
    Level = LogLevel.Debug,
    Message = "Request body: {Body}",
    SkipEnabledCheck = true)]
private partial void RequestBody(string body);

// call site
if (logger.IsEnabled(LogLevel.Debug))
{
    RequestBody(await SerializeAsync(request, ct));  // only runs when Debug is on
}

Это тот паттерн, который возвращает пропускную способность, которую интерполированные вызовы LogDebug тихо у вас отбирали.

7. Раскрывайте объекты через [LogProperties]

Message = "Processing {Order}" с параметром Order даёт одно свойство, содержащее вывод ToString(). Чтобы получить поля объекта отдельными свойствами, добавьте Microsoft.Extensions.Telemetry.Abstractions и разметьте параметр:

// .NET 11, Microsoft.Extensions.Telemetry.Abstractions
[LoggerMessage(
    EventId = 1004,
    Level = LogLevel.Information,
    Message = "Processing order")]
private partial void ProcessingOrder([LogProperties] Order order);

Каждое публичное свойство Order попадает в состояние журнала как order.Id, order.CustomerId и так далее. Тот же пакет включает редактирование классифицированных параметров, и это правильный ответ, когда вас просят записать в журнал объект запроса, содержащий адрес электронной почты.

Проверка

Проходите этот чек-лист после каждой фазы, а не один раз в конце:

План отката

Каждый шаг откатывается независимо через git revert, и ни один шаг не меняет публичный API или формат передачи. Есть одна оговорка, которую стоит сказать громко: как только ваш backend журналов начнёт индексировать новые имена свойств, панели и оповещения, построенные на них, сломаются, если вы откатите код. Откатывайте сначала код, потом панели, и держите оба изменения в отдельных коммитах, чтобы порядок был вам доступен.

Повышение серьёзности в .editorconfig стоит сохранить, даже если вы откатите изменения кода. Оставленное на warning правило CA2254 не даёт новым интерполированным вызовам появляться, пока вы решаете.

Что нас укусило

Фигурные скобки в данных приводят к FormatException. У интерполированной формы есть отказ, с которым большинство команд впервые знакомится в продакшене. Microsoft.Extensions.Logging считает аргумент message строкой формата и прогоняет его через LogValuesFormatter, который переписывает {Name} в {0} и вызывает string.Format. Если ваш интерполированный результат содержит скобки, например потому что вы записали в журнал полезную нагрузку JSON, форматтер видит заполнители без соответствующих аргументов и выбрасывает исключение (aspnet/Logging#351 это каноническое сообщение об этом). Шаблоны сообщений к этому невосприимчивы: JSON это аргумент, а не часть строки формата.

// .NET 11 -- throws FormatException at runtime when json contains { }
_logger.LogInformation($"Response: {json}");

// safe
_logger.LogInformation("Response: {Json}", json);

{@Property} из Serilog это не возможность Microsoft.Extensions.Logging. Если вы на Serilog, {@Order} деструктурирует объект в структурированное значение. Генератор [LoggerMessage] шаблон примет, но @ это соглашение Serilog, которое обрабатывает Serilog.Extensions.Logging. Не считайте, что оно что-то делает в обычном провайдере OTLP или консоли. Используйте [LogProperties], когда вам нужно раскрытие, не зависящее от провайдера.

Тесты, проверяющие текст журнала. Assert.Contains("Order 4711 failed", sink.Messages) продолжает проходить через всю миграцию, потому что отрендеренное сообщение не меняется. Это ловушка: получается, что кодовую базу можно перевести, а тесты так и не докажут, что свойства существуют. Добавьте хотя бы один тест на подсистему, который проверяет ключ состояния.

Собственные журналы EF Core уже используют шаблоны. Не надо их “чинить”. Если вы пытаетесь получить читаемый SQL от провайдера, то журналирование SQL, который генерирует EF Core 11 это вопрос конфигурации, а не точки вызова.

Миграция backend это другая работа. Перевод точек вызова никуда не переносит журналы. Если целью является OTLP, сначала сделайте эту миграцию, чтобы шаблоны были правильными, а затем следуйте руководству переход с Serilog на журналирование через OpenTelemetry. Делать и то и другое одновременно значит лишиться возможности понять, какое изменение сломало панель.

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад