Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру 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 возможных варианта развития событий:
Весь функционал размещать в статических классах
Размещать функционал в НЕ статических классах и инициализировать их в контейнере nuke-класса
Размещать функционал в НЕ статических классах и использовать 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 подход.
Буду искренне рад замечаниям / пожеланиям, да и в целом вашим мыслям об этой статье в комментариях.