Открываем Konfeature, нашу open-source библиотеку для удобной работы с Feature Flags

от автора

Привет! Это Саша Таболин — старший android-разработчик в red_mad_robot. Мы создали открытую библиотеку Konfeature для оптимизации работы с Feature Flags и хотим поделиться нашей разработкой.

Feature Flags в android-разработке 

«Фича Флаги» — переключатели в коде, которые запускают и останавливают работу его компонентов. Они помогают выстраивать непрерывный CI/CD-процесс и гибко внедрять новые функции в процесс разработки.

Основные плюсы использования Feature Flags:

  • Постепенный rollout — вместо мгновенного выкатывания фичи для всех пользователей можно включать её поэтапно, минимизируя риски;

  • A/B-тестирование — чтобы принимать решения на основе данных сравнения ключевых метрик разных версий;

  • Быстрый откат — если после релиза обнаружилась критическая ошибка, проблемную фичу можно отключить без выпуска Hotfix;

  • Условный доступ — открывает доступ только определённым группам пользователей, например, по геолокации или подписке.

Пять требований к удобной системе Feature Flags

1. Чёткое разделение на remote и local-only флаги

  • Remote флаги — управляются сервером — для динамического включения или выключения фич;

  • Local-only флаги — хардкод или debug-конфиги — для внутреннего тестирования и разработки.

2. Поддержка нескольких remote-источников

В реальных проектах конфигурация может приходить из разных сервисов:

  • Firebase Remote Config — для экосистемы Google;

  • HMS Remote Configuration — для устройств Huawei;

  • Собственный Backend — например, Feature Flags могут быть частью микросервисов.

3. Детальное логирование для отладки

При работе с Feature Fags важно понимать:

  • Какое значение было применено;

  • Откуда оно получено: локальный конфиг, Firebase, кастомный API;

  • Были ли ошибки, например, сервер вернул строку вместо ожидаемого Boolean.

4. Гибкое переопределение значений при разработке

Необходимые инструменты разработчиков и QA, чтобы тестировать разные сценарии:

  • Принудительного включения/выключения флагов в runtime;

  • Эмуляции разных конфигураций, например, проверку поведения для разных стран.

5. Поддержка многомодульности

В современных android-приложениях код делится на модули, поэтому Feature Flags должны поддерживать:

  • Изолированные конфиги для каждого модуля;

  • Независимое управление модулей своими флагами.

Как Konfeature упрощает работу с Feature Flags 

В противовес пяти требованиям к системе Feature Flags, мы выделили пять компонентов нашей библиотеки в рамках открытого API.

  1. FeatureConfig — содержит информацию о группе Feature Flags;

  2. FeatureSource — источник получения значений Feature Flags;

  3. Interceptor — перехватчик значений Feature Flags, может посмотреть и изменить значения;

  4. Spec — содержит информацию обо всех зарегистрированных FeatureConfig;

  5. Logger — логирует события внутри библиотеки.

1. FeatureConfig

Как создать новую группу Feature Flags с помощью FeatureConfig

Создаём новый класс, который наследует FeatureConfig, указав уникальное name и description конфигурации.

class FeatureAConfig: FeatureConfig(       name = "Feature A",       description = "Description of Feature A"   )

Объявляем Feature Flag, используя делегат by toggle, в котором нужно указать:

  • key — ключ для получения значений в FeatureSource;

  • description — описание Feature Flag для документирования;

  • defaultValue — значение по умолчанию;

  • sourceSelectionStrategy — стратегия выбора FeatureSource, по умолчанию стоит SourceSelectionStrategy.None — это local-only флаг.

class FeatureAConfig: FeatureConfig(       name = "Feature A",       description = "Description of Feature A"   ) {          val isDetailedFeatureDescriptionEnabled by toggle(           key = "detailed_feature_a_description",           description = "show detailed description of feature A",           defaultValue = true,         sourceSelectionStrategy = SourceSelectionStrategy.None       )     }

Последнее действие — регистрируем FeatureConfig в Konfeature.

val featureAConfig = FeatureAConfig()  konfeature {       register(featureAConfig) }

И теперь можно прокинуть FeatureAConfig во ViewModel.

class SomeViewModel(featureAConfig: FeatureAConfig): ViewModel() {      init {           if (featureAConfig.isDetailedFeatureDescriptionEnabled) {              showDetailedFeatureDescription()          }      }      private fun showDetailedFeatureDescription() { ... } }

Для многомодульного проекта — на примере DI с Hilt. 

Допустим у нас есть модуль app и два фича-модуля: featureA и featureB.

В модуле featureA объявим Singleton конфигурацию.

@Singleton   class FeatureAConfig @Inject constructor(): FeatureConfig(       name = "Feature A",       description = "Description of Feature A"   ) {          val isDetailedFeatureDescriptionEnabled by toggle(           key = "detailed_feature_a_description",           description = "show detailed description of feature A",           defaultValue = true,           sourceSelectionStrategy = SourceSelectionStrategy.None       )      }

Воспользуемся возможностью Dagger DI и добавим FeatureAConfig в общий Set<FeatureConfig>.

@InstallIn(SingletonComponent::class)   @Module   class FeatureAModule {          @Provides @IntoSet       fun provideFeatureConfig(config: FeatureAConfig): FeatureConfig = config      }

Аналогичным образом создадим конфигурацию в модуле featureB. В app модуле зарегистрируем все конфигурации из Set<FeatureConfig>.

@InstallIn(SingletonComponent::class)   @Module   class AppModule {          @Singleton       @JvmSuppressWildcards         @Provides         fun provideKonfeature(configs: Set<FeatureConfig>): Konfeature {           return konfeature {               configs.forEach(::register)           }       }   }

В результате мы избежим изменений кода вне модуля при добавление новых конфигураций.

2. FeatureSource

FeatureSource — это абстракция источника данных для Feature Flags, она имеет уникальное name, которое используется в SourceSelectionStrategy и отдаёт значение по key.

public interface FeatureSource {      public val name: String      public fun get(key: String): Any? }

Посмотрим на примере реализации для FirebaseRemoteConfig.

  class FirebaseFeatureSource(     private val remoteConfig: FirebaseRemoteConfig ) : FeatureSource {          override val name: String = "FirebaseRemoteConfig"      override fun get(key: String): Any? {         return remoteConfig             .getValue(key)             .takeIf { source == FirebaseRemoteConfig.VALUE_SOURCE_REMOTE }             ?.let { value: FirebaseRemoteConfigValue ->                 value.getOrNull { asBoolean() }                     ?: value.getOrNull { asString() }                     ?: value.getOrNull { asLong() }                     ?: value.getOrNull { asDouble() }             }     }              private fun FirebaseRemoteConfigValue.getOrNull(         getter: FirebaseRemoteConfigValue.() -> Any?     ): Any? {                 return try {             getter()             } catch (error: IllegalArgumentException) {             null         }             } }

Чтобы Feature Flags начали получать свои значения из FirebaseFeatureSource, нужно добавить его при создании Konfeature.

val featureAConfig = FeatureAConfig() val source: FeatureSource = FirebaseFeatureSource(remoteConfig)  val konfeatureInstance = konfeature {     addSource(source)     register(featureAConfig)  }

Теперь в FeatureAConfig при изменении значения поля sourceSelectionStrategy c SourceSelectionStrategy.None на SourceSelectionStrategy.Any или SourceSelectionStrategy.anyOf("FirebaseRemoteConfig")— Feature Flag получит значение из FirebaseRemoteConfig.

Важно отметить, что если добавить несколько FeatureSource, библиотека будет опрашивать их в порядке добавления — до первого FeatureSource, который содержит указанное для Feature Flag значение key.

3. Interceptor

Intereceptor — позволяет посмотреть текущее значение Feature Flag, его источник, а также изменить значение при необходимости. Как и FeatureSource, имеет уникальное name.

public interface Interceptor {      public val name: String      public fun intercept(         valueSource: FeatureValueSource,          key: String,          value: Any     ): Any? }

FeatureValueSource — это источник значения для Feature Flag — FeatureSource, Interceptor или значение по умолчанию.

public sealed class FeatureValueSource {          public class Source(public val name: String) : FeatureValueSource()          public class Interceptor(public val name: String) : FeatureValueSource()          public object Default : FeatureValueSource()  }

Если метод intercept возвращает null, то значение Feature Flag не поменяется.

Рассмотрим реализацию Interceptor на основе DebugPanelInterceptor. Её можно использовать для работы с Debug Panel, например, для включения Feature Flags в Debug сборкаx.

class DebugPanelInterceptor : Interceptor {      private val values = mutableMapOf<String, Any>()      override val name: String = "DebugPanelInterceptor"      override fun intercept(         valueSource: FeatureValueSource,          key: String,          value: Any ): Any? {         return values[key]     }      fun setFeatureValue(key: String, value: Any) {         values[key] = value     }      fun removeFeatureValue(key: String) {         values.remove(key)     } }

Эту реализацию тоже нужно добавить при создании Konfeature.

val featureAConfig = FeatureAConfig() val source: FeatureSource = FirebaseFeatureSource(remoteConfig) val debugPanelInterceptor: Interceptor = DebugPanelInterceptor()  val konfeatureInstance = konfeature {     addSource(source)     register(featureAConfig)      addInterceptor(debugPanelInterceptor) }

Важно отметить, что можно добавить несколько Interceptor, но в отличие от FeatureSource, библиотека опросит все Interceptor в порядке добавления.

4. Spec

Konfeature предоставляет доступ к информации обо всех зарегистрированных конфигурациях, что позволяет получить текущее значение и его источник для любого Feature Flag.

public interface Konfeature {          public val spec: List<FeatureConfigSpec>          public fun <T : Any> getValue(spec: FeatureValueSpec<T>): FeatureValue<T>   }

FeatureConfigSpec здесь — это информация о FeatureConfig.

public interface FeatureConfigSpec {       public val name: String              public val description: String       public val values: List<FeatureValueSpec<out Any>>   }

А FeatureValueSpec — информация о Feature Flag.

public class FeatureValueSpec<T : Any>(       public val key: String,       public val description: String,       public val defaultValue: T,       public val sourceSelectionStrategy: SourceSelectionStrategy   )

Передав FeatureValueSpec в метод getValue можно получить актуальное значение FeatureValue.

public class FeatureValue<T>(       public val source: FeatureValueSource,       public val value: T,   )

Этот механизм позволяет увидеть все Feature Flags и их значения, разбитыми по FeatureConfig в рамках приложения. Так удобно выводить Feature Flags в Debug Panel — с отображением ключа, описания, текущего значения и источника этого значения.

5. Logger

Используется для записи и сохранения событий внутри библиотеки.

public interface Logger {          public fun log(severity: Severity, message: String)          public enum class Severity {           WARNING, INFO       }   }

На данный момент логируются два события:

1. информация о Feature Flag в момент запроса его значения;

Get value 'true' by key 'profile_feature' from 'Source(name=FirebaseRemoteConfig)'

2. ошибка, если FeatureSource вернул по ключу неожиданный тип.

Unexpected value type for 'profile_button_appear_duration': expected type is 'kotlin.Long', but value from 'Source(name=FirebaseRemoteConfig)' is 'true' with type 'kotlin.Boolean'

Рассмотрим реализацию Logger на основе библиотеки Timber.

class TimberLogger: Logger {          override fun log(severity: Severity, message: String) {         if (severity == INFO) {             Timber.tag(TAG).i(message)             } else if (severity == WARNING) {             Timber.tag(TAG).w(message)         }     }          companion object {         private const val TAG = "FeatureFlags"     } }

Как и другие компоненты, TimberLogger нужно добавить при создании Konfeature.

val featureAConfig = FeatureAConfig() val source: FeatureSource = FirebaseFeatureSource(remoteConfig) val debugPanelInterceptor: Interceptor = DebugPanelInterceptor() val logger: Logger = TimberLogger()  val konfeatureInstance = konfeature {     addSource(source)     register(featureAConfig)      addInterceptor(debugPanelInterceptor)     setLogger(logger) }

Стоит отдельно отметить

  • библиотека написана на Kotlin Multiplatform, но на данный момент поддерживается только JVM Target;

  • библиотека позволяет использовать делегат by value для получения значений любого типа, отличного от Boolean. 

Что дальше?

  1. Загляните в документацию Konfeature;

  2. Протестируйте библиотеку на своём проекте — она легко интегрируется с DI-фреймворками вроде Hilt;

  3. Делитесь обратной связью — ваши кейсы помогут улучшить библиотеку.


Над материалом работали

текст — Саша Таболин;

редактура — Игорь Решетников;

иллюстрации — Юля Ефимова.


ссылка на оригинал статьи https://habr.com/ru/articles/911972/