Введение

Привет, Хабр! На связи снова разработчик из компании АльфаСтрахование. Наша скрам-команда занимается автоматизацией бизнес-процессов Операционного блока, и для решения многих задач нам часто требуются фоновые операции. В этой статье я расскажу о шаблоне фонового сервиса на .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. Спасибо, что дочитали до конца!