Привет! Меня зовут Антон, я iOS-разработчик в Joom. Из этой статьи вы узнаете, как мы работаем с DI-фреймворком Needle, и реально ли он чем-то выгодно отличается от аналогичных решений и готов для использования в production-коде. Это всё с замерами производительности, естественно.
Предыстория
Во времена, когда приложения для iOS еще писали полностью на Objective-C, существовало не так много DI-фреймворков, и стандартом по умолчанию среди них считался Typhoon. При всех своих очевидных плюсах, Typhoon приносил с собой и определённый overhead в runtime, что приводило к потере производительности в приложении.
На заре Joom мы попытались воспользоваться этим решением, но показанные им характеристики в тестовых замерах оказались не удовлетворительны для нас, и от него решили отказаться в пользу собственного решения. Это было так давно, что те времена из нашей нынешней iOS-команды застал всего один человек, и описанные события восстановлены по его воспоминаниям.
Потом на смену Objective-C пришел Swift, и все больше приложений стало переходить на этот новый язык. Ну а что же мы?
Пока все переходили на Swift, мы продолжали писать на
Objective-C и пользовались самописным решением для DI. В нем было
реализовано все то, что нам нужно было от инструмента для внедрения
зависимостей: скорость и надежность.
Скорость обеспечивалась за счет того, что не надо было
регистрировать никакие зависимости в runtime. Контейнер состоял из
обычных property, которые могли при необходимости предоставляться в
виде:
- обычного объекта, который создается при каждом обращении к зависимости;
- глобального синглтона;
- синглтона для определенного сочетания набора входных
параметров.
При этом все дочерние контейнеры создавались через lazy property у родительских контейнеров. Другими словами, граф зависимостей у нас строился на этапе компиляции проекта, а не в runtime.
Надежность обеспечивалась за счет того, что все проверки проходили в compile time. Поэтому если где-то в header контейнера мы объявили зависимость и забыли реализовать ее создание в implementation, или у зависимости не находилось какое-либо свойство в месте обращения к ней, то об этом мы узнавали на этапе компиляции проекта.
Но у этого решения был один недостаток, который нам мешал жить.
Представьте, что у вас есть граф DI-контейнеров и вам надо из контейнера в одной ветке графа пронести зависимость в контейнер из другой ветки графа. При этом глубина веток запросто может достигать 5-6 уровней.
Вот список того, что нужно было сделать в нашем решении для проброса одной зависимости из родительского контейнера в дочерний:
- сделать forward declaration типа новой зависимости в .h-файле дочернего контейнера;
- объявить зависимость в качестве входного параметра конструктора в .h-файле дочернего контейнера;
- сделать #import header с типом зависимости в .m-файле дочернего контейнера;
- объявить зависимость в качестве входного параметра конструктора в .m-файле дочернего контейнера;
- объявить свойство в дочернем контейнере, куда мы положим эту зависимость.
Многовато, не правда ли? И это только для проброса на один уровень ниже.
Понятно, что половину этих действий требует сама идеология разбиения кода на заголовочные файлы и файлы с имплементацией в языках семейства Cи. Но это становилось головной болью разработчика и требовало от него по сути бездумного набора copy/paste действий, которые убивают любую мотивацию в процессе разработки.
В качестве альтернативы пробросу одной конкретной зависимости можно воспользоваться передачей всего контейнера с зависимостями. Это могло сработать, если было понимание, что в будущем из пробрасываемого контейнера могут понадобится и другие зависимости. И мы частенько так делали.
Но это не правильный путь. В таком случае один объект получает больше знаний, чем ему нужно для работы. Все мы проходили интервью, где рассказывали про принципы SOLID, заветы Дядюшки Боба, и вот это вот все, и знаем, что так делать не стоит. И мы достаточно долго жили только с этим решением и продолжали писать на Objective-C.
Возможно, вы помните нашу первую часть статьи о том, как
писать на этом языке в 2018.
Вторую часть, как и второй том Мертвых душ Гоголя, миру уже не
суждено увидеть.
В начале этого года мы приняли окончательное решение о переводе
разработки новых фичей на Swift и постепенного избавления от
наследия Objective-C.
В плане DI настало время еще раз посмотреть на имеющиеся решения.
Нам нужен был framework, который бы обладал теми же преимуществами, что и наше самописное решение на Objective-C. При этом бы не требовал написания большого объема boilerplate кода.
На данный момент существует множество DI framework-ов на Swift. Cамыми популярными на текущий момент можно назвать Swinject и Dip. Но у этих решений есть проблемы.
А именно:
- Граф зависимостей создается в runtime. Поэтому, если вы забыли зарегистрировать зависимость, то об этом вы узнаете благодаря падению, которое произойдет непосредственно во время работы приложения и обращения к зависимости.
- Регистрация зависимостей так же происходит в runtime, что увеличивает время запуска приложения.
- Для получения зависимости в этих решениях приходится
пользоваться такими конструкциями языка, как force unwrap
!
(Swinject) илиtry!
(Dip) для получения зависимостей, что не делает ваш код лучше и надежнее.
Нас это не устраивало, и мы решили поискать альтернативные решения. К счастью, нам попался достаточно молодой DI framework под названием Needle.
Общая информация
Needle это open-source решение от компании Uber, которое написано на Swift и существует с 2018 года (первый коммит 7 мая 2018).
Главным преимуществом по словам разработчиков является обеспечение compile time safety кода работы для внедрения зависимостей.
Давайте разберемся как это все работает.
Needle состоит из двух основных частей: генератор кода и NeedleFoundation framework.
Генератор кода
Генератор кода нужен для парсинга DI кода вашего проекта и генерации на его основе графа зависимостей. Работает на базе SourceKit.
Во время работы генератор строит связи между контейнерами и
проверяет доступность зависимостей. В результате его работы для
каждого контейнера будет сгенерирован свой собственный
DependencyProvider
, основным назначением которого
является предоставление контейнеру зависимостей от других
контейнеров. Более подробно про это мы поговорим чуть позже.
Но главное, что если какая-либо зависимость не найдена, то генератор выдаст ошибку с указанием контейнера и типом зависимости, которая не найдена.
Сам генератор поставляется в бинарном виде. Его можно получить двумя способами:
- Воспользоваться утилитой homebrew:
brew install needle
- Склонировать репозиторий проекта и найти его внутри:
git clone https://github.com/uber/needle.git & cd Generator/bin/needle
Для подключения в проект необходимо добавить Run
Script
фазу, в которой достаточно указать путь до генератора
и путь до файла, куда будет помещен сгенерированный код. Пример
такой настройки:
export SOURCEKIT_LOGGING=0 && needle generate ../NeedleGenerated.swift
../NeedleGenerated.swift
файл, в которой будет
помещен весь генерированный код для построения графа
зависимостей.
NeedleFoundation
NeedleFoundation это фреймворк, который предоставляет разработчикам набор базовых классов и протоколов для создания контейнеров с зависимостями.
Устанавливается без проблем через один из менеджеров
зависимостей. Пример добавления с помощью
CocoaPods
:
pod 'NeedleFoundation'
Сам граф начинает строиться с создания root-контейнера, который
должен быть наследником специального класса
BootstrapComponent
.
Остальные контейнеры должны наследоваться от класса
Component
.
Зависимости DI-контейнера описываются в протоколе, который
наследуется от базового протокола зависимостей
Dependency
и указывается в качестве generic type-а
самого контейнера.
Вот пример такого контейнера с зависимостями:
protocol SomeUIDependency: Dependency { var applicationURLHandler: ApplicationURLHandler { get } var router: Router { get }}final class SomeUIComponent: Component<SomeDependency> { ...}
Если зависимостей нет, то указывается специальный протокол
<EmptyDependency>
.
Все DI-контейнеры содержат в себе lazy-свойства
path
и name
:
// Component.swiftpublic lazy var path: [String] = { let name = self.name return parent.path + ["\(name)"]}()private lazy var name: String = { let fullyQualifiedSelfName = String(describing: self) let parts = fullyQualifiedSelfName.components(separatedBy: ".") return parts.last ?? fullyQualifiedSelfName}()
Эти свойства нужны для того, чтобы сформировать путь до DI-контейнера в графе.
Например, если у нас есть следующая иерархия контейнеров:
RootComponent->UIComponent->SupportUIComponent
,
то для SupportUIComponent
свойство
path
будет содержать значение [RootComponent,
UIComponent, SupportUIComponent]
.
Во время инициализации DI-контейнера в конструкторе извлекается
DependencyProvider
из специального регистра, который
представлен в виде специального singleton-объекта класса
__DependencyProviderRegistry
:
// Component.swiftpublic init(parent: Scope) { self.parent = parent dependency = createDependencyProvider()}// ...private func createDependencyProvider() -> DependencyType { let provider = __DependencyProviderRegistry.instance.dependencyProvider(for: self) if let dependency = provider as? DependencyType { return dependency } else { // This case should never occur with properly generated Needle code. // Needle's official generator should guarantee the correctness. fatalError("Dependency provider factory for \(self) returned incorrect type. Should be of type \(String(describing: DependencyType.self)). Actual type is \(String(describing: dependency))") }}
Для того, чтобы найти нужный DependencyProvider
в
__DependencyProviderRegistry
используется ранее
описанное свойство контейнера path
. Все строки из
этого массива соединяются и образуют итоговую строку, которая
отражает путь до контейнера в графе. Далее от итоговой строки
берется hash и по нему уже извлекается фабрика, которая и создает
провайдер зависимостей:
// DependencyProviderRegistry.swiftfunc dependencyProvider(`for` component: Scope) -> AnyObject { providerFactoryLock.lock() defer { providerFactoryLock.unlock() } let pathString = component.path.joined(separator: "->") if let factory = providerFactories[pathString.hashValue] { return factory(component) } else { // This case should never occur with properly generated Needle code. // This is useful for Needle generator development only. fatalError("Missing dependency provider factory for \(component.path)") }}
В итоге полученный DependencyProvider
записывается
в свойство контейнера dependency
, с помощью которого
во внешнем коде можно получить необходимую зависимость.
Пример обращения к зависимости:
protocol SomeUIDependency: Dependency { var applicationURLHandler: ApplicationURLHandler { get } var router: Router { get }}final class SomeUIComponent: Component<SomeDependency> { var someObject: SomeObjectClass { shared { SomeObjectClass(router: dependecy.router) } }}
Теперь рассмотрим откуда берутся
DependecyProvider
.
Создание DependencyProvider
Как мы уже было отмечено ранее, для каждого объявленного в коде
DI-контейнера создается свой DependencyProvider
. Это
происходит за счет кодогенерации. Генератор кода
Needle
анализирует исходный код проекта и ищет всех
наследников базовых классов для DI-контейнеров
BootstrapComponent
и Component
.
У каждого DI-контейнера есть протокол описания зависимостей.
Для каждого такого протокола генератор анализирует доступность каждой зависимости путем поиска ее среди родителей контейнера. Поиск идет снизу вверх, т.е. от дочернего компонента к родительскому.
Зависимость считается найденой только если совпадают имя и тип зависимости.
Если зависимость не найдена, то сборка проекта останавливается с ошибкой, в которой указывается потерянная зависимость. Это первый уровень обеспечения compile-time safety.
После того, как будут найдены все зависимости в проекте,
генератор кода Needle создает DependecyProvider
для
каждого DI-контейнера. Полученный провайдер отвечает
соответствующему протоколу зависимостей:
// NeedleGenerated.swift/// ^->RootComponent->UIComponent->SupportUIComponent->SomeUIComponentprivate class SomeUIDependencyfb16d126f544a2fb6a43Provider: SomeUIDependency { var applicationURLHandler: ApplicationURLHandler { return supportUIComponent.coreComponents.applicationURLHandler } // ...}
Если по каким-то причинам на этапе построения связей между
контейнерами потерялась зависимость и генератор пропустил этот
момент, то на этом этапе вы получите не собирающийся проект, так
как поломанный DependecyProvider
не будет отвечать
протоколу зависимостей. Это второй уровень compile-time safety от
Needle.
Теперь рассмотрим процесс поиска провайдера зависимостей для контейнера.
Регистрация DependencyProvider
Получив готовые DependecyProvider и зная связь между контейнерами, генератор кода Needle создает для каждого контейнера путь в итоговом графе.
Каждому пути сопоставляется closure-фабрика, внутри которой возвращается провайдер зависимостей. Код сопоставления создается кодогенератором.
В результате появляется глобальная функция
registerProviderFactories()
, которую мы должны вызвать
в своем коде до первого обращения к каким-либо DI-контейнерам.
// NeedleGenerated.swiftpublic func registerProviderFactories() { __DependencyProviderRegistry.instance.registerDependencyProviderFactory(for: "^->RootComponent") { component in return EmptyDependencyProvider(component: component) } __DependencyProviderRegistry.instance.registerDependencyProviderFactory(for: "^->RootComponent->UIComponent") { component in return EmptyDependencyProvider(component: component) } // ...}
Сама регистрация внутри глобальной функции происходит с помощью
singleton-объекта класса __DependencyProviderRegistry
.
Внутри данного объекта провайдеры зависимостей складываются в
словарь [Int: (Scope) -> AnyObject]
, в котором
ключом является hashValue
от строки, описывающий путь
от вершины графа до контейнера, а значением closure-фабрика. Сама
запись в таблицу является thread-safe за счет использования внутри
NSRecursiveLock
.
// DependencyProviderRegistry.swiftpublic func registerDependencyProviderFactory(`for` componentPath: String, _ dependencyProviderFactory: @escaping (Scope) -> AnyObject) { providerFactoryLock.lock() defer { providerFactoryLock.unlock() } providerFactories[componentPath.hashValue] = dependencyProviderFactory}
Результаты тестирования в проекте
Сейчас у нас порядка 430к строк кода без учета сторонних зависимостей. Из них около 83к строк на Swift.
Все замеры мы проводили на iPhone 11 c iOS 13.3.1 и с использование Needle версии 0.14.
В тестах сравнивались две ветки актуальный develop
и ветка, в которой root-контейнер и все его дочерние контейнеры
были переписаны на needle-конейнеры, и одна ветка контейнеров в
графе полностью заменена на Needle. Все изменения для тестов
проводились именно в этой ветке графа.
Проведенные тесты
Время полной сборки
Номер измерения | Без Needle | С Needle |
---|---|---|
1 | 294.5s | 295.1s |
2 | 280.8s | 286.4s |
3 | 268.2s | 294.1s |
4 | 282.9s | 279.5s |
5 | 291.5s | 293.4s |
Среднее значение без Needle: 283.58s
Среднее значение с Needle: 289.7s
Как видно, время на первоначальный анализ кода проекта, который должен провести кодогенератор Needle, принесло нам +6 секунд ко времени чистой сборки с нуля.
Время инкрементальной сборки
Номер измерения | Без Needle | С Needle |
---|---|---|
1 | 37.8s | 36.1s |
2 | 27.9s | 37.0s |
3 | 37.3s | 33.0s |
4 | 38.2s | 35.5s |
5 | 37.8s | 35.8s |
Среднее значение Без Needle: 35.8s
Среднее значение С Needle: 35.48s
В этом тесте мы добавляли и удаляли к контейнеру в самом низу графа одну и ту же зависимость.
Измерения registerProviderFactories()
Среднее значение (секунды): 0.000103
Замеры:
0.00015008449554443360.00009393692016601560.00009000301361083980.00009202957153320310.00012707710266113280.00009500980377197260.00009107589721679680.00009703636169433590.00009691715240478510.0000959634780883789
В этом тесте мы выяснили, что время на запуск нашего приложения при использовании Needle почти не изменилось.
Измерения первого доступа к зависимости
Номер измерения | Без Needle | С Needle | C Needle + FakeComponents |
---|---|---|---|
1 | 0.000069 | 0.001111 | 0.002981 |
2 | 0.000103 | 0.001153 | 0.002657 |
3 | 0.000080 | 0.001132 | 0.002418 |
4 | 0.000096 | 0.001142 | 0.002812 |
5 | 0.000078 | 0.001177 | 0.001960 |
Среднее значение Без Needle (секунды):
0.000085
Среднее значение C Needle (секунды):
0.001143
(+0.001058
)
Среднее значение C Needle + FakeComponents (секунды):
0.002566
Примечание: SomeUIComponent
в тестируемом
примере лежит на седьмом уровне вложенности
графа:^->RootComponent->UIComponent->SupportUIComponent->SupportUIFake0Component->SupportUIFake1Component->SupportUIFake2Component->SupportUIFake3Component->SomeUIComponent
В этом тесте мы померили скорость первоначального обращения к зависимости. Как видно, наше самописное решение тут выигрывает в десятки раз. Но если посмотреть на абсолютные цифры, то это очень незначительное время.
Измерения повторного доступа к BabyloneUIComponent c Needle
Номер измерения | Без Needle | С Needle | C Needle + FakeComponents |
---|---|---|---|
1 | 0.000031 | 0.000069 | 0.000088 |
2 | 0.000037 | 0.000049 | 0.000100 |
3 | 0.000053 | 0.000054 | 0.000082 |
4 | 0.000057 | 0.000064 | 0.000092 |
5 | 0.000041 | 0.000053 | 0.000088 |
Среднее значение без Needle (секунды):
0.000044
Среднее значение с Needle (секунды):
0.000058
Среднее значение с Needle + FakeComponents
(секунды):0.000091
Повторное обращение к зависимости происходит еще быстрее. Тут наше решение опять выигрывает, но абсолютные цифры так же очень малы.
Выводы
В итоге по результатам тестов мы пришли к выводу, что Needle дает нам именно то, что мы хотели от DI-фреймворка.
Он дает нам надежность благодаря обеспечению compile time safety кода зависимостей.
Он быстрый. Не такой быстрый, как наше самописное решение на Objective-C, но все же в абсолютных цифрах он достаточно быстрый для нас.
Он избавляет нас от необходимости руками вносить зависимости через множество уровней в графе за счет своей кодогенерации. Достаточно реализовать создание зависимости в одном контейнере и задекларировать потребность в ней в другом контейнере через специальный протокол.
При использовании Needle все же остается проблема того, что на старте приложения нам надо выполнить какие-то настройки кода зависимостей. Но как показал тест, прирост времени запуска составил меньше миллисекунды и мы готовы с этим жить.
На наш взгляд, Needle отлично подходит для команд и проектов, которые заботятся, как и мы, о производительности и надежности приложения, а так же удобстве работы с его кодовой базой.