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

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

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

Комментарии (7)


  1. nektopme
    01.10.2021 23:18
    -2

    Только что, на сайте hh.ru, при отклике на вакансию, получил:
    "Произошла ошибка, попробуйте ещё раз".
    Решил посмотреть как в hh.ru делают программы.
    Вижу: носят воду решетом - решают сложность увеличением сложности.

    Так как Ваш продукт лидер и пусть он работает хорошо - не буду иронизировать.
    Проблемы с флагами давно уже решил Шалыто Анатолий Абрамович

    Сила хабра - ошибка исчезла, отклик отправился. Спасибо.


    1. Ztrel Автор
      02.10.2021 21:22
      +1

      Привет.
      Давайте разберём всё по-порядку.

      1. На сайте случилась ошибка
      Если вы отправите нашей службе поддержки как можно больше данных об инциденте, мы получим шанс это исправить.

      2. Решают сложность увеличением сложности
      Укажите, где именно случилось «увеличение сложности»? Мы столкнулись с проблемой merge-конфликтов, довольно дешёвым способом её решили. Захотели собрать разбросанные по кодовой базе флаги — и тоже сделали это довольно простым способом.

      3. Проблемы с флагами давно уже решил Шалыто Анатолий Абрамович
      Пробовал нагуглить его статьи про merge-конфликты, feature toggles, feature flags, не нашёл. На сайте, который вы указали, тоже не нашёл в публикациях подобных статей. Укажите конкретную статью, чтобы и другие читатели могли с ней ознакомиться.

      4. Ошибка исчезла, отклик исправился
      Рад слышать.


  1. nektopme
    02.10.2021 23:39
    -2

    День добрый!

    С удовольствием помогаю совершенствовать полезный сервис hh.ru.

    Есть html версия книги:
    Шалыто А.А. Switch-технология. Алгоритмизация и программирование задач логического управления. СПб.: Наука, 1998., 628 с
    Прям первое вхождение слова флаг.

    Ютуб Шалыто А.А. "Автоматное программирование". 2019 год, с 6ой минуты.

    Вся наука от Шалыто, как устав вооружённых сил - потом и кровью.

    Про увеличение сложности:
    "Есть множество способов собрать разбросанные по кодовой базе модели в единый список;".
    Надо не собирать, надо не разбрасывать.


    1. Ztrel Автор
      03.10.2021 23:06
      +1

      Снова давайте отвечу по пунктам.

      1) Посмотрел видео, по диагонали почитал книгу.
      Моя статья мало имеет отношения к тому, о чём говорит профессор Шалыто =)

      Он рассказывает о построении логики программ на основе стейт-машин / конечных автоматов (или об «автоматной парадигме программирования», если в терминах профессора). Стейт-машины — полезный инструмент, который Android-разработчики давно взяли на вооружение. Мы в hh практически каждую фичу описываем именно с их помощью.

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

      По сути, о таких простых флагах и идёт речь в статье.

      Иногда флаги появляются и для описания переходов внутри стейт-машин, кстати. Немного странно делать 2 стейт-машины в этом случае, гораздо проще описать дополнительный if для одного перехода.

      P.S. Лично мне показалось, что структура тех стейт-машин (автоматов), о которых рассказывает профессор, недостаточно гибкая. В частности, не увидел в структуре способе отдать единичное событие из машины наружу. А такое иногда требуется — например, показать снекбар при переходе в состояние ошибки. Каким-то полем внутри State-а это делать неудобно.

      2) «Надо не собирать, надо не разбрасывать.»

      Недостаточно понятная для зумера сентенция =) Уточните, что имеете в виду, как именно надо было «не разбрасывать»?

      Вот есть проблема: если «не разбрасывать» и помещать код в один-единственный файл (неважно, в виде enum-а, в виде отдельных функций или ещё как-нибудь) — появятся merge-конфликты, от которых мы хотели уйти.

      Если размещать в разных файлах (то бишь, «разбрасывать»), merge-конфликты уходят, но надо будет собирать всё обратно в единый список.

      Хочется увидеть конкретное решение, которое вы предлагаете, реализацию в коде. Необязательно на Java, но тогда есть шанс, что вы используете какую-то специфичную для определённого языка конструкцию, которой просто не существует для Android-разработки.


      1. nektopme
        04.10.2021 00:30
        -1

        Аки богатырь, один из массовки хабра, радею за улучшение hh.ru.

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

        "Не разбрасывать" я имел ввиду: не создавать сущности без надобности.

        Шалыто предлагает не просто отказаться от флагов.
        А так проектировать программу, чтобы у неё не было возможности работать неправильно.
        И объясняет, приведу лишь часть "лайфхаков" (конспект мой):
        на этапе проектирования
        явно определять все
        требуемые состояния и
        применять для их различения 
        только одну многозначную управляющую переменную. 
        После этого необходимо явно определить 
        все возможные переходы между состояниями и 
        построить программу, чтобы она не могла сойти с проложенных "рельс".

        Под автоматным проектированием подразумевают
        не программирование с применением
        автоматов (стейт-машин), а
        технологию создания
        систем со сложным поведением. 
        Хотя базируется на:
        положениях теории автоматов и
        теории автоматического управления. 

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


  1. midery
    06.10.2021 00:01
    +1

    Спасибо за статью, описан достаточно зрелый подход и ресерч мне понравился.

    Только с генерацией экспов - немного не хватило ещё одного приема: codegen'a с помощью Gradle.

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


    1. Ztrel Автор
      06.10.2021 09:49

      Спасибо за комментарий!

      Про codegen через Gradle — расскажите поподробнее, не очень понял, как бы это делалось. Если есть какие-то статейки на примете — отлично.
      Обычно именно с Gradle-ом начинаются «танцы с бубнами» =)

      Зачем мы запускаем дебажное приложение — чтобы менять значение флагов внутри приложения. Это полезно и для тестировщиков, и для разработчиков, так что мы от этого не уйдём. Просто увидеть все эксперименты в проекте можно было бы и навигацией по коду, типо «найти всех наследников интерфейса Experiment» — нам этого недостаточно =)

      Нам нужно было собрать все классы для последующего инстанцирования и использования.