Привет! Меня зовут Александр Миронычев. Я занимаюсь инфраструктурой приложения Яндекс Маркет под 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. Это уже было в «Симпсонах»
Перед тем как начать реализацию собственного решения классической задачи, стоит задать себе несколько вопросов:
- Почему не подходят решения, которые были созданы до вас?
- Почему ваше решение подойдёт лучше?
- Может ли ваше решение подойти кому-то, кроме вас?
Почему не подходят решения, которые были созданы до вас?
Чтобы ответить на этот вопрос, необходимо проанализировать недостатки существующих решений и причины их появления. Возьмём для примера 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")
}
Алгоритм валидации графа и его реализация
В данном разделе описан наш тернистый путь исследования. Его результатом стал удобно конфигурируемый валидатор.
Алгоритм проверки выглядит довольно просто:
- Найти всех наследников типа
Component
, которые присутствуют в коде программы. - Создать экземпляры всех наследников
Component
через рефлексию или иным способом. - Поочерёдно проверить каждый метод каждого экземпляра
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 никак не помогает библиотеке работать быстро. Есть очевидные дополнительные расходы:
- Для получения любой зависимости необходимо сформировать ключ этой зависимости.
- С полученным ключом нужно отправиться в словарь, который лежит в хипе.
- Обращение к словарю приведёт к вычислению хеш-кода ключа.
- По дороге вызовется несколько проверок разных настроек и флагов, а ещё вызов фабрики будет залогирован.
Несмотря на то, что достичь скорости 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
всё работает быстро. Но есть ряд недостатков, с которыми у нас не получилось смириться:
- Как уже было сказано выше, экземпляры
KClass
не кешируются - Библиотека вынуждена вычислять хеш-коды для
KClass
- Обращение к
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-приложении. Не меньше месяца прошло перед тем, как появился действительно работающий валидатор. Многие и многие дни ушли на полный рефакторинг кодовой базы, чтобы избавиться от всего, что мешало фреймворку быть по-настоящему быстрым. Лишь спустя полгода библиотека получила статус стабильной и начала использоваться в повседневной разработке.
Как часто вы боялись что-то начать? Уверен, многим знакомо чувство страха перед неудачей. Страх — это яд. Он появляется в вашей жизни, и чем дольше вы бездействуете, тем сложнее от него избавиться. Пробуйте. Пробуйте и спотыкайтесь. Пробуйте и снова вставайте. Пробуйте до тех пор, пока страх неудачи не сменится азартом успеха.