Привет! Я Андрей Поповский — техлид в бигтехе. Руковожу разработкой, оптимизирую процессы, провожу собесы и разбираю сложные штуки так, чтобы их можно было сразу применять в коде. Сегодня покажу, как монадический подход из функционального программирования может помочь автоматизировать фичефлаги.
Если зашло — заглядывай в мой телеграм-канал, там ещё больше лайфхаков для разработчиков
Последние несколько лет я работал по Git Flow, и меня всё устраивало. Много веток, понятная структура, классика жанра. Но с ростом команды и продукта проявилась серьёзная проблема - потерянные коммиты между ветками релизов и внезапный регресс функциональности.
Ситуация немного облегчалась тем, что в BitBucket есть автомерж. Он сам следит, чтобы релизы были вмержены по-порядку и создаёт PR при необходимости. Но после того как BitBucket импортозаместили на Сферу - стало сильно сложнее, т.к. автомержей туда пока не завезли 🤷♂️
Когда в очередной раз обнаружилось, что фикс из одного релиза не попал в следующий, я задумался о переходе на Trunk-Based Development (TBD)1 — подход с одной основной веткой и фичефлагами для управления функциональностью.
Главное препятствие для перехода на TBD в нашем случае — длинные циклы разработки и необходимость поддерживать несколько параллельных релизов с разным набором фич. Добавлять фичефлаги на каждую новую функцию технически несложно, но вести их учет и управлять включением/выключением для нужного релиза — задача нетривиальная и скучная. Да, в целом, эта задача у нас ложится на аналитиков и QA, но лучшая работа — та, которую не нужно делать
Disclaimer: На самом деле, после написания этого поста я успел еще подумать и полностью переделать API. Но хочу рассказать о пройденном пути и как развивалось моё видение
Первый заход
Ключевая идея, которая пришла мне в голову - можно включать фичу автоматически, ориентируясь на версию из package.json. Мы почти наверняка знаем в каком релизе вы выводим функционал, поэтому почему-бы не сравнить версии используя semver, и на основе этого выбрать вариант
const logger = releaseOn({
currentVersion: PACKAGE_VERSION,
targetVersion: "3.6.0",
legacy: console,
release: prettyLogger,
});
Реализация тут прямолинейная:
👆 Развернуть
import semver from "semver";
type ReleaseOnConfig<T, U> = {
currentVersion: string;
targetVersion: string;
legacy: T;
release: U;
};
function releaseOn<T, U>(params: ReleaseOnConfig<T, U>): T | U {
if (semver.lt(params.currentVersion, params.targetVersion)) {
return params.legacy;
} else {
return params.release;
}
}
✅ Автоматическое включение функционала при обновлении версии
✅ Предсказуемое поведение на основе семантического версионирования
✅ Удобно для фичей, которые должны быть целиком включены или выключены в определенной версии
❌ Каждый переключатель нужно указывать отдельно — бойлерплейтно
❌ Потенциальные проблемы с типизацией при использовании разных типов данных для legacy и release
❌ При изменении планов релиза нужно обновлять все переключатели руками 😒
Будем понемногу разбираться с минусами и начнём по порядку
Боремся с бойлерплейтом
Одно место у нас точно постоянное - версия самого приложения. Ни к чему передавать её в каждом вызове. Значит, вынесем в фабрику, которая будет хранить значение в замыкании
const releaseOn = makeReleaseToggle({ currentVersion: PACKAGE_VERSION });
const logger = releaseOn({
targetVersion: "3.6.0",
legacy: console,
release: prettyLogger,
});
const value = releaseOn({
targetVersion: "3.6.0",
legacy: oldValue,
release: newValue,
});
logger.log(value);
Уже немного лучше, но очевидно, что если мы выводим более-менее крупный кусок функционала, то связанных точек, где нужно сделать переключение по одному флагу может быть множество. Штош, фабрика это хорошо, но фабрика фабрик - лучше 🪄
const releaseOn = makeReleaseToggle({ currentVersion: PACKAGE_VERSION });
const toggle = releaseOn("3.6.0");
const logger = toggle({ legacy: console, release: prettyLogger });
const value = toggle({ legacy: oldValue, release: newValue });
logger.log(value);
Реализация опять-же не должна вызвать затруднений:
👆 Развернуть
type ReleaseToggleConfig = { currentVersion: string };
const makeReleaseToggle = (config: ReleaseToggleConfig) => {
const { currentVersion } = config;
return (targetVersion: string) => {
const shouldUseRelease = semver.gte(currentVersion, targetVersion);
return <L, R>(options: { legacy: L; release: R }): L | R => {
return shouldUseRelease ? options.release : options.legacy;
};
};
};
✅ Меньше бойлерплейта
✅ Централизованное управление переключениями для связанных компонентов через общий toggle
❌ Нет возможности легко тестировать варианты без изменения версии
❌ Отсутствует понимание того, какие флаги активны в текущей сборке
❌ Остается риск "мертвого" кода, если забыть удалить legacy-логику после релиза и стабилизации
❌ Проблемы с типизацией при использовании разных типов данных для legacy и release
Устраняем неочевидности
Решим первые три проблемы разом, добавив:
- Описательные метаданные — каждый переключатель будет содержать описание
- Автоматическое логирование — при создании переключателя его состояние фиксируется в консоли
- Механизм принудительного переключения — через явное указание состояния флага, вместо версии для активации
- Умные предупреждения — система автоматически уведомит нас об устаревших фичефлагах
Вот как это будет выглядеть при использовании:
const releaseOn = makeReleaseToggle({ currentVersion: "3.7.0" });
// logs `'TASK-1234 For 3.8.0' not yet released`
const toggle = releaseOn({
targetVersion: "3.8.0",
description: "TASK-1234 For 3.8.0",
});
// logs `Use release version for 'TASK-2345 For 3.7.0'`
const toggle = releaseOn({
targetVersion: "3.7.0",
description: "TASK-2345 For 3.7.0",
});
// logs `Use release version for 'TASK-0123 For 3.6.0'`
// warns `Feature toggle target for 'TASK-0123 For 3.6.0' is less than current version. Consider to remove toggle`
const toggle = releaseOn({
targetVersion: "3.6.0",
description: "TASK-0123 For 3.6.0",
});
declare const FF_OVERRIDE_VARIANT: 'legacy' | 'release';
const toggle = releaseOn({
overideVariant: FF_OVERRIDE_VARIANT,
targetVersion: "3.6.0",
description: "TASK-0123 For 3.6.0, but may be overrided in runtime",
});
Реализация чуть более замороченная:
👆 Развернуть
type ReleaseToggleConfig = { currentVersion: string };
type ToggleOptions = {
targetVersion: string;
description: string;
overrideVariant?: "legacy" | "release";
};
// Improved implementation with logging capabilities
const makeReleaseToggle = (config: ReleaseToggleConfig) => {
const { currentVersion } = config;
return (options: ToggleOptions) => {
const { targetVersion, description, overrideVariant } = options;
// Determine if the feature should be released based on version comparison
const versionDiff = semver.compare(targetVersion, currentVersion);
const isReleased = versionDiff <= 0;
// Log appropriate messages based on release status
if (overrideVariant) {
console.log(`[TOGGLE] Override to '${overrideVariant}' version for '${description}'`);
} else if (isReleased) {
console.log(`[TOGGLE] Use release version for '${description}'`);
if (versionDiff < 0) {
console.warn(
`[TOGGLE] Feature toggle target for '${description}' is less than current version. Consider removing toggle`
);
}
} else {
console.log(`[TOGGLE] '${description}' not yet released`);
}
// Return the toggle function that selects between legacy and release implementations
return <L, R>(options: { legacy: L; release: R }): L | R => {
if (overrideVariant === "legacy") return options.legacy;
if (overrideVariant === "release") return options.release;
return isReleased ? options.release : options.legacy;
};
};
};
Здорово? Офигенно!
Настало время заняться типизацией!
Управляемся с типами на выходе: знакомимся с монадами
Наконец-то мы добрались до монад! Я давно пользуюсь FP-подходами, но долгое время не мог врубиться что такое эти ваши монады – всё объяснялось либо чересчур абстрактно, либо примитивно до бессмысленности. Недавно у меня наконец-то щёлкнуло, и я целый пост об этом написал.
Что такое монада простыми словами?
Монада — это контейнер для значения с дополнительным контекстом. По сути, это просто объект, который:
- Умеет заворачивать обычное значение в себя (метод
of
) - Позволяет применять к внутреннему значению функции, не разворачивая его каждый раз вручную (метод
chain
)
Примеры монад, которыми мы постоянно пользуемся:
-
Maybe
— значение, которое может отсутствовать -
Either
— значение, которое может быть либо корректным, либо ошибкой -
Promise
— не совсем монада, но очень похоже (и вы с ним уже точно работали)
Весь сок именно в Either
— он выглядит идеальным для нашей задачи переключения между двумя реализациями!
Either - выглядит очень интересно!
Either позволяет очень удобно выбирать из двух вариантов. Но есть нюанс — у нас нет правильного и неправильного варианта.
В Either у нас Left/Right, а мы назовём Legacy/Release. Оммаж, так сказать.
type Legacy<L> = { legacy: L; release?: never };
type Release<R> = { legacy?: never; release: R };
type Either<L, R> = NonNullable<Legacy<L> | Release<R>>;
Конструкция field?: never
тут для следующего:
- помогает указать, что оба типа имеют одинаковые поля, благодаря чему получается аккуратное пересечение типов
-
never
явно указывает, что никакого значения в поле быть не может -
?:
показывает, что поле опциональное и может пропускаться при объявлении соответствующего объекта
Добавим к этому делу пару хелперов:
// быстрое создание новых значений
const makeLegacy = <L>(value: L): Legacy<L> => ({ legacy: value });
const makeRelease = <R>(value: R): Release<R> => ({ release: value });
// type guards для проверки какое значение перед нами
function isLegacy<L, R>(either: Either<L, R>): either is Legacy<L> {
return "legacy" in either && either.legacy !== undefined;
}
function isRelease<L, R>(either: Either<L, R>): either is Release<R> {
return "release" in either && either.release !== undefined;
}
function absurd(value: never): never {
// Эта функция используется для исчерпывающей проверки типов
// Она гарантирует, что все возможные случаи обработаны на этапе компиляции
// Если вызывается во время выполнения, значит мы достигли невозможного состояния
throw new Error(`Reached impossible state: ${JSON.stringify(value)}`);
}
ℹ️ По поводу того что за утилита absurd
можно почитать тут2
Теперь можно гораздо удобнее обходиться с результатом переключения функционала
const logger = toggle({ legacy: console, release: prettyLogger }); // Either<Console, PrettyLogger>
if (isLegacy(logger)) {
logger.legacy.log(`Good 'ol console`);
} else if (isRelease(logger)) {
logger.release.log(`Brand new logging possibilities`);
} else {
// absurd не обязательный, но будет ругаться если мы обработаем не все варианты
absurd(logger);
}
Откровенно говоря, вот эти logger.legacy
и logger.release
тоже не прикольные. Поэтому сделаем еще оператор, который будет извлекать из Either нужное значение, да еще и с правильным типом.
const union = unwrap(either); // Console | PrettyLogger
if (isLegacy(either)) {
const logger = unwrap(either); // Console
} else if (isRelease(either)) {
const logger = unwrap(either); // PrettyLogger
} else {
absurd(either);
}
Для красивого вывода типов используем перегрузку функций3:
👆 Развернуть
function unwrap<L>(legacy: Legacy<L>): NonNullable<L>;
function unwrap<R>(release: Release<R>): NonNullable<R>;
function unwrap<L, R>(either: Either<L, R>): NonNullable<L | R>;
function unwrap<L, R>(either: Either<L, R>): NonNullable<L | R> {
if (either.legacy !== undefined && either.release !== undefined) {
throw new Error("Received both legacy and release values at runtime when opening an Either");
} else if (either.legacy !== null) {
return either.legacy;
} else if (either.release !== null) {
return either.release;
} else {
throw new Error(`Received no legacy or release values at runtime when opening Either`);
}
}
В целом удобнее. Но, что если мы хотим как-то обрабатывать или изменять значения в Either и пробрасывать их дальше?
Chaining
Каждый раз писать громоздкую конструкцию из if совсем не хочется.
Помните, что Promise
это тоже монада? У Promise
есть метод .then(success, failure)
. В лексиконе FP он может называться map
или chain
(знатоки, не придирайтесь, пожалуйста). Вот его мы и реализуем.
function map<L, R, T, U>(
either: Either<L, R>,
onLegacy: (legacy: NonNullable<L>) => T,
onRelease: (release: NonNullable<R>) => U
): Either<T, U> {
if (isLegacy(either)) {
return makeLegacy(onLegacy(either.legacy));
} else if (isRelease(either)) {
return makeRelease(onRelease(either.release));
} else {
return absurd(either);
}
}
// Usage example:
const result = map(
either,
(console) => console.log("Using legacy logger"),
(prettyLogger) => prettyLogger.prettyLog("Using new pretty logger")
);
// Or with more complex transformations:
const tag = "Auth";
const prefixedLogEither = map(
either,
(console) =>
(...values: unknown[]) =>
console.log(`[${tag}]`, ...values),
(prettyLogger) => prettyLogger.tagged(tag)
);
const prefixedLog = unwrap(prefixedLogEither);
Промежуточный итог
Итак, у нас есть монады, unwrap, map и другие полезные функции для работы с нашим Either-подобным типом. Подведем итоги и посмотрим, как это все работает на практике.
Полная реализация
Давайте соберем все части воедино:
👆 Развернуть
import semver from "semver";
// Either type definition
type Legacy<L> = { legacy: L; release?: never };
type Release<R> = { legacy?: never; release: R };
type Either<L, R> = NonNullable<Legacy<L> | Release<R>>;
// Either helpers
const makeLegacy = <L>(value: L): Legacy<L> => ({ legacy: value });
const makeRelease = <R>(value: R): Release<R> => ({ release: value });
function isLegacy<L, R>(either: Either<L, R>): either is Legacy<L> {
return "legacy" in either && either.legacy !== undefined;
}
function isRelease<L, R>(either: Either<L, R>): either is Release<R> {
return "release" in either && either.release !== undefined;
}
function unwrap<L, R>(either: Either<L, R>): NonNullable<L | R> {
if (isLegacy(either)) {
return either.legacy as NonNullable<L>;
} else if (isRelease(either)) {
return either.release as NonNullable<R>;
} else {
throw new Error(`Received no legacy or release values at runtime when opening Either`);
}
}
function map<L, R, T, U>(
either: Either<L, R>,
onLegacy: (legacy: NonNullable<L>) => T,
onRelease: (release: NonNullable<R>) => U
): Either<T, U> {
if (isLegacy(either)) {
return makeLegacy(onLegacy(either.legacy as NonNullable<L>));
} else if (isRelease(either)) {
return makeRelease(onRelease(either.release as NonNullable<R>));
} else {
throw new Error("Invalid Either value");
}
}
// Release toggle implementation
type ReleaseToggleConfig = { currentVersion: string };
type ToggleOptions = {
targetVersion: string;
description: string;
overrideVariant?: "legacy" | "release";
};
const makeReleaseToggle = (config: ReleaseToggleConfig) => {
const { currentVersion } = config;
return (options: ToggleOptions) => {
const { targetVersion, description, overrideVariant } = options;
// Determine if the feature should be released based on version comparison
const versionDiff = semver.compare(targetVersion, currentVersion);
const isReleased = versionDiff <= 0;
// Log appropriate messages based on release status
if (overrideVariant) {
console.log(`[TOGGLE] Override to '${overrideVariant}' version for '${description}'`);
} else if (isReleased) {
console.log(`[TOGGLE] Use release version for '${description}'`);
if (versionDiff < 0) {
console.warn(
`[TOGGLE] Feature toggle target for '${description}' is less than current version. Consider removing toggle`
);
}
} else {
console.log(`[TOGGLE] '${description}' not yet released`);
}
// Return the toggle function that creates appropriate Either
return <L, R>(options: { legacy: L; release: R }): Either<L, R> => {
const { legacy, release } = options;
if (overrideVariant === "legacy") return makeLegacy(legacy);
if (overrideVariant === "release") return makeRelease(release);
return isReleased ? makeRelease(release) : makeLegacy(legacy);
};
};
};
Практическое применение
Рассмотрим несколько сценариев использования нашего решения:
Пример 1: Переключение между реализациями логгера
// Инициализация
const PACKAGE_VERSION = "3.7.0";
const releaseOn = makeReleaseToggle({ currentVersion: PACKAGE_VERSION });
// Создание переключателя для конкретной фичи
const loggerToggle = releaseOn({
targetVersion: "3.8.0",
description: "TASK-1234: Enhanced logging system",
});
// Определение старого и нового логгера
const legacyLogger = {
log: (message: string) => console.log(message),
error: (message: string) => console.error(message),
};
const newLogger = {
log: (message: string) => console.log(`[INFO] ${message}`),
error: (message: string) => console.error(`[ERROR] ${message}`),
debug: (message: string) => console.log(`[DEBUG] ${message}`),
};
// Использование
const logger = loggerToggle({ legacy: legacyLogger, release: newLogger });
// Вариант 1: Проверка типа и использование
if (isLegacy(logger)) {
logger.legacy.log("Using legacy logger");
} else {
logger.release.log("Using new logger");
logger.release.debug("Debug info available only in new logger");
}
// Вариант 2: Извлечение значения
const actualLogger = unwrap(logger);
actualLogger.log("This works with both implementations");
// Применение операций, сохраняющих Either
const taggedLogger = map(
logger,
(legacy) => ({
log: (message: string) => legacy.log(`[Auth] ${message}`),
error: (message: string) => legacy.error(`[Auth] ${message}`),
}),
(release) => ({
log: (message: string) => release.log(`[Auth] ${message}`),
error: (message: string) => release.error(`[Auth] ${message}`),
debug: (message: string) => release.debug(`[Auth] ${message}`),
})
);
// ... unwrap(taggedLogger).log('There will be tag')
Пример 2: Переключение компонентов UI
// Определяем переключатель для новой формы регистрации
const registerFormToggle = releaseOn({
targetVersion: "3.7.0",
description: "TASK-5678: Improved registration form",
});
// Использование в React-компоненте
function Registration() {
const formComponent = registerFormToggle({
legacy: LegacyRegistrationForm,
release: EnhancedRegistrationForm,
});
// Можно просто извлечь компонент
const FormComponent = unwrap(formComponent);
return <FormComponent />;
// Или использовать условный рендеринг с типобезопасностью
/*
if (isLegacy(formComponent)) {
return <formComponent.legacy showLegacyNote={true} />;
} else {
return <formComponent.release withNewFields={true} />;
}
*/
}
Пример 3: Настройка через параметры окружения
// Настраиваем глобальный переключатель с возможностью переопределения
const FF_OVERRIDE = process.env.FEATURE_FLAG_OVERRIDE as "legacy" | "release" | undefined;
const releaseOn = makeReleaseToggle({ currentVersion: PACKAGE_VERSION });
const paymentSystemToggle = releaseOn({
targetVersion: "3.9.0",
description: "TASK-9012: New payment processing system",
overrideVariant: FF_OVERRIDE,
});
// Использование
const paymentSystem = unwrap(
paymentSystemToggle({
legacy: legacyPaymentProcessor,
release: newPaymentProcessor,
})
);
async function processPayment(amount: number) {
try {
return await paymentSystem.process(amount);
} catch (error) {
console.error("Payment processing failed:", error);
return null;
}
}
Преимущества подхода
✅ Типобезопасность — TypeScript помогает отслеживать разницу между версиями реализаций
✅ Автоматизация — переключение происходит на основе версии, без ручного управления
✅ Прозрачность — логирование помогает видеть, какие переключатели активны
✅ Гибкость — можно переопределять поведение через параметры среды
✅ Чистый код — использование монад позволяет писать более декларативный код
✅ Удобство тестирования — можно легко тестировать обе реализации
Недостатки подхода
❌ Увеличение сложности — добавление монад и функциональных конструкций может усложнить код для разработчиков, не знакомых с FP
❌ Накладные расходы — дополнительный слой абстракции может привести к небольшому снижению производительности
❌ Кривая обучения — требуется время, чтобы команда освоила этот подход
❌ Риск забытого кода — несмотря на предупреждения, legacy-код может оставаться в репозитории дольше необходимого
❌ Зависимость от semver — корректная работа системы зависит от правильного версионирования
❌ Дополнительная инфраструктура — необходимо поддерживать систему логирования и мониторинга переключателей
Заключение
Этот подход сочетает:
- Удобство feature toggles
- Автоматизацию на основе семантического версионирования
- Функциональное программирование с Either-like монадой
Это позволяет эффективно управлять функциональностью в условиях trunk-based development с параллельной разработкой нескольких релизов.
В конечном итоге, когда legacy-версия функционала становится ненужной (после нескольких релизов), переключатели можно безопасно удалить, ориентируясь на предупреждения в логах.
Если интересно дальше разбираться в концепциях полезных в повседневной работе — подписывайся на мой телеграм-канал
Top comments (0)