Pull to refresh

Изучая Dependency Injection

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

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

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

Внедрение зависимостей — это шаблон проектирования, при котором поля или параметры создания объекта конфигурируется извне.

Зная, что многие ограничатся чтением первых абзацев, я изменила статью.
Несмотря на то, что подобное «определение» DI встречается во многих источниках — оно неоднозначное, поскольку заставляет пользователя думать, что инъекция — это нечто, что заменяет создание/инициализацию объектов, или, уж по крайней мере, очень активно участвует в этом процессе. Делать такую реализацию DI, конечно, никто не запретит. Но DI может быть пассивной оберткой вокруг создания объекта, которая обеспечивает предоставление входящих параметров. В такой реализации у нас получается еще один уровень абстракции и отличное разделение обязанностей: объект сам отвечает за свою инициализацию, а инъекция реализует хранение данных и обеспечение ими модулей приложения.

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

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

Рассуждения тут очень простые. Допустим вы тестируете функцию с параметрами 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. Этот сайт мне очень нравится, но данный пример не самый удачный, поскольку рассматривает применение контейнера только в автотестах, в то время как контейнер должен быть заложен в архитектуре приложения. Вы в своем коде, можете сами написать контейнер.

На этом всем спасибо. Надеюсь вы не сильно скучали, читая этот текст.
Tags:
Hubs:
+8
Comments 8
Comments Comments 8

Articles