Привет, Хабр!
Меня зовут Василий Материкин, я — Android-разработчик в QIWI. В этом посте я расскажу о применении фича-флагов в QIWI Кошельке.
Внедрение Trunk-Based Development и Feature Flags
В процессе работы над большими приложениями, в которых много фич и над которыми трудится большая команда разработчиков, часто приходится сталкиваться с такой проблемой как конфигурация приложения помощью фича-флагов. Мы в QIWI столкнулись с этим два года назад, когда в QIWI Кошельке было создано несколько feature-команд. Выяснилось, что разрабатывать новые фичи с помощью стандартных feature-веток не так удобно, потому что когда над одним проектом работает несколько feature-команд, ветки становятся достаточно объёмными. Потом смержить их в мастер становится довольно непростой задачей, появляются постоянные конфликты.
Поэтому мы решили перейти на Trunk-Based Development (TBD). TBD предлагает работать в небольших ветках и, желательно, чтобы они как можно быстрее были влиты основную ветку. Для этого, конечно же, реализацию нового функционала нужно оформлять небольшими пулл-реквестами, чтобы они быстро проходили ревью и были влиты в основную ветку. Это, в свою очередь, создает другую проблему — когда в главной ветке может появиться код, который ещё не готов к релизу, но при этом нам нужно как-то релизить с этим кодом приложение. Мы же релизы выпускаем достаточно часто. И для этого TBD предлагает пользоваться такими подходами, как Branch by Abstraction (BBA) и Feature Flags (FF).
BBA позволяет выделить какой-либо функционал в отдельную абстракцию. Для этого создается интерфейс, который описывает контракт для работы с этим функционалом. Сразу же можно создать его текущую реализацию, просто скопировав код, который сейчас в продакшене. Также создается другая реализация (уже с новым функционалом) и с ней уже начинается работа. То есть обычно первый пулл-реквест при работе с фичей — это выделение кода, создается две реализации, эти изменения вливаются в главную ветку и далее продолжаем работать уже над новой реализацией. При этом в проекте (в продакшене) используется всё ещё старая реализация, пока мы не закончим работать над фичей.
Например, мы использовали BBA, когда решили реализовать новый дизайн поиска на главной странице в QIWI-кошельке.
Помимо того, что фича-флаги позволяют нам переключаться между старым и новым функционалом, они еще дают следующие возможности:
A/B-тесты. Есть две реализации фичи, есть механизм переключения между этими реализациями, поэтому можно легко переключаться между ними. Это позволяет поделить пользователей на группы и предоставлять им разные варианты фичи.
Если в новом функционале обнаружилась какая-то проблема, то можно просто отключить его с помощью фича-флага, при этом не нужно релизить хотфикс приложения в маркете.
Так как используются интерфейсы, то нескольким фича-командам проще работать над несколькими фичами параллельно.
Создание собственной библиотеки для Feature Flags
Для того чтобы начать использовать Feature Flags в нашем приложении, мы сначала решили посмотреть, какие были на тот момент opensource-решения. В основном это были различные плагины для Gradle, которые позволяют на этапе сборки собрать приложение с каким-то набором флагов, которые попадают, например, в класс BuildConfig в виде констант. Потом в коде мы, соответственно, можем ссылаться на эти флаги и выбирать, какой же из вариантов фичи нам использовать. Но у этого подхода есть несколько минусов:
Нельзя конфигурировать фича-флаги в рантайме, то есть необходимо выпустить новый релиз приложения, чтобы поменять фича-флаг. ,
Нет нескольких источников, из которых можно было бы получать и использовать фича-флаги.
Не совсем удобно, когда в коде есть ветвления, где выбирается в зависимости от флага, какой же функционал использовать.
Поэтому мы решили создать свою собственную библиотеку, которая позволила бы работать с фича-флагами удобно и реализовывать все наши потребности. Вначале библиотека состояла из одного модуля и находилась непосредственно в репозитории приложения. Для асинхронных операций в библиотеке использовался фреймворк RxJava 2. Мы слегка переписали её с использованием Kotlin Coroutines и разделением на несколько модулей.
Библиотека состоит из нескольких модулей:
compiler — кодогенерация на основе аннотаций;
feature-manager — основной модуль библиотеки;
converter-gson, converter-jackson — разные варианты конвертеров на основе популярных библиотек для парсинга фича-флагов из json;
datasource-firebase, datasource-agconnect — интеграция с Firebase Remote Config и Huawei Remote Config.
datasource-remote — пример реализации источника флагов на основе своего собственного http эндпоинта с конфигом.
datasource-debug — позволяет изменять фича-флаги в рантайме с помощью adb (для дебаг сборок).
Рассмотрим, как мы используем эту библиотеку. Способом взаимодействия с библиотекой является интерфейс FeatureManager, который решает все задачи по конфигурации фича-флагов, и у него можно запросить какую-либо фичу. У FeatureManager достаточно простой контракт. Мы можем у него получить фичу, просто указав ее тип, либо можем указать также свою собственную фабрику. Это нужно на тот случай, когда фабрике требуются какие-то зависимости, чтобы создать реализацию фичи.
interface FeatureManager {
fun <Flag> getFeatureFlag(flagClass: Class<Flag>): Flag?
fun <Feature> getFeature(featureClass: Class<Feature>): Feature
fun <Feature, Flag> getFeature(featureClass: Class<Feature>, factory: FeatureFactory<Feature, Flag>): Feature
}
Для того, чтобы объявить фича-флаги, используются data-классы, в которых может быть одно или несколько полей, с помощью которых можно конфигурировать фичу.
@FeatureFlag("search_config")
data class SearchConfigFlag(
@JsonProperty("newSearch")
val newSearch: Boolean,
@JsonProperty("remoteSearch")
val remoteSearch: Boolean)
Также для того, чтобы создать какую-либо из реализаций, используется FeatureFactory — абстрактный класс, от которого нужно отнаследоваться. С помощью фича-флага, который приходит в метод createFeature, мы можем создать какую-то реализацию фичи.
@Factory
class SearchConfigFactory: FeatureFactory<SearchConfig, SearchConfigFlag>() {
override fun createFeature(flag: SearchConfigFlag): SearchConfig = if(flag.newSearch) {
NewSearchConfig(flag.remoteSearch)
}
else {
createDefault()
}
override fun createDefault(): SearchConfig = OldSearchConfig()
}
Чтобы библиотека знала, какие есть фича-флаги и фабрики для них, используются аннотации @FeatureFlag и @Factory. С помощью Annotation-процессора генерируются реестры с флагами и с фабриками.
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class FeatureFlag (
val key: String
)
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class Factory
Нам этот подход подошел, потому что удобно получать фичи из единого класса, который управляет их конфигурацией и не возникает конфликтов при добавлении новых фичей, потому что используется кодогенерация (реестры, которые содержат список фичей и список флагов генерируются автоматически).
Особенности реализации библиотеки
Посмотрим, как это работает под капотом. Точкой входа в библиотеку является интерфейс FeatureManager. Внутри реализации FeatureManager используется интерфейс FeatureCreator, с помощью которого создаются фичи. Чтобы FeatureCreator знал, какие у него сейчас есть актуальные флаги и фабрики, есть классы-реестры для флагов (FeatureFlagRegistry) и фабрик (FeatureFactoryRegistry). Это те самые классы, которые у нас автоматически генерируются с помощью аннотаций.
Репозиторий FeatureRepository позволяет загружать фича-флаги из нескольких источников данных — FeatureFlagDataSource. Например, значения фича-флагов по умолчанию мы кладем в JSON-файл в папке assets. Это нужно на случай, когда у пользователя, допустим, проблемы с сетевым подключением, и невозможно загрузить актуальную конфигурацию с сервера. Также есть источники, которые реализуется на основе сервиса Firebase Remote Config, если мы собираем приложения для Google Play, или сервиса Huawei Remote Config, если загружаем для AppGallery.
Еще один источник загружает флаги с нашего сервера QIWI. Загруженные флаги из сетевых источников попадают в локальный кэш. Оттуда при последующем запуске приложения можно их взять, если с сетью что-то случилось и из сетевых источников не удалось загрузить флаги.
FeatureCreator
FeatureCreator служит для создания объекта фичи. У него также есть возможность запросить создание фичи, просто передав тип, и, в случае необходимости — кастомную фабрику. Имеется возможность обновить в FeatureCreator значения фича-флагов.
interface FeatureCreator {
fun <Feature> createFeature(featureClass: Class<Feature>): Feature
fun <Feature, Flag> createFeature(featureClass: Class<Feature>, factory: FeatureFactory<Feature, Flag>): Feature
}
Для создания фичи находим сначала фабрику для нее. Потом находим актуальный флаг и с помощью фабрики и флага создаем реализацию фичи. Здесь используется рефлексия и сгенерированные реестры для флагов и фабрик. В случае, если нет актуального флага, создаем фичу с конфигом по умолчанию. Естественно, логируем сообщение об этом, чтобы потом разобраться, что пошло не так и почему фича-флага вдруг не оказалось.
class RealFeatureCreator(
private val flagRegistry: FeatureFlagRegistry,
private val factoryRegistry: FeatureFactoryRegistry,
private val storage: ActualFlagsStorage,
private val logger: Logger
) : FeatureCreator {
@Suppress("UNCHECKED_CAST")
override fun <Feature> createFeature(featureClass: Class<Feature>): Feature {
//Find Pair of key and factory class in registry using provided feature class.
val factoryWithKey = factoryRegistry.getFactoryMap()[featureClass]
?: throw getFeatureCreatorException(featureClass.simpleName)
//Obtain feature key.
val featureKey = factoryWithKey.first
// Create instance of factory class using its first constructor.
val factory = factoryWithKey.second.constructors.first().newInstance() as FeatureFactory<Feature, Any>
return createFeature(featureKey, factory)
}
@Suppress("UNCHECKED_CAST")
override fun <Feature, Flag> createFeature(featureClass: Class<Feature>, factory: FeatureFactory<Feature, Flag>): Feature {
//Because factory is already provided, we can use its generic type parameter to get feature flag class.
val featureFlagClass = (factory::class.java.genericSuperclass as ParameterizedType).actualTypeArguments[1] as Class<Feature>
//Obtain feature key using reversed map in flag registry.
val featureKey = flagRegistry.getFeatureKeysMap()[featureFlagClass] ?: throw getFeatureCreatorException(featureClass.simpleName)
return createFeature(featureKey, factory)
}
@Suppress("UNCHECKED_CAST")
private fun <Feature, Flag> createFeature(featureKey: String, factory: FeatureFactory<Feature, Flag>): Feature {
//First we need to find actual feature flag object using key and cast it to Flag
val featureFlag = storage.getFlag(featureKey) as Flag
return if(featureFlag != null) {
factory.createFeature(featureFlag)
} else {
//If feature flag is null we can ask to create feature using its default implementation.
factory.createDefault().also {
logDefaultFlagWarning(featureKey)
}
}
}
FeatureRepository
В имплементации интерфейса FeatureRepository происходит загрузка и слияние флагов из нескольких источников в зависимости от приоритета. Есть метод для сохранения флагов в локальный кэш, чтобы использовать его при следующем запуске приложения в случае каких-либо проблем с сетевым подключением.
internal class RealFeatureRepository(
private val dataSources: List<FeatureFlagDataSource>,
private val cachedFlagsStorage: CachedFlagsStorage,
private val flagRegistry: FeatureFlagRegistry,
private val converter: FeatureFlagConverter,
private val logger: Logger
): FeatureRepository {
override fun getFlags(): Flow<FeatureFlagsContainer> {
val allSources = dataSources.map { dataSource ->
dataSource.getFlags(flagRegistry, converter, logger)
.map<Map<String, Any>, PrioritizedFlags?> { flags ->
PrioritizedFlags(
flags,
dataSource.sourceType,
dataSource.priority,
dataSource.key
)
}.onStart {
emit(null)
}.catch {
emit(
PrioritizedFlags(
emptyMap(),
dataSource.sourceType,
dataSource.priority,
dataSource.key
)
)
}
}
return combine(allSources) { prioritizedFlags ->
val presentFlags = prioritizedFlags
.filterNotNull()
val actualFlags = presentFlags
.sortedBy { pFlags -> pFlags.priority }
.flatMap { pFlags -> pFlags.flags.map { it.key to it.value } }
.toMap()
val sources = presentFlags.map { it.source }.toSet()
val keys = presentFlags.map { it.key }.toSet()
FeatureFlagsContainer(actualFlags, sources, keys)
}
}
override fun getDataSourceKeys(): Set<String> = dataSources.map { it.key }.toSet()
override suspend fun saveFlags(flags: Map<String, Any>) {
cachedFlagsStorage.saveFlags(flags, converter, logger)
}
FeatureFlagDataSource
FeatureFlagDataSource — это интерфейс, у которого есть несколько реализаций для разных источников. В интерфейсе есть поля для уникального ключа данного источника, приоритета флагов из него и типа источника, например, локальный, удаленный, кэш.
interface FeatureFlagDataSource {
/**
* Loads feature flags from this DataSource.
* DataSource can load and update feature flags config any time by emitting value into result [Flow].
*
* @return A [Flow] of [Map] where key is feature key and value is object that represents feature flag.
*
* @param registry A [FeatureFlagRegistry] that can be used to map feature flag key to feature flag class.
* @param converter A [FeatureFlagConverter] that can be used to convert feature flag from Json string into object.
* @param logger A [Logger] that can be used to log any events that will occur while loading feature flags.
*/
fun getFlags(
registry: FeatureFlagRegistry,
converter: FeatureFlagConverter,
logger: Logger
): Flow<Map<String, Any>>
/**
* Unique key for this [FeatureFlagDataSource].
*/
val key: String
/**
* Type of this [FeatureFlagDataSource].
* @see [FeatureFlagsSourceType].
*/
val sourceType: FeatureFlagsSourceType
/**
* Priority for feature flags from this [FeatureFlagDataSource].
*
* If multiple [FeatureFlagDataSource] return flag with same key,
* flag from [FeatureFlagDataSource] with biggest priority will be used.
*/
val priority: Int
}
Для парсинга фича-флага из json в объект предусмотрен интерфейс FeatureFlagConverter. Для его реализации можно было использовать любую библиотеку для парсинга. У нас в проекте используется Jackson, но есть также пример реализации на основе Gson.
Чтобы распарсить json, используя сгенерированный реестр FeatureFlagRegistry, находим нужный класс и парсим с помощью конвертера содержимое json. В случае, если не удалось распарсить, логируем это исключение.
fun FeatureFlagConverter.convertFeatureFlag(
flagKey: String,
flagValue: Any,
sourceKey: String,
registry: FeatureFlagRegistry,
logger: Logger
): Pair<String, Any>? {
val flagClass = registry.getFeatureFlagsMap()[flagKey]
return if (flagClass != null) {
try {
Pair(flagKey, convert(flagValue, flagClass))
} catch (e: Throwable) {
logger.logConverterException(sourceKey, flagKey, e)
null
}
} else {
null
}
}
FeatureFlagRegistry и FeatureFactoryRegistry
FeatureFlagRegistry позволяет получить мапу, где ключом является имя фича-флага, а значением — класс фича-флага. Есть также метод для получения реверсивной мапы.
interface FeatureFlagRegistry {
/**
* Returns map where key is feature key and value is feature flag class.
*/
fun getFeatureFlagsMap(): Map<String, Class<*>>
/**
* Returns map where key is feature flag class and value is feature key.
*/
fun getFeatureKeysMap(): Map<Class<*>, String>
}
FeatureFactoryRegistry позволяет получить мапу, где ключ — класс фича-флага, а значение — класс фабрики для создания фичи.
interface FeatureFactoryRegistry {
/**
* Returns map where key is feature class and value is [Pair] of feature key and factory class.
*/
fun getFactoryMap(): Map<Class<*>, Pair<String, Class<*>>>
}
Реализации этих интерфейсов генерируются на основе аннотаций с помощью процессора аннотаций и библиотеки KotlinPoet. В настоящий момент используется плагин kapt, но планируем миграцию на Kotlin Symbol Processing после того, как другая библиотека с кодогенерацией, Dagger, начнет его поддерживать.
Итоги
Результаты, которых мы достигли благодаря созданию библиотеки:
Удалось достаточно быстро внедрить TBD. Продуктовые команды смогли быстро добавлять фича-флаги и фичи в проект и решить проблему, когда нужно релизить приложение, но при этом какой-то функционал не готов.
Начали проводить A/B-тесты, так как это стало удобно. В основном мы используем Firebase, но в случае необходимости можем легко перейти на другие источники,так как библиотека позволяет их добавлять.
Если в новом функционале вдруг находим какой-то неприятный баг, можем оперативно его отключить без необходимости релизить хотфикс для приложения в маркете.
Часто бывает так, что для решения какой-то задачи уже есть готовое решение в OpenSource, которое вам подойдёт. Но, бывает, как в нашем случае, что не оказалось библиотеки, которая бы полностью удовлетворяла всем нашим требованиям. Мы решили рискнуть, написали свою и успешно ее используем в продакшене. Поэтому не бойтесь экспериментировать и создавать свои собственные решения для упрощения разработки
Если у вас возникли вопросы о нашем опыте внедрения и использования фича-флагов, не стесняйтесь задавать их в комментариях.
Спасибо!