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

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

Если вы готовы, давайте начнем.

Назови мне причину, по которой я хочу, чтобы ты

Обычно ваше приложение выполняет процессы в фоновом режиме. Хотя в более сложных случаях для выполнения трудоемкой работы вам понадобятся функции Hangfire, Quartz, Azure и т. д., все же есть место, особенно в простых сценариях, где можно применить IHostedService.

IHostedService обычно используется для решения следующих задач:

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

Как это реализовать?

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

Мы начнем с чего-то простого. Представьте, мы хотим увеличивать номер релиза каждый раз, когда мы развертываем наше приложение. Для этого нам нужно реализовать интерфейс IHostedService:

interface IHostedService
{
    // Triggered when the application started
    Task StartAsync(CancellationToken cancellationToken);

    // Triggered when the application is shuting down
    Task StopAsync(CancellationToken cancellationToken);
}

Сама реализация довольно проста:

internal MyHostedService: IHostedService
{
      public async Task StartAsync(CancellationToken cancellationToken)
      {
          var releaseNumber = await DB.GetLatestReleaseNumber();
          releaseNumber++;
          await Db.UpdateReleaseNumber(releaseNumber);
      }

      public Task StopAsync(CancellationToken cancellationToken)
      {
          return Task.CompletedTask;
      }
}

Последняя недостающая часть — это регистрация нашего сервиса в DI-контейнере:

services.AddHostedService<MyHostedService>();

Может показаться, что AddHostedService() делает какие-то махинации под капотом, и в контейнере DI есть новый жизненный цикл, но на практике он просто регистрирует его как Singleton, вызывая этот:

services
  .TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, MyHostedService>());

Конечно, вы также можете вручную зарегистрировать свой размещенный сервис как Singleton , и он будет работать, но AddHostedService() имеет следующие преимущества:

  • читаемое имя
  • он гарантирует, что размещенная служба будет зарегистрирована как IHostedService, поэтому она не может быть введена как MyHostedService в другие службы по ошибке.
  • он гарантирует, что реализация размещенной службы будет зарегистрирована один раз и только один раз
  • это уровень абстракции, позволяющий разработчикам ASP изменять способ управления размещенными службами без внесения критических изменений. Например, сделать его Transient, а не Singleton, как раньше.

Бесконечно выполняющиеся операции

Надеюсь было понятно на простом примере. На этот раз усложним. Представьте, мы хотим реализовать фоновую работу, что-то вроде чтения сообщения из очереди каждые 5 секунд.

Наивная реализация будет иметь следующий вид:

public class QueueReaderBackgroundJob : IHostedService
{
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            var message = await QueueStorage.Peek(cancellationToken);
            if (message is not null) HandleMessage(message);

            await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
        }
    }
    
    public Task StopAsync(CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }
                          .   .   .
}

Как только вы запустите свое приложение, вы обнаружите, что удивляетесь, почему ничего не происходит 🤔 Это ошибка, которую я постоянно совершаю 😅

⚠️ Обратите внимание, ваш хост обнаружит все реализации IHostedService и запустит StartAsync() одну за другой. Это означает, что если у вас есть Task внутри StartAsync(), который работает вечно, он заблокирует запуск для остальной части приложения.

Вот переписанный выше код с нашими новыми знаниями:

public class QueueReaderBackgroundJob : IHostedService
{
    public Task StartAsync(CancellationToken cancellationToken)
    {
        // do not await endless Task!
        // fire and forget
        /*await*/ Task.Run(async () =>
        {
            while (!cancellationToken.IsCancellationRequested)
            {
                var message = await QueueStorage.Peek(cancellationToken);
                if (message is not null) HandleMessage(message);

                await Task.Delay(5_000, cancellationToken);
            }
        }, cancellationToken);

        return Task.CompletedTask;
    }
                      .  .  .
}

Есть много деталей инфраструктуры, которые отвлекают нас от нашей основной логики. С одним простым фоновым заданием это может не быть проблемой, но как только у вас есть несколько таких заданий, становится раздражающим копирование и вставка одного и того же кода снова и снова.

Решением будет введение абстрактного класса, который сделает это. Оказалось, что такой класс уже существует. Он называется BackgroundService:

abstract class BackgroundService : IHostedService
{
  public virtual Task StartAsync(CancellationToken cancellationToken)
  {
      /*await*/ ExecuteAsync(_stoppingCts.Token);

      return Task.CompletedTask;
  }

  public virtual async Task StopAsync(CancellationToken cancellationToken)
  {
      .  .  .
  }

  protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
}

Я избегал подробностей о том, как это реализовано, но все же есть несколько вещей, о которых стоит упомянуть:

  • он реализует IHostesService и работает так же
  • вам нужно только реализовать ExecuteAsync(), но все еще возможно переопределить StartAsync() и StopAsync()
  • ExecuteAsync() — это асинхронный метод без await (сработал и забыл) по той же причине, что описана выше.
  • несмотря на то, что он работает по принципу «выстрелил и забыл», он все еще отслеживается хостом, что означает, что исключения не будут проглочены и будут всплывать.

Давайте еще раз перепишем наш сервис, на этот раз наследуя от BackgroundService. Наконец, мы можем использовать цикл await и инфинитив while, не беспокоясь, что это сломает наше приложение 😌

public class QueueReaderBackgroundJob : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var message = await QueueStorage.Peek(stoppingToken);
            if (message is not null) HandleMessage(message);

            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

Наличие такого универсального механизма, как BackgroundService, делает IHostedService забытым практически во всех сценариях. Единственная причина, по которой вы должны использовать IHostedService, — это если вам срочно нужен доступ к жизни хоста.

Внедрить сервис

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

В нашем фоновом сервисе у нас есть метод HandleMessage(). Допустим, он выполняет какую-то бизнес-логику. Единственная причина, по которой он у нас есть, только потому, что мы торопились. Однако теперь пришло время реорганизовать его и перенести в отдельный сервис с именем IOrderService:

public class QueueReaderBackgroundJob : BackgroundService
{    
    private readonly IOrderService _orderService;

    public QueueReaderBackgroundJob(IOrderService orderService)
    {
        _orderService = orderService;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var message = await QueueStorage.Peek(stoppingToken);

            if (message is not null)
            {
                _orderService.HandleMessage(message.OrderId);
            }

            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

Помните, что BackgroundService зарегистрирован как Singleton. Приведенный выше код будет работать, только если IOrderService зарегистрирован как Singleton или Transient.

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

Итак, как внедрить сервис Scoped в Singleton? Правильный! Нет возможности 😁 Конечно, я шучу. Есть выход. Но он полон болью и сожалениями. Вам нужно будет использовать IServiceScopeFactory:

public class QueueReaderBackgroundJob : BackgroundService
{
    private readonly IServiceScopeFactory _serviceScopeFactory;

    public QueueReaderBackgroundJob(IServiceScopeFactory serviceScopeFactory)
    {
        _serviceScopeFactory = serviceScopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var message = await QueueStorage.Peek(stoppingToken);
            if (message is not null)
            {
                await using var scope = _serviceScopeFactory.CreateAsyncScope();
                var orderService = scope.ServiceProvider.GetRequiredService<IOrderService>();

                orderService.HandleMessage(message.OrderId);
            }

            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

Теперь вы можете внедрить сервис в фоновое задание, не меняя его жизненного цикла.

Есть и другие способы с фабриками, ленивыми делегатами и так далее. Но у всех них есть общая проблема. Все они препятствуют проверке графа зависимостей.

Исключения в BackgroundService

Вы помните, когда я сказал, что BackgroundService запускает Task по принципу выстрелил-забыл, но не совсем так? Раньше было так, что исключения забывались, но не уже.

Теперь, если внутри вашего BackgroundService возникнет исключение, оно остановит приложение.

Чтобы избежать этого, вы можете переопределить конфигурацию по умолчанию в Startup.cs:

services.Configure<HostOptions>((hostOptions) =>
{
    // by default BackgroundServiceExceptionBehavior.StopHost
    hostOptions.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore;
});

Теперь он остановит только BackgroundService, в то время как остальная часть приложения продолжит работу.

Однако нам не хотелось бы останавливать выполнение нашей работы из-за какого-то глупого исключения 😒. Вот почему обычно там есть глобальный try/catch:

public class QueueReaderBackgroundJob : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                // your logic
            }
            catch (Exception ex)
            {
                // log an exception
                // handle an exception
                // additional logic needed to your job
            }

            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

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

Таймер в BackgroundService

До сих пор мы использовали Task.Delay() в качестве таймера для планирования выполнения задания. Однако я открою вам секрет. Task.Delay() это не таймер 😁. Конечно, это не так. Но на поверхностном уровне это работает в большинстве случаев.

Для большинства из нас не имеет значения, когда именно выполняется задание, главное, чтобы между его выполнением была некоторая задержка. Хотя это может быть так для вас, это не обязательно так для других.

Имея запланированное задание каждые 5 секунд, я ожидаю, что оно будет выполняться каждую 5-ю секунду, например, 00:00, 00:05, 00:10, 00:15 и так далее. Это не относится к Task.Delay(). Допустим, наша задача завершается примерно от 3 до 6 секунд, поэтому с задержкой в ​​5 секунд она будет выполняться так: 00:00, 00:08, 00:16. Я думаю, вы видите проблему. Это вообще не детерминировано! 😒

Единственным решением было бы использовать Timer:

public class QueueReaderBackgroundJob : BackgroundService
{
    private System.Timers.Timer timer = null;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        timer = new System.Timers.Timer(TimeSpan.FromSeconds(5));
        timer.Elapsed += async (s, e) => await DoWork(stoppingToken);
        timer.Start();

        // TODO: implement Dispose()
    }

    private async Task DoWork(CancellationToken stoppingToken)
    {
        // there is no while loop 🥄
        var message = await QueueStorage.Peek(stoppingToken);
        if (message is not null) HandleMessage(message);
    }
}

Есть несколько проблем с таймерами:

  • слишком много разных таймеров (System.Timers.Timer, System.Threading.Timer, System.Windows.Forms.Timer, System.Web.UI.Timer, System.Windows.Threading.DispatcherTimer …)
  • реализация всегда будет варьироваться
  • вы должны иметь дело с обратными вызовами
  • вам придется иметь дело с Dispose()
  • вам придется иметь дело с Exceptions
  • вам приходится иметь дело с потенциальными проблемами параллелизма, поскольку таймеры не ждут завершения предыдущей задачи.
  • вам нужно написать дополнительный код инфраструктуры

Если бы только было решение, обеспечивающее простоту Task.Delay() и функциональность таймеров. Оказалось, есть! Называется PeriodicTimer 😃

public class QueueReaderBackgroundJob : BackgroundService
{
    private readonly PeriodicTimer _timer = new PeriodicTimer(TimeSpan.FromSeconds(5));

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            // execute job

            await _timer.WaitForNextTickAsync(stoppingToken);
        }
    }
}

Теперь он будет выполняться, как и ожидалось, каждую 5-ю секунду (00:00, 00:05, 00:10, 00:15) независимо от того, сколько времени требуется для завершения задания.

Порядок выполнения (дополнительно *)

Раздел со звездочкой. Это не случайно. Я не думаю, что вам нужно знать информацию, описанную в этом разделе. Поверьте, это никогда не пригодится, и вы можете его пропустить 😊 Этот раздел только для тех, кто хочет знать немного больше, чем нужно.

По умолчанию ASP начинает выполнение IHostedServices до настройки ПО промежуточного слоя. Это означает, что если у вас есть http-вызов из вашего фонового сервиса к вашему API, он не будет работать, так как маршрутизация еще не полностью настроена.

Чтобы изменить это поведение и запустить IHostedServices после настройки и запуска приложения, вам нужно зарегистрировать свой сервис не в Startup, а в Program.cs:

public class Program
{
    public static void Main(string[] args)
        => CreateHostBuilder(args).Build().Run();

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>();
            })
            // Register your HostedService AFTER ConfigureWebHostDefaults
            .ConfigureServices((services) => 
            {
                services
                  .AddHostedService<QueueReaderBackgroundJob>();
            });
}

Лично мне такой подход не нравится. У вас половина сервисов зарегистрирована в Startup, а половина в Program.cs. Непонятно, почему это сделано именно так, поэтому комментарий обязателен. Его можно легко сломать. Вонючка, так сказать 🤢

Вторым решением было бы ввести IHostApplicationLifetime и дождаться ApplicationStarted:

public class QueueReaderBackgroundJob: BackgroundService
{
    private readonly IHostApplicationLifetime _appLifetime;

    public TestHostedService(IHostApplicationLifetime appLifetime)
    {
        _appLifetime = appLifetime;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // await without CancellationToken exception 
        await Task.WhenAny(
          Task.Delay(Timeout.Infinite, _appLifetime.ApplicationStarted)
        );

        // do whatchu' wanna do
                              .  .  .
    }
}

Однако, если вы следите за новыми тенденциями, вы, вероятно, услышите о Minimal API. У вас нет Startup там. Начиная с .Net6 IHostedServices по умолчанию выполняются после настройки промежуточного программного обеспечения, так что это совершенно не имеет значения.

Я же говорил, этот раздел бесполезен 😅 Но кто знает, может вы еще не перешли на последнюю версию 🙃

Если вы хотите больше, вот несколько полезных ссылок:

Завершение

Фоновые задачи и запланированные задания — это то, что вам может понадобиться в любом приложении. Несмотря на то, что .NET предоставляет интерфейс с именем IHostedService, помогающий легко реализовать фоновые задания, он подходит только для одноразовых действий и требует для работы большого количества дополнительного кода инфраструктуры. Единственная причина, по которой его следует использовать, — это когда вы хотите иметь прямой доступ к жизненному циклу хоста.

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

Он отлично работает для простых сценариев, но для более сложных рассмотрите возможность использования других надежных инструментов и библиотек, таких как Hangfire, Quartz, функция Azure и так далее.

Я надеюсь, что вы найдете эту статью полезной. Если да, не забудьте поставить оценку в виде аплодисментов 👏 Добавляйте комментарии, когда узнаете о других причудливых поступках IHosterService 💬 Поддержите меня по ссылке ниже ☕️ Поделитесь с друзьями 🗣 Смотрите другие мои статьи ⬇️ И не забудьте подписаться ✅ До встречи 😉