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

в 20:01, , рубрики: cocoapods, dependency injection, easydi, swift, viper, разработка под iOS

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

Эффективная DI библиотека на Swift в 200 строк кода - 1
Получилось два независимых графа.

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

Эффективная DI библиотека на Swift в 200 строк кода - 2
На этот раз получился один граф объектов, т.к. 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

Эффективная DI библиотека на Swift в 200 строк кода - 3
Получается, что два графа объектов дают 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.

Автор: shadow_of_irbis

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js