Библиотека EasyDi содержит контейнер зависимостей для Swift. Синтаксис этой библиотеки был специально разработан для быстрого освоения и эффективного использования. Она умещается в 200 строк, при этом умеет все, что нужно взрослой Di библиотеке:

— Создание объектов и внедрение зависимостей в существующие
— Разделение на контейнеры — Assemblies
— Типы разрешения зависимостей: граф объектов, синглетон, прототип
— Разрешение циклических зависимостей
— Подмена объектов и конктесты зависимостей для тестов

В EasyDi нет разделения на register/resolve. Вместо этого зависимости описываются вот так:

var apiClient: IAPIClient {
  return define(init: APIClient()) {
    $0.baseURl = self.baseURL
  }
}

> Cocoapods / EasyDi
> Github / EasyDi

Под катом очень краткое описание «Зачем DI и что это», также примеры использования библиотеки:

  • Как использовать и типы зависимостей
  • Как тестировать c подменой объектов
  • Как можно это использовать для A/B тестов
  • Как собрать VIPER-модуль

Зачем DI и что это?(очень кратко)


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

Вот три базовых сценария, где DI делает жизнь лучше:

  • Параллельная разработка. Один разработчик сможет заниматься UI, а второй данными, если заранее договорятся о протоколе работы. UI тогда может разрабатываться с тестовыми данными, а слой данных вызываться из тестового UI.
  • Тесты. Подменяя сетевой слой на объекты с фиксированными ответами, можно проверить все варианты поведения UI, в том числе в случае ошибок.
  • Рефакторинг. Сетевой слой можно заменить на новый, быстрый с кэшем и другим API, если оставить без изменений протокол с UI.

Суть DI можно так описать одним предложением:

Зависимости для объектов надо закрыть протоколами и передать в объект снаружи.

Т.е. вместо:

class OrderViewController {
  func didClickShopButton(_ sender: UIButton?) {
    APIClient.sharedInstance.purchase(...)
  }
}

Стоит использовать:

protocol IPurchaseService {
  func perform(...)
}

class OrderViewController {
  var purchaseService: IPurchaseService?
  func didClickShopButton(_ sender: UIButton?) {
    self.purchaseService?.perform(...)
  }
}

Подробнее с принципом инверсии зависимостей и концепцией SOLID можно познакомиться
тут (objc.io #15 DI) и тут (wikipedia. SOLID).

Как работать с EasyDi (Простой пример)


Простой пример использования библиотеки: убрать из ViewController работу с сетью в сервисы и разместить их создание и зависимости в отдельном контейнере. Это простой и эффективный способ начать деление приложения на слои. В примере рассмотрим сервис и контроллер из примера выше.

Пример кода сервиса и контроллера
protocol IPurchaseService {
  func perform(with objectId: String, then completion: (success: Bool)->Void)
}    

class PurchaseService: IPurchaseService {

  var baseURL: URL?
  var apiPath = "/purchase/"
  var apiClient: IAPIClient?
  
  func perform(with objectId: String, then completion: (_ success: Bool) -> Void) {
    guard let apiClient = self.apiClient, let url = self.baseURL else {
      fatalError("Trying to do something with uninitialized purchase service")
    }
    let purchaseURL = baseURL.appendingPathComponent(self.apiPath).appendingPathComponent(objectId)
    let urlRequest = URLRequest(url: purchaseURL)
    self.apiClient.post(urlRequest) { (_, error) in
      let success: Bool = (error == nil)
        completion( success )
    }
  }
}

Контроллер:

class OrderViewController {

  var purchaseService: IPurchaseService?
  var purchaseId: String?
  
  func didClickShopButton(_ sender: UIButton?) {

    guard let purchaseService = self.purchaseService, let purchaseId = self.purchaseId else {
      fatalError("Trying to do something with uninitialized order view controller")
    }

    self.purchaseService.perform(with: self.purchaseId) { (success) in
      self.presenter(showOrderResult: success)
    }
  }
}


Зависимости сервиса:

class ServiceAssembly: Assembly {
  
  var purchaseService: IPurchaseService {
    return define(init: PurchaseService()) {
      $0.baseURL = self.apiV1BaseURL
      $0.apiClient = self.apiClient
    }
  }

  var apiClient: IAPIClient {
    return define(init: APIClient())
  }

  var apiV1BaseURL: URL {
    return define(init: URL("http://someapi.com/")!)
  }
}

И вот так мы внедряем сервис в контроллер:

var orderViewAssembly: Assembly {
  
  var serviceAssembly: ServiceAssembly = self.context.assembly()

  func inject(into controller: OrderViewController, purchaseId: String) {
    define(init: controller) {
      $0.purchaseService = self.serviceAssembly.purchaseService
      $0.purchaseId = purchaseId
    }
  }
}

Теперь можно поменять класс сервиса не залезая во ViewController.

Типы разрешения зависимостей (Пример средней сложности)


ObjectGraph


По-умолчанию все зависимости разрешаются через граф объектов. Если объект уже есть в стеке текущего графа объектов, то он используется снова. Это позволяет внедрить один и тот же объект в несколько, а также разрешить циклические зависимости. Для примера возьмём объекты A,B и C со ссылками A->B->C.(Не будем обращать внимания на RetainCycle, он нужен для полноты примера).

class A {
  var b: B?
}

class B {
  var c: C?
}

class C {
  var a: A?
}

Вот так выглядит Assembly и вот такой граф зависимостей для двух запросов A.

class ABCAssembly: Assembly {

  var a:A {
    return define(init: A()) {
      $0.b = self.B()
    }
  }

  var b:B {
    return define(init: B()) {
      $0.c = self.C()
    }
  }

  var c:C {
    return define(init: C()) {
      $0.a = self.A()
    }
  }
}

var a1 = ABCAssembly.instance().a
var a2 = ABCAssembly.instance().a


Получилось два независимых графа.

Singleton


Но бывает так, что нужно создать один объект, который потом будет использоваться везде, например система аналитики или хранилище. Использовать классический Singleton с SharedInstance не стоит, т.к. будет невозможно его подменить. Для этих целей в EasyDi есть scope: singleton. Этот объект создаётся один раз, в него один раз внедряются зависимости и больше EasyDi его не меняет, только возвращает. Для примера сделаем B синглетоном.

class ABCAssembly: Assembly {
  var a:A {
    return define(init: A()) {
      $0.b = self.B()
    }
  }

  var b:B {
    return define(scope: .lazySingleton, init: B()) {
      $0.c = self.C()
    }
  }

  var c:C {
    return define(init: C()) {
      $0.a = self.A()
    }
  }
}

var a1 = ABCAssembly.instance().a
var a2 = ABCAssembly.instance().a



На этот раз получился один граф объектов, т.к. B стал общим.

Prototype


И иногда требуется при каждом обращении получать новый объект. На примере объектов ABC для A-прототипа это будет выглядеть так:

class ABCAssembly: Assembly {
  var a:A {
    return define(scope: .prototype, init: A()) {
      $0.b = self.B()
    }
  }

  var b:B {
    return define(init: B()) {
      $0.c = self.C()
    }
  }

  var c:C {
    return define(init: C()) {
      $0.a = self.A()
    }
  }
}

var a1 = ABCAssembly.instance().a
var a2 = ABCAssembly.instance().a



Получается, что два графа объектов дают 4 копии объекта A

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

Патчи и контексты для тестов (Сложный пример)


При тестировании важно сохранять независимость тестов. В EasyDi это обеспечивается контекстами Assemblies. Например, интеграционные тесты, где используются синглетоны. Используются они вот так:

let context: DIContext = DIContext()
let assemblyInstance2 = TestAssembly.instance(from: context)

При этом важно следить за тем, чтобы контексты у совместно используемых Assemblies совпадали.

class FeedViewAssembly: Assembly {

  lazy var serviceAssembly:ServiceAssembly = self.context.assembly()

}

Другая важная часть тестирования — это моки и стабы, т.е объекты с известным поведением. При известных входных данных тестируемый объект выдаёт известный результат. Если не выдаёт, значит тест не пройден. Подробнее про тестирование можно узнать тут (objc.io #15 весь). А вот так можно подменить объект:

protocol ITheObject {
  var intParameter: Int { get }
}

class MyAssembly: Assembly {

  var theObject: ITheObject {
    return define(init: TheObject()) {
      $0.intParameter = 10
    }
  }
}

let myAssembly = MyAssembly.instance()
myAssembly.addSubstitution(for: "theObject") { () -> ITheObject in
  let result = FakeTheObject()
  result.intParameter = 30
  return result
}

Теперь свойство theObject будет возвращать новый объект другого типа с другим intParameter.

про A / B тесты

про A / B тесты


Этот же механизм можно использовать для a/b тестирования в приложении. Например вот так:

let FeatureAssembly: Assembly {
  
  var feature: IFeature {
    return define(init: Feature) {
      ...
    }
  }
}

let FeatureABTestAssembly: Assembly {

  lazy var featureAssembly: FeatureAssembly = self.context.assembly()

  var feature: IFeature {
    return define(init: FeatureV2) {
      ...
    }
  }

  func activate(firstTest: Bool) {
    if (firstTest) {
      self.featureAssembly.addSubstitution(for: "feature") {
        return self.feature
      }
    } else {
      self.featureAssembly.removeSubstitution(for: "feature")
    }
  }
}

Здесь для теста создается отдельный контейнер, который создает второй вариант объекта и позволяет включить/выключить подстановку этого объекта.

Внедрение зависимостей в VIPER

Внедрение зависимостей в VIPER



Бывает так, что надо внедрить зависимости в существующий объект, а от него тоже кто-то зависит. Самый очевидный пример — это VIPER, когда во ViewController надо добавить Presenter, а он сам должен получить ссылку на ViewController.

Для этого в EasyDi есть ‘ключи’ и плейсхолдеры с помощью которых можно возвращать один и тот же объект из разных методов. Выглядит это так:

сlass ModuleAssembly: Assembly {

  func inject(into view: ModuleViewController) {
    return define(key: "view", init: view) {
      $0.presenter = self.presenter
    }
  }

  var view: IModuleViewController {
    return definePlaceholder()
  }

  var presenter: IModulePresenter {
    return define(init: ModulePresenter()) {
	  $0.view = self.view
      $0.interactor = self.interactor
    }
  }

  var interaction: IModuleInteractor {
    return define(init: ModuleInteractor()) {
	  $0.presenter = self.presenter
      ...
    }
  }
}

Здесь для внедрения зависимостей во ViewController используется метод inject, который связан ключом со свойством viewController. Теперь это свойство возвращает объект, переданный в метод inject. И только при разрешении зависимостей графа объектов, который начинается с метода inject.

Вместо заключения


У меня не было цели упихать все в 200 строк, просто так получилось. Наиболее влияние на эту библиотеку оказал Typhoon, очень хотелось иметь что-то похожее, но на Swift и попроще.

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

Библиотека упакована в 1 файл, чтобы проще было добавлять в переходные проекты, где ещё не используется use_frameworks, но Swift уже есть.

Ссылки на библиотеку:


Текущая версия '1.1.1'
pod 'EasyDi', '~>1.1'

Должна одинаково хорошо работать на Swift 3/4, в iOS 8+.
На iOS 7 — не знаю, не могу проверить.

А депо-приложение — читалка комиксов XKCD.
Поделиться с друзьями
-->

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


  1. bonyadmitr
    26.06.2017 23:35

    Первая строка в классе:


    fileprivate lazy var syncQueue:DispatchQueue = Dispatch.DispatchQueue(label: "", qos: .userInteractive)

    Почему название пустое?


    1. shadow_of_irbis
      27.06.2017 10:27

      Почему

      label: ""
      ?


      1. bonyadmitr
        27.06.2017 10:58

        Ага


      1. ivlevAstef
        27.06.2017 20:42

        Если посмотреть детальнее, то есть более колосальная проблема :) — instance может быть не уникальным.
        Раз появился syncQueue значит планировалось что-то сделать многопоточным. Это что-то это instance метод. но там допущена ошибка — чтение тоже должно быть в синхронном блоке, иначе много потоков могут дойти до точки синхронизации, и все они создадут по экземпляру объекта.


        И пожалуйста, не надо править так: https://ru.wikipedia.org/wiki/Блокировка_с_двойной_проверкой :) на такое тоже часто натыкаюсь :)


        P.S. долго думал куда об этом написать, решил всеже развить тему про syncQueue.
        P.P.S. На самом деле я не понимаю откуда берется такая любовь к созданию потоков для синхронизации. Вы не один такой, я часто вижу подобный код, но не понимаю чем не устраивает то что созданно для синхронизации, а именно: OSSpinLock или objc_sync_enter/exit. Они быстрее, едят меньше памяти и не нужно создавать странный код чтобы получить значение из тела блока.


  1. ivlevAstef
    27.06.2017 10:17

    О, наконец токи кто-то написал нормально с точки зрения внешнего синтаксиса Typhoon на swift.


    Реализация в 200 строчек очень сильно радует — осознал что мои 1к строчек можно видимо оптимизировать.


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


    Конечно есть много возможностей которые дает регистрация с помощью разделения на register/resolve, но они не сильно критичны для мелких проектах, правда на больших могут дать неплохой буст.


    1. shadow_of_irbis
      27.06.2017 10:27

      Спасибо )

      Внедрение во ViewController так чтобы его не трогать — сейчас я так понял предполагается это делать в функции awakeFromNib, хотелось бы чтобы оно автоматически вызывалось.

      Увы, красиво у меня пока не получилось. Есть решение со свизлингом )))
      В текущем проекте решили жить без Segues, поэтому Assembly сразу выдает ViewController с зависимостями.

      А какие возможности дает register/resolve?


      1. ivlevAstef
        27.06.2017 11:22

        Большой список, что-то важно что-то нет:
        Валидацию корректности до момента исполнения — в памяти есть полный граф (надуманная проблема, так как в семантике EasyDi проблема может быть помойму только одна — зацикливание, если объявили не objectGraph)


        Получение множества объектов по заданному критерию (например есть N реализаций одного протокола)


        Уменьшение избыточности синтаксиса — не надо придумывать имена — тип является именем (но есть в этом и преимущество)


        Поддержка позднего связывания — к примеру есть протокол, но наличие его реализации не гарантируется, она может быть, а может и не быть. (Та причина почему мы отказались от тайфуна).


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


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


        Я бы выделил две вещи:
        Позднее связывание, очень важная вещь на больших проектах, и мало важная на мелких
        Получение множества объектов — упрощает всякие паттерны типа Observer.