Всем привет! Меня зовут Паша Стрельченко, и я — Android-разработчик в hh.ru. В этой статье расскажу об укрощении feature-флагов. Если больше нравится аудиовизуальный формат, его можно найти на нашем youtube-канале. В статье я расскажу чуть больше технических подробностей, чем в видео, так что должно получиться интересно.
Что такое feature-флаги? Это обычные булевские флажочки, которыми внутри приложения вы заменяете или закрываете какую-либо функциональность. Например, с помощью одного флажка можно изменить цвет кнопки, с помощью другого – включить или выключить мессенджер внутри приложения.
Если у вас маленькая команда и не очень много feature-флагов, то, скорее всего, вы вообще не сталкивались с проблемами. Однако, если команда растёт, и с каждым днем становится всё больше фичетоглов, то неизбежно возникнут определенные сложности. О них мы и поговорим.
Содержание
Проблемы feature-флагов
Решение merge-конфликтов
Способы сборки feature-флагов по всей кодовой базе
Вручную
Возможности DI-фреймворков
Java Reflections API
Codegen
Выводы
Немного специфики hh.ru
Прежде чем я расскажу о проблемах, с которыми мы сталкивались при работе с feature-флагами, я должен немного погрузить вас в специфику нашего проекта.
Во-первых, мы называем feature-флаги «экспериментами», потому что постоянно «экспериментируем» над нашими пользователями, пока проводим огромное количество A/B тестов. Таким образом мы связали эти два понятия. То есть, включаем какой-то эксперимент = включаем feature-флаг. На протяжении всей статьи я буду оперировать именно понятием "эксперимент".
Во-вторых, уже год назад у нас было около 10 разработчиков, которые постоянно писали продуктовый код, и почти каждый день создавали новые эксперименты в коде.
И вот тут мы столкнулись с определенными проблемами.
Проблемы с feature-флагами
Нашу первую реализацию конфига экспериментов можно было охарактеризовать фразой "Абстракция на абстракции и абстракцией погоняет".
Всё было очень сложно. У нас был базовый модуль экспериментов, в котором мы описывали логику походов в сеть за списком экспериментов и его кэшированием. Было два отдельных модуля, которые содержали все эксперименты двух наших приложений: соискательского и работодательского. И уже от этих двух модулей зависело огромное количество feature-модулей.
И мы выделили для себя три основных проблемы.
Merge-конфликты в общем наборе экспериментов
Как я уже сказал, у нас были модули, в которых мы описывали все наши эксперименты для разных приложений. И выглядело это как гигантский enum — один файл, в котором мы строчка за строчкой описывали эксперименты:
enum class ApplicantExperiments(
val key: String,
val description: String = ""
) {
EXPERIMENT_ANDROID_QUICK_FILTERS_AND_CLUSTERS("android_quick_filters_and_clusters"),
EXPERIMENT_ANDROID_QUICK_FILTERS_AND_ADVANCED_SEARCH("android_quick_filters_and_advanced_search"),
EXPERIMENT_ANDROID_FILTERS_AND_ADVANCED_SEARCH("android_filters_and_advanced_search"),
EXPERIMENT_ANDROID_WANT_TO_WORK_AND_SUBSCRIBE("android_want_to_work_and_subscribe"),
EXPERIMENT_ANDROID_ANDROID_WANT_TO_WORK_ONLY("android_want_to_work_only"),
}
И, серьёзно, почти каждый день мы сталкивались с merge-конфликтами: кто-то смёрджил очередную фичу в develop, кто-то разрабатывает следующую и подмёрдживает к себе develop, и вынужден решать конфликты в одних и тех же файлах.
Когда merge-конфликты раз за разом происходят в одних и тех же файлах, неудивительно, что появляется желание как-то это исправить.
Пересборка приложения при добавлении эксперимента
Помимо public enum-а, который мы меняли при добавлении эксперимента, мы также меняли и специальный публичный интерфейс, который мог использоваться в других модулях.
Когда вы меняете какой-либо публичный интерфейс, доступный другим модулям, вы изменяете ABI модуля, и при изменении ABI будут пересобраны все зависимые от него модули. И поскольку у нас было много feature-модулей, которые зависели от модуля со списком экспериментов, у нас пересобиралось почти всё приложение при добавлении очередного элемента enum-а.
Так быть не должно.
Много кода ради проверки одного флага
Это проблема уже специфичная для android-проекта hh.ru — мы писали очень много кода для проверки одного-единственного эксперимента. Почему так получилось — мы долгое время считали модуль с экспериментами отдельным feature-модулем, который, по правилам нашей кодовой базы, не мог быть напрямую подключен к другому feature-модулю.
Как вы знаете, театр начинается с вешалки (ну или с парковки), в нашем случае, театр начинался с feature-модуля. В нём мы создавали интерфейс, который описывал зависимости этого feature-модуля:
interface FeatureModuleDependencies {
fun isFirstExperimentEnabled(): Boolean
fun isSecondExperimentEnabled(): Boolean
}
Затем мы шли в application-модуль, там описывали класс-медиатор, в котором реализовывали зависимости нужного feature-модуля. Там обращались к DI и доставали значение флага:
class FeatureModuleDependenciesImpl : FeatureModuleDependencies {
override fun isFirstExperimentEnabled(): Boolean =
getExperimentsConfig().isFirstExperimentEnabled()
override fun isSecondExperimentEnabled(): Boolean =
getExperimentsConfig().isSecondExperimentEnabled()
private fun getExperimentsConfig(): ExperimentsConfig =
DI.getAppScope().getInstance(ExperimentsConfig::class.java)
}
В модуле экспериментов у нас был собственный публичный интерфейс (о нём я упоминал выше), к которому мы и обращались из application-модуля:
interface ApplicantExperimentsConfig {
fun isFirstExperimentEnabled(): Boolean
fun isSecondExperimentEnabled(): Boolean
}
Внутри модуля экспериментов мы добавляли реализацию необходимых методов, в которых мы просто проверяли наличие флажочка в локальном кэше:
@InjectConstructor
internal class ApplicantExperimentsConfigImpl(
private val experimentInteractor: ExperimentInteractor
): ApplicantExperimentsConfig {
override fun isFirstExperimentEnabled(): Boolean =
experimentInteractor.isUserAffected(ApplicantExperiments.FIRST_EXPERIMENT.key)
override fun isSecondExperimentEnabled(): Boolean =
experimentInteractor.isUserAffected(ApplicantExperiments.SECOND_EXPERIMENT.key)
}
Ну и, наконец, изменялся enum с экспериментами, туда добавлялись новые элементы с нужными ключами:
internal enum class ApplicantExperiments(val key: String) {
FIRST_EXPERIMENT("first_key"),
SECOND_EXPERIMENT("second_key")
}
Итого — у нас получилась длинная-предлинная церемония добавления и проверки очередного эксперимента. Разумеется, нас это не устраивало.
Какие выводы мы в итоге сделали:
Нам нужны эксперименты в кодовой базе, просто выбросить их мы не можем;
Мы тратим время на merge-конфликты, от них однозначно хотим уйти;
И мы пишем слишком много кода ради одного эксперимента.
Давайте решать эти проблемы!
З.Ы. Подробнее о медиаторах можно послушать в докладе Саши Блинова "Властелин модулей", но я очень рекомендую смотреть обновлённую версию этого доклада в нашем блоге.
Решаем проблемы
Первая проблема, которую мы постарались решить — это merge-конфликты. Та техника, о которой я расскажу, на самом деле подходит для любой ситуации, когда у вас возникают merge-конфликты из-за добавления строчек внутри одного файла.
И техника проста — нужно разделить весь добавляемый контент файла на множество файлов.
Для этого мы ввели интерфейс эксперимента, и разделили элементы enum-а на отдельные классы, реализующие этот интерфейс:
// :core:experiments --> Experiment.kt
interface Experiment {
val key: String
}
// :core:experiments --> FirstExperiment.kt
class FirstExperiment : Experiment {
override val key: String get() = "first_key"
}
// :core:experiments --> SecondExperiment.kt
class SecondExperiment : Experiment {
override val key: String get() = "second_key"
}
По нашему код-стайлу мы размещаем каждый класс в отдельном файле. Это значит, что теперь добавление нового эксперимента никак не будет затрагивать файлы, в которых объявлены другие эксперименты, то есть проблема merge-конфликтов решена.
Затем мы решили упростить процесс проверки эксперимента: для этого создали специальный объект Experiments, куда добавили метод для проверки наличия эксперимента в кэше:
object Experiments {
private var experimentInteractor: ExperimentInteractor? = null
fun init(experimentInteractor: ExperimentInteractor) {
this.experimentInteractor = experimentInteractor
}
fun isUserAffected(experiment: Experiment): Boolean {
return experimentInteractor?.isUserAffected(experimentKey = experiment.key)
?: false
}
}
Для большего удобства можно сделать extension-метод на интерфейсе Experiment, тогда код проверки будет ещё короче:
fun Experiment.isUserAffected(): Boolean {
return Experiments.isUserAffected(this)
}
// ...
if (MyExperiment().isUserAffected()) {
// do something
}
Ах, да, чтобы не тащить код проверок через медиаторы-application-модули и т.п., пусть теперь модуль с базовой логикой экспериментов считается core-модулем, значит его можно подключать напрямую.
Проблему длинных церемоний в коде — тоже решили.
Ну и, наконец, решаем проблему пересборок. Так как теперь каждый эксперимент — это отдельный класс, их можно размещать в РАЗНЫХ модулях и отмечать класс эксперимента модификатором internal.
// :feature:first-feature --> FirstExperiment.kt
internal class FirstExperiment : Experiment {
override val key: String get() = "first_key"
}
// :feature:second-feature --> SecondExperiment.kt
internal class SecondExperiment : Experiment {
override val key: String get() = "second_key"
}
Добавляя новый эксперимент таким образом мы не будем вызывать пересборку половины приложения.
Но возникает вопрос: а что делать, если эксперимент нужно проверять в нескольких разных модулях? Ответ простой: выносите общие модели экспериментов в core-модули, которые будут подключаться к вашим feature-модулям. Здесь главное не увлекаться и не складывать абсолютно все эксперименты в одну корзину, чтобы снова не страдать от лишних пересборок.
// :core:experiments:common --> CommonExperiment.kt
class CommonExperiment : Experiment {
override val key: String get() = "common_key"
}
// :feature:first --> somewhere
CommonExperiment.isUserAffected()
// :feature:second --> somewhere
CommonExperiment.isUserAffected()
Ура, все три проблемы решены!
Собираем feature-флаги
Но перед нами встала новая интересная задача: а как нам теперь собрать разбросанные по нашей кодовой базе классы экспериментов в единый список?
Зачем нам вообще это нужно: у нас в hh для облегчения тестирования приложений есть специальная debug-панель, которая доступна на debug-сборке и на минифицированной debuggable-сборке (мы называем это preRelease-ом).
Внутри debug-панели есть раздел, посвященный экспериментам — в нём мы и наши тестировщики в любой момент можем изменять значения флажков для тестирования той или иной функциональности. Изменили флаг в debug-панели, приложение перезагружается, можем тестировать.
Мы ушли от enum-чика, а значит у нас больше нет встроенной в язык возможности получить разом список экспериментов (раньше это делали через ApplicantExperiment.values()). Плюс к этому, наш сервер не присылает нам ВЕСЬ список возможных ключей экспериментов, он нам присылает только тот список, который активен для того или иного пользователя. А значит в debug-панели нельзя просто взять и отобразить ответ сервера.
Что же делать?
И вот тут начинается магия. Способов оказалось довольно много, и я хочу вам о них рассказать. Я разбил эти способы на несколько групп:
Собрать список вручную
Воспользоваться DI-фреймворком
Вспомнить про Java Reflections API
Сгенерировать нужный код
Для демонстрации я подготовил Github-репозиторий. Каждый из способов, который я рассмотрю в статье, можно пощупать руками в отдельных ветках.
Сборка вручную
Этот способ выглядит сомнительно. Мы бы столкнулись всё с той же проблемой merge-конфликтов: просто на этот раз не в enum-е, а в build.gradle-файлах или же при описании списка, в котором мы бы инстанцировали модели экспериментов:
// :debug-panel/build.gradle.kts
dependencies {
implementation(project(":features:first"))
implementation(project(":features:second"))
implementation(project(":features:third"))
...
// Нужно добавить все модули, где есть модельки экспериментов
// --> ALARM, возможен merge-конфликт
}
// :debug-panel/DebugPanelViewModel.kt
private fun getAllExperiments(): List<Experiment> {
return listOf(
FirstExperiment(),
SecondExperiment(),
...
// ALARM --> возможен merge-конфликт
)
}
От чего ушли, к тому и пришли. Непорядок.
С другой стороны, у этого способа тоже есть плюсы:
Вам не нужны никакие дополнительные зависимости
И реализация очень простая.
Возможности DI-фреймворка
Оказывается, некоторые DI-фреймворки могут собирать объекты, которые объединены каким-то признаком (реализация интерфейса, наследники абстрактного класса и т.п.) в списки.
В частности, у Dagger-а 2 и у Hilt-а есть такая фича, которая называется Mutlibindings. С её помощью вы можете собирать объекты либо в Set, либо в Map с любыми ключами.
Как это делается?
Во-первых, вы создаёте dagger-модули в нужных feature-модулях, в которых описываете метод, отмеченный аннотациями @Provides и @IntoSet, и в реализации метода описываете создание объекта:
// :feature:first
@Module @InstallIn(SingletonComponent::class)
internal class ExperimentOneModule {
@Provides @IntoSet
fun providesExperiment(): Experiment = ExperimentOne()
}
// :feature:second
@Module @InstallIn(SingletonComponent::class)
internal class ExperimentTwoModule {
@Provides @IntoSet
fun providesExperiment(): Experiment = ExperimentTwo()
}
После этого вы готовы заинжектить собранный список в нужное вам место:
// :debug-panel/DebugPanelViewModel.kt
@HiltViewModel
internal class DebugPanelViewModel @Inject constructor(
private val allExperiments: Set<@JvmSuppressWildcard Experiment>
): ViewModel() {
...
}
Тут есть интересный момент: в описании generic-а для Set-а мы добавили аннотацию @JvmSuppressWildcard. Без этой аннотации Dagger будет пытаться найти не совсем тот класс, который вам нужен, он будет искать джавовый интерфейс, отвечающий нотации ? extends Experiment. Добавляем аннотацию, проблемы нет.
Ок, я сказал, что DI-фреймворки умеют собирать объекты в списки. Но что там у Toothpick/Koin? К сожалению, ничего.
Единственное, что есть у этих фреймворков — это issue на гитхабе, в которых разработчики просят добавить эту возможность (issue для Koin-а, issue для Toothpick-а).
Таким образом, если у вас в проекте уже используется Dagger — вам повезло, вы можете пользоваться встроенной фичей для сбора списка экспериментов. Если же используете другой фреймворк — вам подойдут другие способы. У нас в hh используется Toothpick, так что мы продолжили ресёрч.
Java Reflections API
Java Reflections API — это возможность языка Java в рантайме узнавать или изменять поведение приложений. И в этом разделе есть несколько интересных способов сбора множества разрозненных кусочков кода в единый список, о которых я хочу вам рассказать.
ClassLoader + DexFile
Первая связка, о которой я хочу рассказать — это использование ClassLoader-а и android-ного DexFile-а.
В Java, прежде чем использовать какой-то класс, нужно его загрузить, этим и занимается ClassLoader. Dex-файлы — это специальные файлы внутри APK, которые содержат в себе скомпилированный код ваших приложений. Фишка в том, что формат байт-кода, используемый в Android, отличается от стандартного формата в Java, и этот формат называется DEX.
Скомбинировав ClassLoader и DexFile, можно получить то, что нам нужно — список разрозненных экспериментов. Давайте разобьём реализацию на несколько последовательных шагов.
Абстрактный сканер кодовой базы
Первым шагом мы создадим абстрактный сканер нашей кодовой базы, который получит доступ к содержимому DexFile-ов и отфильтрует нужные классы:
abstract class ClassScanner {
protected abstract fun isAcceptableClassName(className: String): Boolean
protected abstract fun isAcceptableClass(clazz: Class<>): Boolean
protected abstract fun onScanResult(clazz: Class<>)
fun scan(applicationContext: Context) {
val classLoader = Thread.currentThread().contextClassLoader
val dexFile = DexFile(applicationContext.packageCodePath)
val classNamesEnumeration = dexFile.entries()
runScanning(classNamesEnumeration, classLoader)
}
}
Мы получили ClassLoader из объекта текущего потока, Затем открываем DexFile, передав туда package name нашего приложения. Так мы получим доступ к списку всех имён классов, доступных в DEX-файле.
abstract class ClassScanner {
protected abstract fun isAcceptableClassName(className: String): Boolean
protected abstract fun isAcceptableClass(clazz: Class<*>): Boolean
protected abstract fun onScanResult(clazz: Class<*>)
...
fun runScanning(classNamesEnumeration: Enumeration<String>, classLoader: ClassLoader) {
while (classNamesEnumeration.hasMoreElements()) {
val nextClassName = classNamesEnumeration.nextElement()
if (isAcceptableClassName(nextClassName)) {
val clazz = classLoader.loadClass(nextClassName)
if (isAcceptableClass(clazz)) {
onScanResult(clazz)
}
}
}
}
}
Теперь нам надо как-то отфильтровать полученный список имён. Современные Android-приложения — это довольно сложные системы, которые подключают к себе тонну различных библиотек и работают на основе объёмной кодовой базы. Поэтому список имён классов может быть очень длинным. Если пытаться загружать каждый класс внутри DEX-а и анализировать, является ли он реализацией нужного нам интерфейса, это может быть довольно долгим процессом.
Поэтому сначала мы фильтруем классы по имени с помощью метода isAcceptableClassName и только после этого загружаем класс и проверяем, подходит ли он нам — проверка будет описана в методе isAcceptableClass.
Если класс нам подходит, вызываем метод-аккумулятор — onScanResult.
Реализация конкретного сканера
Опишем конкретный сканер для наших классов-экспериментов, реализовав наследника ClassScanner:
internal class ExperimentsClassScanner : ClassScanner() {
val experiments = mutableListOf<Experiment>()
override fun isAcceptableClassName(className: String): Boolean {
return className.endsWith("Experiment")
}
override fun isAcceptableClass(clazz: Class<*>): Boolean {
return try {
Experiment::class.java.isAssignableFrom(clazz) && clazz != Experiment::class.java
} catch (ex: ClassCastException) {
false
}
}
override fun onScanResult(clazz: Class<*>) {
experiments += clazz.newInstance() as Experiment
}
}
Чтобы быстро отфильтровать эксперименты по имени, мы вводим соглашение по их именованию — каждый класс эксперимента должен иметь суффикс Experiment. В методе isAcceptableClass мы проверяем, что класс является реализацией интерфейса Experiment, и что проверяемый класс не является самим интерфейсом — это нужно для того, чтобы мы могли создать инстанс класса через clazz.newInstance.
Метод-аккумулятор просто складывает инстанцированные эксперименты в список.
Использование сканера
Запускаем сканер, получаем из него список:
// :debug-panel/DebugPanelViewModel.kt
fun getAllExperiments(): List<Experiment> {
return ExperimentsScanner().apply {
scan(applicationContext)
experiments
}
}
Но возникает вопрос: что отобразится в debug-панели при минифицированной сборке?
buildTypes {
named("release") {
isMinifiedEnabled = true
}
}
Включаем Proguard и обнаруживаем, что в списке экспериментов пусто.
Почему так произошло: потому что названия классов были минифицированы, суффикс Experiment пропал --> ни одного класса, отвечающего нашему условию, внутри DEX-а больше нет. Поэтому, чтобы способ продолжал работать, нужно дописать одну строчку в proguard-rules:
# :app --> proguard-rules.pro
# Keep names of every 'Experiment' implementation
-keepnames class * implements ru.hh.shared.core.experiments.Experiment
После этого всё заработает.
Выводы по ClassLoader-у и DexFile-у
Из плюсов:
Это рабочий способ, несмотря на то, что класс DexFile стал deprecated с API 26.
Но минусов вагон и маленькая тележка:
Способ требует соглашения по неймингу классов-экспериментов. Ко мне часто обращались коллеги, которые не находили свой эксперимент в debug-панели, потому что забывали о необходимом суффиксе Experiment;
Если вы хотите в minified-сборках получить доступ к списку экспериментов, придётся включать keep имён классов-экспериментов. Чем это плохо: потенциальные конкуренты могут посмотреть содержимое вашего APK-файла, увидеть там все названия классов-экспериментов, по именам понять, о чём этот эксперимент, и скопировать логику к себе;
Год спустя я уже не понимаю, почему мы выбрали именно этот способ работы с нашими экспериментами, ведь есть способы проще и лучше =)
З.Ы. По поводу "конкурентов" — чтобы затруднить реверс логики экспериментов в приложении, одной обфускации названий классов недостаточно. По-хорошему, ключами экспериментов должны быть какие-то чиселки, по которым нельзя отдалённо понять суть эксперимента, в релизном APK не должно быть никаких строковых литералов-описаний экспериментов.
ServiceLoader + META-INF/services
ServiceLoader — это специальный утилитный класс в Java, который существует там с незапамятных времён. Этот класс позволяет загружать список нужных объектов (сервисов) с помощью service provider-ов.
Service provider обычно представляет собой текстовый файл, который лежит по адресу/src/resources/META-INF/services/fqn.YourClass. Здесь fqn.YourClass — это полное имя с пакетом вашего общего интерфейса/абстрактного класса. В файле на каждой строчке описывается provider — полное имя класса, который реализует нужный вам интерфейс:
ru.hh.feature_intentions.onboarding.experiments.GhExperimentOne
ru.hh.feature_search.experiments.AnotherExperiment
...
Кстати, совершенно неважно, в каком именно модуле вы создадите такой файлик, Gradle подхватит его, и добавит в итоговый APK.
Описав такой файлик, вы сможете воспользоваться методом ServiceLoader.load(YourClass::class.java), который отдаст вам Iterable с объектами. Главное, чтобы у ваших классов был дефолтный конструктор без параметров:
// :debug-panel/DebugPanelViewModel.kt
fun getAllExperiments(): List<Experiment> {
return ServiceLoader.load(Experiment::class.java).toList()
}
И вроде бы всё хорошо, но камнем преткновения становится как раз этот конфигурационный файл с описанием provider-ов, ведь мы снова приходим к merge-конфликтам, на этот раз — в файле META-INF/services.
Ещё один минус этого файла — он не поддерживает авторефакторинг. Если вы захотите изменить имя класса-эксперимента, перенести в другой пакет, то нужно будет не забыть подправить его имя в META-INF.
Делаем выводы:
Из плюсов: никаких внешних дополнительных зависимостей и простая реализация
Из минусов: проблема ручного заполнения файла META-INF.
Генерация META-INF/services файла
Довольно логичным кажется попробовать автоматически сгенерировать файл META-INF/services, чтобы не страдать от merge-конфликтов. Для этого, конечно же, уже есть несколько готовых библиотек, в частности:
Первая немного устарела, она начинает писать в билд-лог приложения довольно много всяких warning-ов, связанных с версией языка Java.
Таких warning-ов нет, если воспользоваться библиотечкой ClassIndex. Как с ней работать:
Подключаем её через compileOnly + kapt;
Навешиваем аннотацию @IndexSubclasses на нужный базовый интерфейс:
import org.atteo.classindex.IndexSubclasses
@IndexSubclasses
interface Experiment {
val key: String
}
Подключаем библиотеку к каждому feature-модулю, где есть модельки экспериментов, для их индексации:
// library-module/build.gradle.kts
...
dependencies {
compileOnly(Libs.debug.classIndex)
kapt(Libs.debug.classIndex)
}
После этого мы спокойно используем метод ServiceLoader.load, получаем список экспериментов, радуемся.
Но есть, как говорится, нюансы.
Приключение с ClassIndex
В ролике я говорил, что из рассмотренных вариантов, использование библиотеки ClassIndex для сбора классов по кодовой базе, пожалуй, является самым привлекательным способом. Импакта на скорость сборки практически нет, требований к именованию классов-экспериментов — нет, специфических правил для proguard-а тоже не нужно указывать. Недолго думая, я решил мигрировать наш проект с выбранного когда-то ClassLoader-а на ClassIndex.
Однако при использовании этого способа есть интересный момент.
Одним из недостатков способа с ClassLoader-ом я назвал необходимость сохранять имена классов-экспериментов в минифицированном APK. К чему это может привести? К тому, что злоумышленник-конкурент может легко обнаружить все классы-тоглы внутри вашего приложения, по названиям классов понять логику экспериментов, скопировать её, ну и так далее.
Разумеется, хотелось бы избежать аналогичной проблемы при использовании ClassIndex-а. Но вот незадача — ClassIndex по итогу своей работы генерирует файл META-INF/services/fqn.of.your.class.ClassName, который попадает в конечный APK. В этом файле аккуратно перечислены все имена классов-экспериментов, причём при обработке кода R8 будут перечислены уже минифицированные имена классов, что сильно упростит работу злоумышленникам. Что же делать?
Я отыскал только один надёжный способ выключения генерации META-INF-файла: не добавлять в library-модули, в которых, в основном, и объявляют классы-эксперименты, зависимости для ClassIndex-а. Из-за этого annotation processor не видит, что какой-то класс является наследником индексируемого интерфейса — и не добавляет его описание в итоговый META-INF-сервис.
Для этого я добавил простой Gradle-параметр, в зависимости от которого либо добавляем ClassIndex к library-модулю, либо нет:
// library-module/build.gradle.kts
...
dependencies {
if (project.hasProperty("disableIndex").not()) {
compileOnly(Libs.debug.classIndex)
kapt(Libs.debug.classIndex)
}
}
В каждом модуле такое писать неудобно, поэтому это можно вынести в convention-плагин и подключать уже его.
На локальную разработку это никак не влияет — продуктовые разработчики не должны добавлять никаких дополнительных флагов или вообще как-то менять свой привычный флоу работы. А для сборки release APK на CI мы можем легко пробросить параметр:
./gradlew :headhunter-applicant:assembleHhruRelease -PdisableIndex
Таким образом, мы избавились от двух проблем способа с ClassLoader-ом:
Нам не нужно иметь какое-то специальное соглашение по именованию классов-экспериментов;
Нам не нужно сохранять имена классов-экспериментов в минифицированном APK, и злоумышленнику-конкуренту будет чуть труднее разом получить всю информацию.
Profit.
А где приключения-то?
А приключения были, пока я искал рабочий способ отключения генерации META-INF.
Первым делом я решил попробовать воспользоваться возможностью AGP убирать какие-либо файлы из итогового APK. Это можно сделать при помощи объекта PackagingOptions, к которому можно получить доступ через метод DSL-а packagingOptions. Мы настраиваем наши application-модули через convention-плагины, так что я дописал небольшой блок кода туда:
import com.android.build.gradle.BaseExtension
configure<BaseExtension> {
...
packagingOptions {
...
exclude("META-INF/services/ru.hh.shared.core.experiments.Experiment")
}
}
Но даже если написать такой exclude, то в конечном APK указанного файл всё равно будет присутствовать. Я до конца не понимаю почему так происходит, ведь annotation processor должен отрабатывать раньше, чем начинается запаковка файлов в итоговый APK.
Объект packagingOptions почему-то не помог нам в решении ситуации. Я подумал, что может быть это из-за того, что я указываю не минифицированное имя интерфейса-эксперимента. Добавил proguard-правило, которое сохраняло имя интерфейса Experiment, ничего не изменилось;
Пробовал удалять временные файлы META-INF, которые появлялись в разных модулях, в течение работы annotation processor-а. Я заметил, что в каждом модуле, куда я подключал kapt и зависимость для ClassIndex-а, в папке build/tmp/kapt3/classes/release/META-INF/services появлялся файл для ServiceLoader-а, в котором был записан FQN класса-эксперимента. Я пробовал удалять эти временные файлы до сборки итогового APK (через TaskProvider-ы, доступные в android.applicationVariants), но это не помогало;
Гипотетически, форкнув ClassIndex, и добавив в него флаг для annotation processor-а, можно было бы выключать создание META-INF файла для релизного APK, но это уже немного другая история.
Выводы по ServiceLoader-у и META-INF
Из плюсов:
Это самый простой способ из найденных мною;
Не требуется никаких зависимостей в runtime.
Плюс-минус: вам может потребоваться дополнительная работа kapt-а, правда, совсем незначительная, поэтому серьёзного импакта на скорость сборки быть не должно.
Reflections / Scannoation / Classgraph / etc
Третий способ внутри направления Java Reflections API — это использование различных библиотек. Когда вы попробуете загуглить что-то вроде "java Collect classes with annotation across the codebase", вы обнаружите огромное количество библиотек, связанных с Reflections API.
Я не стал рассматривать абсолютно все, а посмотрел на те, по которым была хорошая документация и много информации на StackOverflow — это Reflections и ClassGraph.
Я сэкономлю вам время и просто скажу, что эти библиотеки из коробки не работают под Android. Потому что у Android-а свой формат байт-кода, и когда эти библиотеки пытаются что-то сгенерить, там возникают рантаймовые краши, так что использовать их в runtime не получится.
С другой стороны, в репозиториях на Github-е этих библиотек можно отыскать issues, где разработчики пытаются найти способ завести их под Android. Чётких инструкций вы там не найдёте, но там описывается способ, которым, теоретически, можно воспользоваться — список нужных вам классов можно собрать на момент компиляции ваших приложений. Когда компиляция полностью пройдет, вы в build-скриптах можете воспользоваться библиотекой, сгенерируете промежуточный файл, а потом в рантайме его прочитаете и всё будет офигенно!
Нет. Ну, по крайней мере, у меня не получилось это сделать за разумное время, возможно, вам повезёт больше.
Codegen / Bytecode patching
Это последнее направление, о котором я хотел рассказать — возможности кодогенерации.
В решении нашей задачи могут помочь два способа:
Написать свой собственный annotation processor;
Модифицировать байт-код
Писать свой annotation processor можно, но зачем, если уже есть тот же ClassIndex, который сделает ровно то, что нужно. А вот вторая возможность выглядит интересно.
Для модификации байт-кода существует фреймворк ASM. После того, как ваше приложение уже скомпилировано, итоговый байт-код получен, вы можете его модифицировать как вам нужно.
Коллеги из компании Joom написали библиотеку Colonist, которая идеально подходила под наши нужды. Забавный факт: статья про Colonist вышла буквально на следующий день после начала моего ресёрча.
Несмотря на то, что библиотека так и не вышла из alpha-статуса, ей можно пользоваться. Как это сделать:
Во-первых, нужно будет подключить библиотеку к вашему приложению. Подробнее про это можно почитать на Github-е:
// rootProject --> build.gradle.kts
buildscript {
dependencies {
classpath("com.joom.colonist:colonist-gradle-plugin:0.1.0-alpha9")
}
}
// :app --> build.gradle.kts
plugins {
id("com.android.application")
kotlin("android")
id("com.joom.colonist.android")
}
// :debug-panel --> build.gradle.kts
dependencies {
compileOnly("com.joom.colonist:colonist-core:0.1.0-alpha9")
}
Во-вторых, мы объявляем аннотацию-"колонию". Колония — это место, куда мы будем направлять "поселенцев", то есть, некоторый аккумулятор, который будет собирать нужные нам классы по правилам, которые мы задаём при помощи тонны аннотаций:
// :debug-panel --> ExperimentsColony.kt
@Colony
@SelectSettlersBySuperType(Experiment::class)
@ProduceSettlersViaConstructor
@AcceptSettlersViaCallback
@Target(AnnotationTarget.CLASS)
internal annotation class ExperimentsColony
Мы описали аннотацию-колонию ExperimentsColony, в которую будем пускать определённых поселенцев — наследников класса Experiment (не оговорился, именно абстрактного класса Experiment). Также мы говорим Colonist-у, что поселенцев мы будем обрабатывать через специальный callback.
Остаётся описать класс-колонию / наш коллектор:
// :debug-panel --> ExperimentsCollector.kt
@ExperimentsColony
internal class ExperimentsCollector {
private val experiments = mutableListOf<Experiment>()
init {
Colonist.settle(this)
}
@OnAcceptSettler(colonyAnnotation = ExperimentsColony::class)
fun onAcceptExperiment(experiment: Experiment) {
experiments += experiment
}
fun getAllExperiments(): List<Experiment> = experiments
}
Отмечаем его аннотацией колонии (@ExperimentsColony), добавляем метод для приёма поселенцев (отмечен аннотацией @OnAcceptSettler), готово!
Что происходит под капотом: если компиляции APK посмотреть на него с помощью утилиты jadx-gui, то можно увидеть, что код коллектора отличается от того, что вы написали. Произошла магия изменения байт-кода, и теперь есть специальный кусочек кода, который собирает наследников нужного класса и складывает их в определенный метод.
Остаётся только использовать колонию
// :debug-panel --> DebugPanelViewModel.kt
fun getAllExperiments(): List<Experiment> {
return ExperimentsCollector().getAllExperiments()
}
Выводы по Colonist
Из плюсов:
Это рабочее решение, хоть и используется альфа-версия библиотеки;
Из минусов:
Вы не можете отдебажить метод, который взаимодействует с поселенцами, потому что IDE ничего не знает про ваши манипуляции с байт-кодом;
У библиотеки магия под капотом. Если обычный annotation processor — это ещё более-менее понятная вещь, вы можете пощупать сгенерированный код, то здесь происходит гораздо больше волшебства (если захотите разобраться с ASM — вот томик документации);
Подведём итоги
Во-первых, необязательно страдать от merge-конфликтов, ведь можно от них уйти;
Есть множество способов собрать разбросанные по кодовой базе модели в единый список;
Если бы у нас был Dagger, этой статьи, возможно, не было =)
Совет для вас — не создавайте лишних абстракций, упрощайте жизнь себе и коллегам.
Буду рад вопросам и дополнениям в комментариях.
Маленький опрос для большого исследования
Мы проводим ежегодное исследование технобренда крупнейших IT-компаний России. Нам очень нужно знать, что вы думаете про популярных (и не очень) работодателей. Опрос займет всего 10 минут.
Пройти опрос можно здесь.