Несмотря на то, что паттерну уже более десятка лет и есть немало статей (и переводов), тем не менее споров, комментариев, вопросов и разных реализаций становится все больше и больше.

Предыстория
В 2004 Мартин Фаулер написал известную статью “Inversion of Control Containers and the Dependency Injection pattern”, в которой описывал вышеуказанный паттерн и его реализацию для Java. С этих пор паттерн стал широко обсуждаться и внедрятся. В мобильную разработку, особенно на IOS, это пришло с существенной задержкой. На хабре есть хорошие переводы статьи, удачи и светлой кармы их автору.

Информации достаточно даже на хабре, но к написанию поста меня подвигло то обстоятельство, что везде обсуждается КАК сделать, но практически нигде – ЗАЧЕМ. Можно ли создать хорошую архитектуру, если вы не знаете для чего она нужна и в чем именно должна быть хороша? Можно принимать во внимание определенные принципы и явные тренды, — это поможет свести к минимуму непредвиденные проблемы, но понимать – это еще лучше.

Внедрение зависимостей — это шаблон проектирования, при котором поля или параметры создания объекта конфигурируется извне. Простыми словами, в приложении создается объект или группа объектов, ответственных за хранение данных и обеспечение ими модулей приложения. Как вы увидите далее, этот паттерн прост и понятен, хотя в некоторых реализациях он так сильно обрастает бородой, что это серьезно мешает пониманию.

Начну с простого, почему возникла потребность в новых паттернах, и почему некоторые старые паттерны стали сильно ограничиваться в области применения?

На мой взгляд, основная часть перемен привнесена массовым внедрением автотестирования. И для тех, кто активно пишет автотесты, данная статья очевидна как белый день, можно дальше не читать. Только вы не представляете, как много людей их не пишет. Я понимаю, что у маленьких компаний и стартапов нет не это ресурсов, но, к сожалению, и в больших компаниях часто находятся более первоочередные проблемы.

Рассуждения тут очень простые. Допустим вы тестируете функцию с параметрами a и b, и вы ожидаете получить результат x. В какой-то момент, ваши ожидания не сбываются, функция выдает результат y, и потратив некоторое время, вы обнаруживаете внутри функции синглтон, который в некоторых состояниях приводит результат выполнения функции к другому значению. Этот синглтон назвали неявной зависимостью, и всячески зареклись использовать его в подобных ситуациях. К сожалению, слов из песни не выбросишь, иначе получится уже совсем другая песня. А потому, вынесем наш синглтон как входящую переменную в функцию. Теперь у нас уже 3 входящие переменные a, b, s. Вроде все очевидно: меняем параметры – получаем однозначный результат.

Пока примеры приводить не буду. Более того, речь не только о функциях внутри класса, это схематичное рассуждение, которое можно применить также и к созданию класса, модуля и тп.

Замечение 1. Если, учитывая критику паттерна синглтон, вы решили заменить его, ну например, на UserDefaults, то применительно к данной ситуации, вырисовывается все та же неявная зависимость.

Замечение 2. Не совсем корректно говорить, что только из-за автотестирования не стоит использовать синглтоны внутри тела функции. В целом, с точки зрения программирования не совсем правильно, что при одинаковых входящих — функция выдает разные результаты. Просто на автотестах эта проблема вырисовалась более отчетливо.

Дополним вышеуказанный пример. У вас есть объект, который содержит 9 настроек пользователя(переменных), например права на чтение/редактирование/подпись/печать/пересылку/удаление/блокировку/ исполнение/копирование документа. В вашей функции используются только три переменные из этих настроек. Что вам передавать в функцию: весь объект с 9 переменными как один параметр, или только три нужные настройки тремя отдельными параметрами? Очень часто мы укрупняем передаваемые объекты, чтобы не задавать много параметров, то есть выбираем первый вариант. Такой способ будет считаться передачей «неоправданно широких зависимостей». Как вы уже сами догадались, для целей автотестирования лучше использовать второй вариант и передавать только те параметры, которые используются.

Мы сделали 2 вывода:
— функция должна получить все необходимые параметры на входе
— функция не должна получать излишних параметров на входе

Хотели как лучше – а получили функцию с 6-тью параметрами. Предположим, что внутри функции все в порядке, но кто-то должен взять на себя работу по обеспечению входящих параметров функции. Как я уже писала, мои рассуждения схематичны. Я подразумеваю не просто обычную функцию класса, а скорее функцию инициализации/создание модуля (vip, viper, объект с данными и тп). В этом контексте перефразируем вопрос: кто должен обеспечить входящие параметры для создания модуля?

Одно из решений было бы переложить это дело на вызывающий модуль. Но тогда получается, что вызывающему модулю нужно передать параметры дочернего. Это влечет следующие осложнения:

Во-первых, немного ранее мы решили избегать «неоправданно широких зависимостей». Во-вторых, не надо сильно напрягаться, чтобы понять, что параметров будет много, и каждый раз редактировать их при добавлении дочерних модулей будет весьма нудно, про удаление дочерних модулей — больно даже думать. Кстати, в некоторых приложениях вообще нельзя выстроить иерархию модулей: посмотрите на любую соц сеть: профиль -> друзья -> профиль друга -> друзья друга и т.п. В третьих, на эту тему можно вспомнить принцип SOLID: «Модули верхнего уровня не зависят от модулей нижнего уровня»

Отсюда рождается мысль вынести создание/инициализацию модуля в отдельную конструкцию. Тут пришло время написать несколько строк в качестве примера:

class AccountList {
  public func showAccountDetail(account: String) {
    let accountDetail = AccountDetail.make(account: account)
    // to do something with accountDetail
  }
}

class AccountDetail {
  private init(account: String, permission1: Bool, permission2: Bool) {
    print("account = \(account), p1 = \(permission1), p2 = \(permission2)")
  }
}

extension AccountDetail {
  
  public static func make(account: String) -> AccountDetail? {
    let p1 = ...
    let p2 = ...
    return AccountDetail(account: account, permission1: p1, permission2: p2)
  }
}

В примере есть модуль списка счетов AccountList, который вызывает модуль детальной информации по счету AccountDetail.

Для инициализации модуля AccountDetail нужны 3 переменные. Переменную account AccountDetail получает от родительского модуля, переменные permission1, permission2 впрыскиваются путем инъекции. За счет инъекции, вызов модуля с деталями счета будет выглядеть:

let accountDetail = AccountDetail.make(account: account)

вместо

let accountDetail = AccountDetail(account: account, permission1: p1, permission2: p2)

и родительский модуль списка счетов AccountList будет освобожден от обязанности передавать параметры c пермишенсами, про которые он ничего не знает.

Я вынесла реализацию инъекции (сборку) в статическую функцию в расширении класса. Но реализация может быть любой на ваше усмотрение.

Как мы видим:

  1. Модуль получил необходимые параметры. Его создание и выполнение можно спокойно протестировать на всех наборах значений.
  2. Модули независимы, не нужно ничего передавать для детей или только необходимый минимум.
  3. Модули НЕ выполняют работу по обеспечению данных, они используют уже готовые данные(p1, p2). Таким образом, если вы захотите изменить что-то в хранении или предоставлении данных, то вам не придется вносить изменения в фйункциональный код модулей(а также в их автотесты), а нужно будет только изменять саму систему сборки, или в расширения со сборкой.

Суть инъекции зависимостей — это построение такого процесса, в котором при вызове одного модуля из другого, независимый объект/механизм передает(впрыскивает) данные в вызываемый модуль. Другими словами, вызываемый модуль конфигурируется извне.

Существует несколько способов конфигурации:
Constructor Injection, Property injection, Interface Injection.
Для Swift:
Initializer Injection, Property Injection, Method Injection.

Наиболее распространенные — это инъекции конструктора(инициализации) и свойств.
Важно: практически во всех источниках рекомендуется отдавать предпочтение инъекции конструктора. Сравните Constructor/Initializer Injection и Property injection:

let account = ..
let p1 = ...
let p2 = ...
let accountDetail = AccountDetail(account: account, permission1: p1, permission2: p2)

лучше, чем

let accountDetail = AccountDetail()
accountDetail.account = ..
accountDetail.permission1 = ...
accountDetail.permission2 = ...

Вроде бы преимущества первого способа очевидны, но почему-то некоторые понимают инъекцию, как конфигурирование уже созданного объекта и используют второй способ. Я за первый способ:

  1. создание конструктором гарантирует валидный объект;
  2. при Property injection непонятно, нужно ли тестировать изменение свойства, в других местах кроме создания;
  3. в языках, использующих опциональность, для реализации Property injection нужно делать поля опциональными, или придумывать хитрые методы инициализации(ленивить не всегда получится). Излишняя опциональность добавляет ненужный код и ненужные наборы тестов.

Однако, пока мы не освободились не от каких зависимостей, лишь переложили их с одних плеч на другие. Закономерный вопрос, откуда все-таки брать данные в самой сборке (функция make в примере).

Использование синглтонов в механизме сборки уже не приводит к вышеописанным проблемам со скрытой зависимостью, т.к. тестировать создание модулей вы можете с любым набором данных.
Но здесь мы сталкиваемся с другим минусом синглтонов: плохая управляемость (можно наверное привести еще много ненавистнических аргументов, но лень). Нет ничего хорошего, в том, чтобы разбрасывать свои многочисленных хранилки/синглтоны в сборках, по аналогии с кем, как они были разбросаны в функциональных модулях. Но даже такой рефакторинг уже будет первым шагом в сторону гигиены, потому что, навести потом порядок в сборках можно почти не затрагивая код и тесты модулей.

Если вы хотите упорядочивать архитектуру и дальше, а также потестировать переходы и работу сборки, то придется еще немного поработать.

Концепция DI предлагает нам хранить все необходимые данные в контейнере. Это удобно. Во-первых сохранение(регистрация) и получение(resolve) данных идет через один объект-контейнер, соответственно, так проще управлять данными и тестировать. Во-вторых, можно учитывать зависимость данных друг от друга. Во многих языках, в том числе и в swift, есть уже готовые контейнеры управления зависимостями, обычно зависимости формируют дерево. Остальные плюсы-минусы я не буду перечислять, можно про них почитать по тем ссылкам, которые я выложила в начале поста.

Вот примерно, как может выглядеть сборка с использованием контейнера.

import Foundation
import Swinject

public class Configurator {
  
  private static let container = Container()
  
  public static func register<T>(name: String, value: T) {
    container.register(type(of: value), name: name) { _ in value }
  }

  public static func resolve<T>(service: T.Type, name: String) -> T? {
    return container.resolve(service, name: name)
  }
} 

extension AccountDetail {
  
  public static func make(account: String) -> AccountDetail? {
    
    if let p1 = Configurator.resolve(service: Bool.self, name: "permission1"),
       let p2 = Configurator.resolve(service: Bool.self, name: "permission2") {
       return AccountDetail(account: account, permission1: p1, permission2: p2)
    } else {
       return nil
    }
  }
}

// где-то в других модулях, например при входе в приложение вы должны получить
// разрешения и зарегистрировать(сохранить) их
Configurator.register(name: "permission1", value: true)
Configurator.register(name: "permission2", value: false)
...


Это возможный пример реализации. В примере используется фреймворк Swinject, который народился не так уж давно. Swinject позволяет создать контейнер для автоматизированного управления зависимостями, а также позволяет создавать контейнеры для Storyboards. Более подробно о Swinject можно посмотреть в примерах на raywenderlich. Этот сайт мне очень нравится, но данный пример не самый удачный, поскольку рассматривает применение контейнера только в автотестах, в то время как контейнер должен быть заложен в архитектуре приложения. Вы в своем коде, можете сами написать контейнер.

На этом всем спасибо. Надеюсь вы не сильно скучали, читая этот текст.
Для более подробного ознакомления рекомендую почитать перевод от Kiselioff.

Комментарии (5)


  1. Maksclub
    19.06.2019 11:34
    -1

    везде обсуждается КАК сделать, но практически нигде – ЗАЧЕМ

    Мне казалось, что DI паттерн всегда объясняется со словом ЗАЧЕМ, всегда рядом есть такие аргументы как тестирование и принципы DIP (Dependency Inversion Principe) и OCP (Open/Closed Principe) из SOLID. Так как благодаря инверсии зависимостей мы можем использовать полиморфизм (тем самым облегчая подмену зависимости снаружи, чем и обеспечиваем оба принципа SOLID).


    1. IrixV Автор
      19.06.2019 11:57

      Можно объяснять с точки зрения принципов, а можно — с точки зрения целей, это не совсем одно и тоже. Объяснять с точки зрения соблюдения принципов неплохо, потому что все принципы произошли из неких производственных необходимостей, и созданы для того, чтобы решать проблемы. Если человек это понимает — этого достаточно, если нет — начинаются споры о исполнение принципов ради самих принципов и т.п.


      1. Maksclub
        19.06.2019 12:39
        +1

        но помимо принципов я же указал: тестирование и использование полиморфизма для облегчения подмены реализации :--)


      1. Maksclub
        19.06.2019 13:17

        Кстати вчера задавали похожий вопрос на Тостере, мое объяснение «покороче» вашего вышло в силу специфики формата, так что в целом посту вашему плюс, разъяснять нужно это дело:
        toster.ru/q/641030#answer_1406923


  1. KonstantinSpb
    19.06.2019 20:50

    Есть отличная книга по DI
    www.amazon.com/Dependency-Injection-NET-Mark-Seemann/dp/1935182501