80LevelElf about IT Записи Мои проекты Обо мне
# Часть 2. Введение в IOC-контейнеры на примере Ninject. ####Ссылки на другие части: - [Первая часть: Теоретические основы Dependency Injection и IOC-контейнеров](http://80levelelf.com/Post?postId=20 "Первая часть: Теоретические основы Dependency Injection и IOC-контейнеров") - Вторая часть: Введение в IOC-контейнеры на примере Ninject И так, в прошлой части было рассмотрение идеи Dependency Injection и причин, по которым в «сыром виде» Dependency Injection не очень удобен для использования. И плавно повествование перешло к IOC-контейнерам. IOC-контейнер (Inversion of control) – это автоматизированная настраиваемая фабрика (factory), которая берет на себя всю «черновую работу». Для рассмотрения мы возьмем именно Ninject, хотя в общем-то это не самый лучший выбор. Он весьма удобный и легкий в использовании, [но очень медленный](http://www.palmmedia.de/blog/2011/8/30/ioc-container-benchmark-performance-comparison "Он весьма удобный и легкий в использовании, но очень медленный"). Так или иначе, Ninject стал де-факто стандартном .Net IOC-контейнеров, и не смотря на низкую скорость, является довольно распространенным. Тем не менее, все нижеизложенные принципы применимы и ко всем другим IOC-контейнерам (отличаться могут лишь названия каких-то методов или атрибутов). ##Глава 1. Установка Ninject. Установка Ninject происходит так же, как и установка большинства других распространенный библиотек – через NuGet. 1) Открываем в Visual Studio: Tools -> NuGet Package Manager -> Manager NuGet Packages for Solution… 2) Выбираем табу Browse, вбиваем в поиск Ninject: ![Install Ninject 1](https://i.imgur.com/9KHdvxX.png) 3) В окне справа, выбираем нужные Project'ы нашего Solution'a для установки пакета: ![Install Ninject 2](https://i.imgur.com/10qXfVg.png) > Если вдруг вы используете отличную от моей Visual Studio или другую среду разработки (например, Rider) – вы можете установить пакет, используя UI вашей среды разработки, или же через консоль NuGeta (ссылка на пакет: https://www.nuget.org/packages/Ninject/). ###В какие Project'ы нужно ставить Ninject? В общем-то – это вопрос очень религиозный и навязывать свое мнение я здесь не собираюсь. Вы можете использовать Ninject (определять конфиги, использовать ядро (kernel)) как в каждом Project’e, так и придумать свою схему. Например, использовать Ninject только на самом верхнем уровне вашего приложения (в нашем тестовом проекте из Части 1 – это слой Web Api). Лично я предпочитаю примерно такую логику: - Конфигурационные классы в каждом Project’e (который нуждается в Dependency Injection). - Создание и использование ядра в самом верхнем слое (или в нескольких верхних слоях, если таковые имеются). Именно такая логика и будет подразумеваться далее. > Не переживайте, если вы не поняли, что такое ядро или конфигурационные классы – все это будет подробно описано далее. ##Глава 2. Конфигурация. Основная идея IOC-контейнеров: отделение контракта (интерфейса) от конкретной реализации. > Вообще говоря, то, что я называю «контрактом» совсем не обязательно должно быть именно интерфейсом. Оно может быть абстрактным классом или даже обычным классом. Но с идеологической точки зрения, намного более подходящая конструкция для контракта – интерфейс. Соответственно, нужно как-то описывать логику соответствия контракта и реализации, для чего и применяются конфигурации (или конфигурационные файлы). В общем-то, конфигурация обычно состоит из правил типа: Bind<ISomeInterface>().To<SomeRealization>(); И описывается в отдельном классе, унаследованным от NinjectModule: public class MyNinjectConfig : NinjectModule { public override void Load() { Bind<Interface1>().To<Realization1>(); Bind<AbstractClass2>().To<Relization2>(); Bind<Realization3>().To< Realization3>(); // ... } } Прочитать это можно так: Bind<при запросе на получение экземпляра этого контракта>().To<верни экземпляр этой реализации> (); Или Bind<TFrom>().To<TTo>(); > Bind<Realization3>().To< Realization3>(); > В чем вообще может быть логика такого биндинга? Это мы рассмотрим чуть позже. Правила для создания биндинга в общем-то простые и логичные: - TTo – должен быть наследником TFrom (или тем же самым типом) - Ninject должен иметь возможность создать экземпляр типа TTo - Биндинг для контракта TFrom должен быть однозначным ###Scop'ы Ninject позволяет нам как-то ограничить (или наоборот расширить) время жизни того или иного объекта. Сделать это можно с помощью так называемых Scop’ов. Ниже перечислю основные из них: **Transient**: Scope по умолчанию. Создает новый экземпляр объекта на каждый вызов. **Singleton**: Scope, который реализует паттерн Singleton (один экземпляр объекта на все вызовы). **Thread**: Scope, который создает экземпляр объекта на каждый поток (который запрашивает получение экземпляра). **Request**: Scope, используемый в Asp.net MVC / Web Api. Создает новый экземпляр объекта на каждый новый запрос (для использования этого Scop’a понадобиться установить пакет Ninject.MVC5 – или для другой версии MVC). Выглядит это как-то так: Bind<TFrom>().To<TTo>().InTransientScope(); Bind<TFrom>().To<TTo>().InSingletonScope(); Bind<TFrom>().To<TTo>().InThreadScope(); Bind<TFrom>().To<TTo>().InRequestScope(); Это основные Scop'ы, но не единственные. К тому же, всегда можно создать свой собственный. Для дополнительной информации по теме, смотри документацию: https://github.com/ninject/ninject/wiki/Object-Scopes. ###Условия для биндинга Ninject, позволяет нам определять ту или иную логику для выбора нужной реализации в зависимости от чего-лиюо. Делается это с помощью методов WhenXXX(). Например, есть вот такая вот связка логики для инъекции на основе наличия или отсутствия в месте инъекции (в конкретном классе или свойстве – об этом чуть ниже) какого-то атрибута. Есть нас вот такой-вот класс: [NeededClassAttribute] [NeededClassOrMemberAttribute] public class SomeClass { [NeededMemberAttribute] public IField SomeField { get; set; } [NeededClassOrMemberAttribute] public IOtherField OtherField { get; set; } } И мы хотели бы, чтобы Ninject автоматически вставил нужные объекты реализации в свойства SomeField и OtherField (о том, КАК он будет это делать и что для этого надо добавить в SomeClass - далее). Тогда: Условие **WhenClassHas** выполниться, только если объект инъекции (то есть место подстановки реализаций – в нашем случае экземпляр объекта SomeClass) имеет нужный атрибут (например, NeededClassAttribute). Условие **WhenMemberHas** выполнится, только если поле объекта инъекции (например, SomeField или OtherField) имеет нужный атрибут, например, NeededMemberAttribute. Условие **WhenTargetHas** соединяет два предыдущих условия и срабатывает только тогда, когда объект инъекции ИЛИ поле инъекции (именно конкретное поле, куда происходит инъекция, а не любое поле класса) содержит атрибут, например, NeededClassOrMemberAttribute. Реализация будет примерно такая: Bind<TFrom>().To<TTo>().WhenClassHas(typeof(NeededClassAttribute)); Bind<TFrom>().To<TTo>().WhenMemberHas(typeof(NeededMemberAttribute)); Bind<TFrom>().To<TTo>().WhenTargetHas<NeededClassOrMemberAttribute>(); Вообще, различных условий для биндинга очень и очень много (частично, вам может помочь вот этот раздел документации: https://github.com/ninject/Ninject/wiki/Contextual-Binding ) и описывать их все не имеет смысла. Особо отмечу то, что на мой взгляд, чрезмерно использовать условия биндинга – очень плохая задумка, так как: - Эту логику не так просто поддерживать - Если что-то пойдет не так (например, возникнет неоднозначность в каком-то конкретном редком примере) вы можете узнать об этом очень поздно ###Настройка данных создаваемого объекта Когда Ninject пытается создать реальный объект того класса, который вы указали в To<MyClass> он пытается как-то передать создаваемому объекту нужные зависимости (о том, как они определяются – далее). Но что если нужная нам зависимость не какой-то интерфейс, а скажем базовый тип – например, поле типа int или string (а говоря более обще – просто какой-то объект)? Для того, чтобы сделать мои рассуждения чуть менее абстрактными, давайте рассмотрим какой-нибудь более конкретный пример: Большинство ORM (библиотек для удобной работы с базами данных – Entity Framework, Line2DB и другие) предоставляют класс DbContext, который служит для связи с какой-то конкретной базой данных. Обычно объекты типа DbContext принимают на вход в конструкторе connectionName – имя соответствующей конфигурации из секции connectionStrings в app.config / web.config: <connectionStrings> <add name="DefaultConnection" connectionString="credentials" providerName="System.Data.SqlClient" /> </connectionStrings> Но что если во время работы своего проекта, вы захотите использовать в разных случаях разные базы данных? Например, одну базу данных вы будете использовать для разработки, а другую – для того, чтобы запускать в ней тесты. > Ну может, вы в рамках тестирования DAL слоя добавляете миллионы объектов и очень не хотелось бы иметь эти миллионы объектов в базе разработки, если вдруг какой-то из тестов кинет исключение и тестовые данные не успеют удалиться. Поэтому нужно придумать как нам в разных случаях передавать разные connectionName. Можно, конечно, создать базовый класс BaseDbContext, а от него унаследовать ProjectDbContext (с connectionName нормальной базы) и TestDbContext (с connectionName тестовой базы). Но не очень-то хочется плодить кучу лишних классов ради разницы в одной строчке! Куда проще было бы держать один ProjectDbContext, принимающий при создании нужный connectionName. Но вот только как его передать? Для этого в конфигурации Ninject существует связка методов, начинающихся со слова **With**. С помощью **WithConstructorArgument** вы можете указать имя и значение, которое будет использовано для параметра конструктора с нужным именем. В случае нашей проблемы c connectionName, мы можем сделать так: kernel.Bind<DbContext>().To<ProjectDbContext>().WithConstructorArgument("connectionName", "DefaultConnection"); В конфигурационном файле для разработки и эксплуатации нашего проекта, и вот так: kernel.Bind<DbContext>().To<ProjectDbContext>().WithConstructorArgument("connectionName", "TestConnection"); В конфигурационном файле для тестов. С помощью **WithProperty** мы можем подставить какое-то значение в нужное свойство создаваемого объекта. С помощью свойства **WithParameter** можно определить какую-то свою логику подстановки какого-то значения в создаваемый объект (посредством переопределения интерфейса IParameter или настройки одного из предоставленных готовых наследников). ###Биндинг какого-то интерфейса не к классу Частенько, вспомогательных методов, предоставленных Ninject'ом становиться недостаточно и логику биндинга вы хотите определить как-то по-другому. Сделать это вы можете, выбрав нужную вам реализацию **ToXXX**(). Метод **ToConstant**() привяжет ваш интерфейс не к классу, а к конкретному объекту: Bind<IInterface>().ToConstant(new SomeRealization()) Метод **ToMethod**() привяжет ваш интерфейс к результату, возвращаемому каким-то методом: Bind<IInterface>().ToMethod(context => GetInstanceFromContext(context)) Метод *GetInstanceFromContext* является реализацией паттерна «Фабричный Метод». Метод **ToProvider**() привяжет ваш интерфейс к результату, возвращаемому каким-то провайдером (унаследованным от IProvider): Bind<IInterface>().ToProvider(new InstanseProvider()) Класс *InstanseProvider* является реализацией паттерна «Фабрика». Больше описания вы можете найти в официальной документации: https://github.com/ninject/Ninject/wiki/Providers,-Factory-Methods-and-the-Activation-Context. ###Биндинг какого-то типа к самому себе Ранее, мы уже встречались с таким странным типом биндинга: Bind<SomeRealization>().To<SomeRealization>() Тоже самое можно написать и по-другому: Bind<SomeRealization>().ToSelf() Но зачем это надо? Ответ по сути один: для того, чтобы можно было создавать объекты через Ninject, который сам предоставит создаваемому объекту все нужные зависимости. ##Глава 3. Как реализовывать Inject в конкретные типы? Предположим, что мы создали все нужные конфигурационные файлы и у вас резонным образом возник вопрос: а как именно нужные зависимости предоставляются создаваемым объектам? ###Через конструкторы. С идеологической точкой зрения – это наиболее правильный вариант. В конце концов, в чем главное отличие конструктора от обычного метода? Конструктор не позволяет создать объект с информацией, недостаточной для своей работы! Для того, чтобы обеспечить передачу нужных зависимостей в конструктор, его нужно просто объявить: public class MyRealization : ISomeInterface { public MyRealization(ISomeDependence someDependence) { } } При создании объекта типа MyRealization, Ninject сам передаст в конструктор нужную реализацию ISomeDependence. Если же такой реализации нет – Ninject кинет вам исключение (что в общем-то логично). Как быть с несколькими конструкторами? Логично было бы предположить, что при наличии нескольких конструкторов Ninject кинет вам исключение, так как совершенно не понятно какой из конструкторов выбрать. Что же – это не совсем так. - Ninject попробует вызвать конструктор с максимальным количеством аргументов, которые он может передать. - А если несколько конструкторов имеют одинаковое количество таких аргументов – будет брошено исключение. > В прочем вы всегда можете настроить свою конфигурацию так, чтобы использовать какой-то определенный конструктор с помощью метода ToConstructor() **Проблема передачи зависимости через конструктор**. Одной из главных проблем ручного Dependency Injection, которую я описывал в первой части, является циклическая зависимость. То есть для того, чтобы создать объект А, нужно передать ему в конструктор объект Б. А для того, чтобы создать объект Б, нужно передать ему в конструктор объект А. Увы, но передача зависимостей через конструктор чревато созданием таких вот циклический зависимостей. Что делать, если создалась циклическая зависимость? В идеале – переписать логику проекта (иерархию зависимостей), чтобы избежать такой ситуации. Но все мы понимаем, что: - Зачастую это невозможно - Очень часто циклические зависимости состоят из огромной цепочки, которую поменять весьма сложно - Бывает так, что циклическая зависимость – это не ошибка проектирования и такая зависимость была образована с какой-либо целью Поэтому лично я предпочитаю передачу зависимостей через [Inject]. > С идеологической точки зрения, повторюсь, именно использование конструкторов является наиболее правильным, в том числе и потому, что в этом случае вы можете правильно создать объект нужного вам типа и без Ninject. ###Передача зависимостей через [Inject] Посредством атрибута [Inject] вы можете отметить те методы или свойства, которые будут вызваны Ninject’ом сразу же после создания объекта. > Особенно хотел бы отметить, что в отличие от конструкторов, Ninject попытается передать нужные зависимости во ВСЕ методы и поля, отмеченные атрибутом [Inject]! На практике, выглядеть это будет примерно так: Public class SomeClass : ISomeClass { [Inject] public IFieldDependency FieldDependency { get; set; } [Inject] public void SetUp(IMethodDependency methodDependency) { // ... } } При создании экземпляра SomeClass, Ninject попытается найти нужную зависимость для интерфейса IFieldDependency и IMethodDependency. __Плюсы такого подхода__: - Самый главный плюс: вы избегаете опасности циклической зависимости (что очень важно!) - Возможно, кому-то такой подход покажется куда более гибким (ведь вы можете помечать какие-то методы и поля в классе-предке как [Inject] и не заморачиваться насчет передачи нужной реализации напрямую) __Минусы__: - С идеологической точки зрения, конечно, такой подход чуть менее правильный, чем инициализация экземпляра через конструктор. Во-первых, он куда менее явный. Во-вторых, так вы очень сильно затачиваете ваш код на работу с Ninject. - Если вставлять [Inject] не осмотрительно, то можно прийти к такой ситуации, что зависимости какого-то класса становятся очень неочевидными. Что же использовать? Лично я предпочитаю такую схему: public class SomeClass : ISomeClass { [Inject] public void SetUp(IMethodDependency methodDependency, …) { // ... } } То есть: - Только один метод инициализации на класс - Называться он должен везде одинаково (SetUp) - Если в классе-наследнике нужен свой SetUp – делаем SetUp в классе-предке виртуальным, переопределяем SetUp в классе наследнике так, чтобы он вызывал SetUp из класса-предка и делал дополнительные действия, уникальные для инициализации класса-наследника: public abstract class BaseClass : IBaseClass { [Inject] public virtual void SetUp(IMethodDependency methodDependency) { // ... } } public class SomeClass : BaseClass, ISomeClass { [Inject] public override void SetUp(IMethodDependency methodDependency, IOtherDependency otherDependency) { base.SetUp(methodDependency); // ... } } ##Глава 4. Соединяем все вместе – ядро И так, мы рассмотрели самые главные вопросы, касающиеся использования Ninject: - Конфигурацию - Описание типов Теперь возник резонный вопрос – а как, наконец, заставить это все работать? Объединяющим объектом для всего этого служит так называемое ядро. Ядро – это экземпляр автоматизированной фабрики, которая принимает конфигурационные файлы при создании и умеет создавать «целостные» объекты (со всеми нужными зависимостями). Стандартной реализацией ядра является класс StandardKernel (унаследованный от IKernel), но вы всегда можете реализовать свою версию ядра. Стандартное создание нового ядра выглядит примерно так: IKernel myKernel = new StandardKernel(module1, module2); Где module1 и module2 – и есть те самые модули конфигурации, реализующие интерфейс INinjectModule. После того как вы создали ядро, его уже можно использовать для создания объектов: IMyInterface neededRealization = myKernel.Get<IMyInterface>(); В этом примере Ninject: - Посмотрит на конфигурацию и найдет нужную реализацию интерфейса IMyInterface - Проанализирует создаваемый объект типа, унаследованного от IMyInterface - Если объект ожидает каких-либо зависимостей, Ninject попытается предоставить их ему И так далее. ###Окей, а где хранить само ядро? Ну, наверно, это - самый скользкий вопрос, потому что нормального ответа на него не существует. Но я все равно попытаюсь на него ответить кратко. **Ответ 1 (общий)** Если вы хотите самый общий ответ (подходящий для всех ситуаций): - Создайте какой-нибудь статический объект или синглтон (назовем его NinjectContext), который и будет хранить ядро. - Используйте это ядро только для создания объектов верхнего уровня! Сам NinjectConfig может выглядеть, например, так: public static class NinjectContext { public static IKernel Kernel { get; private set; } public static void SetUp(params INinjectModule[] modules) { Kernel = new StandardKernel(modules); } } Особенно хотел бы заострить ваше внимание на последнем пункте. Не надо в проекте использовать NinjectContext для получения объектов нужного класса всегда, когда вам это будет нужно (этот паттерн называется ServiceLocator и это очень плохой паттерн). Используете нормальные зависимости через конструктора или [Inject]. Вспомним, например, наш вымышленный проект из первой части: ![](https://i.imgur.com/ZHEQkHj.png) Неплохой идеей было бы создание отправной точки (например, ProjectBLL), которая содержала бы ссылку на объект с ссылками на все более специфические BLL (например, UserBLL для работы с юзерами и тд – см первую часть): public class ProjectBLL { public static IBLLScope Instance { get { return NinjectContext.Kernel.Get<IBLLScope>(); } } } Тогда в самих контроллерах Web Api можно было бы просто использовать нужные методы BLL через ProjectBLL: public class UserInfoController : ApiController { // GET: api/UserInfo [HttpGet] public UserInfo Get() { return ProjectBLL.Instance.UserInfo.Get(User.Identity.GetUserId()); } } **Ответ 2. Для Asp.net MVC / Web Api** Для контроллеров Asp.net MVC и Web Api уже существует очень качественная встроенная возможность использования DI. Для того, чтобы ей воспользоваться, установим еще один NuGet пакет: Ninject.MVC5 ( https://www.nuget.org/packages/Ninject.MVC5/ ). > Если вы используете более старую версию Asp.net MVC (например, 3), то вам придется установить Nuget-пакет для более старой версии (например, Ninject.MVC3 - https://www.nuget.org/packages/Ninject.MVC3/ ) После установки пакета, вы увидите, что в вашем проекте (в папке App_Start) появиться файл *NinjectWebCommon.cs* со следующей реализацией: public static class NinjectWebCommon { private static readonly Bootstrapper bootstrapper = new Bootstrapper(); /// <summary> /// Starts the application /// </summary> public static void Start() { DynamicModuleUtility.RegisterModule(typeof(OnePerRequestHttpModule)); DynamicModuleUtility.RegisterModule(typeof(NinjectHttpModule)); bootstrapper.Initialize(CreateKernel); } /// <summary> /// Stops the application. /// </summary> public static void Stop() { bootstrapper.ShutDown(); } /// <summary> /// Creates the kernel that will manage your application. /// </summary> /// <returns>The created kernel.</returns> private static IKernel CreateKernel() { var kernel = new StandardKernel(); try { kernel.Bind<Func<IKernel>>().ToMethod(ctx => () => new Bootstrapper().Kernel); kernel.Bind<IHttpModule>().To<HttpApplicationInitializationHttpModule>(); RegisterServices(kernel); return kernel; } catch { kernel.Dispose(); throw; } } /// <summary> /// Load your modules or register your services here! /// </summary> /// <param name="kernel">The kernel.</param> private static void RegisterServices(IKernel kernel) { // Ваши модули конфигурации добавляйте здесь: kernel.Load(myModule); } } По сути, тут уже все реализовано (скажем спасибо за это), осталось лишь добавить свои модули конфигурации в *RegisterServices*. **Но что мы сделали-то?** Мы указали Asp.net MVC / Web Api системе то, что теперь она должна создавать контроллеры через Ninject. Теперь мы можем передавать наши зависимости напрямую в контроллеры: public class HomeController : Controller { public HomeController(IDependency myDependency) { // ... } }
(18.10.2017)

blog comments powered by Disqus