Привет, Хабр! Меня зовут Василий Материкин, я Android-разработчик в QIWI. В этом посте я расскажу об использовании флагов функций в QIWI Кошельке.
Реализация магистральной разработки и флагов функций
При работе над большими приложениями с множеством функций и над ними работает большая команда разработчиков часто сталкиваешься с проблемой настройки приложения с использованием флагов функций.Мы в QIWI столкнулись с этим два года назад, когда в QIWI Кошельке было создано несколько функциональных команд. Оказалось, что разрабатывать новые фичи с использованием стандартных фиче-веток не так уж и удобно, потому что когда над одним проектом работают несколько фиче-команд, ветки становятся довольно объемными.
Тогда объединить их в мастер становится достаточно сложной задачей и возникают постоянные конфликты.
Поэтому мы решили перейти на Магистральная разработка (TBD).
TBD предлагает работать небольшими ветками и желательно как можно быстрее объединить их с основной веткой.
Для этого, конечно, реализацию нового функционала нужно оформлять небольшими пулл-реквестами, чтобы они быстро прошли проверку и были объединены в основную ветку.
Это, в свою очередь, создает еще одну проблему — когда в основной ветке может появиться код, еще не готовый к выпуску, но при этом нам нужно как-то выпустить приложение с этим кодом.
Мы выпускаем релизы довольно часто.
И для этого TBD предлагает использовать такие подходы, как ветвление по абстракции (BBA) и флаги функций (FF).
BBA позволяет изолировать любую функциональность в отдельную абстракцию.
Для этого создается интерфейс, описывающий контракт на работу с данным функционалом.
Вы можете сразу создать его текущую реализацию, просто скопировав код, который сейчас находится в производстве.
Также создается еще одна реализация (с новым функционалом) и с ней начинается работа.
То есть обычно первый пул-реквест при работе с фичей — это выделение кода, создаются две реализации, эти изменения сливаются в основную ветку, а затем мы продолжаем работать над новой реализацией.
При этом проект (в производстве) по-прежнему использует старую реализацию, пока мы не закончим работу над этой функцией.
Например, мы использовали BBA, когда решили реализовать новый дизайн поиска на главной странице в QIWI кошельке.
Помимо того, что флаги функций позволяют нам переключаться между старым и новым функционалом, они также предоставляют следующие возможности:
- А/Б тесты.
Есть две реализации фичи, есть механизм переключения между этими реализациями, поэтому вы можете легко между ними переключаться.
Это позволяет разделить пользователей на группы и предоставить им разные варианты использования той или иной функции.
- Если в новом функционале обнаружена какая-то проблема, вы можете просто отключить его с помощью флага функции, без необходимости выпуска исправления приложения на рынке.
- Поскольку используются интерфейсы, нескольким фиче-группам проще работать над несколькими фичами параллельно.
Создание собственной библиотеки для флагов функций
Чтобы начать использовать флаги функций в нашем приложении, мы сначала решили посмотреть, какие решения с открытым исходным кодом были доступны на тот момент. По сути, это были различные плагины для Gradle, позволяющие на этапе сборки собрать приложение с некоторым набором флагов, попадающих, например, в класс BuildConfig в виде констант. Затем в коде мы можем соответственно обратиться к этим флагам и выбрать, какой вариант функции нам следует использовать.Но этот подход имеет ряд недостатков:
- Вы не можете настроить флаги функций во время выполнения, то есть вам необходимо выпустить новую версию приложения, чтобы изменить флаг функции.
,
- Не существует нескольких источников, из которых можно получить и использовать флаги функций.
- Не совсем удобно, когда в коде есть ветки, где ты в зависимости от флага выбираешь, какой функционал использовать.
Изначально библиотека состояла из одного модуля и располагалась непосредственно в репозитории приложения.
Для асинхронных операций библиотека использовала фреймворк RxJava 2. Мы его немного переписали, используя Kotlin Coroutines и разделив на несколько модулей.
Библиотека состоит из нескольких модулей:
- компилятор — генерация кода на основе аннотаций;
- менеджер функций — основной модуль библиотеки;
- конвертер-gson, конвертер-джексон — различные варианты конвертеров на основе популярных библиотек для парсинга флагов функций из json;
- источник данных-firebase, источник данных-agconnect — интеграция с Firebase Remote Config и Huawei Remote Config.
- удаленный источник данных — пример реализации источника флагов на основе вашей собственной конечной точки http с помощью config.
- отладка источника данных — позволяет менять флаги функций во время выполнения с помощью 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 }
@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 И @Фабрика .
С помощью процессора аннотаций генерируются реестры с флагами и фабриками.
@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 в папке с ресурсами.
Это нужно в том случае, если у пользователя, скажем, проблемы с сетевым подключением, и невозможно загрузить текущую конфигурацию с сервера.
Также есть исходники, которые реализованы на базе сервиса 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<*>>>
}
Реализации этих интерфейсов генерируются из аннотаций с использованием процессора аннотаций и библиотеки.
Сейчас использую плагин капитан , но мы планируем мигрировать на Обработка символов Котлина после того, как ее начинает поддерживать другая библиотека генерации кода, Dagger.
Полученные результаты
Результаты, которых мы достигли благодаря созданию библиотеки:- Нам удалось реализовать TBD довольно быстро.
Продуктовые команды смогли быстро добавить в проект фич-флаги и фичи и решить проблему, когда приложение нужно выпустить, но какой-то функционал не готов.
- Мы начали проводить A/B-тесты, потому что это стало удобно.
В основном мы используем Firebase, но при необходимости легко можем переключиться на другие источники, благо библиотека позволяет их добавлять.
- Если мы вдруг обнаружим какой-то неприятный баг в новом функционале, мы сможем быстро его отключить, не выпуская исправление для приложения в маркете.
Мы решили рискнуть, написали свой и успешно используем его в производстве.
Так что не бойтесь экспериментировать и создавайте свои собственные решения для упрощения разработки.
Если у вас есть вопросы о нашем опыте реализации и использования флагов функций, не стесняйтесь задавать их в комментариях.
Спасибо! Теги: #Разработка Android #Разработка мобильных приложений #программирование #Kotlin #переключение функций #флаги функций #разработка на основе транков
-
Защита Веб-Сайта
19 Oct, 24 -
It-Мысли, Выпуск 30
19 Oct, 24