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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Вступление

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


Интересные статьи

Интересные статьи

AltUnity Tester - это инструмент автоматизации тестирования на основе пользовательского интерфейса с открытым исходным кодом, который помогает находить объекты в вашей игре Unity и взаимодействовать с...
В данной пошаговой инструкции мы подробно опишем весь процесс получения доступа к WhatsApp Business API через официального партнера Facebook — сервис Gupshup и подключени...
Субботний вечер омрачен скандалом - сайт не работает, провайдер негодяй, админы - не специалисты, а сервера - решето. Вызов принят, или почему при всей нелюбви к 1С-Битри...
Многие компании в определенный момент приходят к тому, что ряд процессов в бизнесе нужно автоматизировать, чтобы не потерять свое место под солнцем и своих заказчиков. Поэтому все...
Привет, друзья! Меня зовут Петр, я представитель малого белорусского бизнеса со штатом чуть более 20 сотрудников. В данной статье хочу поделиться негативным опытом покупки 1С-Битрикс. ...