Как стать автором
Обновить
90.7
red_mad_robot
№1 в разработке цифровых решений для бизнеса

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

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

Привет! Это Саша Таболин — старший 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. Делитесь обратной связью — ваши кейсы помогут улучшить библиотеку.


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

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

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

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

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

Публикации

Информация

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