.NET Разработчик
6.75K subscribers
489 photos
5 videos
14 files
2.49K links
Дневник сертифицированного .NET разработчика. Заметки, советы, новости из мира .NET и C#.

Для связи: @SBenzenko

Поддержать канал:
- https://boosty.to/netdeveloperdiary
- https://patreon.com/user?u=52551826
- https://pay.cloudtips.ru/p/70df3b3b
Download Telegram
Please open Telegram to view this post
VIEW IN TELEGRAM
День 2822. #ЗаметкиНаПолях
Таймаут, Который Никогда не Срабатывал

У нас был такой код:
public async Task<Result<TData>> SendAsync<TData>(
Func<HttpRequestMessage> requestFactory,
RequestOptions? opts = null,
CancellationToken ct = default)
{
opts ??= new RequestOptions();
var policy = opts.Policy ?? DefaultPollyPolicy;
var result = await
policy.ExecuteAndCaptureAsync(async () =>
{
using var request = requestFactory();

if (ct == CancellationToken.None)
{
// Если таймаут не задан, ставим 5 сек
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
ct = cts.Token;
}

using var response =
await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
// …
});
// …
}

Отправка запроса выполняется с помощью HttpClient, который обёрнут в политику Polly (по умолчанию настроенную на одну повторную попытку). Если вызывающий код не передаёт токен отмены, мы создаём свой токен с тайм-аутом 5 секунд.
Выглядит логично. Сможете найти здесь ошибку? Напишите в комментариях, если нашли, не читая дальше.

На самом деле их тут целых две :)

Ошибка 1. Тайм-аут никогда не срабатывает
Конструкция using var освобождает ресурс по завершении области видимости, в которой она объявлена. В данном случае такой областью является блок if, а не лямбда-выражение. В результате CancellationTokenSource уничтожается (вызывается метод Dispose()) в момент закрывающей фигурной скобки — ещё до того, как запрос будет отправлен. При вызове Dispose() на CancellationTokenSource его внутренний таймер также уничтожается. Токен, полученный из этого объекта, формально продолжает «работать» (исключения не возникает), но он никогда не перейдёт в отменённое состояние.

Небольшой пример для воспроизведения:
var ct = CancellationToken.None;
var sw = Stopwatch.StartNew();
if (ct == CancellationToken.None)
{
using var cts =
new CancellationTokenSource(TimeSpan.FromSeconds(1));
ct = cts.Token;
}

try
{
await Task.Delay(TimeSpan.FromSeconds(3), ct);
Console.WriteLine($"Завершено спустя
{sw.Elapsed.TotalSeconds:F1}сек");
}
catch (Exception e)
{
Console.WriteLine($"{e.GetType().Name} спустя
{sw.Elapsed.TotalSeconds:F1}сек");
}

// Вывод
Завершено спустя 3.0сек

Тайм-аут составляет 1 секунду, однако задержка длится все 3 секунды.

Таким образом, в изначальном коде единственным действующим тайм-аутом остается HttpClient.Timeout, значение которого по умолчанию составляет 100 секунд.

Ошибка 2. При повторной попытке используется тот же токен
Токен отмены ct является параметром метода, и лямбда-выражение захватывает его. Следовательно, когда лямбда-выражение присваивает ему значение, меняется не локальная копия, а одна и та же переменная при каждом вызове лямбды.

При выполнении повторной попытки через Polly условие ct == CancellationToken.None перестаёт быть истинным. В результате блок if пропускается, и для повторной попытки используется токен из первой попытки — тот самый, который относится к уже уничтоженному объекту CancellationTokenSource.

Из этого также следует, что простого переноса конструкции using var за пределы блока if недостаточно. Это решило бы проблему для первой попытки, но повторная попытка все равно выполнялась бы без тайм-аута.

Исправление:
using var timeoutCts = ct == CancellationToken.None
// таймаут 5 сек
? new CancellationTokenSource(TimeSpan.FromSeconds(5))
: null;
var token = timeoutCts?.Token ?? ct;

using var response =
await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);

Теперь CancellationTokenSource существует на протяжении всей попытки, а каждая попытка получает собственный локальный токен, что исключает перезапись захваченного параметра.

Источник:
https://josef.codes/the-timeout-that-never-fired/
👍10👎5
День 2823. #СтатьяДня #AltenarCollab
Heartbeat: как мы создали систему управления системой вместо обычных сигналов
Обычно никто не обращает особого внимания на вспомогательные сервисы вроде мониторинга здоровья (HeartBeat). Они обслуживающие, клиент не видит ошибок в них, да и вообще не имеет к ним доступа. Поэтому (если только у команды не было негативного опыта и – больше того – извлечённых из этого опыта правильных уроков), заботятся об их правильной работе или отказоустойчивости вроде как в последнюю очередь. Есть? Хорошо. Что-то там мониторят? Отлично. А правильно мониторят? Могут своевременно сообщить о сбое до того, как вам начнут звонить разгневанные клиенты? Этот ваш HeartBeat, он вообще, вот прямо сейчас, работает?

А что, если система представляет собой высоконагруженный поток данных в реальном времени, неполадки в одном из сервисов обнаруживаются клиентами также в реальном времени, и устранить их желательно не завтра, после утреннего кофе, а также в реальном времени?

Александр Илларионов из Altenar в своей статье на Хабре рассказывает, как они разрабатывали такую систему, которая могла не просто мониторить real-time сервисы, но и своевременно поднимать тревогу и даже предпринимать некоторые действия, вроде отключения сбоящего сервиса.

Статье немного не хватает последовательности, и автор упускает из повествования некоторые детали. Очевидно, он «в контексте» и понимает, что, зачем и почему. Мне этого понимания в некоторых местах не хватало. Например:
Именно поэтому для нас принципиально важна последовательность в этих действиях: сначала заблокировать трафик, а потом уже отключить его и сообщить клиенту о проблеме.

При этом ранее не упоминалось, что значит заблокировать трафик и чем это отличается от отключения.

Но в принципе тема довольно интересная. Хотелось бы, чтобы Александр её развил и рассказал поподробнее. Возможно, в серии статей. Какие конкретно сервисы мониторинга и управления были созданы, как распределялись между ними обязанности и, возможно, детали их реализации.
👍3
День 2824. #ВопросыНаСобеседовании
Марк Прайс предложил свой набор из 60 вопросов (как технических, так и на софт-скилы), которые могут задать на собеседовании.

51. Стратегии интеграционного тестирования
«Опишите ваш подход к интеграционному тестированию .NET-приложений. Расскажите, как вы планируете, реализуете и автоматизируете такие тесты, а также какие инструменты или фреймворки при этом используете».

Хороший ответ
Интеграционное тестирование в .NET-приложениях предполагает объединение модулей и их проверку как единого целого, чтобы убедиться, что взаимодействие между ними происходит ожидаемым образом. Мой подход к интеграционному тестированию включает следующие этапы:

1. Определение сценариев тестирования: выявление критических точек интеграции в приложении, где происходит взаимодействие между различными модулями, сервисами или внешними системами.

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

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

Для написания тестов можно использовать xUnit, а также пакет Microsoft.AspNetCore.TestHost, который предоставляет тестовый сервер для запуска приложения. Пример организации теста:
public class IntegrationTests : IDisposable
{
private readonly TestServer _server;
private readonly HttpClient _client;

public IntegrationTests()
{
var builder = WebApplication.CreateBuilder();
// … Добавляем сервисы …
builder.Services.AddControllers();
_server = new TestServer(
new WebHostBuilder().UseStartup<Startup>());
_client = _server.CreateClient();
}

[Fact]
public async Task TestEndpointReturnsSuccess()
{
var response = await
_client.GetAsync("/api/values");

response.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

public void Dispose()
{
_client.Dispose();
_server.Dispose();
}
}

Автоматизация интеграционного тестирования
- Непрерывная интеграция: выполнение интеграционных тестов запускаются в рамках CI-конвейера, чтобы гарантировать их запуск при каждом значимом изменении кодовой базы.
- Отчётность: подробное протоколирование и формирование отчётов о результатах в CI-конвейере позволит оперативно выявлять сбои и устранять их причины.

Лучшие практики
- Изоляция сценариев тестирования: несмотря на то что интеграционные тесты фокусируются на взаимодействии между компонентами, важно изолировать сценарии тестирования, привязывая их к конкретным точкам интеграции. Это упрощает поиск неисправностей и повышает наглядность тестов.
- Использование заглушек для внешних зависимостей, чтобы имитировать внешние системы (см. подробнее о заглушках). Это уменьшает количество зависимостей, а также повышает надёжность и скорость выполнения тестов.
- Мониторинг производительности: важно следить за тем, сколько времени занимают интеграционные тесты, чтобы они не стали «узким местом» в процессе разработки.

Такой подход гарантирует, что интеграционные тесты будут всесторонними и эффективными, а также внесут реальный вклад в общее качество приложения.

Источник: https://github.com/markjprice/tools-skills-net8/blob/main/docs/interview-qa/readme.md
👍4👎2
Please open Telegram to view this post
VIEW IN TELEGRAM
👍3
Please open Telegram to view this post
VIEW IN TELEGRAM
👍4
Please open Telegram to view this post
VIEW IN TELEGRAM
👍4
День 2828. #ЗаметкиНаПолях #Logging
Настраиваем Логирование в
ASP.NET. Часть 1

Настройка стандартных провайдеров логирования в
ASP.NET Core через конфигурацию
После развёртывания нового приложения в рабочей среде мы обнаружили, что в журнал событий (EventLog) записывается множество лишних сообщений. Поскольку мы не настраивали провайдер логирования для EventLog в нашем приложении, это стало для нас неожиданностью. Оказалось, что при создании нового приложения ASP.NET Core с помощью WebApplication.CreateBuilder активируется целый набор провайдеров логирования.

Возникает вопрос: какие именно провайдеры включены и как отключить ненужные?

Какие провайдеры включены по умолчанию?
Стандартный построитель (builder) регистрирует следующие провайдеры:
- Console (консоль),
- Debug (отладка),
- EventSource,
- EventLog (только для Windows).

Вы можете убедиться в этом самостоятельно, проверив зарегистрированные службы ILoggerProvider:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var providers = app.Services.GetServices<ILoggerProvider>();
foreach (var provider in providers)
Console.WriteLine(provider.GetType().Name);

// Вывод в Windows:
// ConsoleLoggerProvider
// DebugLoggerProvider
// EventSourceLoggerProvider
// EventLogLoggerProvider


Подход на основе кода
Очевидный путь — начать с чистого листа и добавить обратно только то, что нужно:
var builder = WebApplication.CreateBuilder(args);

builder.Logging.ClearProviders();
builder.Logging.AddConsole();

Это работает, но настройки «зашиты» в код. Если вы хотите изменять поведение системы в зависимости от окружения без выполнения нового развёртывания, потребуется другое решение.

Подход на основе конфигурации
Конфигурация логирования считывается из раздела «Logging» в файле appsettings.json. Помимо общего раздела «LogLevel», можно добавить отдельный раздел для каждого провайдера, используя его имя в качестве ключа:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Debug": {
"LogLevel": {
"Default": "None"
}
},
"EventSource": {
"LogLevel": {
"Default": "None"
}
},
"Console": {
"LogLevel": {
"Default": "Warning"
}
}
}
}


Что мы здесь сделали?
1. Для Debug и EventSource задано значение None, поэтому они больше ничего не записывают в лог.
2. Console продолжает работать, но фиксирует только сообщения уровня Warning и выше.
3. Для всех остальных провайдеров действуют общие настройки из раздела LogLevel.
Примечание: установка уровня None не удаляет провайдер. Он остаётся зарегистрированным, но отфильтровывает все сообщения. Если вы хотите полностью удалить провайдер, используйте метод ClearProviders().

Если для провайдера задаётся свой раздел LogLevel, общие правила из секции Logging:LogLevel перестают к нему применяться. В приведённом выше примере правило Microsoft.AspNetCore: Warning никак не влияет на провайдер Console. Если вы хотите, чтобы оно действовало и для него, добавьте его повторно:
"Console": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
}
}


Изменение без правки кода
Поскольку это обычная конфигурация, её также можно переопределить с помощью переменных окружения. В качестве разделителя используйте двойное подчёркивание:
Logging__Debug__LogLevel__Default=None
Logging__Console__LogLevel__Default=Warning

Это удобно при использовании Azure Container Apps или Kubernetes: можно изменить уровень ведения журнала для отдельного провайдера, не пересобирая образ.

Совет: названия провайдеров нечувствительны к регистру, но должны совпадать с их псевдонимами: Console, Debug, EventSource и EventLog. Опечатки игнорируются без вывода сообщений об ошибках, поэтому, если ничего не меняется, в первую очередь проверьте правильность написания.

Источник:
https://bartwullems.blogspot.com/2026/10/tuning-default-logging-providers-in.html
👍4
День 2829. #ЗаметкиНаПолях #Logging
Настраиваем Логирование в
ASP.NET. Часть 2

Очищаем старые лог-файлы в Serilog
Мы получили оповещение от команды эксплуатации: на одном из серверов заканчивается место на диске. Причиной стал лог-файл, который рос настолько быстро, что занял всё доступное пространство. Команда разработки не настроила механизм очистки, поэтому файл продолжал бесконтрольно увеличиваться. В приложении уже использовался Serilog. Давайте разберёмся, какие существуют способы ограничить объём логов.

Конфигурация по умолчанию
Самый простой способ записи логов в файл с помощью Serilog выглядит так:
Log.Logger = new LoggerConfiguration()
.WriteTo.File("logs/log.txt")
.CreateLogger();

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

Примечание: это не совсем так. Для записи в файл по умолчанию установлен лимит размера файла (fileSizeLimitBytes), равный 1ГБ. Когда файл достигает этого предела, Serilog просто перестает записывать события лога — без каких-либо уведомлений. В итоге диск не переполняется, но вы теряете логи. Что, впрочем, не лучше.

Ротация файлов
Первый шаг — обеспечить создание нового файла через определённые промежутки времени. Это делается с помощью параметра rollingInterval:
Log.Logger = new LoggerConfiguration()
.WriteTo.File("logs/log-.txt", rollingInterval: RollingInterval.Day)
.CreateLogger();

Примечание: в более старых версиях Serilog существовал отдельный приёмник — RollingFile. Теперь же этот функционал полностью встроен непосредственно в приёмник File.

Serilog добавляет дату в имя файла, в результате чего создаются файлы вида log-20261029.txt.

Доступны следующие интервалы:
- Infinite (бесконечный): ротация не выполняется (по умолчанию),
- Year (год),
- Month (месяц),
- Day (день),
- Hour (час),
- Minute (минута).

Сама по себе ротация не удаляет старые файлы — вместо одного большого файла у вас просто появляется множество отдельных. Для управления этим процессом предусмотрены параметры хранения.

Ограничение количества файлов
Параметр retainedFileCountLimit определяет, сколько файлов логов будет сохранено (включая текущий):
….WriteTo.File(
"logs/log-.txt",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14)

При ежедневной ротации сохраняются логи примерно за две недели. По умолчанию это значение составляет 31. Передайте null, если хотите сохранить всё (хотя вряд ли вам это нужно).

Ограничение срока давности файлов
В новых версиях Serilog.Sinks.File также предусмотрен параметр retainedFileTimeLimit. Вместо подсчёта количества файлов вы определяете, как долго файл может храниться:
….WriteTo.File(
"logs/log-.txt",
rollingInterval: RollingInterval.Day,
retainedFileTimeLimit: TimeSpan.FromDays(7))

Если задать оба параметра одновременно, будут действовать оба правила.

Что выбрать? Ограничение по количеству файлов позволяет предсказать объём занимаемого дискового пространства, а ограничение по времени — глубину хранения истории. Часто имеет смысл использовать оба параметра.

Ограничение размера отдельного файла
Именно здесь кроется причина возникшего у нас инцидента. Ежедневная ротация не помогает, если за одни сутки генерируется 40ГБ логов. Поэтому необходимо также ограничить размер каждого отдельного файла:
….WriteTo.File(
"logs/log-.txt",
rollingInterval: RollingInterval.Day,
fileSizeLimitBytes: 100 * 1024 * 1024,
rollOnFileSizeLimit: true,
retainedFileCountLimit: 14)

- fileSizeLimitBytes: максимальный размер одного файла (в данном случае — 100 МБ);
- rollOnFileSizeLimit: если установлено true, то при достижении лимита размера Serilog создаёт новый файл, а не отбрасывает события.
При создании нового файла из-за превышения лимита размера Serilog добавляет к имени порядковый номер: log-20261029.txt, log-20261020_001.txt, log-20261029_002.txt и т.д.

Теперь можно рассчитать максимальный объём дискового пространства, который может быть занят: fileSizeLimitBytes × retainedFileCountLimit. В нашем примере это 100 МБ × 14 = 1,4 ГБ.

Примечание 1: параметр retainedFileCountLimit задаёт количество файлов, а не дней. При ротации по размеру в периоды высокой активности может создаваться большое количество файлов, из-за чего старые файлы будут удаляться быстрее, чем вы ожидаете. Если вам необходимо гарантированное хранение логов за определённый период, используйте retainedFileTimeLimit.
Примечание 2: старые файлы удаляются только в момент создания нового файла. Если ротация файлов в вашем приложении не происходит, то очистка старых файлов также выполняться не будет.

Настройки в appsettings.json
Конечно, можно задать эти параметры непосредственно в коде C#, но, скорее всего, вы захотите иметь возможность изменять их без повторного развёртывания приложения. При использовании пакета Serilog.Settings.Configuration та же конфигурация будет выглядеть следующим образом:
{
"Serilog": {
"Using": [ "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "File",
"Args": {
"path": "logs/log-.txt",
"rollingInterval": "Day",
"fileSizeLimitBytes": 104857600,
"rollOnFileSizeLimit": true,
"retainedFileCountLimit": 14,
"retainedFileTimeLimit": "7.00:00:00"
}
}
]
}
}

И в Program.cs:
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration));


Другие опции
Стоит также обратить внимание на некоторые другие параметры записи в файл:
- shared: позволяет нескольким процессам записывать данные в один и тот же файл. Полезно в определённых сценариях хостинга, но снижает производительность.
- buffered: выполняет буферизацию записей в оперативной памяти, а не записывает каждое событие на диск немедленно. Это работает быстрее, но в случае сбоя процесса можно потерять часть событий.
- flushToDiskInterval: определяет, как часто буферизованные данные сбрасываются на диск.
- restrictedToMinimumLevel: позволяет записывать в файл только те события, уровень важности которых соответствует заданному или превышает его.
- outputTemplate: позволяет настроить формат каждой строки. Более короткий шаблон означает меньший размер файлов.
- hooks: реализация интерфейса FileLifecycleHooks, позволяющая выполнять пользовательскую логику при открытии или закрытии файла (например, для сжатия ротированных файлов с помощью GZip).

Совет: Ротация и политики хранения данных лишь защищают ваш диск, но не устраняют первопричину проблемы. В нашем случае основной объём логов создавался компонентами из пространства имен Microsoft.*. Повышение минимального уровня логирования для этих пространств имен (см. часть 1) позволило сократить размер логов гораздо эффективнее, чем любые настройки хранения.

Источник: https://bartwullems.blogspot.com/2026/10/cleaning-up-old-log-files-with-serilog.html
👍2
День 2830. #ЗаметкиНаПолях #Logging
Настраиваем Логирование в
ASP.NET. Часть 3

Использование FileLifecycleHooks в Serilog
В предыдущей части мы рассмотрели, как настроить ротацию и политику хранения логов, чтобы предотвратить переполнение диска бесконтрольно растущими файлами. Это решает проблему, но возникает вопрос: а что, если вы хотите выполнить какие-либо действия с файлами до того, как они будут удалены?

Возможно, вы хотите сжать файлы, чтобы сэкономить место, или же требования нормативных актов обязывают вас архивировать их. В Serilog.Sinks.File для этого предусмотрена точка расширения — FileLifecycleHooks.

Что это?
Механизм «хуков жизненного цикла» позволяет выполнять ваш код в определённые моменты существования лог-файла. Для этого нужно создать класс, наследующий от абстрактного класса FileLifecycleHooks, и переопределить один или оба из следующих методов:
- OnFileOpened: вызывается, когда приемник открывает новый лог-файл. Вы получаете доступ к базовому потоку и можете вернуть свой поток, который будет его «оборачивать».
- OnFileDeleting: вызывается непосредственно перед тем, как файл будет удалён в соответствии с политикой хранения.
У обоих методов есть реализация по умолчанию, которая ничего не делает, поэтому вы можете переопределять только то, что вам нужно. Затем передайте свой «хук» в приёмник через параметр hooks.

Примечание: в более новых версиях Serilog.Sinks.File появилась перегрузка метода OnFileOpened, предоставляющая путь к файлу. Хук OnFileDeleting был добавлен одновременно с параметром retainedFileTimeLimit, поэтому убедитесь, что вы используете актуальную версию библиотеки.

Пример 1: сжатие логов
Поскольку логи представляют собой текстовые данные, они отлично поддаются сжатию, что позволяет существенно сократить объём занимаемого ими дискового пространства.
using System.IO.Compression;
using System.Text;
using Serilog.Sinks.File;

public class GZipHooks : FileLifecycleHooks
{
public override Stream OnFileOpened(
Stream stream,
Encoding enc)
=> new GZipStream(
stream,
CompressionLevel.Fastest);
}

Этот хук оборачивает поток, в который пишет Serilog, в GZipStream. Всё, что записывается в файл, сжимается «на лету». Использование:
Log.Logger = new LoggerConfiguration()
.WriteTo.File(
"logs/log-.gz",
rollingInterval: RollingInterval.Day,
fileSizeLimitBytes: 100 * 1024 * 1024,
rollOnFileSizeLimit: true,
retainedFileCountLimit: 14,
hooks: new GZipHooks())
.CreateLogger();

Обратите внимание на расширение .gz в пути. Без него вы получите сжатые файлы, которые будут выглядеть как обычный текст.

Примечание 1: Поток gzip, который не был корректно закрыт, считается неполным. В случае сбоя процесса можно потерять последние события, записывавшиеся в текущий файл. Кроме того, не используйте этот параметр вместе с shared: true. Попытка записи нескольких процессов в один и тот же сжатый поток ни к чему хорошему не приведёт.
Примечание 2: ограничение по размеру проверяется по количеству байтов, записываемых в файл, то есть по размеру уже сжатых данных. Таким образом, лимит в 100МБ означает 100МБ данных в формате gzip, что соответствует гораздо большему числу событий в логах. Учитывайте это при расчёте дискового пространства.

Пример 2: архивирование файлов перед их удалением
Иногда удаление файла через 14 дней допустимо для локального хранилища, но при этом возникает необходимость сохранить логи где-то ещё на более длительный срок. Для этого предназначен параметр OnFileDeleting:
public class ArchiveBeforeDeleteHooks
: FileLifecycleHooks
{
private string _archFolder;

public ArchiveBeforeDeleteHooks(string archFolder)
{
_archFolder = archFolder;
Directory.CreateDirectory(_archFolder);
}

public override void OnFileDeleting(string path)
{
var target = Path.Combine(
_archFolder, Path.GetFileName(path));
File.Copy(path, target, overwrite: true);
}
}

В этом примере файл копируется в общую сетевую папку. В реальных условиях это также может быть загрузка в Azure Blob Storage:
….WriteTo.File(
"logs/log-.txt",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
hooks: new ArchiveBeforeDeleteHooks(@"\\fileserver\archive\myapp"))

Примечание: хук выполняется синхронно в рамках конвейера логирования — в тот момент, когда приёмник переключается на новый файл. Позаботьтесь о том, чтобы он работал быстро: медленная операция загрузки заблокирует процесс логирования. В случае возникновения ошибки исключение в самом приложении выброшено не будет. Чтобы узнать, что именно пошло не так, включите SelfLog:
Serilog.Debugging.SelfLog.Enable(Console.Error);


Пример 3: добавление заголовка в начало каждого файла
Это удобно, если файл в дальнейшем будет обрабатываться другим инструментом — например, в случае с CSV-файлом, содержащим названия столбцов:
public class HeaderHooks : FileLifecycleHooks
{
private readonly string _header;

public HeaderHooks(string header)
=> _header = header;

public override Stream OnFileOpened(
Stream stream,
Encoding enc)
{
if (stream.Length == 0)
{
var bytes = enc.GetBytes(_header + Environment.NewLine);
stream.Write(bytes, 0, bytes.Length);
}
return base.OnFileOpened(stream, enc);
}
}

Проверка Length == 0 важна. Один и тот же файл может быть открыт повторно после перезапуска приложения, и вы не хотите, чтобы в середине файла появился ещё один заголовок.

Не обязательно писать всё самостоятельно
Для решения описанных выше задач существуют готовые пакеты, созданные сообществом — например, Serilog.Sinks.File.Archive и Serilog.Sinks.File.Header. Прежде чем приступать к написанию собственного решения, проверьте, нет ли уже готового инструмента, который выполняет нужные вам функции.

Когда использовать?
- вы храните лог-файлы на сервере, а дисковое пространство ограничено;
- требования регуляторов или правила аудита обязывают вас хранить логи дольше, чем предусмотрено вашей стандартной политикой хранения;
- другие инструменты используют эти файлы и ожидают определённый формат данных;
- необходимо шифровать лог-файлы, хранящиеся на диске.
Если же логи уже передаются в централизованную систему (например, Seq, Elasticsearch или Application Insights), использование хуков, скорее всего, будет излишним. В такой ситуации локальный файл служит лишь резервным хранилищем, и для него достаточно настроить ротацию с ограничением срока хранения.

Источник:
https://bartwullems.blogspot.com/2026/10/using-filelifecyclehooks-in-serilog.html
👍3
День 2831. #ЗаметкиНаПолях
Первичные Конструкторы — Не Просто Синтаксический Сахар
Знаете ли вы, что переход от «обычного» конструктора к первичному может изменить, момент, когда инициализируются поля? Разберёмся, почему это важно.

Проблема
Представьте, что у вас есть базовый класс, который выполняет определённые действия в своем конструкторе, причем эти действия включают вызов виртуального члена класса:
abstract class Base
{
protected Base(Func<int?> check)
{
Console.WriteLine(
$"{GetType().Name}: check()={check()},Message:{Message}");
}

protected abstract string Message { get; }
}

Да-да, вызов виртуальных членов внутри конструктора — это код «с душком». Но в реальной жизни всё не всегда так однозначно. В bUnit базовый конструктор класса WaitForHelper запускает таймер, а по истечении заданного времени таймер обращается к виртуальному свойству TimeoutErrorMessage. Проблема та же, просто она «спрятана» за таймером и другим потоком выполнения.

А теперь создадим производный класс с обычным конструктором:
class Regular : Base
{
private readonly int? count;

public Regular(int? count)
: base(() => count)
{
this.count = count;
}

protected override string Message
=> $"count={count?.ToString() ?? "null"}";
}

И такой же с первичным конструктором:
class Primary(int? count) : Base(() => count)
{
protected override string Message
=> $"count={count?.ToString() ?? "null"}";
}

Они выглядят одинаково. Выполним код:
new Regular(3);
new Primary(3);

// Вывод:
Regular: check()=3,Message:count=null
Primary: check()=3,Message:count=3


Что делает компилятор
Если взглянуть на преобразованный код (можно на sharplab.io), разница становится очевидной. Вот класс Regular:
public Regular(Nullable<int> count)
{
var obj = new <>c__DisplayClass1_0();
obj.count = count;
base..ctor(new Func<int?>(obj.<.ctor>b__0));
this.count = obj.count;
}

Лямбда-выражение захватывает параметр в замыкание (вспомогательный класс DisplayClass), поэтому метод check() видит корректное значение. Однако полю this.count присваивается значение только после того, как завершит работу конструктор базового класса. Всё, что происходит в конструкторе базового класса до этого момента, «видит» лишь null.

А вот класс Primary:
public Primary(Nullable<int> count)
{
<count>P = count;
base..ctor(new Func<int?>(<.ctor>b__0_0));
}

Поскольку переменная count используется внутри члена класса (в данном случае — в свойстве Message), компилятор сохраняет её значение в скрытом поле <count>P. Это поле инициализируется до вызова конструктора базового класса. Такой порядок соответствует работе инициализаторов полей, которые в C# также выполняются до вызова base(…).

В контексте bUnit это означает следующее: при использовании «классического» конструктора, если таймер срабатывал до выполнения тела конструктора производного класса, возникала ошибка с сообщением «did not result in any matching element(s)» (не найдено никаких подходящих элементов) вместо «did not result in exactly matching element(s)» (не найдено точно подходящих элементов). При использовании первичного конструктора значение доступно сразу.

Небольшой нюанс
Сгенерированное поле <count>P не является readonly. Вы можете изменить значение count внутри класса, и компилятор не будет препятствовать этому. Если вам необходима гарантия неизменяемости, следует объявить поле самостоятельно — инициализатор поля (например, private readonly int? count = count;) также сработает до вызова конструктора базового класса. Однако избегайте одновременного использования параметра и в членах класса, и в инициализаторе поля: в этом случае создаются две копии одного и того же значения, о чём компилятор предупредит вас ошибкой CS9124.

Источник: https://steven-giesel.com/blogPost/a3c741d3-f261-4bda-8da6-bffbbbd7aa5a/primary-constructors-are-more-than-syntactic-sugar
👍4👎1
День 2832. #ВопросыНаСобеседовании
Марк Прайс предложил свой набор из 60 вопросов (как технических, так и на софт-скилы), которые могут задать на собеседовании.

52. Тестирование производительности
Опишите ваш подход к тестированию производительности .NET приложений. Расскажите об этапах работы: от планирования и выбора инструментов до проведения тестирования и анализа полученных результатов.

Хороший ответ
Тестирование производительности в .NET-приложениях предполагает системный подход, направленный на выявление потенциальных «узких мест» и обеспечение эффективной работы приложения при ожидаемой нагрузке. Здесь можно выделить несколько важных моментов.

1. Определение критериев производительности: установление того, что именно считается «хорошей производительностью» для конкретного приложения — включая допустимое время отклика, пропускную способность и уровень потребления ресурсов.

2. Разработка сценариев тестирования: создание тестов, имитирующих различные режимы нагрузки: штатный, пиковый и стрессовый.

3. Выбор подходящих инструментов: подбор инструментов, способных реалистично имитировать пользовательскую активность и эффективно измерять показатели производительности. Для .NET-приложений часто используется BenchmarkDotNet для тестирования отдельных блоков кода и NBomber для стресс-теста всей системы.

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

5. Проведение тестирования: запуск подготовленных сценариев с помощью выбранных инструментов. Мониторинг работы приложения: отслеживание времени отклика, пропускной способности и использования ресурсов.

6. Автоматизация: по возможности — автоматизация запуска тестов и сбора метрик для их интеграции в процесс CI/CD.

7. Анализ данных: сбор и изучение результатов тестирования для выявления «узких мест» или областей, где показатели не соответствуют заданным критериям.

8. Оптимизация: внесение изменений в код, конфигурацию или инфраструктуру на основе полученных данных. Это может включать оптимизацию SQL-запросов, улучшение стратегий кэширования или масштабирование аппаратных ресурсов.

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

Основные принципы
- Всесторонний охват: тесты должны затрагивать все критически важные функциональные возможности и сценарии взаимодействия с пользователем.
- Реалистичность сценариев: максимально точная имитация поведения реальных пользователей и объёмов данных.
- Документирование: ведение подробных записей об условиях тестирования, конфигурациях и результатах необходимо для дальнейшего анализа и проведения регрессионного тестирования.

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

Распространенный, но неудачный подход:
«Мы нагружаем основные конечные точки системы перед релизом. Если система не «падает» — значит, всё в порядке. После следим за «длинными ответами» в проде».

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

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

- Неверное понимание критериев производительности: убеждение в том, что отсутствие сбоев — единственный показатель приемлемой работы системы, говорит о непонимании целей нагрузочного тестирования. Критерии производительности должны включать такие показатели, как время отклика, пропускная способность, потребление ресурсов и другие метрики.

Подобная ошибка обычно возникает из-за отсутствия опыта в проведении нагрузочного тестирования или недооценки его значимости для успешной работы и масштабируемости приложения. Кроме того, это может указывать на корпоративную культуру, в которой недостаточно внимания уделяется тщательному тестированию.

Источник:
https://github.com/markjprice/tools-skills-net8/blob/main/docs/interview-qa/readme.md
👍2
День 2833. #ЗаметкиНаПолях #Async
Ещё Раз Про Async/Await в C#. Часть 7

Сразу прошу прощения за оченьмногокода. Но раз уж взялся за эту серию, выложу её целиком (хоть и по кусочкам).

Пишем свой токен отмены с нуля. Начало
В предыдущей части мы создали класс MyTask, который может запускать выполнение задачи и сообщать о её завершении. Однако он не умеет отменять выполнение. Этот не недоработка. Отмена в .NET – отдельная задача, реализованная с помощью двух типов. В этой части мы создадим MyCancellationTokenSource и MyCancellationToken, а затем интегрируем в MyTask. Реализация поможет ответить на распространённые вопросы о том, как устроен токен отмены.

Самым очевидным API был бы тот, которого на самом деле не существует: task.Kill(). В .NET когда-то было нечто подобное — Thread.Abort(). Он прерывал выполнение в произвольный момент. В результате система оказывалась в состоянии, к которому код не был готов, и понять, какие операции успели выполниться, а какие — нет, становилось невозможно. В .NET Core Thread.Abort нет, и не существует способа принудительно остановить код, который «не хочет» останавливаться.

Поэтому отмена в .NET — «скоординированная» (cooperative): операции лишь сообщают, что пора завершиться, а она сама выбирает подходящий для этого момент. Механизм отмены передаёт флаг и, при необходимости, уведомляет заинтересованные стороны о его изменении.

Почему два типа?
- MyCancellationTokenSource инициирует отмену (метод Cancel());
- MyCancellationToken лишь наблюдает за состоянием — он не может ничего отменить.
Метод, принимающий CancellationToken, получает право *узнать* о запросе на отмену, но не право *принять решение* об отмене. Иначе любой метод, которому вы передали токен, мог бы отменить всю операцию целиком — даже те её части, о которых он ничего не знает.

Токен – структура со ссылкой на источник:
// MyCancellationToken.cs
public readonly struct MyCancellationToken(
MyCancellationTokenSource? source)
{
public static MyCancellationToken None
=> default;

public bool IsCancellationRequested
=> source is { IsCancellationRequested: true };

public bool CanBeCanceled
=> source is not null;
}

Следствия:
1. default — допустимый токен, который никогда не переходит в состояние «отменён». Поэтому запись CancellationToken token = default - способ задать необязательный параметр.

2. Свойство CanBeCanceled стоит проверять. Для default оно возвращает false, что позволяет в «горячих» путях кода избежать создания механизмов регистрации отмены, которые всё равно никогда не сработают.

Состояние источника (source) определяется одним полем:
// MyCancellationTokenSource.cs
public sealed class MyCancellationTokenSource
: IDisposable
{
private bool _disposed;
private volatile bool _cancellationRequested;

public bool IsCancellationRequested
=> _cancellationRequested;

public MyCancellationToken Token
{
get
{
ObjectDisposedException.ThrowIf(_disposed, this);
return new MyCancellationToken(this);
}
}
}

volatile здесь не для красоты. Допустим, у вас реализована отмена через пулинг в цикле:
while (!token.IsCancellationRequested)
DoOneChunkOfWork();

Внутри этого цикла ничего не меняет флаг, и ничто, что видит JIT-компилятор, его не меняет. Без volatile JIT читает его один раз, кэширует в регистре и переписывает цикл в while (true). Это допустимая оптимизация однопоточной программы, и это привело бы к тому, что отмена никогда бы не срабатывала. volatile заставляет читать значение на каждой итерации.

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

ThrowIfCancellationRequested
//MyCancellationToken.cs
public void ThrowIfCancellationRequested()
{
if (IsCancellationRequested)
throw new MyOperationCanceledException(this);
}

// MyOperationCanceledException.cs
public sealed class MyOperationCanceledException(
MyCancellationToken token)
: OperationCanceledException("The operation was canceled.")
{
public MyCancellationToken Token { get; } = token;
}

Исключение содержит в себе токен. Это важно в месте перехвата (блоке catch): в противном случае ситуации «была отменена именно та операция, которую я просил» и «была отменена какая-то другая операция, а её исключение просто перехвачено тут» выглядели бы одинаково. Свойство CancellationToken в OperationCanceledException нужно для того, чтобы их различать — благодаря ему задача может решить, перейти ли ей в состояние «отменена» (Canceled) или в состояние «завершена с ошибкой» (Faulted).

Продолжение следует…

Источник:
https://www.meziantou.net/writing-your-own-cancellationtoken-from-scratch.htm
👍13
День 2834. #ЗаметкиНаПолях #Async
Ещё Раз Про Async/Await в C#. Часть 7

Пишем свой токен отмены с нуля. Продолжение

Начало

Обратные вызовы и проблема «гонки»
Пулинг (периодический опрос) работает, только когда операция выполняет какой-либо код. Если же это, например, чтение из сокета приостановлено в ожидании данных, пулинг не сработает — а ведь такие операции чаще всего и требуется отменять. В подобных случаях отмена должна быть реализована через механизм событий:
// MyCancellationTokenSource.cs
private readonly Lock _lock = new();
private List<Registration>? _registrations = [];

internal MyCancellationRegistration
Register(Action callback)
{
lock (_lock)
{
if (!_cancellationRequested)
{
var registration = new Registration(callback);
_registrations!.Add(registration);
return new MyCancellationRegistration(this, registration);
}
}

// Register на отменённом источнике
// выполняет коллбек сразу вне блокировки
callback();
return default;
}

internal sealed class Registration(Action callback)
{
public Action Callback { get; } = callback;
}

Конец метода – то же состояние гонки, что было в MyTask. Cancel() и Register() могут быть вызваны одновременно в разных потоках, при этом обратный вызов должен сработать ровно один раз: либо потому, что Cancel обнаружил его в списке, либо потому, что Register увидел, что флаг отмены уже установлен. Решение принимается внутри защищённой блокировкой области, а само действие выполняется уже вне её.

Регистрация реализована в виде структуры со ссылкой на источник:
// MyCancellationRegistration.cs
public readonly struct MyCancellationRegistration : IDisposable
{
private MyCancellationTokenSource? _source;
private MyCancellationTokenSource.Registration? _registration;

internal MyCancellationRegistration(
MyCancellationTokenSource source,
MyCancellationTokenSource.Registration registration)
{
_source = source;
_registration = registration;
}

public void Dispose()
{
if (_source is not null
&& _registration is not null)
_source.Unregister(_registration);
}
}

Предупреждение: MyCancellationRegistration.Dispose() завершает выполнение, в то время как обратный вызов всё ещё может выполняться в другом потоке. Это упрощение в нашей реализации. Стандартный метод CancellationTokenRegistration.Dispose() блокирует поток до тех пор, пока обратный вызов не завершится.

В методе Cancel выполняются зарегистрированные обратные вызовы и собираются исключения:
// MyCancellationTokenSource.cs
public void Cancel()
{
List<Registration>? registrations;

lock (_lock)
{
if (_cancellationRequested)
return;

_cancellationRequested = true;

registrations = _registrations;
_registrations = null;
}

if (registrations is null)
return;

List<Exception>? exceptions = null;

foreach (var reg in registrations)
{
try
{
reg.Callback();
}
catch (Exception ex)
{
exceptions ??= [];
exceptions.Add(ex);
}
}

if (exceptions is not null)
throw new AggregateException(exceptions);
}

Важные моменты:
1. Отмена уже отменённого источника ничего не делает. Она идемпотентна, а не является ошибкой.
2. Обратные вызовы выполняются вне блокировки. Обратный вызов, который принимает другую блокировку или регистрирует другой обратный вызов, не должен приводить к взаимоблокировке с собой.
3. Сбой в одном обратном вызове не останавливает остальные. Это несвязанные части процесса очистки; исключения собираются и выбрасываются вместе.

Регистрацию можно отменить, потому что обратный вызов, который существует дольше операции, которая его требовала, является утечкой:
// MyCancellationTokenSource.cs
private bool _disposed;

internal void Unregister(Registration registration)
{
lock (_lock)
{
_registrations?.Remove(registration);
}
}

public void Dispose()
{
lock (_lock)
{
if (_disposed)
return;

_disposed = true;
_registrations = null;
}
}


Окончание следует…

Источник:
https://www.meziantou.net/writing-your-own-cancellationtoken-from-scratch.htm
👍4
Please open Telegram to view this post
VIEW IN TELEGRAM
👍4
День 2836. #ЧтоНовенького #NuGet
Настройка Минимального Возраста NuGet-пакета

В .NET11 Release Candidate 2 в NuGet добавлена новая опция командной строки, позволяющая разработчикам задавать минимальный «возраст» (часов с момента публикации) для версий пакетов.

Это поможет избежать «слишком ранних» обновлений, чтобы избежать случаев, когда в опубликованном пакете обнаружится какая-нибудь ошибка (обычно сообщество находит их довольно быстро, за 1-2 дня).

Опция --min-publish-age-hours доступна при использовании команд dotnet nuget add source и dotnet nuget update source. Она позволяет настроить параметр minPublishAgeHours в файле nuget.config без необходимости редактировать его вручную.

Команды добавления и обновления пакетов учитывают это ограничение при выборе версий. Если более новая версия исключается из-за того, что она «слишком свежая», система сообщит об этом при выполнении обновления.

Эта настройка также влияет на проекты, в которых используются «плавающие» версии пакетов. Если после применения ограничения не остается ни одной подходящей версии, процесс восстановления (restore) завершается с ошибкой NU1020 — вместо того чтобы просто продолжиться без установки нужного пакета.

В Visual Studio 2026 параметр можно настроить в меню Tools > Options… > NuGet Package Manager > Sources (Инструменты > Параметры… > Диспетчер пакетов NuGet > Источники). Там нажать Edit (Изменить) у выбранного источника и задать значение поля Minimum publish age (hours) (Минимальный возраст публикации (часы)).

Остаётся под вопросом, что делать со срочными критическими обновлениями. Пока система не предполагает наличия исключений из правила возраста.

Источник: https://visualstudiomagazine.com/articles/2026/10/14/net-11-rc2-adds-post-quantum-crypto-maui-csharp-ui-and-nuget-controls.aspx
👍5
День 2837. #ЗаметкиНаПолях
5 Малоиспользуемых Функций .NET. Начало
В .NET есть компоненты, которыми большинство разработчиков пользуются постоянно, даже не задумываясь о том, как они устроены внутри. Однако, когда вы сталкиваетесь с кодом, в котором одна и та же операция выполняется миллионы раз, детали внезапно обретают огромное значение. Именно здесь на сцену выходят менее известные возможности современного .NET. Можно создать вполне работоспособное приложение, ни разу ими не воспользовавшись, но, если разобраться, для чего они предназначены, они становятся ценными инструментами, которые стоит держать под рукой на случай возникновения соответствующих задач.

1. FrozenDictionary для неизменяемых справочных данных
Обычный словарь спроектирован как изменяемая структура. В него можно добавлять элементы, удалять, обновлять значения и выполнять поиск. Это подходит, если содержимое коллекции меняется в процессе работы приложения. Но что, если оно не меняется? Представьте приложение, которое при запуске формирует справочник, а затем на протяжении всего времени своей работы только считывает из него данные:
var statusCodes = new Dictionary<int, string>
{
[200] = "OK",
[201] = "Created",
[400] = "Bad Request",
[401] = "Unauthorized",
[404] = "Not Found",
[500] = "Internal Server Error"
};

В этом нет ничего плохого. Но вы используете структуру данных, которая изначально спроектирована с расчётом на возможность внесения изменений, хотя логике приложения этого не надо. Для такого сценария существует FrozenDictionary<TKey, TValue>:
var statusCodes = new Dictionary<int, string>
{
[200] = "OK",
[201] = "Created",
// …
}.ToFrozenDictionary();

Важно понимать компромисс. Создание «замороженной» коллекции не бесплатно: среда выполнения тратит дополнительные ресурсы на анализ и построение оптимизированного представления данных. FrozenDictionary спроектирован с расчётом на то, что вы один раз на это потратитесь, а затем будете многократно выполнять операции поиска.

Благодаря этому он отлично подходит для таких задач, как статические конфигурации, карты ключевых слов, сопоставления MIME-типов, метаданные маршрутизации и любые другие данные, которые инициализируются единожды, а затем часто используются для поиска.

Кроме того, это даёт полезную семантическую гарантию: после создания коллекцию невозможно изменить. Это не то же самое, что просто договориться не менять обычный словарь.

Если ваш словарь меняется в процессе работы приложения, FrozenDictionary — неподходящий выбор. Затраты на постоянное пересоздание «замороженной» коллекции сведут на нет весь смысл её использования.

Суть здесь не в том, что FrozenDictionary «быстрее», чем обычный словарь. Дело в том, что среда выполнения может применять иные подходы к оптимизации, когда точно знает, что коллекция никогда не изменится.
И этот паттерн стоит взять на вооружение — он применим не только в случае словаря.

2. SearchValues<T> для многократного поиска
Поиск в тексте — операция, затратность которой часто недооценивают.
Простой вызов, например:
if (text.Contains(':'))
{
// …
}

— это тривиально.
Однако парсерам часто приходится многократно искать один из нескольких символов. Например, вы можете искать разделители: , ; | :. Наивная реализация может заключаться в нескольких вызовах IndexOf() для каждого символа:
int comma = text.IndexOf(',');
int semicolon = text.IndexOf(';');
int pipe = text.IndexOf('|');
int colon = text.IndexOf(':');

Это означает, что в одних и тех же входных данных приходится многократно искать различные значения. Класс SearchValues<T> предназначен для ситуаций, когда требуется неоднократно выполнять поиск одного значения из заранее известного набора:
private static readonly SearchValues<char> Separators =
SearchValues.Create(",;|:");

int index = text.AsSpan().IndexOfAny(Separators);

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

Это также наглядный пример того, почему при оптимизации важно измерять результат. Если вам нужно выполнить поиск в строке длиной 20 символов 1 раз, SearchValues<char> не поможет. Но, если вы обрабатываете гигабайты текста и постоянно ищете одни и те же разделители, ситуация кардинально меняется.

PS: в NET9 SearchValues расширен, и теперь поддерживает поиск строк, а не только символов.

Окончание следует…

Источник:
https://medium.com/codetodeploy/i-bet-youre-not-using-these-5-net-apis-937360c1b5e7
👍7