Перейти к содержанию

Фоновая служба

HTTP-запрос имеет ограниченное время жизни. Иногда приложению нужно выполнить работу независимо от конкретного браузера: например, периодически прочитать число курсов и записать служебное наблюдение. Если запустить такую работу через Task.Run из handler, она потеряет понятное владение ресурсами и условия завершения. Рассмотрим фоновую службу, которой управляет host.

Ветка lesson-25/after в архиве продолжения является отдельным Worker-проектом. Она читает знакомую таблицу курсов из базы урока 16, но не продолжает память и файлы HTTPS-ветки 24. Чтение не изменяет курсы и не отправляет внешние сообщения. .NET, NuGet, база, служба и проверки при подготовке не запускались; приведённые события являются ожидаемыми, а интервал не считается измеренной характеристикой.

Host владеет запуском и остановкой

BackgroundService предоставляет метод ExecuteAsync, который host вызывает при запуске. Токен stoppingToken сообщает о завершении приложения. Служба должна передавать его в зависимые ожидания и прекращать работу, а не продолжать бесконечно после команды остановки.

Регистрация выполняется через AddHostedService<CatalogMonitor>. Сам hosted service имеет singleton-время жизни, поэтому он не должен прямо захватывать scoped repository. Иначе короткая зависимость будет жить вместе с процессом или ошибка DI обнаружится ещё при создании host. Для каждой итерации создадим отдельный scope.

await using var scope = scopes.CreateAsyncScope();
var observation = scope.ServiceProvider
    .GetRequiredService<CatalogObservation>();
var count = await observation.CountAsync(stoppingToken);
logger.LogInformation("Catalog contains {CourseCount} courses", count);

CatalogObservation использует общий NpgsqlDataSource, создаёт собственную команду SELECT count(*) FROM courses и возвращает число. Data source зарегистрирован DI-фабрикой как singleton, поэтому host владеет его завершением. Временное соединение внутри команды не становится полем фоновой службы.

Для исходных четырёх курсов ожидается событие с CourseCount=4. Здесь нет сериализации названий, строк подключения и личных данных редакторов. Структурированное поле числа позволяет искать наблюдения, не разбирая вручную случайный текст. Однако запись журнала не является обещанием внешнего мониторинга: сбор, хранение и оповещения этой веткой не настроены.

Итерации не запускаются одновременно

После первого чтения служба ожидает PeriodicTimer с периодом десять секунд. В следующей итерации она создаёт новый scope и снова выполняет чтение. Полный цикл последователен: одна итерация заканчивается до следующей. Если запрос занял дольше периода, программа не создаёт бесконтрольно ещё несколько чтений в параллельных Task.Run.

using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
do
{
    await ObserveOnceAsync(stoppingToken);
}
while (await timer.WaitForNextTickAsync(stoppingToken));

Это периодическое наблюдение, а не точное расписание задач по календарю. Процесс может задержаться, остановиться или пропустить периоды. Не следует использовать такой цикл для обещания «каждый платёж обязательно ровно в полночь» без устойчивого состояния и отдельного планировщика. Период задаёт способ ожидания, но не гарантирует выполнение после простоя.

Отмена WaitForNextTickAsync вызывает ожидаемый путь остановки. В полном коде OperationCanceledException с отменённым stoppingToken завершает цикл. Другие исключения не выдаются за нормальное выключение. Ошибка чтения базы журналируется без секретов; служба продолжает следующую итерацию, потому что выбранный контракт — наблюдение, а не обязательная обработка финансового события.

Фоновая работа имеет собственный контекст

Внутри службы нет текущего HttpContext и его RequestAborted. Нельзя сохранить объект HTTP-запроса в очередь и читать его headers через минуту, когда request scope уже завершён. Если работа появилась из запроса, в устойчивое сообщение передаются только нужные значения: ID курса, ID операции и серверно удостоверенный субъект при необходимости.

У нашего мониторинга нет пользовательского инициатора, поэтому не нужно придумывать claim или использовать cookie последнего редактора. Служба действует с выбранными серверными правами к базе. Эти права должны быть минимальными: для count(*) не требуется роль, способная удалять таблицы. Строка подключения остаётся внешней, а проверка локального host и имени базы только помогает заметить ошибку настройки, не доказывая изоляцию.

Когда потребуется предметное изменение, важно определить, повторяемо ли оно. Чтение числа безопасно повторять, потому что оно не создаёт новых объектов. Отправка письма или обработка оплаты не имеет той же границы. Само использование BackgroundService не добавляет дедупликацию, транзакцию или доставку после перезапуска.

Завершение не заменяет сохранённое намерение

Host предоставляет время на корректную остановку, но процесс может быть принудительно завершён. Код после очередного await или блок finally не обязан исполниться при падении машины. Поэтому память службы не является надёжным местом для единственного экземпляра важного задания. После старта монитор просто начинает читать заново; это допустимо для выбранного результата.

Если хотелось бы обработать каждый запрос на экспорт курса, понадобились бы ID задания, состояние принятия, повтор после сбоя и наблюдаемый результат. Следующий урок определит именно такую модель очереди в PostgreSQL. Не превращайте нашу периодическую службу в обещание устойчивой очереди только потому, что она запускается вне HTTP-handler.

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

Для будущего исполнения запустите только выбранный Worker с отдельной базой и посмотрите начальное число четыре. Затем штатно остановите процесс во время ожидания timer: не должно начаться новое чтение после наблюдаемой остановки. Другой сценарий — недоступная база; ожидается журнал ошибки, а не выдуманный нулевой каталог. Частоту и длительность чтения нужно измерять отдельно, здесь мы их не получали.

Результат этого урока — управляемая циклическая работа с понятным временем жизни ресурсов и отменой. Scope заканчивается после каждой итерации, общий data source — вместе с host. Этот механизм полезен сам по себе, но устойчивые задания требуют дополнительной модели. Принцип scoped-зависимостей описан в официальном руководстве .NET, устройство периодического ожидания — в API PeriodicTimer.

Число наблюдений не следует использовать как предметный счётчик успешной обработки. Два одинаковых события CourseCount=4 могут означать две итерации, повтор после ошибки либо два запущенных экземпляра Worker. Если нужны уникальные итерации, добавляется отдельный идентификатор и назначение записи. В нашей ветке это не требуется, потому что журнал служит просмотру состояния, а не бухгалтерии. Масштабирование также меняет смысл: каждый экземпляр начнёт читать каталог самостоятельно. Для единственного исполнителя важного задания понадобится координация через очередь или другой устойчивый механизм. Один AddHostedService гарантирует регистрацию внутри выбранного процесса, а не глобальное число потребителей во всей инфраструктуре.