Знакомимся с Needle, системой внедрения зависимостей на Swift

в 14:27, , рубрики: iOS, Joom, needle, open source, swift, Блог компании Joom, разработка мобильных приложений, разработка под iOS

Привет! Меня зовут Антон, я iOS-разработчик в Joom. Из этой статьи вы узнаете, как мы работаем с DI-фреймворком Needle, и реально ли он чем-то выгодно отличается от аналогичных решений и готов для использования в production-коде. Это всё — с замерами производительности, естественно.

Знакомимся с Needle, системой внедрения зависимостей на Swift - 1

Предыстория

Во времена, когда приложения для 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, основным назначением которого является предоставление контейнеру зависимостей от других контейнеров. Более подробно про это мы поговорим чуть позже.

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

Сам генератор поставляется в бинарном виде. Его можно получить двумя способами:

  1. Воспользоваться утилитой homebrew:
    brew install needle
  2. Склонировать репозиторий проекта и найти его внутри:
    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.swift
public 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.swift
public 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.swift
func 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->SomeUIComponent
private class SomeUIDependencyfb16d126f544a2fb6a43Provider: SomeUIDependency {
    var applicationURLHandler: ApplicationURLHandler {
        return supportUIComponent.coreComponents.applicationURLHandler
    }
    // ...
}

Если по каким-то причинам на этапе построения связей между контейнерами потерялась зависимость и генератор пропустил этот момент, то на этом этапе вы получите не собирающийся проект, так как поломанный DependecyProvider не будет отвечать протоколу зависимостей. Это второй уровень compile-time safety от Needle.

Теперь рассмотрим процесс поиска провайдера зависимостей для контейнера.

Регистрация DependencyProvider

Получив готовые DependecyProvider и зная связь между контейнерами, генератор кода Needle создает для каждого контейнера путь в итоговом графе.

Каждому пути сопоставляется closure-фабрика, внутри которой возвращается провайдер зависимостей. Код сопоставления создается кодогенератором.

В результате появляется глобальная функция registerProviderFactories(), которую мы должны вызвать в своем коде до первого обращения к каким-либо DI-контейнерам.

// NeedleGenerated.swift
public 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.swift
public 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.0001500844955444336
0.0000939369201660156
0.0000900030136108398
0.0000920295715332031
0.0001270771026611328
0.0000950098037719726
0.0000910758972167968
0.0000970363616943359
0.0000969171524047851
0.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 отлично подходит для команд и проектов, которые заботятся, как и мы, о производительности и надежности приложения, а так же удобстве работы с его кодовой базой.

Автор: Антон Грачёв

Источник

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


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