Что такое dependency injection
Dependency Injection. JavaScript
Понятия «инверсия управления» и «внедрение зависимостей» не являются новыми, но в сообществе JavaScript, несмотря на его бурный и продолжительный рост, почему-то встречаются довольно редко.
Независимо от контекста исполнения, расширяемое и поддерживаемое javascript-приложение, как и приложение, написанное на любом другом языке, должно соответствовать некоторым архитектурным принципам. Одним из которых является инверсия управления.
Что это?
Если вы не знаете, что такое «инверсия управления» и «внедрение зависимостей», или не совсем понимаете, о чем это — не волнуйтесь, здесь нет никакой магии или чего-то сложного. Ниже я попытаюсь дать сжатые определения этих понятий в контексте темы, однако будет более полезно изучить материалы по ссылкам в конце статьи.
Инверсия управления (англ. Inversion of Control, IoC) — это принцип объектно-ориентированного программирования, при котором объекты программы не зависят от конкретных реализаций других объектов, но могут иметь знание об их абстракциях (интерфейсах) для последующего взаимодействия.
Внедрение зависимостей (англ. Dependency Injection) — это композиция структурных шаблонов проектирования, при которой за каждую функцию приложения отвечает один, условно независимый объект (сервис), который может иметь необходимость использовать другие объекты (зависимости), известные ему интерфейсами. Зависимости передаются (внедряются) сервису в момент его создания.
Библиотеки, реализующие эти принципы часто называют IoC контейнерами (англ., Inversion of Control Container).
Пример из жизни
Предположим, вам необходимо отобразить на карте всех зарегистрированных пользователей вашего приложения. Каким-то образом список пользователей вы уже получили. Далее этот список передается объекту, реализующему функцию локации, который мы будем называть Локатор. Локатор, в свою очередь, использует какой-либо публичный сервис работы с картами. Упрощенный код выглядел бы примерно так:
Все работает, и вы переходите к следующим задачам. Но если со временем вдруг вы решите использовать другой сервис карт, вам придется переписать большую часть кода Локатора — от загрузки клиента и его инициализации, до общения с API нового клиента. Причина такой ситуации в том, в ответственности Локатора не только функция локации пользователей, но и функции создания, конфигурации и общения с клиентом карт.
Чтобы убрать из ответственности Локатора функции работы с картами, вынесем их в интерфейс, который будем реализовывать под конкретные карты. Реализацию такого интерфейса будем называть Картограф:
Имплементация такого интерфейса под конкретный сервис карт:
Теперь, если мы захотим поменять сервис карт, нам нужно будет просто создавать объект другой реализации внутри Локатора:
Таким образом мы решили проблему избытка ответственности Локатора, но появилась зависимость от конкретной реализации Картографа.
Если мы захотим перенести Локатор в другой проект, мы не сможем перенести только его реализацию. В другом проекте используется другие карты и, соответственно, другой клиент карт. Поэтому, разработчикам другого проекта придется править код Локатора — инстанцировать внутри уже свою реализацию Картографа.
Другими словами, пока что мы не можем использовать Локатор как плагин:
Именно подобного рода проблемы помогает решить инверсия контроля.
В контексте нашего примера — Локатор оставит знание лишь об интерфейсе Картографа, и позволит внедрять в себя любую его имплементацию, тем самым исчезнет зависимость от конкретной реализации:
При такой организации Локатор весьма независим и его легко использовать как плагин и переносить между проектами:
Теперь можно беспокоиться лишь о том, чтобы картограф в другом проекте поддерживал интерфейс нашего Картографа (обычно такие задачи решает шаблон Адаптер).
Вот так выглядит использование Локатора в итоге:
Остается один вопрос — на каком уровне должно происходить создание сервисов и внедрение в них зависимостей, и можно ли как-то автоматизировать такую «сборку»?
Во многих языках программирования существует достаточное количество библиотек, позволяющих строить приложения по принципам инверсии контроля и внедрения зависимостей. Такие библиотеки позволяют автоматизировать процесс создания и конфигурации объектов-сервисов.
В мире JavaScript таких библиотек меньше, и далеко не все из них полностью автоматизируют создание и внедрение объектов. Так же далеко не многие могут работать независимо от среды исполнения.
Поэтому мне захотелось (и понравилось) написать свою имплементацию — dm.js.
При ее использовании, пример с Локатором мог бы выглядеть следующим образом:
Весь процесс создания, конфигурации и внедрения объектов dm.js берет на себя. Для описания зависимостей используется объект с определенной структурой и синтаксисом.
Dm.js создает сервисы асинхронно, возвращая Promises/A+ обещания. При помощи адаптеров поддерживаются любые загрузчики модулей и библиотеки Promises.
С помощью библиотеки можно описывать зависимость не только от сервисов, но и ресурсов — например, шаблонов или json файлов. Синтаксис конфигураций так же позволяет строить рекурсивные зависимости.
Подробная документация и описание конфигурации библиотеки представлены на странице проекта на github.
P.S. или инверсия в контексте Веб
Приложение, архитектура которого следует принципу инверсии управления, имеет ряд положительных особенностей, которые облегчают его модификации и увеличивают жизненный цикл. Централизованное управление зависимостями позволяет описывать различные конфигурации приложения, тем самым, в совокупности сервисов, меняя его поведение. Например, приложение на тестовом сервере может использовать другие сервисы и/или их конфигурации, чем то же приложение использует на боевом. Внедрение зависимостей облегчает юнит тестирование, позволяя подменять зависимости тестируемого сервиса заглушками. Использование интерфейсов при проектировании позволяет описывать новые функции приложения более изолированно, ограничивая ответственность сервисов.
Помимо внедрения зависимостей, библиотеки (dm.js не исключение) часто реализуют и другой вид инверсии управления, известный как паттерн Сервис Локатор. При таком подходе зависимости не только внедряются в сервис, но и сам сервис может запрашивать объекты у IoC контейнера, который выполняет роль локатора сервисов.
В некоторых статьях была высказана мысль, что такой вид инверсии управления является антипаттерном. С этой мыслью можно согласиться по нескольким причинам. Во-первых, при таком использовании информация о зависимостях размывается и переносится в код сервисов, которые запрашивают у контейнера нужные им объекты. Такое поведение не позволяет контролировать все конфигурации сервисов централизованно. Во-вторых, каждый сервис получает дополнительную зависимость от сервис-локатора. Аналогичное мнение сложилось у меня и про внедрение типов по интерфейсам (Interface Injection в статье Мартина Фаулера) — информация о зависимостях переносится в реализуемые сервисом интерфейсы.
Однако, в контексте веб-разработки в браузере, использовать паттерн Сервис Локатор в целях оптимизации оправданно — ведь далеко не всегда нужно загружать сразу все сервисы приложения и грузить тем самым много килобайт кода.
Спасибо за внимание, вопросы и комментарии приветствуются!
Этот класс создает MessageWriter и напрямую зависит от этого класса. Включенные в код зависимости, как в предыдущем примере, представляют собой определенную проблему. Их следует избегать по следующим причинам:
Внедрение зависимостей устраняет эти проблемы следующим образом:
При использовании шаблона внедрения зависимостей рабочая служба имеет следующие характеристики:
Реализацию интерфейса IMessageWriter можно улучшить с помощью встроенного API ведения журнала:
Обновленный метод ConfigureServices регистрирует новую реализацию IMessageWriter :
Использование цепочки внедрений зависимостей не является чем-то необычным. Каждая запрашиваемая зависимость запрашивает собственные зависимости. Контейнер разрешает зависимости в графе и возвращает полностью разрешенную службу. Весь набор зависимостей, которые нужно разрешить, обычно называют деревом зависимостей, графом зависимостей или графом объектов.
В терминологии внедрения зависимостей — служба:
Несколько правил обнаружения конструктора
Если тип определяет более одного конструктора, поставщик служб включает логику для определения используемого конструктора. Выбирается тот конструктор, который имеет больше всего параметров, в которых типы могут разрешаться с внедрением зависимостей. Рассмотрим следующий пример службы на C#:
Если при определении конструктора возникает неоднозначность, выдается исключение. Рассмотрим следующий пример службы на C#:
Код ExampleService с неоднозначными параметрами типов, которые могут разрешаться с внедрением зависимостей, выдаст исключение. Не делайте этого, это всего лишь демонстрация того, что подразумевается под «неоднозначными типами, разрешаемыми с внедрением зависимостей».
Регистрация групп служб с помощью методов расширения
Расширения Microsoft используют конвенцию для регистрации группы связанных служб. Соглашение заключается в использовании одного метода расширения Add
Платформенные службы
В следующей таблице перечислены некоторые примеры этих зарегистрированных платформой служб.
Тип службы | Время существования |
---|---|
Microsoft.Extensions.DependencyInjection.IServiceScopeFactory | Одноэлементный |
IHostApplicationLifetime | Одноэлементный |
Microsoft.Extensions.Logging.ILogger | Одноэлементный |
Microsoft.Extensions.Logging.ILoggerFactory | Одноэлементный |
Microsoft.Extensions.ObjectPool.ObjectPoolProvider | Одноэлементный |
Microsoft.Extensions.Options.IConfigureOptions | Временный |
Microsoft.Extensions.Options.IOptions | Одноэлементный |
System.Diagnostics.DiagnosticListener | Одноэлементный |
System.Diagnostics.DiagnosticSource | Одноэлементный |
Время существования служб
Службы можно зарегистрировать с одним из следующих вариантов времени существования:
Они описываются в следующих разделах. Для каждой зарегистрированной службы выбирайте подходящее время существования.
Временный
Временные службы времени существования создаются при каждом их запросе из контейнера служб. Это время существования лучше всего подходит для простых служб без отслеживания состояния. Регистрируйте временные службы с помощью AddTransient.
В приложениях, обрабатывающих запросы, временные службы удаляются в конце запроса.
Область действия
Для веб-приложений время существования, привязанное к области, означает, что службы создаются один раз для каждого запроса (подключения) клиента. Регистрируйте службы с заданной областью с помощью AddScoped.
В приложениях, обрабатывающих запросы, службы с заданной областью удаляются в конце запроса.
При использовании Entity Framework Core метод расширения AddDbContext по умолчанию регистрирует типы DbContext с заданной областью времени существования.
Разрешать службу с заданной областью из одноэлементной службы запрещено, и будьте внимательны, чтобы не сделать это неявно, например, через временную службу. При обработке последующих запросов это может вызвать неправильное состояние службы. Допускается следующее:
По умолчанию в среде разработки разрешение службы из другой службы с более длинным временем существования вызывает исключение. Дополнительные сведения см. в разделе Проверка области.
Одноэлементный
Одноэлементные службы времени существования создаются в следующих случаях.
Каждый последующий запрос на реализацию службы из контейнера внедрения зависимостей использует тот же экземпляр. Если в приложении нужно использовать одноэлементные службы, разрешите контейнеру служб управлять временем их существования. Не реализуйте одноэлементный подход и предоставьте код для удаления одноэлементных объектов. Службы никогда не должны удаляться кодом, который разрешил службу из контейнера. Если тип или фабрика зарегистрированы как одноэлементный объект, контейнер автоматически удалит одноэлементные объекты.
Зарегистрируйте одноэлементные службы с помощью AddSingleton. Одноэлементные службы должны быть потокобезопасными и часто использоваться в службах без отслеживания состояния.
В приложениях, обрабатывающих запросы, отдельные службы удаляются, когда ServiceProvider удаляется по завершении работы приложения. Поскольку память не освобождается до завершения работы приложения, рекомендуется учитывать использование памяти одноэлементным объектом.
Методы регистрации службы
Платформа предоставляет методы расширения регистрации службы, которые полезны в определенных сценариях.
Метод | Автоматически object удаление | Несколько реализации | Передача аргументов |
---|---|---|---|
Add services.AddSingleton (); | Да | Да | Нет |
Add services.AddSingleton (sp => new MyDep()); | Да | Да | Да |
Add services.AddSingleton (); | Да | Нет | Нет |
AddSingleton (new services.AddSingleton (new MyDep()); | Нет | Да | Да |
AddSingleton(new services.AddSingleton(new MyDep()); | Нет | Нет | Да |
Дополнительные сведения об удалении типа см. в разделе Удаление служб.
Регистрация службы только с типом реализации эквивалентна регистрации этой службы с той же реализацией и типом службы. Именно поэтому несколько реализаций службы не могут быть зарегистрированы с помощью методов, которые не принимают явный тип службы. Эти методы могут регистрировать несколько экземпляров службы, но все они будут иметь одинаковую реализацию типа.
Платформа также предоставляет методы расширения TryAdd
Параметр TryAddSingleton не применяется, так как он уже был добавлен, поэтому выполнение «try» завершится ошибкой. В ExampleService будут следующие утверждения:
Дополнительные сведения см. в разделе:
Регистрация службы обычно не зависит от порядка, за исключением случаев регистрации нескольких реализаций одного типа.
IServiceCollection является коллекцией объектов ServiceDescriptor. В следующем примере показано, как зарегистрировать службу, создав и добавив ServiceDescriptor :
Встроенные методы Add
Поведение внедрения через конструктор
Службы можно разрешать с помощью:
Конструкторы могут принимать аргументы, которые не предоставляются внедрением зависимостей, но эти аргументы должны назначать значения по умолчанию.
Проверка области
Когда приложение выполняется в среде Development и вызывает CreateDefaultBuilder для создания узла, поставщик службы по умолчанию проверяет следующее:
Корневой поставщик службы создается при вызове BuildServiceProvider. Время существования корневого поставщика службы соответствует времени существования приложения — поставщик запускается с приложением и удаляется, когда приложение завершает работу.
Сценарии применения области
Интерфейс IServiceScopeFactory всегда регистрируется как отдельный (singleton), но IServiceProvider зависит от времени существования содержащего класса. Например, если при разрешении служб из области какая-то из служб принимает интерфейс IServiceProvider, это будет экземпляр с заданной областью.
Для получения служб с заданной областью в реализациях IHostedService, например службы BackgroundService, не внедряйте зависимости служб через конструктор. Вместо этого внедрите IServiceScopeFactory, создайте область, а затем используйте разрешение зависимостей из области, чтобы применить подходящее время существования служб.
В приведенном выше коде во время выполнения приложения фоновая служба:
В примере исходного кода можно увидеть, как реализации IHostedService могут использовать преимущества времени существования служб с заданной областью.
Изучая Dependency Injection
Несмотря на то, что паттерну уже более десятка лет и есть немало статей (и переводов), тем не менее споров, комментариев, вопросов и разных реализаций становится все больше и больше.
Информации достаточно даже на хабре, но к написанию поста меня подвигло то обстоятельство, что везде обсуждается КАК сделать, но практически нигде – ЗАЧЕМ. Можно ли создать хорошую архитектуру, если вы не знаете для чего она нужна и в чем именно должна быть хороша? Можно принимать во внимание определенные принципы и явные тренды, — это поможет свести к минимуму непредвиденные проблемы, но понимать – это еще лучше.
Внедрение зависимостей — это шаблон проектирования, при котором поля или параметры создания объекта конфигурируется извне.
Зная, что многие ограничатся чтением первых абзацев, я изменила статью.
Несмотря на то, что подобное «определение» DI встречается во многих источниках — оно неоднозначное, поскольку заставляет пользователя думать, что инъекция — это нечто, что заменяет создание/инициализацию объектов, или, уж по крайней мере, очень активно участвует в этом процессе. Делать такую реализацию DI, конечно, никто не запретит. Но DI может быть пассивной оберткой вокруг создания объекта, которая обеспечивает предоставление входящих параметров. В такой реализации у нас получается еще один уровень абстракции и отличное разделение обязанностей: объект сам отвечает за свою инициализацию, а инъекция реализует хранение данных и обеспечение ими модулей приложения.
Теперь обо всем по-порядку и в подробностях.
Начну с простого, почему возникла потребность в новых паттернах, и почему некоторые старые паттерны стали сильно ограничиваться в области применения?
На мой взгляд, основная часть перемен привнесена массовым внедрением автотестирования. И для тех, кто активно пишет автотесты, данная статья очевидна как белый день, можно дальше не читать. Только вы не представляете, как много людей их не пишет. Я понимаю, что у маленьких компаний и стартапов нет не это ресурсов, но, к сожалению, и в больших компаниях часто находятся более первоочередные проблемы.
Рассуждения тут очень простые. Допустим вы тестируете функцию с параметрами a и b, и вы ожидаете получить результат x. В какой-то момент, ваши ожидания не сбываются, функция выдает результат y, и потратив некоторое время, вы обнаруживаете внутри функции синглтон, который в некоторых состояниях приводит результат выполнения функции к другому значению. Этот синглтон назвали неявной зависимостью, и всячески зареклись использовать его в подобных ситуациях. К сожалению, слов из песни не выбросишь, иначе получится уже совсем другая песня. А потому, вынесем наш синглтон как входящую переменную в функцию. Теперь у нас уже 3 входящие переменные a, b, s. Вроде все очевидно: меняем параметры – получаем однозначный результат.
Пока примеры приводить не буду. Более того, речь не только о функциях внутри класса, это схематичное рассуждение, которое можно применить также и к созданию класса, модуля и тп.
Замечение 1. Если, учитывая критику паттерна синглтон, вы решили заменить его, ну например, на UserDefaults, то применительно к данной ситуации, вырисовывается все та же неявная зависимость.
Замечение 2. Не совсем корректно говорить, что только из-за автотестирования не стоит использовать синглтоны внутри тела функции. В целом, с точки зрения программирования не совсем правильно, что при одинаковых входящих — функция выдает разные результаты. Просто на автотестах эта проблема вырисовалась более отчетливо.
Дополним вышеуказанный пример. У вас есть объект, который содержит 9 настроек пользователя(переменных), например права на чтение/редактирование/подпись/печать/пересылку/удаление/блокировку/ исполнение/копирование документа. В вашей функции используются только три переменные из этих настроек. Что вам передавать в функцию: весь объект с 9 переменными как один параметр, или только три нужные настройки тремя отдельными параметрами? Очень часто мы укрупняем передаваемые объекты, чтобы не задавать много параметров, то есть выбираем первый вариант. Такой способ будет считаться передачей «неоправданно широких зависимостей». Как вы уже сами догадались, для целей автотестирования лучше использовать второй вариант и передавать только те параметры, которые используются.
Мы сделали 2 вывода:
— функция должна получить все необходимые параметры на входе
— функция не должна получать излишних параметров на входе
Хотели как лучше – а получили функцию с 6-тью параметрами. Предположим, что внутри функции все в порядке, но кто-то должен взять на себя работу по обеспечению входящих параметров функции. Как я уже писала, мои рассуждения схематичны. Я подразумеваю не просто обычную функцию класса, а скорее функцию инициализации/создание модуля (vip, viper, объект с данными и тп). В этом контексте перефразируем вопрос: кто должен обеспечить входящие параметры для создания модуля?
Одно из решений было бы переложить это дело на вызывающий модуль. Но тогда получается, что вызывающему модулю нужно передать параметры дочернего. Это влечет следующие осложнения:
Отсюда рождается мысль вынести создание/инициализацию модуля в отдельную конструкцию. Тут пришло время написать несколько строк в качестве примера:
В примере есть модуль списка счетов AccountList, который вызывает модуль детальной информации по счету AccountDetail.
Для инициализации модуля AccountDetail нужны 3 переменные. Переменную account AccountDetail получает от родительского модуля, переменные permission1, permission2 впрыскиваются путем инъекции. За счет инъекции, вызов модуля с деталями счета будет выглядеть:
и родительский модуль списка счетов AccountList будет освобожден от обязанности передавать параметры c пермишенсами, про которые он ничего не знает.
Я вынесла реализацию инъекции (сборку) в статическую функцию в расширении класса. Но реализация может быть любой на ваше усмотрение.
Существует несколько способов конфигурации:
Constructor Injection, Property injection, Interface Injection.
Для Swift:
Initializer Injection, Property Injection, Method Injection.
Наиболее распространенные — это инъекции конструктора(инициализации) и свойств.
Важно: практически во всех источниках рекомендуется отдавать предпочтение инъекции конструктора. Сравните Constructor/Initializer Injection и Property injection:
Вроде бы преимущества первого способа очевидны, но почему-то некоторые понимают инъекцию, как конфигурирование уже созданного объекта и используют второй способ. Я за первый способ:
Использование синглтонов в механизме сборки уже не приводит к вышеописанным проблемам со скрытой зависимостью, т.к. тестировать создание модулей вы можете с любым набором данных.
Но здесь мы сталкиваемся с другим минусом синглтонов: плохая управляемость (можно наверное привести еще много ненавистнических аргументов, но лень). Нет ничего хорошего, в том, чтобы разбрасывать свои многочисленных хранилки/синглтоны в сборках, по аналогии с кем, как они были разбросаны в функциональных модулях. Но даже такой рефакторинг уже будет первым шагом в сторону гигиены, потому что, навести потом порядок в сборках можно почти не затрагивая код и тесты модулей.
Если вы хотите упорядочивать архитектуру и дальше, а также потестировать переходы и работу сборки, то придется еще немного поработать.
Концепция DI предлагает нам хранить все необходимые данные в контейнере. Это удобно. Во-первых сохранение(регистрация) и получение(resolve) данных идет через один объект-контейнер, соответственно, так проще управлять данными и тестировать. Во-вторых, можно учитывать зависимость данных друг от друга. Во многих языках, в том числе и в swift, есть уже готовые контейнеры управления зависимостями, обычно зависимости формируют дерево. Остальные плюсы-минусы я не буду перечислять, можно про них почитать по тем ссылкам, которые я выложила в начале поста.
Вот примерно, как может выглядеть сборка с использованием контейнера.
Это возможный пример реализации. В примере используется фреймворк Swinject, который народился не так уж давно. Swinject позволяет создать контейнер для автоматизированного управления зависимостями, а также позволяет создавать контейнеры для Storyboards. Более подробно о Swinject можно посмотреть в примерах на raywenderlich. Этот сайт мне очень нравится, но данный пример не самый удачный, поскольку рассматривает применение контейнера только в автотестах, в то время как контейнер должен быть заложен в архитектуре приложения. Вы в своем коде, можете сами написать контейнер.
На этом всем спасибо. Надеюсь вы не сильно скучали, читая этот текст.