Worker Services в .NET
Написание воркер-сервисов на .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.