Dependency Injection в системе автоматизации сборок NUKE. Ответы на вопросы «зачем?» и «как?»

от автора

Вступление

Всем привет, сегодня поговорим о внедрении Dependency Injection (далее — DI) в Nuke и рассмотрим моё видение. Кто не знаком с Nuke вы можете ознакомиться или на официальном сайте или посмотреть вот эту презентацию, если коротко — то это очень удобная система автоматизации сборок, которая по факту консольное приложение на C#.

Специфика жизненного цикла Nuke

Помним, что Nuke — обычное консольное приложение, но если взглянуть на типичный пример метода Main() такого приложения:

class Program: NukeBuild, ICanDoSomething {   static int Main(string[] args) => Execute<Program>(x => x.DefaultTarget); }

Видно, что мы оформляем всю логику внутри класса, который наследуется от NukeBuild и передаем его через дженерик в метод Execute<T>(). Вся магия фреймворка начинается под капотом Execute<T>(), но из-за этого основной nuke-класс не может иметь конструктора с параметрами.

Также из-за того, что запуск фреймворка = передача метода через дженерик, появляется ещё одна особенность — функционал может быть «размазан» по nuke-классу и интерфейсам.

Объяснение почему функционал может быть «размазан» по nuke-классу и интерфейсам

Так как мы передаем один класс, то все таргеты должны быть описано в нём. Если у вас достаточно много таргетов, то намечается огромный класс на 1000 строк например, что не ок.

Один из способов решить эту проблему — использовать интерфейсы с реализацией по умолчанию. Соответственно nuke-класс может имплементировать (хотя в данном контексте возможно это слово не очень подходит) неограниченное количество интерфесов, каждый из которых содержит в себе только один таргет. При таком подходе с кодом гораздо проще работать и структурировать его.

Для реализации DI наличие таргетов и в nuke-классе и в интерфейсах является слегка проблемой. Потому что не получится разместить DI контейнер в nuke-классе, ведь он должен быть доступен и для nuke-класса и для интерфейсов, единственный способ это реализовать — вынести контейнер в статический класс.

Зачем?

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

При создании классов с функционалом, лично я, вижу 3 возможных варианта развития событий:

  1. Весь функционал размещать в статических классах

  2. Размещать функционал в НЕ статических классах и инициализировать их в контейнере nuke-класса

  3. Размещать функционал в НЕ статических классах и использовать DI контейнер

Из этих 3-х вариантов мне больше всего импонирует DI, из-за того что:

  • При использовании статических классов усложняется тестирование

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

  • При использовании DI контейнера появляется возможность регистрировать сервисы как Scoped

  • Появляются гарантии, что при регистрации как Singleton в любом месте кода я получу один и тот же экземпляр класса

  • И наверное основное — логика работы с зависимостями точно такая же как в привычных мне веб-проектах

Как?

Как я уже писал выше, в силу особенностей Nuke контейнер должен находится в статическом классе, я не придумал ничего лучше, как назвать его DependencyInjection. Этот класс нужен для хранения DI контейнера и обеспечения удобного доступа к нему. Также сразу озвучу некоторые важные для понимания кода моменты:

  • Я использую встроенный в .NET Core IServiceProvider, если вам нравится DI из какого-то другого NuGet пакета — смысл будет таким же.

  • В силу определенных причин в библиотеке я использую получаю сервисы из контейнера через IServiceScope, чтобы получать Scoped экземпляры класса для каждого таргета отдельно.

  • Так как этот код у меня находится в библиотеке, в комментариях я использую слово «пользователь» имеется в виду когда библиотеку установили и к стандартным зависимостям добавили какие-то свои.

public static class DependencyInjection {   private static IServiceScope _scope; // Scope сервисов чтобы получать разные экземпляры класса для разных таргетов   private static IServiceProvider _сontainer; // Непосредственно контейнер    // Методы для получения экземпляров классов   public static T Get<T>()   {     return _scope.ServiceProvider.GetService<T>() ?? throw new Exception($"Can`t get service {typeof(T).Name} from DI container");   }      public static object Get(Type type)   {     return _scope.ServiceProvider.GetService(type) ?? throw new Exception($"Can`t get service {type.Name} from DI container");   }    // Метод для того чтобы создать новый Scope   public static void StartNewScope() => _scope = _сontainer.CreateScope();    // Инициализирующий метод, в нём регистрируются все зависимости   // [overrideDependencies] - параметр через какой добавляются зависимости пользователя   internal static void RegisterDependencies(NukeBase nuke, Action<IServiceCollection> overrideDependencies)   {     //Создаем ServiceCollection где будем регистрировать зависимости     var services = new ServiceCollection();      services.AddSingleton(nuke);   // Место для регистрации зависимостей библиотеки     // Например services.AddSingleton<SomeClass>();     // Например services.AddScoped<SomeScopedClass>();      var nukeDependencies = new ServiceCollection();     overrideDependencies.Invoke(nukeDependencies); // получаем зависимости пользователя      // проходимся по всем зависимостям пользователя     // и добавляем или заменяем их в контейнер     foreach (var dependency in nukeDependencies)     {       services.Replace(dependency);     }          //Собираем всё в кучу     _сontainer = services.BuildServiceProvider();     _scope = _сontainer.CreateScope();   } }

Теперь научим Nuke взаимодействовать с нашим контейнером. Начнём с создания абстрактного класса наследника NukeBase

public abstract partial class NukeBaseWithDI {   // Метод для регистрации пользовательских зависимостей   protected virtual void AddOrOverrideDependencies(IServiceCollection services)   {   }      // Метод для более лаконичного доступа к контейнеру    public static T Get<T>() => DependencyInjection.Get<T>();      // Создаем отдельный scope для каждого таргета   protected override void OnTargetRunning(string target)   {     DependencyInjection.StartNewScope();     base.OnTargetRunning(target);   } }

Ещё DI может пригодится в интерфейсах, так как там используется реализация по умолчанию. Значит создадим базовый для всех таргетов интерфейс. Получается небольшое дублирование, но его тут не избежать(

public interface INukeTarget {   public T Get<T>() => DependencyInjection.Get<T>(); }

Теперь можно использовать это, например так:

class Program: NukeBaseWithDI {   static int Main(string[] args) => Execute<Program>(x => x.TestTarget);    public Target TestTarget => _ => _     .Executes(() => Get<TestService>().Run());    protected override void AddOrOverrideDependencies(IServiceCollection services)   {     // Важный момент, так как может быть целая цепочка наследований     // следует обязательно добавлять base.AddOrOverrideDependencies(services)     base.AddOrOverrideDependencies(services);     services.AddSingleton<TestService>();   } }

В примере выше, использование DI выглядит притянутым за уши, но когда классов и таргетов становится очень много всё становится на свои места. Кроме того основываясь на таком DI я сделал другие прикольные штуки в Nuke, о которых расскажу в следующих статьях, а пока можете почитать о Fail-fast design при автоматизации сборок с помощью Nuke там тоже используется такой DI подход.

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


ссылка на оригинал статьи https://habr.com/ru/post/599789/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *