Введение
Привет, Хабр! На связи снова разработчик из компании АльфаСтрахование. Наша скрам-команда занимается автоматизацией бизнес-процессов Операционного блока, и для решения многих задач нам часто требуются фоновые операции. В этой статье я расскажу о шаблоне фонового сервиса на .NET, который мы используем в своей работе.
Сам шаблон был создан в нашей команде еще до моего прихода. Однако, приступив к проекту «Исходящий Диадок», я заметил, что прежний шаблон недостаточно гибко справляется с поставленными задачами и требует доработки. В этой статье я поделюсь нашим опытом и расскажу, как мы развивали шаблон фоновых сервисов для решения новых вызовов.
Шаблон 1.0

Изначально фоновые операции были простыми с точки зрения требований к запуску. Это отражалось и на простоте самого шаблона 1.0. По сути, он запускал менеджеры очередей (на рисунке — QueueManager), зарегистрированные в .NET-приложении, по таймеру.
Использование шаблона начиналось с создания различных менеджеров очередей. Затем они регистрировались в контейнере внедрения зависимостей (Dependency Injection). После этого необходимо было создать объект-строитель, который настраивал приложение на вызов менеджеров в фоне. Этот строитель вызывался в файле Program.cs при инициализации приложения. На последнем этапе в конфигурационном файле указывался интервал запуска — в секции TimerConfig:IntervalSeconds (использование этой секции было захардкожено, альтернативы не предусматривалось).
Ниже — пример использования шаблона:
// 1. Нужно создать csproj проект для воркеров <Project Sdk="Microsoft.NET.Sdk.Worker"> // 2. В проекте создать менеджеров очередей, унаследованных от интерфейса // IQueueManager, для обработки элементов очереди public class ExampleQueueManager : IQueueManager { public void ProcessQueue() { // Код обрабатывающий очередь элементов } } // 3. Сформировать builder для воркера, в котором будет происходить регистрация зависимостей // (в том числе менеджеров очередей) public class ExampleWorkerHostBuilder : BaseWorkerHostBuilder<QueueIntervalWorker> { public ExampleWorkerHostBuilder( HostBuilderContext hostContext, IServiceCollection services) : base(hostContext, services) { } protected override void Configure(IConfiguration configuration) { Services.AddTransient<IQueueManager, ExampleQueueManager>(); } } // 4. Применить builder-а в Program.cs public class Program { public static void Main(string[] args) { Logger logger = NLogBuilder .ConfigureNLog("nlog.config") .GetCurrentClassLogger(); try { Host.CreateDefaultBuilder(args) .UseWindowsService() .ConfigureServices((hostContext, services) => { var builder = new ExampleWorkerHostBuilder( hostContext, services); builder.Build(); }) .UseNLog() .Build() .Run(); } catch (Exception ex) { logger.Error(ex); } finally { LogManager.Shutdown(); } } } // 5. В конфигурации приложения в секции TimerConfig:IntervalSeconds // указать интервал запуска менеджеров
Анализ проблем и формирование требований
Из проблем, которые имел шаблон 1.0, можно выделить 4 основных:
Нет запуска по расписанию. Шаблон позволяет запускать задачи только по таймеру. Если нужно, чтобы задача выполнялась каждый день в определённое время, приходится вручную запускать приложение тогда, когда это требуется — даже в нерабочие часы;
Нельзя настроить отдельный таймер для каждого менеджера очередей. Все менеджеры запускаются с одним и тем же интервалом, индивидуально настроить частоту для каждого не представляется возможным;
Экземпляр менеджера очередей создаётся один раз при первом запуске. Это усложняет работу с зависимостями, особенно если использовать такие ресурсы, как контекст базы данных или HTTP-клиент;
Сложности интеграции с брокерами сообщений. Из-за того, что приложение по таймеру подряд запускает все менеджеры очередей, для взаимодействия с брокерами приходится заводить отдельный менеджер, который бесконечно слушает очередь. В итоге:
становится неочевидно, что приложение вообще работает с брокером сообщений;
нельзя удобно настраивать работу одновременно с несколькими брокерами или несколькими очередями одного брокера в рамках одного приложения.
Большинство неудобств долгое время не мешали работе — с ними можно было мириться или обходить их стороной. Однако игнорирование недостатков не могло длиться вечно. Критическим моментом стал проект исходящий Диадок: в его рамках нам понадобилось запускать в одном приложении несколько фоновых задач, причём каждая из них должна была стартовать в разное время. Это особо остро высветило и другие проблемы, которые прежде удавалось избегать.
Исходя из указанных проблем, мы сформировали список требований к новому шаблону фоновых сервисов:
Минимальные изменения при подключении воркера. Подключение шаблона воркера не должно требовать серьёзных изменений существующего кода, бизнес-логики или архитектуры взаимодействия модулей;
Гибкая настройка триггеров через конфигурацию. Должно быть возможно задавать тип триггера (таймер, расписание cron, Kafka и др.) и его параметры через конфигурационные файлы (appsettings.json, переменные среды и пр.);
Пользовательские и расширяемые триггеры. Возможность реализовывать и подключать собственные типы событий-триггеров (например, через расширение или наследование), помимо встроенных;
Индивидуальные триггеры для разных менеджеров. Для каждого менеджера очередей должен настраиваться свой собственный триггер, конфигурируемый отдельно;
Поддержка нескольких менеджеров очередей. Приложение должно работать сразу с несколькими менеджерами (например, чтобы слушать несколько топиков Kafka; один менеджер запускать по таймеру, а другой по расписанию);
Гибкость в одновременном запуске задач. Когда несколько событий-триггеров срабатывают одновременно — должно быть предусмотрено:
последовательное выполнение менеджеров очередей;
либо немедленный параллельный запуск, если хватает ресурсов (вариант выбирается в конфигурации);
Обработка сообщений из Kafka. Необходимо предусмотреть возможность обработки очередей, основанных на топиках Kafka (как основной вариант, либо с последующей расширяемостью);
Запуск задач по расписанию или таймеру. Новый шаблон должен поддерживать оба варианта;
Работа через Dependency Injection (DI). Все основные компоненты (менеджеры очередей, обработчики событий, триггеры и вспомогательные сервисы) должны регистрироваться и разрешаться через стандартный механизм dependency injection в .NET.
Выбор решения
В первую очередь мы рассмотрели существующие готовые решения:
Требования | Hangfire | Quartz.NET | KafkaFlow | Confluent.Kafka |
Минимальные изменения при подключении воркера | ~ Средняя | ✓ Низкая | ✗ Высокая | ✗ Высокая |
Гибкая настройка триггеров через конфигурацию | ~ Средне | ✓ Легко | ✓ Легко | ✗ Сложно |
Пользовательские и настраиваемые триггеры | ✗ Сложно | ✓ Легко | ✓ Легко | ✗ Сложно |
Индивидуальные триггеры для разных менеджеров | ~ Средне (только разграничением) | ✓ Легко | ✓ Легко | ✗ Сложно |
Поддержка нескольких менеджеров очередей | ✓ Да | ✓ Да | ✓ Да | ✓ Да |
Варианты одновременного запуска | ✓ Да | ✓ Да | ✓ Да | ✓ Да |
Обработка сообщений из Kafka | ~ Средняя (через job) | ~ Средняя (через job) | ✓ Легко | ✓ Легко |
Обработка задач по таймеру/расписанию | ✓ Да | ✓ Да | ✗ Нет | ✗ Нет |
Работа через dependency injection (DI) | ✓ Да | ✓ Да | ✓ Да | ✓ Легко (без DI, но интегрируется легко) |
Сравнив разные варианты, мы пришли к выводу, что ни одно из существующих решений полностью не покрывает наши требования — каждая из библиотек хорошо справляется только с отдельной группой задач.
Hangfire, Quartz.NET и им подобные — отличные планировщики задач, но возникают трудности, когда требуется обрабатывать бесконечные очереди (например, Kafka или RabbitMQ).
KafkaFlow, Confluent.Kafka и аналоги, в свою очередь, изначально рассчитаны на работу с бесконечными потоками сообщений, но слабо подходят для обычного планирования задач по расписанию или таймеру.
Исходя из этого, мы решили не искать компромиссное готовое решение, а заняться улучшением собственного шаблона. Это позволило нам точечно решить именно наши задачи — без избыточной функциональности или компромиссов.
Шаблон 2.0
Очередь фоновых действий

Сразу отмечу: на последующих схемах я буду опускать ранее изображенные детали и показывать только изменившиеся элементы — это упростит восприятие иллюстраций.
Пе��вым шагом реализации стала очередь фоновых действий (далее — ОФД). Мы не стали изобретать велосипед и взяли за основу реализацию от Microsoft, немного дополнив её под наши задачи. Для этого мы создали обёртку для действий, выполняемых в фоне — BackAction. Этот вспомогательный класс инкапсулирует не только само действие, которое необходимо выполнить, но и связанную с ним задачу (Task), позволяющую отслеживать завершение работы во внешнем коде. В приложении появилась очередь этих действий, а также фоновая служба, которая на протяжении всего времени работы .NET-приложения читает эту очередь и запускает фоновые действия.
В большинстве случаев доступ к сервису управления очередью действий осуществляется через интерфейс IBackActionQueueService: добавляете действие — оно автоматически попадёт в ОФД и будет выполнено службой QueuedHostedService.
Запуск фонового менеджера в рамках фоновых действий

На этой схеме показан запуск фоновых менеджеров с использованием ОФД.
Сначала про терминологию: в шаблоне 2.0 мы отказались от термина «менеджер очередей». Вместо него мы ввели новый термин — фоновый менеджер (ФМ): это сервис, который вызывается фоновой службой и выполняет работу по событию. Эту работу можно реализовать так, что она будет обрабатывать очередь элементов — как это делал менедже�� очередей раньше, но без жёсткой привязки к очереди сообщений.
Центральное место на схеме занимает PushBackgroundManagerHostedService — фоновая служба, отвечающая за взаимодействие с ФМ, которые нужно запускать в рамках ОФД. Сначала служба определяет все объекты BmInfo, относящиеся к ней. Для каждого такого объекта ожидается определённое событие-триггер. При наступлении события формируется BackAction, который отправляется в ОФД. В рамках одного BackAction заложено два действия: создать новый экземпляр ФМ и запустить его. После выполнения действия сервис снова начинает ждать следующее событие-триггер для этого BmInfo, повторяя цикл.
Стоит отметить, что PushBackgroundManagerHostedService — это обобщённое понятие. На практике таких служб несколько, и каждая может отслеживать своё событие: срабатывание таймера, наступление определённого времени, получение сообщения из Kafka и так далее. Такой подход позволяет легко расширять функциональность и добавлять новые сценарии.
Запуск фоновых менеджеров в обрабатывающих службах

Как видно из схемы, здесь отличие в одном: вместо того чтобы отправлять BackAction в ОФД, служба RunBackgroundManagerHostedService сама занимается созданием и запуском экземпляров ФМ напрямую.
Такой способ полезен, например, когда приложению требуется обрабатывать несколько топиков Kafka независимо друг от друга.
Пример использования
Пример использования ОФД в WebAPI
// Program.cs var builder = WebApplication.CreateBuilder(args); // Подключаем ОФД builder.Host.UseBackgroundManagers(); // Добавляем контроллеры builder.Services.AddControllers(); var app = builder.Build(); app.UseHttpsRedirection(); app.MapControllers(); app.Run(); // Файл контроллера CountersController.cs [ApiController] [Route("[controller]")] public class CountersController(IBackActionQueueService backActionQueueService) : Controller { private static int _counter; [HttpGet] public int Get() { return _counter; } [HttpPost] public async ValueTask PostAsync(CancellationToken cancellationToken) { BackAction backAction = new(_ => { _counter++; return default; }); await backActionQueueService.EnqueueAsync(backAction, cancellationToken); } }
Пример использования ФМ в WebAPI
Пример конфигурации ФМ в appsettings.json файле:
{ "Managers": { // Конфигурация менеджера, запускаемого по таймеру в рамках очереди фоновых действий "ExampleTimer": { "RunMode": "QueueBackAction", "TriggerType": "Timer", "Timer": "00:00:40", // Запускаем каждые 40 секунд "RunOnStartupApp": true // Первый запуск при старте приложения }, } }
Пример использования ФМ в WebAPI:
// ExampleTimerConfig.cs | Создаем конфигурацию для нашего ФМ public class ExampleTimerConfig: BaseBackgroundManagerConfig; // ExampleTimerManager | Создаем менеджера с некоторой логикой public class ExampleTimerManager ( IOptionsSnapshot<ExampleTimerConfig> options, ILogger<ExampleTimerManager> logger) : IBackgroundManager { public ValueTask ExecuteAsync(Dictionary<string, object> args, CancellationToken cancellationToken) { ExampleTimerConfig config = options.Value; logger.LogInformation($"Мод запуска: {config.RunMode}, Тип события-триггера: {config.TriggerType}, Таймер: {config.Timer}"); return default; } } // Program.cs | Добавляем сервисы для работы ФМ и сам ФМ в DI var builder = WebApplication.CreateBuilder(args); // Подключаем сервисы для фоновых менеджеров в DI builder.Host.UseBackgroundManagers(); // Добавляем контроллеры builder.Services.AddControllers(); // Добавляем фоновых менеджеров в DI builder.Services.AddBackgroundManager<ExampleTimerManager, ExampleTimerConfig>(builder.Configuration, "Managers:ExampleTimer"); var app = builder.Build(); app.UseHttpsRedirection(); app.MapControllers(); app.Run();
Результат
Шаблон 2.0 показал себя на практике очень достойно — интеграция с существующими приложениями проходит легко и удобно. Перевести более 30 наших фоновых сервисов на новый шаблон удалось всего за пару двухнедельных спринтов (с учётом тестирования). Стоит отметить, что основное время ушло именно на тестирование: само переписывание всех приложений под шаблон 2.0 занило до 5 дней.
После внедрения нового шаблона мы получили не только выполнение всех поставленных требований, но и несколько приятных бонусов.
Во-первых, стало возможным запускать фоновые действия непосредственно — без обязательного создания отдельных менеджеров. Это удобно, например, когда в ходе HTTP-запроса нужно инициировать какие-то дополнительные фоновые действия — они уходят в очередь, освобождая основной поток.
Во-вторых, новый шаблон оказалось гораздо проще встраивать в .NET-приложение. Это упростило и ускорило создание новых воркеров. А также позволило легко и быстро «переобувать» сервисы из Worker Service в WebAPI и обратно, что существенно упрощает и ускоряет миграцию с Windows-сервисов на контейнеры в Kubernetes.
Также важно отметить, что шаблон изначально проектировался как расширяемое решение и уже сейчас поддерживает несколько популярных сценариев запуска фоновых действий:
выполнение по таймеру: задание откладывается на N секунд
плановый запуск по расписанию — реализовано на базе hangfireIO Cronos
реакция на поступление сообщений из Kafka — реализовано на базе confluent kafka dotnet
реакция на поступление сообщений из очереди RabbitMQ — реализовано на базе easynetq
Ознакомиться с реализацией шаблона 2.0 можно по этой ссылке. Примеры использования находятся в том же репозитории — смотрите тут. Выложенная версия шаблона заметно упрощена: убран функционал работы с брокерами сообщений, а еще пару моментов — так проще разобраться с основными идеями.
P.S. Спасибо, что дочитали до конца!
