Всем привет! Я Павел, тимлид команды SLA, и занимаюсь оценкой надёжности Авито. В своей прошлой статье я рассказал про стратегии ветвления и Trunk Based Development. Если не читали, переходите по ссылке. А сейчас я хочу рассказать про фича-флаги, которые появляются именно в контексте TBD. 

Что такое фича-флаг

В Авито в рамках TBD-подхода мы создаём фичи, которые показывают готовность проекта к релизу. Нам важно, чтобы они не были видны пользователю, поэтому мы закрываем их защитным тогглом.

Самый простой вариант: if (false):

func (s *Service) Handle {
...
  // мы не хотим, чтобы фича работала пока не готова
  // @see <link to task> is in active development
  if (false) {
    // Добавим функционал со списком покупок, но список всегда пустой
    result["purchases"] = []Purchase{}
  }  
...
}

Согласно TBD-подходу, каждую итерацию фича расширяется и обрастает «мясом»:

func (s *Service) Handle {
...
  // мы не хотим, чтобы фича работала пока не готова
  // @see <link to task> is in active development
  if (false) {
    // делаем запрос в сервис, за списокм покупок
    purchases := s.purchasesRepository.GetLast(limit, userID)
    // Добавим список покупок к ответу
    result["purchases"] = purchases
  }  
...
}

И в момент, например, когда фича частично или полностью работоспособна, мы меняем false на динамический фича-флаг:

const PurchaseListFeature = "some-string"
func (s *Service) Handle {
...
  // проверим, включена ли фича
  if (s.FeatureFlags.IsEnable(PurchaseListFeature)) {
    // делаем запрос в сервис, за списокм покупок
    purchases := s.purchasesRepository.GetLast(limit, userID)
    // Добавим список покупок к ответу
    result["purchases"] = purchases
  }  
...
}

Теперь мы можем динамически включать и выключать фичу. Например, для проведения регрессионных тестов или экстренного выключения, если рискованный функционал начнет «стрелять». Но сами тогглы могут применяться и в отрыве от TBD-процессов.

Типы тогглов

Мартин Фаулер выделяет 4 типа тогглов, исходя из срока их жизни и частоты изменений:

  1. Release Toggles: самые важные тогглы. Они нужны для включения фичей, которые сделаны в TBD-подходе или не прошли процесс регрессионного тестирования.

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

  3. Permission Toggles: тогглы для определённых групп пользователей, которым включается новый функционал. Например для сотрудников или тестировщиков.

  4. Experiment Toggles: тогглы для A/B-тестов.

Типы тогглов по Мартину Фаулеру
Важно!

Типы тогглов по Мартину Фаулеру — это не изолированные категории. Ops-тоггл можно совместить, например, с Experiment-тогглом.

Способы реализации динамических тогглов

Bool-toggles — простые тогглы-константы. 

Константы можно зашивать прямо в код, положить в конфиг, или даже класть в Redis\BD и управлять ими из админки. В них важна простота и только два стейта: true\false.

Они хорошо подходят для Realease-тогглов и для Ops-тогглов:

type Toggles struct {
  constToggles map[string]bool
}

func (t *Toggles) IsEnable(toggleName string) bool {
  return t.constToggles[toggleName]
}

...
if (toggleService.IsEnable(Feature)) {
  // здесь описываем функционал под тогглом
}
...

Percent Toggles включаются с некоторой вероятностью. Удобны как Ops-тогглы для плавной раскатки фичи и регулирования нагрузки. Например, с их помощью мы проверяем запрос через усиленную антибот-систему или семплируем трафик, метрики, трейсы.

Значение процента вероятности можно также хранить в виде константы в коде, конфиге или Redis\BD для управления из админки.

type Toggles struct 
  percentToggles map[string]int
}

func (t *Toggles) IsEnable(toggleName string) bool {
  // значение в диапазоне [0, 100]
  percent := max(0, min(100, t.percentToggles[toggleName]))
  return percent >= rand.Intn(100)+1
}

...
if (toggleService.IsEnable(Feature)) {
  // здесь описываем функционал под тогглом
}
...

Обратите внимание, что «бросок кубиков» — random. То есть, каждый вызов будет приводить к новому результату и будет сходиться к нужной нам вероятности.

Но такой подход может привести к «морганию» визуальной фичи для пользователя: открыл объявление — есть кнопка, обновил — кнопка пропала.

Idempotent Percent Toggles такие же, как процентные тогглы. Но их поведение не меняется с обновлением страницы для одного пользователя. Они подходят для Release-, Ops-, Experiment-тогглов.

Значение процента вероятности можно также хранить в виде константы в коде, конфиге или Redis\BD для управления из админки. А вот критерий разбиения лучше хранить в коде, и не делать конфигурированным. Это сильно унифицирует способы задания параметров тоггла — ровно один скаляр.

type Toggles struct 
  idempotentPercentToggles map[string]int
}

func (t *Toggles) IsEnable(toggleName string) bool {
  // значение в диапазоне [0, 100]
  percent := max(0, min(100, t.idempotentPercentToggles[toggleName]))
  // cчитаем md5 от критерия
  // превращаем  в число
  // считаем остаток от деления на 100
  // ВАЖНО - что для одного и того-же критерия тоггл имеет одно и то-же значение
  return percent >= t.getMd5Mod100(criteria)
}

...
if (toggleService.IsEnable(Feature)) {
  // здесь описываем функционал под тогглом
}
...

Во всех этих типах важно использовать устойчивый критерий, который будет редко меняться. Им могут стать DeviceID, Fingerprint, UserID. Если критерий неустойчивый, то «кубики» будут бросаться каждый раз, и каждый раз поведение для пользователя будет определяться заново.

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

Фича статистики закрыта обычным процентным тогглом

Тогглы в микросервисах

«Куда выносить тогглы?» — частый вопрос, особенно при распиле монолита. Часто бывает, что один тоггл определяет поведение, которое выносится в несколько разных сервисов. К примеру, тоггл «показать статистику» есть и в сервисе для мобильных приложений, и в сервисе для десктопа. Мы в Авито много думали над этим вопросом.

И вариантов тут всего два:

Синхронные тогглы

Первая идея, которая приходит в голову — сделать сервис тогглов и вынести их туда. Тогда микросервисы будут получать состояние тогглов из него. 

Это не лучший выбор, потому что:

  • нет явной связи между сервисами;

  • может получиться асинхронный монолит, а не микросервисы;

  • придётся ждать релиза всех сервисов, где есть тоггл, прежде чем его включать;

  • включение и выключение — всегда огромный риск: не предугадаешь, где и что бомбанёт.

Отдельные тогглы на каждый микросервис

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

Этот подход работает лучше, потому что: 

  • Каждый сервис изначально не рассчитывает на то, что тогглы будут включены одновременно;

  • Нет неявных связей между сервисами;

  • Тестирование тоггла будет происходить внутри одного сервиса и влиять только на него.

  • Ваши сервисы не превращаются в распределенный монолит.

A/B-тесты и Experiment-toggles

Тогглы для проведения A/B-тестов мы обычно реализуем отдельно. По своему поведению они похожи на процентные идемпотентные.

Важно использовать разные механизмы для обычных тогглов и A/B-тестов, потому что A/B-тогглы:

  • всегда обкладываются тонной избыточной аналитики, в отличие от обычных;

  • имеют множество настроек, например: «только платящим», «только для Android»;

  • должны иметь отдельный интерфейс для работы аналитиков и продактов.

Если использовать один и тот же механизм:

  • вы никогда не отделите просто тогглы от A/B,

  • будет сложнее удалять устаревший код,

  • будут постоянные риски создать ОГРОМНУЮ и лишнюю нагрузку на системы аналитики. Например, процентный тоггл легко раскатить на 100%, и начать слать вагон аналитики в 100% случаев вместо каноничных 2%.

Проблемы в использовании фича-тогглов

У тогглов есть не только плюсы, есть и ворох проблем. Но если знать про них, знать подходы к решению, то последствия легко минимизировать.

Накопление тогглов

Со временем количество тогглов может стать просто огромным! Они будут встречаться в самых разных кусочках приложения. Я как-то видел тоггл, который был не актуален более 5 лет, но до сих пор существовал.

Последствия:

  • сложно проводить рефакторинг;

  • будут появляться вложенные друг в друга тогглы;

  • ошибки после включения или отключения не того тоггла: из-за схожих названий, ошибочных описаний.

Решение: нужно перестраивать процессы работы в компании. Появление любого нового тоггла должно приводить и к появлению задачи на его удаление.

Тестирование

У вас же есть тесты, да? А в каком состоянии тогглов вы тестируете? А все состояния тогглов вы проверяете? Привет комбинаторному взрыву :)

Последствия:

  • разное состояние тогглов на prod и dev;

  • тестируешь совсем не то, что будет на проде;

  • комбинаторный взрыв вариативности тогглов — проверить все сочетания просто невозможно

Решение: dev-среда должна синхронизироваться с продом. Самые важные тогглы могут иметь отдельные автотесты, проверяющие их поведение во включенном и выключенном состоянии.

Тогглы неизвестного происхождения

Авторы тогглов могут уволиться. Тогда даже полезный тоггл становится вредным — менять его состояние некому. Да и понять, нужен функционал, который он закрывает, можно только зайдя в историю и разобравшись: кто, когда и под какую задачу его заводил.

Последствия:

  • старые тогглы: неясно, зачем они нужны;

  • новые тогглы: неясно, как они работают.

Решение в интерфейсе управления тогглами нужно отражать весь цикл жизни тоггла: 

  • задача, в которой он добавлен;

  • задача в которой он будет удалён;

  • описание;

  • владелец.

Выводы

Тогглы в концепции фича-флагов необходимы для быстрой скорости Delivery и Trunk Based Development. В современной разработке с её гибкими практиками без этого не обойтись. Это позволяет нам реализовывать TBD-подходы, плавно управлять нагрузкой. А ещё быть гибче, смелее катить новые фичи, зная что они закрыты тогглом. 

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

Предыдущая статья: Критерий Манна-Уитни — самый главный враг A/B-тестов