— Создание объектов и внедрение зависимостей в существующие
— Разделение на контейнеры — 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 тестирования в приложении. Например вот так:
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, когда во 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)
ivlevAstef
27.06.2017 10:17О, наконец токи кто-то написал нормально с точки зрения внешнего синтаксиса Typhoon на swift.
Реализация в 200 строчек очень сильно радует — осознал что мои 1к строчек можно видимо оптимизировать.
Правда кое чего думаю не хватит многим:
Внедрение во ViewController так чтобы его не трогать — сейчас я так понял предполагается это делать в функции awakeFromNib, хотелось бы чтобы оно автоматически вызывалось.
Конечно есть много возможностей которые дает регистрация с помощью разделения на register/resolve, но они не сильно критичны для мелких проектах, правда на больших могут дать неплохой буст.
shadow_of_irbis
27.06.2017 10:27Спасибо )
Внедрение во ViewController так чтобы его не трогать — сейчас я так понял предполагается это делать в функции awakeFromNib, хотелось бы чтобы оно автоматически вызывалось.
Увы, красиво у меня пока не получилось. Есть решение со свизлингом )))
В текущем проекте решили жить без Segues, поэтому Assembly сразу выдает ViewController с зависимостями.
А какие возможности дает register/resolve?ivlevAstef
27.06.2017 11:22Большой список, что-то важно что-то нет:
Валидацию корректности до момента исполнения — в памяти есть полный граф (надуманная проблема, так как в семантике EasyDi проблема может быть помойму только одна — зацикливание, если объявили не objectGraph)
Получение множества объектов по заданному критерию (например есть N реализаций одного протокола)
Уменьшение избыточности синтаксиса — не надо придумывать имена — тип является именем (но есть в этом и преимущество)
Поддержка позднего связывания — к примеру есть протокол, но наличие его реализации не гарантируется, она может быть, а может и не быть. (Та причина почему мы отказались от тайфуна).
Подробнее про позднее связывание:
У нас есть N модулей. В одном модуле описаны протоколы — сервисы. В разных сборках мы имеем разную комбинацию этих модулей, соответственно в разных сборка у нас или есть или нету реализации этих протоколов. Возникает проблема — под каждую сборку надо писать немного разное объявление DI.
Ну и этоже проблема пораждает другую проблему — модульности как такой нету — один Assembly должен знать о другом Assembly из другого модуля, что не всегда приемлимо, см. выше.
Я бы выделил две вещи:
Позднее связывание, очень важная вещь на больших проектах, и мало важная на мелких
Получение множества объектов — упрощает всякие паттерны типа Observer.
bonyadmitr
Первая строка в классе:
Почему название пустое?
shadow_of_irbis
Почему
?bonyadmitr
Ага
ivlevAstef
Если посмотреть детальнее, то есть более колосальная проблема :) — instance может быть не уникальным.
Раз появился syncQueue значит планировалось что-то сделать многопоточным. Это что-то это instance метод. но там допущена ошибка — чтение тоже должно быть в синхронном блоке, иначе много потоков могут дойти до точки синхронизации, и все они создадут по экземпляру объекта.
И пожалуйста, не надо править так: https://ru.wikipedia.org/wiki/Блокировка_с_двойной_проверкой :) на такое тоже часто натыкаюсь :)
P.S. долго думал куда об этом написать, решил всеже развить тему про syncQueue.
P.P.S. На самом деле я не понимаю откуда берется такая любовь к созданию потоков для синхронизации. Вы не один такой, я часто вижу подобный код, но не понимаю чем не устраивает то что созданно для синхронизации, а именно: OSSpinLock или objc_sync_enter/exit. Они быстрее, едят меньше памяти и не нужно создавать странный код чтобы получить значение из тела блока.