Эффективная DI библиотека на Swift в 200 строк кода

    Библиотека 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

      0

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


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

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

        0
        Почему
        label: ""
        ?
          0

          Ага

            0

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


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


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

          0

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


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


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


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

            0
            Спасибо )

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

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

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

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


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


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


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


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


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


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

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

          Самое читаемое