Библиотека Scout — быстрый и безопасный DI на Kotlin

  • Александр Миронычев
    Александр Миронычев

    Александр Миронычев

    Ведущий мобильный разработчик

Привет! Меня зовут Александр Миронычев. Я занимаюсь инфраструктурой приложения Яндекс Маркет под Android. Около двух лет назад при работе над модульностью у меня появилось желание написать собственную библиотеку для внедрения зависимостей, которая позволила бы ускорить сборку приложения и упростить процесс модуляризации. Так появился Scout. Сегодня его код мы выложили в открытый доступ.

Эта статья — рассказ о том, как пройти путь от безумной идеи до конкурентоспособного опенсорс-фреймворка. Статья будет полезна тем, кто ищет замену DI-фреймворку в своем проекте, а также тем, кто мечтает написать свою библиотеку, но никак не может начать.

Пролог. Смерть от тысячи порезов

Кодовой базе приложения Яндекс Маркет под Android недавно исполнилось 9 лет. В далёком 2014 году команда из трёх разработчиков, начинавших проект, вряд ли могла поверить, что спустя годы в нём будет участвовать 50 разработчиков одновременно. Долгое время три первопроходца разрабатывали новое приложение для просмотра товаров. Цель — выйти на рынок, архитектура подождёт.

Постепенно команда начала расти, пришли свежие умы. В приложении начал появляться фундамент для будущего маркетплейса. Одной из наиболее обсуждаемых проблем было отсутствие DI-фреймворка. На тот момент Kotlin был экспериментальной технологией, а Dagger отлично показывал себя в проектах любой сложности. Выбор был очевиден — интегрируем библиотеку от Google. Команда не подозревала, сколько чашек кофе будет выпито из-за этого решения.

С каждым месяцем Kotlin гремел в индустрии всё громче. Всё новые и новые авторы восхваляли язык в статьях на «Хабре» и «Медиуме». Всё больше команд решались на использование Kotlin в продакшене. Конечно, у молодой и амбициозной команды не было ни малейшего шанса проигнорировать новую и перспективную технологию.

В проекте появился Kotlin, граф зависимостей становился всё больше, а команда не переставала расти. Пройдёт совсем немного времени, и обсуждение скорости сборки станет регулярным на командных встречах. Каждый новый файл на Kotlin, каждая новая зависимость в графе приложения, каждый новый сабкомпонент — будут понемногу замедлять сборку проекта.

Через два года использования Kotlin, Kapt и Dagger-Android разработка приложения превратится в сплошное ожидание сборки проекта, а пуфики в кабинете станут куда популярнее офисных кресел.

Поезд прибыл на станцию «Невозвратная». Kotlin и Dagger в проекте на долгие годы. Стоянка — 25 минут. Можно пройтись по перрону, подышать свежим воздухом и подумать, как мы вообще умудрились сесть в этот поезд и куда он нас привезёт.

Как сели — понятно. Поезд стоял на вокзале, голова состава была направлена в сторону светлого будущего, а билет стоил совсем недорого. С конечной станцией всё сложнее: про неё не написано ни в билете, ни в расписании на двери проводника. Похоже, никто в этом поезде не знает, куда мы едем.

Глава 1. На комьюнити надейся, а сам не плошай

Все персонажи и события вымышлены. Любые совпадения с реальными людьми случайны.

Тимлид: В проекте нужны кардинальные изменения. Время сборки нужно срочно сокращать. Какие есть варианты?

Парень с ноутом: Может, посмотрим на профиль сборки в Android Studio? Я только что собрал проект. Много времени занимают compileKotlin, kaptKotlin и какой-то kaptGenerateStubsKotlin.

Тимлид: К чему ты ведёшь? Предлагаешь нам отказаться от Kotlin и вернуться к разработке на Java?

Парень с ноутом: Нет, я не об этом. Не понимаю, почему kaptGenerateStubsKotlin занял такое чудовищное количество времени. Что это вообще?

Тот, кому больше всех надо: Это предварительный шаг, чтобы процессор аннотаций работал корректно с Kotlin-классами.

Проницательный джун: У меня эти таски заняли больше половины времени сборки. Получается, генерация кода занимает у нас больше времени, чем всё остальное. А сколько кода мы генерируем?

Парень с ноутом: Сейчас посмотрю, секунду… 70% кода находится в папке generated. Почти весь код внутри сгенерирован с помощью Dagger.

Тимлид: Великолепно! Ты предлагаешь отказаться от Dagger и переписать тысячи файлов?

Парень с ноутом: Да нет же, я просто…

Тот, кому больше всех надо: А почему бы и нет?

Тимлид: Вы что, сговорились? Все проекты используют Dagger. Я слышал, что есть какой-то Koin, но не уверен, что его хоть кто-то использует в крупных проектах.

Проницательный джун: Я читал несколько статей про Koin и пробовал его в пет-проекте. Мне, в принципе, понравилось, но я несколько раз забывал указать фабрику, и приложение падало.

Тот, кому больше всех надо: Мне кажется, нам нужен свой фреймворк. У меня есть классная идея, думаю, я смогу её реализовать…

Тимлид: Опять ты со своими идеями! В прошлый раз вся команда осталась на неделю без работающих тестов.

Глава 2. Это уже было в «Симпсонах»

Перед тем как начать реализацию собственного решения классической задачи, стоит задать себе несколько вопросов:

  1. Почему не подходят решения, которые были созданы до вас?
  2. Почему ваше решение подойдёт лучше?
  3. Может ли ваше решение подойти кому-то, кроме вас?

Почему не подходят решения, которые были созданы до вас?

Чтобы ответить на этот вопрос, необходимо проанализировать недостатки существующих решений и причины их появления. Возьмём для примера Dagger и Koin.

Dagger — эталон библиотеки. Именитые разработчики, надёжный мейнтейнер, прекрасная документация, огромная база пользователей, использование спецификации JSR-330, отличный API для описания графа зависимостей и связей в нём. Основным недостатком можно назвать кодогенерацию, но как же без неё? Придётся писать руками столько же кода, сколько сейчас генерирует процессор аннотаций.

На мой взгляд, основная проблема Dagger — это его попытка быть идеальным. Это почти идеальный самолёт: самый быстрый, самый безопасный, самый знакомый инженерам-механикам. Вот только такой самолёт очень дорог в обслуживании — это естественное последствие желания быть самым-самым. Чтобы обслуживать корректность связей в графе, нужна кодогенерация. Чтобы обеспечивать удобный API, нужно выполнять обработку аннотаций. Чтобы обслуживать модульность, необходимо поддерживать тяжёлый механизм сабкомпонентов.

Вторая проблема Dagger — его академичность. Все связи будущего графа должны однозначно выводиться из написанного разработчиком кода. Пока в вашем коде один-два компонента, никаких проблем нет, поскольку большая часть связей выводится автоматически из модулей и проаннотированных конструкторов. Но как только в вашем проекте начинают регулярно появляться новые компоненты, вы становитесь заложником библиотеки. Теперь вы вынуждены самостоятельно описывать связи, только не между узлами, а между целыми графами зависимостей. Именно эта проблема становится критичной при разбиении кодовой базы на большое количество модулей.

Ответ: Dagger не подходит, потому что на масштабах приложения Яндекс Маркет его обслуживание оказывается слишком дорогим, а процесс разбиения на модули усложняется.

Библиотека Koin — отличный пример опенсорс-продукта. Koin был нацелен на аудиторию Android-разработчиков и побеждал Dagger дешевизной обслуживания. Библиотека смогла нарастить вокруг себя большое комьюнити и стать DI-фреймворком №2.

У Koin длинный список контрибьюторов и множество адаптеров под Android-библиотеки. Она простая, легковесная, предоставляет лаконичный API. Основным недостатком кажется относительная небезопасность использования библиотеки в продакшене из-за отсутствия валидации графа, но это только верхушка айсберга.

Koin готовился к конкуренции с Dagger, из-за чего был вынужден предоставлять основной набор фичей, которые есть у Dagger, а киллер-фичей должна была стать быстрая сборка из-за отсутствия процессинга аннотаций. Так в библиотеке появились qualifiers, мультибиндинги, чрезмерная лаконичность, возможность подключать модули динамически, дефолтный application (чтобы разработчик мог не создавать свой) и прочие удобства. Архитектура библиотеки создаётся на основании требований к её возможностям, и такой обширный запрос привёл к небезопасной и медленно работающей архитектуре.

Ответ: Koin не подходит, потому что его архитектура не позволяет обеспечить должный уровень безопасности и делает граф зависимостей заведомо медленным.

Почему ваше решение подойдёт лучше?

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

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

Ответ: наше решение будет лучше, потому что оно будет легковесным, безопасным и быстрым. Мы будем валидировать граф зависимостей и предоставлять лаконичный API, а разбиение на модули не будет усложнять структуру графа.

Может ли ваше решение подойти кому-то, кроме вас?

Первым делом стоит задуматься о том, насколько вероятно, что существует кодовая база со схожими проблемами. Если ваши проблемы не уникальны, ответ на поставленный вопрос будет зависеть от того, содержит ли решение специфику именно вашей кодовой базы. Дальше дело за мастерством разработчиков решения.

Глава 3. Только не списывай точь-в-точь

Ответ: Если вы собрались написать собственную библиотеку, у меня для вас плохая новость: проектирование библиотек требует опыта. Есть и хорошая новость: этот опыт не обязательно должен быть ваш.

На первый взгляд наш Scout может показаться похожим на Koin. Однако при более детальном рассмотрении становится понятно, что библиотеки кардинально отличаются как по идеологии, так и по реализации.

Этот раздел рассказывает о том, как принимались дизайн-решения и какие проблемы они устраняли. Если вы никогда не работали с Koin, ниже приведен пример описания фабрик. Это наша отправная точка. Поехали.

val myModule = module {
    factory { MyController(get()) }
    single { MyService() }
    single<Service>(named("test")) { ServiceImpl2() }
    single<Service>(createdAtStart=true) { TestServiceImp() }
}

Фабрика singleton

Методfactory — понятно, single — странно, выглядит как фабричный метод для создания типа Single из библиотеки RxJava. Логичнее было бы назвать метод singleton по аналогии с аннотацией @Singleton в Dagger. Так и сделаем.

Фабрика reusable

В Dagger есть аннотация @Reusable, которая позволяет значительно экономить на создании часто используемых объектов. В репозитории Koin есть feature request про необходимость добавления аналога. Поддержим тип фабрики reusable, чтобы исправить этот недочёт.

reusable<MyHelper> {
    MyHelper()
}

Явный вывод типов

Строчка factory { MyController(get()) } объявляет фабрику для типа MyController. Фабрик в графе сотни, а может, и тысячи. Тип MyController может создаваться в десятках разных мест. В проекте Яндекс Маркета опция Find Usages может отрабатывать несколько минут, поэтому на неё рассчитывать не стоит.

Ответ: Во время дизайна API задумывайтесь не только о том, как будут писать код, но и о том, как с этим кодом будут взаимодействовать после.

Чтобы было легко находить фабрику нужного типа, необходимо поддержать надёжный полнотекстовый поиск. Запретим неявный вывод типа объекта, который предоставляет фабрика, чтобы её гарантированно можно было найти через полнотекстовый поиск по строке <MyController>.

factory<MyController> { // line matches with '<MyController>'
    MyController(get())
}

factory { // compile error! can't infer parameter type
    MyService()
}

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

Отказ от qualifiers

Qualifiers — это фича, которая позволяет промаркировать конкретный экземпляр типа в месте объявления фабрики и получить этот экземпляр, используя qualifier при обращении к графу.

single<Service>(named("test")) { ServiceImpl2() }

Опыт использования Dagger показал нам, что qualifiers неудобны в многомодульной кодовой базе, поскольку становятся публичным интерфейсом модулей и значительно усложняют работу с графом зависимостей.

Чтобы разделить экземпляры одного и того же типа в графе зависимостей, лучше создавать классы-обёртки, названия которых однозначно идентифицируют экземпляры в графе.

class IoScheduler(val instance: Scheduler)
class ComputationScheduler(val instance: Scheduler)

reusable<IoScheduler> {
    IoScheduler(Schedulers.io())
}

reusable<ComputationScheduler> {
    ComputationScheduler(Schedulers.computation())
}

Мультибиндинги

В Koin есть возможность собирать несколько объектов одного типа при помощи метода getAll, но нет никакого специального синтаксиса для описания элементов списка. Метод getAll находит все фабрики, создающие запрашиваемый тип, и вызывает их. Случай, когда объект мог бы пригодиться и в качестве индивидуальной зависимости, и в качестве элемента списка, довольно редкий (мы не нашли ни одного в нашем коде).

factory { "foo" }
factory { "bar" }

Учитывая неявный вывод типа при объявлении фабрики, найти все элементы списка в коде становится проблематично.

Чтобы описание списка было явным, добавим специальный тип фабрики element для элементов списка, а также метод collect для получения списка.

element<String> { "foo" }
element<String> { "bar" }
element<String> { "baz" }
factory<Words> { Words(collect()) }

class Words(val words: List<String>)

В Dagger есть ещё один вид мультибиндингов — по ключу. Этот тип довольно часто оказывается полезным, поэтому добавим ещё одну фабрику mapping для описания пары «ключ – значение» и метод associate для получения словаря.

mapping<String, String> { "ru" to "Привет!" }
mapping<String, String> { "en" to "Hello!" }
mapping<String, String> { "fr" to "Bonjour!" }
factory<Translation> { 
    Translation(associate()) 
}

class Translation(val translations: Map<String, String>)

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

Переопределение фабрик

Мультибиндинги подсветили большую проблему в API Koin: никто не контролирует перезапись фабрик.

factory { "foo" }
factory { "bar" }

koin.getAll<String>() // ["foo", "bar"]
koin.get<String>() // "bar"

В условиях работы в большой команде такой API может привести к очень серьёзным проблемам. Из-за разработки в параллельных ветках в графе могут появиться дубли singleton или несколько фабрик с разными стратегиями для одного типа. Добавим в методы регистрации фабрик параметр allowOverride для явного обозначения намерения перезаписи уже добавленной фабрики.

factory<String> { "foo" }
factory<String>(allowOverride = true) { "bar" }

Ленивые зависимости

Несколько лет назад приложение Яндекс Маркета открывалось неприлично долго. Профилировка показала, что основная часть времени на старте тратится на создание объектов и их зависимостей. Тогда мы решили, что по умолчанию будем использовать ленивые зависимости.

class MyUseCase(
    private val repository: Lazy<MyRepository>
    private val otherUseCase: Lazy<OtherUseCase>
)

В Dagger работа с Lazy не требует никаких дополнительных усилий: факт ленивости обрабатывается на этапе кодогенерации. Кажется, нам не обойтись без поддержки Lazy в библиотеке.

Реализуем метод get так, чтобы он проверял запрашиваемый тип и самостоятельно оборачивал зависимость в Lazy, если нужно. Реализовать такое поведение можно при помощи встроенного в Kotlin метода typeOf<T>.

factory<MyUseCase> {
    MyUseCase(
        repository = get(),
        otherUseCase = get()
    )
}

Через несколько месяцев в ходе исследования очередной деградации старта приложения выяснится, что метод typeOf выполняется непозволительно долго, замедляя получение зависимостей в несколько раз. Это будет самый тяжёлый удар по развитию Scout.

Отказ от умного метода get вызовет глобальный рефакторинг графа зависимостей и длительную стагнацию разработки библиотеки. Умный get (который на тот момент обрабатывал Lazy<T>, Provider<T>, List<T>, Map<K,V> и опциональность) заменится на явные сигнатуры get, opt, getLazy, optLazy, getProvider, optProvider, collect, collectLazy, collectProvider, associate, associateLazy, associateProvider.

API — ничто без реализации!

Мы изучили API и модифицировали его под свои требования. Теперь попробуем залезть под капот и сделать API работоспособным.

Совет. Обязательно реализуйте прототип перед тем как начинать проектировать архитектуру. Так вы поймёте, какие особенности нужно будет в неё заложить.

Пару часов спустя у нас есть прототип библиотеки и перечень проблем, которые придётся решить при реализации.

Глава 4. Тебе не хватает изюминки

Совет. Характеристики технического решения во многом определяются архитектурой решения. Закладывайте в фундамент библиотеки принципы, которые помогают достичь требуемых свойств.

В этом разделе рассмотрим наиболее важную и сложную часть библиотеки — дизайн ключевых типов и реализацию механизма валидации.

Делаем граф иммутабельным

Чтобы получить возможность валидировать граф зависимостей, нам придётся сделать его иммутабельным. Это свойство обеспечивает Dagger возможность гарантировать валидность графа во время компиляции и ломается только на стыке с компонентами Android, из-за чего нередко возникают проблемы.

Чтобы добиться неизменяемости графа, конфигурировать его нужно заранее. Пора задуматься о структуре графа.

У зависимостей может быть разный жизненный цикл: какой-то экземпляр должен храниться на протяжении всей работы программы, а какой-то — только во время работы конкретного экрана.

Нам нужен способ управления жизненным циклом зависимости. В Dagger для этого используются аннотации @Scope. Название подходящее, но на практике работа со скоупами очень непрозрачна. Дело в том, что скоуп в Dagger — это лишь маркер того, что тип находится в некоем скоупе. Скоуп никак не контролирует временной интервал существования объекта.

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

Чтобы упростить локализацию проблем с графом, добавим скоупам обязательный параметр name. Создавать скоуп будем при помощи функции с лямбдой-билдером:

val myScope = scope("my-scope") { // this: ScopeBuilder
    // add scope content here
}

Организуем API для регистрации фабрик. Поскольку на момент регистрации фабрик скоупа ещё не существует (иначе он был бы мутабельным), заводим тип Registry, который будет отвечать за хранение фабрик во время формирования будущего скоупа. Наследуем ScopeBuilder от Registry.

val myScope = scope("my-scope") { // this: ScopeBuilder
    // call 'Registry.factory' to register new factory
    factory<MyController> { MyController(get()) } 
    // call 'Registry.singleton' to register new singleton
    singleton<MyService> { MyService() }
}

В графе будут тысячи типов, а скоупов будет всего несколько, поэтому количество кода для регистрации фабрик внутри лямбды метода scope начнёт быстро расти. Чтобы декомпозировать регистрацию фабрик, можно сгруппировать фабрики логически при помощи методов-расширений для типа Registry:

fun Registry.useMyBeans() {
    factory<MyController> { MyController(get()) } 
    singleton<MyService> { MyService() }
}

fun Registry.useOtherBeans() {
    ...
}

val myScope = scope("my-scope") { // this: ScopeBuilder(Registry)
    useMyBeans()
    useOtherBeans()
}

Название beans для узлов графа зависимостей заимствовано из фреймворка Spring. Вы можете использовать любое другое название, о котором договоритесь внутри команды. Главное, чтобы все участники разработки использовали одинаковую конвенцию именования подобных методов.

Чтобы связывать скоупы, добавим метод dependsOn для типа ScopeBuilder:

val globalScope = scope("global-scope") {
    ...
}

val myScope = scope("my-scope") { // this: ScopeBuilder
    dependsOn(globalScope)
    ...
}

Выглядящее безобидным, недостаточно продуманное внедрение дерева скоупов станет причиной факапа. Дубли в дереве скоупов приведут к дублям элементов при вызове метода collect. Один из таких продублированных элементов приведёт к кратному росту нагрузки на сервер.

Делаем граф валидируемым

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

Тем не менее валидация графа в unit-тестах выглядит достаточной мерой, чтобы использование библиотеки стало безопасным. Пусть это не избавит от падений из-за отсутствия фабрики нужного типа во время разработки, но позволит отлавливать проблемы при помощи локального запуска тестов, а также на этапе пулл-реквеста.

Осталось понять, как должен работать тест. Варианты полуавтоматической валидации графа с участием человека рассматривать не будем — человеческий фактор нужно исключить. Чтобы проверить валидность графа, достаточно убедиться, что граф может предоставить все типы, которые у него запрашивают. Метод inject в Koin может быть вызван в любом месте с любым generic-параметром, что на корню ломает возможность вычисления запрашиваемых типов. К тому же собрать вызовы метода из кода — почти не решаемая задача.

Если собрать вызовы методов не получается, можно зайти с другой стороны: собирать не вызовы, а объявления методов. Рефлексия позволяет инспектировать описания методов, присутствующих в коде. На этом приёме и построим нашу валидацию: запретим прямой доступ к типам внутри графа и будем явно описывать методы для обращения к содержимому графа.

Component — базовый класс, предоставляющий доступ к содержимому графа. Название выбрано по аналогии с Dagger, поскольку у его компонентов схожая роль. Этот класс предоставляет protected-методы для обращения к графу. Они совпадают по сигнатуре с методами, доступными внутри фабрик. Конструктор класса Component требует обязательный параметр — скоуп, из которого могут быть получены экземпляры зависимостей. Теперь граф будет доступен только через наследников Component.

class MyComponent : Component(myScope) {
    fun getMyController(): MyController = get()
    fun getMyService(): MyService = get()
    fun getMyPresenter(someEntityId: String) = MyPresenter(
        someEntityId = someEntityId,
        service = get()
    )
}

fun main() {
    val component = MyComponent()
    val controller = component.getMyController()
    val presenter = component.getMyPresenter("test-id")
}

Алгоритм валидации графа и его реализация

В данном разделе описан наш тернистый путь исследования. Его результатом стал удобно конфигурируемый валидатор.

Алгоритм проверки выглядит довольно просто:

  1. Найти всех наследников типа Component, которые присутствуют в коде программы.
  2. Создать экземпляры всех наследников Component через рефлексию или иным способом.
  3. Поочерёдно проверить каждый метод каждого экземпляра Component. В проверку входит вызов метода и вызов созданных во время вызова метода экземпляров Lazy и Provider.

Если все методы всех компонентов прошли проверку — граф валиден.

Чтобы этот алгоритм действительно гарантировал валидность графа, необходимо соблюдать ряд правил при работе с библиотекой.

Чтобы собрать всех наследников класса Component, используем библиотеку ClassGraph. После тестирования множества решений эта библиотека оказалась самой быстрой и удобной в использовании.

val result = ClassGraph()
    .enableClassInfo()
    .ignoreClassVisibility()
    .scan()
val components = result.getSubclasses(Component::class.java)

Все классы компонентов найдены, но код работает долго, поскольку библиотека читает все jar-файлы, попавшие в сборку. Идём оптимизировать.

val result = ClassGraph()
    .enableClassInfo()
    .ignoreClassVisibility()
    .acceptPackages("my.app", "my.lib") // Filter by packages
    .filterClasspathElements { element -> 
        !element.contains("./gradle") &&
        !element.contains("/jetified-") &&
        !element.endsWith("/res.jar") &&
        !element.endsWith("/R.jar")
    }
    .scan()

Теперь сбор классов работает быстро (на масштабах Маркета — пару секунд). Переходим к следующему шагу.

Чтобы создать экземпляры всех компонентов, можно пойти двумя путями: использовать рефлексию либо явно прописывать фабрики для тестовых компонентов. Поскольку нам нужна полная автоматизация, используем рефлексию.

«Хьюстон, у нас проблема! У компонентов могут быть аргументы!»

Хьюстон даже не догадывался, о каком количестве проблем с валидацией ему предстоит узнать. Лишь спустя год валидатор обретёт финальный API, а процесс валидации станет действительно надёжным.

Глава 6. Ты можешь лучше!

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

Не думаю, что кому-нибудь удастся написать runtime-фреймворк, который будет работать быстрее Dagger. Скорость этой библиотеки гарантируется её дизайном. Получение любой зависимости после грамотной кодогенерации превращается в вызов цепочки методов. Таким образом, время тратится только на создание самих экземпляров зависимостей.

Дизайн библиотеки Koin никак не помогает библиотеке работать быстро. Есть очевидные дополнительные расходы:

  1. Для получения любой зависимости необходимо сформировать ключ этой зависимости.
  2. С полученным ключом нужно отправиться в словарь, который лежит в хипе.
  3. Обращение к словарю приведёт к вычислению хеш-кода ключа.
  4. По дороге вызовется несколько проверок разных настроек и флагов, а ещё вызов фабрики будет залогирован.

Несмотря на то, что достичь скорости Dagger не удастся, попробуем улучшить всё, что поддаётся улучшению.

Избавляемся от проверок и логов

Современные процессоры работают очень быстро. Любые атомарные операции выполняются за считанные наносекунды. Тем не менее эти наносекунды — дополнительное время, которое стоит учитывать. Нет проблемы в том, что вы проверите лишнюю ссылку на null. Есть проблема в том, что, написав эту проверку на том участке кода, который вызывается чаще всего, вы будете выполнять дополнительную работу огромное количество раз. Те самые наносекунды на масштабе Яндекс Маркета (6000 узлов в графе зависимостей) превращаются в десятки миллисекунд уже на старте приложения. Согласитесь, неприятно.

Выкидываем все лишние операции! Возможно, какие-то фичи не нужны, и мы можем избавиться от них, увеличив скорость работы графа. Некоторые участки кода можно переписать, чтобы избавиться от необязательных ветвлений (помогает нам в этом подход Branchless Programming). Но что делать с опциональными фичами? Например, необходимость логирования зависит от настроек библиотеки, и полностью убирать логирование из кода нельзя.

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

Добавляем в библиотеку тип FactoryEnhancer, который позволит выполнять дополнительный код вокруг фабрик. Каждая фабрика после своего создания будет проходить через FactoryEnhancer, который будет возвращать либо экземпляр без изменений, либо пропатченную версию (например с добавлением логирования).

Теперь для включения логирования необходимо передать в Scout FactoryEnhancer, который займётся апгрейдом логики фабрик. Такой подход позволяет исключить влияние инструментов отладки на работу релизной сборки.

Нарушаем принципы проектирования

Принципы проектирования позволяют создавать тестируемые кодовые базы, устойчивые к изменениям. Обязательно следуйте принципам проектирования в своих проектах. Оптимизация, о которой сейчас пойдёт речь, может нанести колоссальный вред кодовой базе.

Высокоуровневые языки, такие как C#, Java, Kotlin, избегают концепции указателей, объявляя большинство типов ссылочными. Это означает, что доступ к любому методу или полю экземпляра будет требовать обращения к памяти для получения самого экземпляра. Однако расходы на получение экземпляров можно сократить, ослабив декомпозицию или применив кеширование значений вложенных объектов.

class WrappedAccess(private val valueHolder: ValueHolder) {
    val value get() = valueHolder.value
}

class ReducedAccess(valueHolder: ValueHolder) {
    private val _value = valueHolder.value
    val value get() = _value
}

/**
 * Benchmark               Mode  Cnt  Score   Error  Units
 * WrappedAccess.value     avgt  150  3.932 ± 0.354  ns/op
 * ReducedAccess.value     avgt  150  2.991 ± 0.007  ns/op
 */

Подобные оптимизации привели к тому, что реализация типа Scope в библиотеке выглядит немного монструозной. Когда-то код этого класса был декомпозирован на несколько классов, но бенчмарки внесли свои коррективы.

Облегчаем ключ

Библиотека Koin использует строки в качестве ключей. Такое решение было принято из-за квалификаторов, от которых мы отказались ещё на этапе дизайна API.

// Метод из репозитория koin для построения ключа
fun indexKey(
    clazz: KClass<*>, 
    typeQualifier: Qualifier?, 
    scopeQualifier: Qualifier
): String {
    val tq = typeQualifier?.value ?: ""
    return "${clazz.getFullName()}:$tq:$scopeQualifier"
}

Несмотря на то, что строки — это встроенный в Java тип, работа с ними может приводить к проблемам с производительностью. Во-первых, строки с интерполяцией (то есть использующие ${...}) приводят к созданию новых экземпляров строк. Пытаясь получить экземпляр зависимости из графа, мы вынуждены создавать экземпляр строки. Во-вторых, строки необходимо идентифицировать на основании содержимого. А поскольку из-за интерполяции ключ — это только что созданная строка, хеш-код в ней ещё не посчитан.

Поскольку необходимости в строках в качестве ключей у нас нет, выбираем KClass в качестве идентификатора зависимости. Базовая версия библиотеки использует именно этот тип в качестве ключа.

Позже выяснится, что Kotlin не кеширует экземпляры KClass, и это приведёт к новому витку оптимизаций библиотеки.

fun main() {
    println(String::class.java == String::class.java) // true
    println(String::class === String::class) // false
}

Заменяем класс на число

Может показаться, что KClass — это оптимальный способ идентификации зависимости. Действительно, с KClass всё работает быстро. Но есть ряд недостатков, с которыми у нас не получилось смириться:

  1. Как уже было сказано выше, экземпляры KClass не кешируются
  2. Библиотека вынуждена вычислять хеш-коды для KClass
  3. Обращение к KClass приводит к загрузке класса типа

Загрузка классов — это очень важный этап работы JVM. Тем не менее в подавляющем большинстве задач программисту абсолютно неважно, когда и как происходит загрузка классов. Очевидно, это не наш случай.

День, когда мы выяснили, что KClass не кешируется и загружает код класса в память, не хочется вспоминать. На лицах разработчиков проглядывалась паника: «Почему мы этого не заметили раньше? Мы же делаем огромное количество лишней работы!».

Да, библиотека работает очень быстро, но эти проблемы с KClass... «У нас нет никакого морального права не исправить эти проблемы!» — прозвучало на встрече команды. Через несколько недель после этой встречи у Scout появилась версия с числовыми ключами, а проблема времени инициализации графа была решена окончательно.

Глава 7. Ты на финишной прямой

Scout — это библиотека, появившаяся как результат большого количества проб и ошибок. Пет-проекты с кастомными DI-контейнерами. Изучение каждого нового DI-фреймворка, который появлялся в комьюнити. Бессонные ночи в попытках сделать лучше, чем то, что успел сделать днём. Написанный за 4 часа прототип, на удивление хорошо воспринятый командой. Всё это — части одного пути. И вот куда он привёл.

Проект приложения Яндекс Маркет состоит из более чем 350 gradle-модулей. Все эти модули работают с графом зависимостей при помощи Scout. Он обслуживает 6000 зависимостей, демонстрируя отличные показатели скорости и возможности масштабирования. Каждое принятое решение было пересмотрено множество раз, но API библиотеки на протяжении полутора лет оставался практически неизменным. Мы создавали Scout для себя, и нам нравится результат. Теперь пора им поделиться.

Сегодня в Гитхабе Яндекса появился новый репозиторий — Scout. Помимо библиотеки в нём есть набор инструментов, утилит, тестов и бенчмарков, а также подробная документация. У библиотеки есть две версии: scout с ключами типа KClass и scout-with-compiled-keys с ключами типа int.

Бенчмарки

Перед тем как выкладывать решение в открытый доступ, мы решили проверить, насколько быстрым оно получилось. Первые тесты привели авторов в лёгкий шок. Ниже представлены наиболее интересные бенчмарки Scout и популярных мануал DI-фреймворков: Koin, Kodein и Katana. Также мы решили узнать, насколько близко удалось подобраться к скорости Dagger.

Бенчмарки позволяют сравнить производительность участков кода, а не измерить реальное время выполнения тех же участков кода в реальных условиях. Результаты могут отличаться в зависимости от железа, операционной системы, количества вкладок в браузере и даже температуры в помещении. Мы измерили лишь несколько базовых сценариев и не исключаем, что в других сценариях результаты могут быть противоположными. В репозитории есть вся необходимая инфраструктура, чтобы проверить результаты самостоятельно.

Получение константы

Объявляем фабрики с числом 42 и запрашиваем из графа тип Int. Тест позволяет сравнить накладные расходы при работе одной фабрики. Единицы измерения: наносекунды (1 миллисекунда = 1 000 000 наносекунд). Название теста в репозитории: GetConstantBenchmark.

Benchmark           Mode  Cnt    Score   Error  Units
GetConstant.dagger  avgt   15    0.606 ± 0.060  ns/op
GetConstant.scout   avgt   15    2.829 ± 0.029  ns/op
GetConstant.katana  avgt   15    8.878 ± 0.171  ns/op
GetConstant.kodein  avgt   15   55.541 ± 0.620  ns/op
GetConstant.koin    avgt   15  118.045 ± 2.643  ns/op

Dagger заинлайнил константу из фабрики в метод компонента, работает почти мгновенно. Koin и Kodein потратили много времени на создание ключей: Koin использует строковые ключи, а Kodein — составные объекты.

Получение дерева зависимостей (горячее)

Генерируем дерево зависимостей с указанным количеством узлов и запрашиваем из графа корень этого дерева. Тест позволяет сравнить время получения узла с зависимостями из графа. В тесте участвуют деревья из 5, 25 и 125 узлов. Единицы измерения: наносекунды (1 миллисекунда = 1 000 000 наносекунд). Название теста в репозитории: WarmGet*Benchmark.

Benchmark          Mode  Cnt      Score     Error  Units
WarmGet5.dagger    avgt   15      6.061 ±   0.053  ns/op
WarmGet5.scout     avgt   15     23.210 ±   0.210  ns/op
WarmGet5.katana    avgt   15     71.354 ±   1.133  ns/op
WarmGet5.kodein    avgt   15    380.893 ±  22.466  ns/op
WarmGet5.koin      avgt   15    762.883 ±  12.784  ns/op
Benchmark          Mode  Cnt      Score     Error  Units
WarmGet25.dagger   avgt   15     34.494 ±   0.140  ns/op
WarmGet25.scout    avgt   15    119.257 ±   0.952  ns/op
WarmGet25.katana   avgt   15    360.979 ±   0.901  ns/op
WarmGet25.kodein   avgt   15   2680.446 ±  22.288  ns/op
WarmGet25.koin     avgt   15   4037.316 ± 142.354  ns/op
Benchmark          Mode  Cnt      Score     Error  Units
WarmGet125.dagger  avgt   15    165.933 ±   0.749  ns/op
WarmGet125.scout   avgt   15    590.490 ±   6.947  ns/op
WarmGet125.katana  avgt   15   1999.054 ±  42.825  ns/op
WarmGet125.kodein  avgt   15  21275.740 ± 720.142  ns/op
WarmGet125.koin    avgt   15  25147.055 ± 161.720  ns/op

Dagger генерирует статичные фабрики и инлайнит создание листьев. Тайминги Kodein растут сверхлинейно от размера дерева. Дерево из 5 узлов Kodein получил вдвое быстрее Koin, а на дерево из 125 узлов обе библиотеки потратили сравнимое количество времени. В этом тесте тайминги Scout стабильно втрое меньше таймингов Katana и в 33 раза меньше таймингов Koin для деревьев 5 и 25 узлов. Дерево из 125 узлов Scout получил уже в 42 раза быстрее Koin.

Получение дерева зависимостей (холодное)

Этот тест отличается от предыдущего тем, что каждая итерация запускается в новом процессе. Теперь попытка получения дерева приводит к инициализации всех фабрик и загрузке классов узлов. Тест эмулирует холодный запуск программы/приложения. Единицы измерения: миллисекунды. Название теста в репозитории: ColdGet*Benchmark.

Benchmark          Mode  Cnt    Score   Error  Units
ColdGet5.dagger    avgt   20    8.164 ± 0.880  ms/op
ColdGet5.scout     avgt   20   22.317 ± 1.722  ms/op
ColdGet5.katana    avgt   20   36.781 ± 1.544  ms/op
ColdGet5.koin      avgt   20   66.593 ± 3.903  ms/op
ColdGet5.kodein    avgt   20   75.201 ± 2.217  ms/op
Benchmark          Mode  Cnt    Score   Error  Units
ColdGet25.dagger   avgt   20   19.654 ± 1.436  ms/op
ColdGet25.scout    avgt   20   30.751 ± 1.890  ms/op
ColdGet25.katana   avgt   20   48.565 ± 2.795  ms/op
ColdGet25.koin     avgt   20   78.126 ± 7.376  ms/op
ColdGet25.kodein   avgt   20   92.008 ± 3.004  ms/op
Benchmark          Mode  Cnt    Score   Error  Units
ColdGet125.dagger  avgt   20   50.280 ± 3.445  ms/op
ColdGet125.scout   avgt   20   53.896 ± 2.886  ms/op
ColdGet125.katana  avgt   20   73.844 ± 2.882  ms/op
ColdGet125.koin    avgt   20  103.786 ± 3.629  ms/op
ColdGet125.kodein  avgt   20  140.931 ± 3.802  ms/op

Разброс результатов значительно уменьшился, а единицы измерения сменились с наносекунд на миллисекунды. Дело в том, что непрогретая JVM работает значительно медленнее, а загрузка классов отнимает много времени. На дерево из 125 узлов Dagger и Scout потратили почти одинаковое количество времени. Запуски на бóльших размерах дерева показали, что в этом тесте тайминги Scout асимптотически стремятся к таймингам Dagger.

Инициализация графа

Генерируем очень большой граф зависимостей и получаем экземпляр графа. Это приводит к инициализации графа и может занимать много времени. Возьмём дерево из 5000 узлов. Тест позволяет сравнить затраты на инициализацию большого графа на запуске программы/приложения. Единицы измерения: миллисекунды. В репозитории нет этого теста, поскольку он на несколько минут замедляет компиляцию бенчмарков.

Benchmark           Mode  Cnt    Score     Error  Units
Init5000.dagger     avgt   20    60.611 ±  20.525  ms/op
Init5000.scout(int) avgt   20   591.009 ± 300.563  ms/op
Init5000.scout(cls) avgt   20  1010.567 ± 298.631  ms/op
Init5000.koin       avgt   20  1142.099 ± 327.294  ms/op
Init5000.katana     avgt   20  1231.669 ± 974.310  ms/op
Init5000.kodein     avgt   20  1734.318 ± 650.391  ms/op

Dagger почти не тратит время на инициализацию. Это связано с тем, что классы большинства фабрик загружаются лениво во время первого обращения к ним. Koin и Katana показали схожие результаты. В отчёте присутствует версия Scout с числовыми ключами scout(int) и версия Scout с классами в качестве ключей scout(cls). Версия с числовыми ключами ярко выделяется, обгоняя Koin, Kodein и Katana в два раза и даже больше. Это происходит из-за того, что числовые ключи не загружают классы, для которых объявлены фабрики.

Эпилог. Чудес не бывает

Написание библиотеки — творческий процесс. Он требует большого количества усилий, знаний и опыта. Несколько недель ушло на выбор библиотеки для сбора всех наследников Component из кодовой базы, которая хорошо работала бы как в Big-Java-, так и в Android-приложении. Не меньше месяца прошло перед тем, как появился действительно работающий валидатор. Многие и многие дни ушли на полный рефакторинг кодовой базы, чтобы избавиться от всего, что мешало фреймворку быть по-настоящему быстрым. Лишь спустя полгода библиотека получила статус стабильной и начала использоваться в повседневной разработке.

Как часто вы боялись что-то начать? Уверен, многим знакомо чувство страха перед неудачей. Страх — это яд. Он появляется в вашей жизни, и чем дольше вы бездействуете, тем сложнее от него избавиться. Пробуйте. Пробуйте и спотыкайтесь. Пробуйте и снова вставайте. Пробуйте до тех пор, пока страх неудачи не сменится азартом успеха.

Другие публикации