
Фича‑флаги — это переключатели в коде, которые позволяют включать и выключать поведение без релиза. За счёт этого:
Важно: у каждого флага должен быть владелец, цель и срок жизни. Постоянные флаги допустимы только для конфигурации и лицензирования.
Часто используют гибрид: серверы — локальная оценка с коротким TTL; мобильные/браузер — тонкие SDK с минимальной логикой и защитой от утечек сегментов.
Важно: проценты должны быть детерминированы. Один и тот же пользователь/арендатор всегда попадает в один и тот же «ведро» при одинаковом семени.
Ниже — минималистичная реализация локальной оценки флагов на сервере с безопасными значениями по умолчанию, процентовкой и таргетингом по пользователю и арендатору. Подходит как референс или стартовый прототип.
Структура:
{
"features": {
"new_checkout": {
"type": "boolean",
"enabled": false,
"seed": "new_checkout_v1",
"rules": [
{ "match": { "tenantId_in": ["ent-01", "ent-02"] }, "value": true },
{ "match": { "userId_in": ["u-100", "u-101"] }, "value": true },
{ "match": { "percentage": 10 }, "value": true }
],
"killSwitch": false
},
"exp_price_algo": {
"type": "variant",
"enabled": true,
"seed": "exp_price_algo_v2",
"variants": [
{ "name": "control", "weight": 50 },
{ "name": "algo_a", "weight": 30 },
{ "name": "algo_b", "weight": 20 }
],
"killSwitch": false
}
}
}
// hashing.ts
// Детерминированное «ведро» из строки [0..99]
export function bucket100(key: string, seed = ""): number {
const str = seed + ":" + key;
let h = 2166136261 >>> 0; // FNV-1a 32-bit
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h % 100;
}
export function pickWeighted<T extends { weight: number }>(items: T[], key: string, seed = ""): T {
const total = items.reduce((s, x) => s + x.weight, 0);
if (total <= 0) throw new Error("Total weight must be > 0");
const b = bucket100(key, seed);
let acc = 0;
for (const it of items) {
const share = Math.floor((it.weight / total) * 100);
acc += share;
if (b < acc) return it;
}
return items[items.length - 1];
}
// telemetry.ts
// Заглушки телеметрии: замените на вашу метрику/логгер
export function recordExposure(params: { feature: string; variant: string; userId?: string; tenantId?: string }) {
// Пример: отправить в очереди/метрики
console.log("exposure", JSON.stringify(params));
}
export function recordCounter(name: string, value = 1, labels: Record<string, string> = {}) {
console.log("metric", JSON.stringify({ name, value, labels }));
}
// app.ts
import express from "express";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { bucket100, pickWeighted } from "./hashing";
import { recordExposure, recordCounter } from "./telemetry";
// Типы
type Context = { userId?: string; tenantId?: string };
type BooleanRule =
| { match: { tenantId_in: string[] }; value: boolean }
| { match: { userId_in: string[] }; value: boolean }
| { match: { percentage: number }; value: boolean };
type Variant = { name: string; weight: number };
type Feature =
| { type: "boolean"; enabled: boolean; seed: string; rules: BooleanRule[]; killSwitch: boolean }
| { type: "variant"; enabled: boolean; seed: string; variants: Variant[]; killSwitch: boolean };
type FlagsConfig = { features: Record<string, Feature> };
// Инициализация
const app = express();
app.use(express.json());
const FLAGS_PATH = path.join(__dirname, "flags.json");
let cfg: FlagsConfig = JSON.parse(fs.readFileSync(FLAGS_PATH, "utf8"));
let etag = computeEtag(cfg);
function computeEtag(obj: unknown) {
const h = crypto.createHash("sha256").update(JSON.stringify(obj)).digest("hex");
return `W/"${h}"`;
}
// Горячая перезагрузка конфигов с защитой от ошибок
fs.watchFile(FLAGS_PATH, { interval: 1000 }, () => {
try {
const raw = fs.readFileSync(FLAGS_PATH, "utf8");
const next: FlagsConfig = JSON.parse(raw);
// Валидация минимальная: уникальные имена, веса > 0
for (const [name, f] of Object.entries(next.features)) {
if (f.type === "variant") {
if (!f.variants.length) throw new Error(`Feature ${name} has no variants`);
if (f.variants.some(v => v.weight <= 0)) throw new Error(`Feature ${name} has non-positive weight`);
}
}
cfg = next;
etag = computeEtag(cfg);
console.log("flags reloaded");
} catch (e) {
console.error("flags reload failed:", e);
}
});
// Оценка флага
function evalBoolean(name: string, ctx: Context): boolean {
const f = cfg.features[name];
if (!f || f.type !== "boolean") return false; // безопасное значение
if (f.killSwitch) return false;
if (!f.enabled) return false;
// Правила: первый матч выигрывает
for (const r of f.rules) {
if ("tenantId_in" in r.match && ctx.tenantId && r.match.tenantId_in.includes(ctx.tenantId)) return r.value;
if ("userId_in" in r.match && ctx.userId && r.match.userId_in.includes(ctx.userId)) return r.value;
if ("percentage" in r.match) {
const key = ctx.userId || ctx.tenantId || "anonymous";
const b = bucket100(key, f.seed);
if (b < Math.max(0, Math.min(100, r.match.percentage))) return r.value;
}
}
return false; // дефолт выключен
}
function evalVariant(name: string, ctx: Context): string {
const f = cfg.features[name];
if (!f || f.type !== "variant") return "control"; // безопасный вариант
if (f.killSwitch || !f.enabled) return "control";
const key = ctx.userId || ctx.tenantId || "anonymous";
const pick = pickWeighted(f.variants, key, f.seed);
return pick.name;
}
// HTTP API
app.get("/flags", (req, res) => {
// Отдаём ETag для кэширования на клиенты/прокси
res.setHeader("ETag", etag);
if (req.headers["if-none-match"] === etag) return res.status(304).end();
res.json({ version: etag, features: Object.keys(cfg.features) });
});
app.post("/evaluate", (req, res) => {
const { feature, context } = req.body as { feature: string; context?: Context };
const ctx: Context = context || {};
const f = cfg.features[feature];
if (!f) return res.status(404).json({ error: "feature_not_found" });
if (f.type === "boolean") {
const val = evalBoolean(feature, ctx);
recordExposure({ feature, variant: val ? "on" : "off", userId: ctx.userId, tenantId: ctx.tenantId });
recordCounter("feature.evaluate", 1, { feature, type: "boolean" });
return res.json({ type: "boolean", value: val });
} else {
const variant = evalVariant(feature, ctx);
recordExposure({ feature, variant, userId: ctx.userId, tenantId: ctx.tenantId });
recordCounter("feature.evaluate", 1, { feature, type: "variant" });
return res.json({ type: "variant", value: variant });
}
});
app.post("/killswitch", (req, res) => {
const { feature, on } = req.body as { feature: string; on: boolean };
const f = cfg.features[feature];
if (!f) return res.status(404).json({ error: "feature_not_found" });
f.killSwitch = !!on;
etag = computeEtag(cfg);
recordCounter("feature.killswitch", 1, { feature, on: String(on) });
res.json({ ok: true, feature, killSwitch: f.killSwitch });
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`flags server on :${port}`));
Запуск:
npm init -y
npm i express @types/express typescript ts-node-dev
npx tsc --init --rootDir src --outDir dist --esModuleInterop true --resolveJsonModule true
mkdir src
# поместите app.ts, hashing.ts, telemetry.ts в src, а flags.json рядом с ними
npx ts-node-dev src/app.ts
Проверка:
curl -s localhost:3000/flags | jq
curl -s -X POST localhost:3000/evaluate \
-H 'Content-Type: application/json' \
-d '{"feature":"new_checkout","context":{"tenantId":"ent-01","userId":"u-42"}}' | jq
# Включить аварийный выключатель
curl -s -X POST localhost:3000/killswitch \
-H 'Content-Type: application/json' \
-d '{"feature":"new_checkout","on":true}' | jq
Что важно в примере:
Расширения для продакшена:
Фича‑флаги — это не только про «включить кнопку». Это про управляемые риски, быстрые эксперименты и уверенность в релизах. При правильной архитектуре и процессах вы выкатываете чаще, безопаснее и дешевле.