Вступление

Всем привет, сегодня поговорим о внедрении 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 подход.

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

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


  1. niyaho8778
    10.01.2022 12:22
    +1

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

    service.Singleton<MyClassA>();

    service.Singleton<MyClassB>();

    service.Singleton<MyClassC>();


    1. Duskone39 Автор
      10.01.2022 12:29

      тут по факту не nuke, а встроенный в .Net Core DI через IServiceCollection. Я честно говоря такой функции не встречал, мне кажется что нет (но это не точно))) ). Мне лично нравится контроллировать процесс регистрации классов в контейнере, но согласен что кода так значительно больше.


  1. questor
    11.01.2022 01:55

    Nuke как фреймворк не имеет встроенной реализации DI

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

    Я с nuke не работал, но мне кажется что было бы неплохо, если бы к nuke были бы готовые адаптеры под несколько распространённых ioc-фреймворков, чтобы не приходилось городить свои велосипеды.

    К тому как реализовано прикручивание ioc в вашей статье есть пара серьёзных замечаний.

    Начать с того, что вы в базовый класс NukeBaseWithDI на конструктор передаёте IServiceCollection, что идеологически неверно с точки зрения того, как вообще DI строиться. Возможно, вы слышали о SOLID и том, что "Абстракции не должны зависеть от деталей", тут это нарушается вот как. Если вы строите типичное слоёное приложение, то у него выделяется домен который не зависит от деталей реализации (типа как отправлять почту, ходить в базу и т.п.) и в проекте домена классы не зависят от инфраструктуры - там нет подключений nuget-пакетов с почтой, базой и т.п. У вас же с текущими классами в проекте доменной логики гарантированно будет подключаться nuget-пакет Microsoft.DependencyInjection или как его там. В правильном приложении такой пакет будет подключен только в одном проекте - самом консольном приложении и именно в нём будет класс бутстрапера с .Configure который будет являться единственной точкой в которой происходит конфигурирование приложения. А у вас в домен просочилась несвойственная ответственность, не должно быть её там.

    У вас появилось две точки бутстраппинга, хотя должна быть одна. У вас и в RegisterDependencies место под пачку services.AddScoped/AddTransient и такое же в AddOrOverrideDependencies

    Из-за того, что вы крайне слабо описали работу со scope я не могу чётко объяснить, что не так с работой со scope (мне для этого надо очень многобукв для того, чтобы описать разные варианты того, кода который вы не показали - не хочу гадать). Но я уверен, что текущая реализация - это меткий выстрел себе в ногу и хотя у вас сейчас "всё работает" но под будущее надёжно заложена мина замедленного действия, которая рванёт так что мало не покажется. Возможно, кто-то хорошо пояснит этот пункт или наоборот развеет мои сомнения.

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


    1. Duskone39 Автор
      11.01.2022 12:26

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

      В моем случае (и на сколько я понимаю NUKE достаточно часто используется именно по такой схеме). В каждое приложение, которое мы разрабатываем добавляется консольный проект для деплоя/сборки. Так как 98% кода таких приложений повторяется логично вынести его в какое-то отдельное место - Nuget пакет.

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

      class Program : MyNukeBase, ICanWorkWithDocker, ICanWorkWithHelm
      {
        static int Main(string[] args)
        {
          return Execute<Program>(x => x.DefaultTarget);
        }
      }

      То есть внутри каждого проекта для его сборки и деплоя получаем один класс на 7 строк, а весь функционал находится в библиотеке, которую используем через наследование от MyNukeBase или другое название.

      Теперь немного больше по существу

      Начать с того, что вы в базовый класс NukeBaseWithDI на конструктор передаёте IServiceCollection, что идеологически неверно с точки зрения того, как вообще DI строиться

      Ну не совсем так, там IServiceCollection передается не через конструктор а через аргумент метода, но я понял о чём Вы, действительно лучше передавать абстракции абсолютно согласен и стараюсь так делать, но возможно тут произошел мисандерстендинг, описанное мной в статье - это не идеи для расширения nuke как фреймворка, это добавление функционала в проекты которые используют nuke. И получается что итоговый код приложения которе будет использоваться для деплоя на 98% находится в библиотеке именно поэтому конкретизация (например использование IServiceCollection начинается уже в библиотеке).

      Если вы строите типичное слоёное приложение

      Это сложно названить "типичным слоёным приложением", чаще слышал сравнение nuke проекторв для деплоя со скриптами. Поэтому проецировать привычные подходы труднова-то

      В правильном приложении такой пакет будет подключен только в одном проекте - самом консольном приложении и именно в нём будет класс бутстрапера с .Configure который будет являться единственной точкой в которой происходит конфигурирование приложения

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

      Поэтому регистрация зависимостей внутри библиотеки работает для nuke уменьшая дублирование кода и упрощая поддерживание проектов. Но повторюсь всё это из-за особенностей nuke и специфики его применения

      У вас появилось две точки бутстраппинга, хотя должна быть одна. У вас и в RegisterDependencies место под пачку services.AddScoped/AddTransient и такое же в AddOrOverrideDependencies

      На самом деле точка в которой зависимости добавляются в контейнер одна и это RegisterDependencies(). Метод AddOrOverrideDependencies() позволяет прокинуть зависимости внутрь библиотеки он вызывается внутри RegisterDependencies().

      Как это работает 99.9% зависимостей регистрируются внутри RegisterDependencies() потому что они и находятся внутри библиотеки, AddOrOverrideDependencies() используется ооочень редко, когда есть какой-то ну очень специфичный проект в котором нужно использовать другую зависимость.

      Из-за того, что вы крайне слабо описали работу со scope я не могу чётко объяснить, что не так с работой со scope

      Пока что я использую scope в одном случае. Для более корректного логгирования

      services.AddScoped<TargetSummary>();
      public class TargetSummary
      {
        private readonly GPDNukeBase _nuke;
        private readonly Dictionary<string, string> _storage = new Dictionary<string, string>();
      
        public TargetSummary(GPDNukeBase nuke)
        {
          _nuke = nuke;
        }
      
        public void AddProperty(string name, object value)
        {
          _storage[name] = value.ToString();
          _nuke.ReportSummary(_ => _storage);
        }
      
        public Dictionary<string, string> GetValues() => _storage.ToDictionary(pair => pair.Key, pair => pair.Value);
      }

      Метод ReportSummary() позволяет добавить информацию в итоговую таблицу, информация эта как итог выполнения конкретного шага

      Класс TargetSummary в виду того что он scoped создает отдельные инстенсы для каждого таргета и позволяет удобным образом выводить в ReportSummary() больше одного параметра для каждого таргета

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

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

      Надеюсь что у меня получилось внести ясность по некоторым моментам и ещё раз спасибо за комментарий)