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

Комментарии (24)


  1. egorozh
    05.11.2021 17:39

    Почему бы не сделать пару виртуальных методов:
    "Task ExecuteIterationAsync(CancellationToken ct)" и
    "void ExecuteIteration(CancellationToken ct)",
    чтобы исключить все эти ограничения на то, как должен быть определён метод???


    PS: Всё понял... тогда DI в методе не сделать


    1. alexalok Автор
      05.11.2021 17:57
      +3

      Да, Вы правы, это ограничение вызвано именно необходимостью дать возможность инъекции scoped-зависимостей. При проектировке конкретно этого момента я опирался на то, как это сделано для middleware в ASP.NET Core.


  1. Dmitry3A
    05.11.2021 18:49

    Важно! Автоматическая регистрация работает только для работ, определенных в той же сборке, что и код, вызвавший метод регистрации.
    А если сделать указание assembly в имени типа? Тогда по идее можно подгрузить эту assembly по необходимости. Т.е. использовать assembly qualified name, на подобие:

    System.Web.Script.Services.ScriptHandlerFactory, System.Web.Extensions, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35


    1. alexalok Автор
      05.11.2021 18:54

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

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


      1. Dmitry3A
        05.11.2021 20:25

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

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

        Можно поиспользовать Assembly.Load

        И наверное было бы ещё полезно, не столько загружать по конкретному имени типа, а сказать — вот у нас есть интерфейс, загрузи с этой ассембли, воркер реализующий этот интерфейс.


        1. alexalok Автор
          05.11.2021 21:12

          Похоже, это я Вас изначально неправильно понял. Интересная идея, спасибо!


  1. buldo
    05.11.2021 19:04

    Есть ли какие-то киллер фичи, чтобы выбрать для использования вашу библиотеку, а не hangfire?


    1. alexalok Автор
      05.11.2021 19:42

      Hangfire намного более продвинутая библиотека, скорее даже целый фреймворк. Однако, по моему мнению, там много своего бойлерплейта, если мы не берем в расчет несерьезные примеры вроде создания работы из лямбда-выражения. В dotWork я старался решить одну конкретную задачу - уменьшение бойлерплейта, а весь остальной функционал основывается на стандартном для .NET классе BackgroundService. Кроме того, могу ошибаться, но в hangfire вроде бы нельзя использовать scoped и transient сервисы со стандартным DI контейнером.


      1. buldo
        05.11.2021 19:53

        Если мне не изменяет память, то можно делать вот так:

        public class ExampleWork
        {
          	private readonly ISomeDependency _someDep;
            public ExampleWork(ISomeDependency someDep){_someDep = someDep}
            public async Task ExecuteIteration(CancellationToken ct)
            {
                await _someDep.DoWorkAsync(TimeSpan.FromSeconds(1), ct); // симулируем работу
                Console.WriteLine("Work iteration finished!");
            }
        }

        Далее регистрируем джобу:

        BackgroundJob.Enqueue<ExampleWork>(x => x.ExecuteIteration(CancellationToken.None));

        И в итоге каждый раз будет создаваться экземпляр ExampleWork и зависимости с любым временем жизни подойдут.


        1. alexalok Автор
          05.11.2021 20:05
          +1

          Спасибо, не знал, выглядит очень лаконично. Здесь хочу подчернуть несколько моментов:

          • если мы регистрируем много работ, то нужно дублировать указанный Вами код регистрации, в то время как dotWork позволяет зарегистрировать "все сразу" вызовом AddWorks;

          • если нам потребуется получить настройки (а это достаточно частая необходимость, по крайней мере, по моему опыту), нужно будет в каждую работу инжектить свой IOptions<MyOptType> (или даже IOptionsMonitor<MyOptType>, чтобы поддерживать горячую перезагрузку), а если один тип используется несколькими работами - пользоваться именованными настройками... В общем, Вы меня поняли :)

          Если же стандартных возможностей .NET не хватает, то можно и нужно использовать hangfire.


  1. AlexanderKovalenko
    05.11.2021 19:42

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

    Возможно https://github.com/khellang/Scrutor помог бы зарегестрировать работы определенные и в других подключенных сборках. Если конечно сами сборки не грузить ручками.


    1. alexalok Автор
      05.11.2021 19:44

      Спасибо за идею! Я с осторожностью отношусь к автоматическому сканированию всех сборок, потому что это может привести к регистрации "лишних" работ, а также преждевременной подргузке тех библиотек, которые еще не загружены в домен приложения. Мне больше импонирует вариант, предложенный чуть выше - разрешить пользователю самому указывать сборки, которые нужно просканировать на предмет наличия работ.


      1. AlexanderKovalenko
        30.11.2021 23:08

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


      1. AlexanderKovalenko
        30.11.2021 23:23

        Ну и по поводу автоматического сканирования. Мой текущий проект содержит порядка полторы сотни различных сервисов, простыня из AddSingleton/AddTransient значительно перегружает конфигурацию. Я использую пометку атрибутом [SingletonService]/[TransientService], и сканированием регистрирую именно их. Очень удобно, и не надо волноваться забыл ты зарегестрировать или нет.
        Более того, если сборку под проект делает другой человек, а мы их подключаем через нугет-репозиторий, то ему достаточно пометить атрибутом и его сервисы также будут зарегистрированы. Таким образом, если я использую какую либо зависимость, а она имеет еще 20-30 своих зависимостей, то простыню моих 150 сервисов расширять еще 30 не нужно.
        Единственный момент, о сканировании надо помнить, потому как это не явная регистрация зависимостей.


        1. alexalok Автор
          30.11.2021 23:41

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


  1. korsetlr473
    05.11.2021 19:54

    в DI .net core можно если в конструктор передается класс с дефолтным конструктором в мэпинге не указывать?

    или нужно партянки вот такие писать?

    services.AddSingleton<АProvider>();

    services.AddSingleton<BProvider>();

    services.AddSingleton<DProvider>();

    services.AddSingleton<CProvider>();


    1. alexalok Автор
      05.11.2021 20:07

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


    1. lightmg
      09.11.2021 21:51

      Если я правильно понял вопрос, то может помочь класс ActivatorUtilities. В нем определены методы, позволяющие конструировать сервисы, не зарегистрированные в IServiceProvider, при этом зависимости конструктора резолвятся из него. Если коротко, то он просто делает provider.GetService() для каждого параметра конструктора


      1. alexalok Автор
        09.11.2021 21:53

        Я так понял, что в данном случае ситуация обратная - у нас есть сервисы, которых нет в контейнере, и нам нужно их подать в конструктор сервиса (в данном случае работы), который в контейнере есть.


  1. blanabrother
    05.11.2021 20:56

    Вы проводили какой-нибудь R&D? Например, есть Quartz.NET либа, которая покрывает перечисленные Вами в статье требования и предлагает даже больше.


    1. alexalok Автор
      05.11.2021 21:19

      Вы проводили какой-нибудь R&D?

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

      Quartz.NET либа, которая покрывает перечисленные Вами в статье требования и предлагает даже больше

      И это отлично! Если я буду разрабатывать приложение, в котором будут специфичные требования, сложнореализуемые через BackgroundWorker, я выберу Quartz.NET, или Hangfire, или любой другой фреймворк, который мне подойдет. Если же я захочу реализовать воркер, которому достаточно возможностей "сырого" .NET, то для уменьшения бойлерплейта выберу dotWork. Заметьте - не чтобы закрыть потребность в функционале, а именно чтобы потратить меньше кода на функционал, который уже имеется в .NET.


  1. Administarter
    06.11.2021 10:05

    Мы еще такую либу использовали в проекте. Норм работала: https://docs.coravel.net/Installation/


    1. alexalok Автор
      06.11.2021 10:06

      Спасибо, выглядит интересно.


  1. RudeWalt
    06.11.2021 12:17

    Спасибо автору, интересный проект. Недавно использовал такие сервисы, понадобилось запускать по крону. Может быть имеет смысл тоже добавить такую фичу вместе/вместо delay