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

Знакомство с библиотекой

Библиотека называется dotWork, у нее есть репозиторий на GitHub, а сборки выкладываются на NuGet. По большей части библиотека представляет собой обертку над BackgroundService, встроенным в .NET решением для написания воркеров.

Что конкретно упрощает dotWork?

Если вкратце, dotWork упрощает следующие аспекты разработки воркеров:

  • регистрацию работ (works);

  • регистрацию соответствующих работам настроек;

  • написание кода для повторения итераций.

В моей практике именно эти три момента требуют практически шаблонного кода и тем не менее не имеют возможности стандартизации "из коробки".

Установка

Для установки достаточно поставить NuGet пакет:

dotnet add package dotWork

Использование

Для использования библиотеки нам нужно создать один или несколько классов-работ (works), описывающих, что должен делать работник (worker). В контексте .NET работником выступает само приложение, либо хост, если приложение содержит несколько хостов.

Создание класса-работы

Для создания работы добавим в приложение новый класс, унаследованный от WorkBase:

public class ExampleWork : WorkBase<DefaultWorkOptions> { }

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

public class ExampleWork : WorkBase<DefaultWorkOptions>
{
    public async Task ExecuteIteration(CancellationToken ct)
    {
        await Task.Delay(TimeSpan.FromSeconds(1), ct); // симулируем работу
        Console.WriteLine("Work iteration finished!");
    }
}

Важно! Чтобы dotWork нашла метод итерации, необходимо, чтобы он соответствовал следующим правилам:

  • имел название ExecuteIteration;

  • был помечен модификатором public;

  • возвращал void или Task.

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

Внедрение зависимостей

dotWork позволяет внедрять зависимости двумя способами. Выбор конкретного способа зависит от того, как зависимость зарегистрирована в контейнере.

Singleton-зависимости

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

public class ExampleWork : WorkBase<DefaultWorkOptions>
{
    readonly SingletonService _singletonService;

    public ExampleWork(SingletonService singletonService)
    {
        _singletonService = singletonService;
    }

    ...
}

Scoped и Transient зависимости

Часто возникает необходимость внедрить не-singleton зависимости в работу. Типичный пример - база данных. Поскольку работа живет до остановки хоста, то внедрение в нее коннектора базы данных может привести к тому, что соединение с БД будет открыто с самого начала и до самого конца работы программы. Это может быть нежелательно, например, если работа короткая и выполняется раз в длительный промежуток времени.

В таком случае, зависимость можно внедрить напрямую в метод итерации:

public async Task ExecuteIteration(ScopedService scopedService)
{
    await Task.Delay(TimeSpan.FromSeconds(1), ct);
    Console.WriteLine("Work iteration finished!");
}

Сервисы, внедренные таким образом, будут удалены после окончания итерации. dotWork создает новый scope на каждую итерацию.

Помимо сервисов, в метод итерации можно также внедрить CancellationToken. Он сработает, когда хост начнет остановку

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

Регистрация работ

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

Регистрация одной работы

Зарегистрировать одну отдельную работу можно, добавив следующий вызов в метод ConfigureServices HostBuilder-a:

.ConfigureServices(services =>
{
    services.AddWork<ExampleWork, DefaultWorkOptions>(); // <- наш метод
})

Здесь первым дженерик-параметром является тип работы, а вторым - тип настроек работы. Важно, чтобы тип настроек, указанный при регистрации, совпадал с тем, который указан при наследовании работы от класса WorkBase.

На данном этапе наша работа полностью готова к запуску. Однако, если запустить ее, обнаружится, что итерация выполняется только один раз. Это нормально - настройка работы по-умолчанию (DefaultWorkOptions) устанавливает бесконечную задержку между итерациями. Мы можем изменить это, переопределив стандартную настройку:

services.AddWork<ExampleWork, DefaultWorkOptions>(configure: opt =>
{
    opt.DelayBetweenIterationsInSeconds = 10;
});

Теперь следующая итерация работы начнется через 10 секунд после окончания предыдущей.

Автоматическая регистрация работ

Вместо того, чтобы регистрировать каждую работу по-отдельности, мы можем зарегистрировать все работы сразу. Очевидно, что при этом у нас нет возможности настроить каждую работу, поэтому этот способ регистрации требует обязательного указания раздела конфигурации с настроками работ. Этот раздел должен представлять из себя словарь, где ключами являются названия классов работ. Легче всего продемонстрировать это на примере. Предcтавим, что у нас есть приложение с двумя работами:

  • ExampleWork1

  • ExampleWork2

Файл appSettings.json, в таком случае, может выглядеть следующим образом:

{
    "Works": {
        "ExampleWork1": {
            "IsEnabled": false,
            "DelayBetweenIterationsInSeconds": 86400 // 1 day
        },
        "ExampleWork2": {
            "DelayBetweenIterationsInSeconds": 3600 // 1 hour
        }
    }
}

И мы можем зарегистрировать все наши работы одной строчкой кода:

.ConfigureServices((ctx, services) =>
{
    services.AddWorks(ctx.Configuration.GetSection("Works"));
});

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

.ConfigureServices((ctx, services) =>
{
    var cfg = ctx.Configuration;
    services.AddWorks(cfg.GetSection("Works"));
    services.AddWork<ExampleWork1, DefaultWorkOptions>(configure: opt =>
    {
        opt.DelayBetweenIterationsInSeconds = 86400 * 2; // 2 days
    });
})

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

Изменение класса настроек

Работа необязательно должна использовать DefaultWorkOptions в качестве настроек. Можно создать свой класс, унаследовав его от DefaultWorkOptions или даже напрямую от интерфейса IWorkOptions:

public class MyWorkOptions : DefaultWorkOptions
{
    public string MyProp { get; set; }
}

public class Work_With_DefaultWorkOptions2 : WorkBase<DefaultWorkOptions2>
{
    public async Task ExecuteIteration()
    {
        Console.WriteLine("MyProp value is: " + Options.MyProp);
    }
}

Заключение

Надеюсь, dotWork поможет сэкономить время при написании приложений-воркеров. Если после начала использования библиотеки Вы обнаружите, что в ней отсутствует какая-то важная ожидаемая функциональность, пожалуйста, откройте issue в репозитории GitHub.