25
0
0
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
Назад

Feature Flags в библиотеках и фреймворках

Время чтения 18 минут
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
25
0
0
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники

Когда мы говорим о фича-флагах, чаще всего представляем себе веб-приложение, где можно включать и выключать функции для определенных пользователей. Но что делать, если вы разрабатываете библиотеку или внутренний NuGet-пакет, который используют десятки микросервисов в компании?

Меня зовут Дмитрий Бахтенков, и я ведущий .NET-разработчик. В этой статье разберем, какие подходы к feature toggles существуют именно для библиотек и фреймворков, посмотрим на реальные примеры из .NET-экосистемы и попробуем понять, какой подход выбрать в вашей ситуации.

Feature Flags в библиотеках и фреймворках

Что такое feature toggles

Feature toggles (фича-флаг) — это механизм, позволяющий включать или выключать определенную функциональность без изменения кода и повторного деплоя. Применяется почти везде: от обычных приложений до целых подходов к разработке.

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

А в разработке существует подход, называемый Trunk Based Development, при котором все разработчики работают в одной основной ветке. Здесь фича-флаги — необходимость.

Представьте: вы работаете над большой фичей, которая займет две недели. При классическом подходе с feature-ветками вы бы создали отдельную ветку и мержили ее при завершении задачи. Но что, если за это время основная ветка уйдет далеко вперед? Merge hell обеспечен.

С Trunk Based Development вы мержите код каждый день, но скрываете незаконченную функциональность за флагом. Код уже в продакшене, но пользователи его не видят. Когда фича готова — просто включаете флаг. Никаких сложных мержей, конфликтов и долгих релизов.

Подходы к фича-флагам в обычных приложениях

Прежде чем погружаться в библиотеки, проведем небольшой обзор того, как это делается в приложениях.

Своя реализация с хранением в БД

Многие команды пишут свою систему флагов с хранением состояния в базе данных. Это удобно, когда нужна гибкость и привязка к доменным сущностям: конкретным пользователям, ролям, тенантам или чему-то специфичному для вашего бизнеса.

Microsoft.FeatureManagement

Для ASP.NET Core приложений Microsoft предлагает готовую библиотеку Microsoft.FeatureManagement.AspNetCore. Она интегрируется с системой конфигурации, поддерживает фильтры (по времени, проценту пользователей, таргетингу) и даже предоставляет атрибут [FeatureGate] для контроллеров.

// Регистрация в Startup


services.AddFeatureManagement();
 
// Использование в контроллере
[FeatureGate("BetaFeature")]
public IActionResult BetaEndpoint() => Ok("Welcome to beta!");
 
// Или программно
if (await _featureManager.IsEnabledAsync("BetaFeature"))
{
    // Делаем что-то экспериментальное
}

 

Конфигурация задается в appsettings.json:


{
  "FeatureManagement": {
    "BetaFeature": true,
    "GradualRollout": {
      "EnabledFor": [
        {
          "Name": "Percentage",
          "Parameters": { "Value": 50 }
        }
      ]
    }
  }
}

 

Это отличное решение для приложений, но для библиотек оно не подходит: слишком много зависимостей и предположений об инфраструктуре.

Подходы в библиотеках и фреймворках

Когда вы разрабатываете библиотеку, у вас совсем другие ограничения:

  • Хочется сделать что-то максимально простое и без лишних зависимостей.
  • Зависимость от DI в некоторых случаях допустима, а вот зависимость от конфигурации, JSON-файлов и БД — уже перебор.
  • Нужно думать об обратной совместимости и версионировании.

Options и булевые флаги

Самый простой и очевидный подход — создать класс с настройками и булевыми свойствами. Именно так, например, делает System.Text.Json:


var options = new JsonSerializerOptions
{
    AllowOutOfOrderMetadataProperties = true,  // появилось в .NET 9
    RespectNullableAnnotations = true,
    RespectRequiredConstructorParameters = true
};
 
JsonSerializer.Deserialize<MyType>(json, options);

 

Каждый флаг — это просто свойство в классе опций. Потребитель библиотеки создает экземпляр, настраивает нужные флаги и передает в методы.

Плюсы:

  • Простота и очевидность — никакой магии.
  • Нет зависимостей.
  • Хорошо работает с IntelliSense.
  • Легко документировать.

Минусы:

  • Флагов со временем может стать очень много.
  • Нужен строгий контроль над версиями — старые флаги надо удалять, но это ломает обратную совместимость.
  • Все флаги всегда видны.

Параллельный API

Другой подход — не менять поведение существующих методов, а добавлять новые. Старые методы помечаются как [Obsolete], а новые предлагают улучшенное поведение.


// Старый метод
[Obsolete("Use ProcessDataAsync instead")]
public void ProcessData(Data data) { /* старая реализация */ }
 
// Новый метод с улучшенным поведением
public Task ProcessDataAsync(Data data, CancellationToken ct = default) 

    /* новая реализация */ 
}
 

 

Это не совсем фича-флаг в классическом понимании, но решает похожую задачу — позволяет постепенно мигрировать на новое поведение.

Плюсы:

  • Полная обратная совместимость.
  • Четкий путь миграции.
  • Компилятор подсказывает, что пора обновиться.

Минусы:

  • API разрастается.
  • Не подходит для изменений в глубине реализации.

AppContext.SetSwitch

.NET предоставляет глобальный механизм переключателей через AppContext.SetSwitch, и некоторые библиотеки его активно используют.

Делал я как-то пет-проект, и там был функционал оповещений — нужно было хранить время в PostgreSQL. Использовал Npgsql через EF Core, и всё работало… пока не обновил Npgsql до версии 6.0.

После обновления посыпались ошибки:

Cannot write DateTime with Kind=Unspecified to PostgreSQL type 'timestamp with time zone', only UTC is supported.

Оказалось, в Npgsql 6.0 кардинально изменили логику работы с временными типами. Раньше библиотека делала неявные конверсии часовых поясов, теперь требует явного указания UTC. Изменение правильное, с ним поведение более предсказуемое, но оно ломает существующий код.

И вот тут на помощь приходит AppContext.SetSwitch:


// Добавляем в самом начале приложения, до любых операций с Npgsql
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);

 

Одна строчка — и библиотека работает по-старому. Это дало мне время спокойно мигрировать код на новое поведение.

Внутри библиотеки проверка выглядит примерно так:


private static bool EnableLegacyTimestampBehavior
{
    get
    {
        if (AppContext.TryGetSwitch("Npgsql.EnableLegacyTimestampBehavior", out var enabled))
            return enabled;
        return false;  // по умолчанию — новое поведение
    }
}
 
// Где-то в коде обработки timestamp
if (EnableLegacyTimestampBehavior)
{
    // Старая логика с неявными конверсиями
}
else
{
    // Новая строгая логика
}

 

Переключатель можно задать не только в коде, но и через конфигурацию в .csproj:


<ItemGroup>
  <RuntimeHostConfigurationOption 
    Include="Npgsql.EnableLegacyTimestampBehavior" 
    Value="true" />
</ItemGroup>

 

Плюсы:

  • Нет зависимостей — AppContext есть в .NET из коробки.
  • Глобальный переключатель — не надо пробрасывать настройки через весь код.
  • Можно задать через конфигурацию или переменные окружения.
  • Хорошо подходит для breaking changes при обновлении мажорной версии.

Минусы:

  • Глобальность — это и плюс, и минус. Нельзя использовать разное поведение в разных частях приложения.
  • Неочевидность — нужно знать, что такой переключатель существует.
  • Magic strings — опечатка в названии переключателя не вызовет ошибку компиляции.
  • IDE не подскажет, какие переключатели доступны.

Feature Interface (коллекция фич)

Это мой любимый подход для сложных случаев. Идея в том, чтобы определить класс или интерфейс для каждой фичи и собрать их в коллекцию.

ASP.NET Core использует похожий паттерн — IFeatureCollection в HttpContext. Только там он нужен для описания возможностей среды выполнения (поддерживает ли сервер WebSockets, HTTP/2 и т. д.), но сама идея отлично подходит и для фича-флагов.


// Базовый интерфейс для всех фич
public interface IFeature
{
    string Name { get; }
}
 
// Конкретные фичи — просто классы, реализующие IFeature
public class UseImmutableModels : IFeature
{
    public string Name => nameof(UseImmutableModels);
}
 
public class StrictValidation : IFeature
{
    public string Name => nameof(StrictValidation);
    
    // Фича может содержать дополнительные настройки
    public bool ThrowOnFirstError { get; init; } = false;
}

 

Коллекция фич хранит их по типу:


public class FeatureCollection
{
    private readonly Dictionary<Type, IFeature> _features = new();
 
    public void AddFeature<TFeature>(TFeature feature) where TFeature : IFeature
    {
        _features[typeof(TFeature)] = feature;
    }
 
    public bool ContainsFeature<TFeature>() where TFeature : IFeature
    {
        return _features.ContainsKey(typeof(TFeature));
    }
 
    public bool TryGetFeature<TFeature>(out TFeature? feature) where TFeature : class, IFeature
    {
        if (_features.TryGetValue(typeof(TFeature), out var stored))
        {
            feature = (TFeature)stored;
            return true;
        }
 
        feature = default;
        return false;
    }
}

 

Для удобства можно добавить методы расширения, чтобы клиенту библиотеки не нужно было думать о том, какие классы фич вообще существуют:


public static class FeatureCollectionExtensions
{
    public static FeatureCollection WithStrictValidation(
        this FeatureCollection features, 
        bool throwOnFirstError = false)
    {
        features.AddFeature(new StrictValidation { ThrowOnFirstError = throwOnFirstError });
        return features;
    }
}

 

Теперь использование становится максимально простым и читаемым:


services.AddMyLibrary(options =>
{
    options.Features
        .WithStrictValidation(throwOnFirstError: true);
});

 

А если необходимо использовать фичи не глобально, а только при вызове конкретного метода?


public class MyService
{
    private readonly FeatureCollection _globalFeatures;

    public MyService(IOptions<MyLibraryOptions> options)
    {
        _globalFeatures = options.Value.Features;
    }

    public void DoWork(FeatureCollection? overrides = null)
    {
        // Сначала проверяем переопределения, потом глобальные настройки
        var features = overrides ?? _globalFeatures;
        
        if (features.ContainsFeature<UseImmutableModels>())
        {
            // Используем immutable-модели
        }

        if (features.TryGetFeature<StrictValidation>(out var validation))
        {
            // Строгая валидация с учетом настроек
            if (validation.ThrowOnFirstError)
            {
                // ...
            }
        }
    }
}

// Вызов с переопределением
var localFeatures = new FeatureCollection()
    .WithStrictValidation(throwOnFirstError: false); // переопределяем глобальную настройку

service.DoWork(localFeatures);

Плюсы:

  • Очень гибко — фичи могут содержать не только флаги, но и настройки.
  • Строгая типизация — компилятор проверит правильность использования.
  • Можно переопределять на разных уровнях: глобально и для конкретного вызова.
  • Чистый API благодаря extension-методам — пользователь просто вызывает WithSomeFeature().
  • Нет ограничения на количество — добавление новой фичи не меняет существующий API.
  • Хорошо интегрируется с DI, но не требует его.

Минусы:

  • Сложнее в реализации, чем простые флаги.
  • Может быть избыточным для простых случаев.

Рекомендации при разработке библиотек

  1. Для простых случаев используйте класс опций с флагами. Если у вас 2–3 настройки и они вряд ли будут сильно расти — не усложняйте.
  2. Для breaking changes используйте AppContext.SetSwitch. Это позволит пользователям плавно мигрировать на новое поведение. Назовите переключатель понятно, включите версию MyLibrary.V2.EnableStrictMode. Используйте константы, чтобы был единый список фич.
  3. Для сложных сценариев используйте Feature Collection. Особенно если вам нужно переопределение на разных уровнях или фичи содержат больше, чем просто bool.
  4. Документируйте всё. Какие флаги есть, что они делают, какое поведение по умолчанию, когда стоит включать/выключать. Помните мой пример с Npgsql: я нашел решение только потому, что оно было в сообщении об ошибке.
  5. Планируйте жизненный цикл флагов. Флаг не должен жить вечно. Определите, когда вы удалите legacy-поведение, и сообщите об этом пользователям через [Obsolete] или release notes.
  6. Минимизируйте зависимости. Если можно обойтись без внешних пакетов — обойдитесь. Ваши пользователи скажут спасибо.

Благодарю за прочтение статьи! Кроме того, я веду телеграм-канал Flexible Coding, где делюсь своими инженерными инсайтами. Подписывайтесь, буду рад!

Комментарии0
Тоже интересно
Комментировать
Поделиться
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники