Как стать автором
Обновить
147.68
hh.ru
HR Digital

Укрощение feature-флагов

Время на прочтение21 мин
Количество просмотров7.4K

Всем привет! Меня зовут Паша Стрельченко, и я — 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-конфликты в одном файле
Бесконечные merge-конфликты в одном файле

Когда 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-панель и открытая секция экспериментов

Внутри 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 и обнаруживаем, что в списке экспериментов пусто.

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

Смотрим итоговый байт-код через jadx-gui
Смотрим итоговый байт-код через jadx-gui
  • Остаётся только использовать колонию

// :debug-panel --> DebugPanelViewModel.kt

fun getAllExperiments(): List<Experiment> {
    return ExperimentsCollector().getAllExperiments()
}

Выводы по Colonist

Из плюсов:

  • Это рабочее решение, хоть и используется альфа-версия библиотеки;

Из минусов:

  • Вы не можете отдебажить метод, который взаимодействует с поселенцами, потому что IDE ничего не знает про ваши манипуляции с байт-кодом;

  • У библиотеки магия под капотом. Если обычный annotation processor — это ещё более-менее понятная вещь, вы можете пощупать сгенерированный код, то здесь происходит гораздо больше волшебства (если захотите разобраться с ASM — вот томик документации);

Подведём итоги

  • Во-первых, необязательно страдать от merge-конфликтов, ведь можно от них уйти;

  • Есть множество способов собрать разбросанные по кодовой базе модели в единый список;

  • Если бы у нас был Dagger, этой статьи, возможно, не было =)

  • Совет для вас — не создавайте лишних абстракций, упрощайте жизнь себе и коллегам.

Буду рад вопросам и дополнениям в комментариях.

Маленький опрос для большого исследования

Мы проводим ежегодное исследование технобренда крупнейших IT-компаний России. Нам очень нужно знать, что вы думаете про популярных (и не очень) работодателей. Опрос займет всего 10 минут.

Пройти опрос можно здесь

Полезные ссылки

Теги:
Хабы:
+17
Комментарии9

Публикации

Информация

Сайт
hh.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия