
Вступление
Всем привет, сегодня поговорим о внедрении 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 подход.
Буду искренне рад замечаниям / пожеланиям, да и в целом вашим мыслям об этой статье в комментариях.
ссылка на оригинал статьи https://habr.com/ru/post/599789/
Добавить комментарий