
Флаги фич (feature flags) — это управляемые переключатели внутри приложения. С их помощью мы:
Итог для бизнеса — меньше рисков в релизах, быстрее время до ценности и больше уверенности в изменениях.
Разные типы — разные правила жизни: эксперименты — короткие, с отчётами; релизные — удаляются после стабилизации; операционные — живут долго, но с аудитом.
Есть два пути:
Практика: критичные решения — на сервере, а на клиентах — лёгкие визуальные правки, которые не страшно показать раньше времени.
Что важно в реализации:
Пользователь не должен «прыгать» между вариантами. Для этого применяем стабильное «приклеивание» (sticky bucketing):
Простые и надёжные идентификаторы для таргетинга — внутренние ID пользователя/организации. Если их нет (анонимный веб), используем стабильные куки или device‑ID, уважаем приватность и даём отказоустойчивость при их отсутствии.
Ниже — простой сервер, который:
package main
import (
"encoding/json"
"fmt"
"hash/fnv"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
)
type Flag struct {
Key string `json:"key"`
Enabled bool `json:"enabled"`
Salt string `json:"salt"`
Percent int `json:"percent"` // 0..100 — доля аудитории
Variants map[string]int `json:"variants"` // веса вариантов, сумма >0
Force map[string]string `json:"force"` // userID -> variant (обязательное назначение)
}
type Config struct {
Flags []Flag `json:"flags"`
}
type Store struct {
mu sync.RWMutex
byKey map[string]Flag
mtime time.Time
path string
}
func newStore(path string) *Store {
return &Store{byKey: make(map[string]Flag), path: path}
}
func (s *Store) load() error {
f, err := os.Open(s.path)
if err != nil {
return err
}
defer f.Close()
var cfg Config
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
return err
}
info, _ := os.Stat(s.path)
m := make(map[string]Flag)
for _, fl := range cfg.Flags {
// Нормализуем проценты
if fl.Percent < 0 { fl.Percent = 0 }
if fl.Percent > 100 { fl.Percent = 100 }
// Пустые варианты недопустимы: добавим единственный "on"
if len(fl.Variants) == 0 {
fl.Variants = map[string]int{"on": 100}
}
m[fl.Key] = fl
}
s.mu.Lock()
s.byKey = m
if info != nil {
s.mtime = info.ModTime()
}
s.mu.Unlock()
return nil
}
func (s *Store) maybeReload() {
info, err := os.Stat(s.path)
if err != nil {
return
}
s.mu.RLock()
prev := s.mtime
s.mu.RUnlock()
if info.ModTime().After(prev) {
if err := s.load(); err != nil {
log.Printf("reload failed: %v", err)
} else {
log.Printf("reloaded flags from %s", s.path)
}
}
}
func hash64(s string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(s))
return h.Sum64()
}
func pickVariant(fl Flag, userID string) (enabled bool, variant string) {
if !fl.Enabled {
return false, "off"
}
if v, ok := fl.Force[userID]; ok && v != "" {
return true, v
}
// Gate по проценту аудитории
gate := int(hash64(fl.Salt+":"+userID) % 100)
if gate >= fl.Percent { // не попал в выборку
return false, "off"
}
// Выбор варианта по весам
total := 0
for _, w := range fl.Variants { total += w }
if total <= 0 {
return true, "on"
}
r := int(hash64(fl.Salt+"|var|"+userID) % uint64(total))
acc := 0
for name, w := range fl.Variants {
acc += w
if r < acc {
return true, name
}
}
return true, "on"
}
func (s *Store) handleEval(w http.ResponseWriter, r *http.Request) {
flagKey := r.URL.Query().Get("flag")
userID := r.URL.Query().Get("user")
if flagKey == "" || userID == "" {
http.Error(w, "missing flag or user", http.StatusBadRequest)
return
}
s.mu.RLock()
fl, ok := s.byKey[flagKey]
s.mu.RUnlock()
if !ok {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "flag not found"})
return
}
enabled, variant := pickVariant(fl, userID)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"flag": flagKey,
"user": userID,
"enabled": enabled,
"variant": variant,
})
}
func (s *Store) handleReload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
if err := s.load(); err != nil {
http.Error(w, "reload error: "+err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func main() {
path := os.Getenv("FLAGS_FILE")
if path == "" { path = "flags.json" }
abs, _ := filepath.Abs(path)
log.Printf("using flags file: %s", abs)
store := newStore(path)
if err := store.load(); err != nil {
log.Printf("initial load failed: %v (flags default to off)", err)
}
// Фоновая проверка обновлений раз в 5 секунд
go func() {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for range t.C {
store.maybeReload()
}
}()
mux := http.NewServeMux()
mux.HandleFunc("/eval", store.handleEval)
mux.HandleFunc("/reload", store.handleReload)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
addr := ":8080"
if p := os.Getenv("PORT"); p != "" {
addr = ":" + p
}
log.Printf("listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
Содержимое файла конфигурации flags.json:
{
"flags": [
{
"key": "checkout_new",
"enabled": true,
"salt": "v1",
"percent": 10,
"variants": {"A": 50, "B": 50},
"force": {"user-42": "B"}
},
{
"key": "hotfix_kill_switch",
"enabled": true,
"salt": "k1",
"percent": 100,
"variants": {"on": 100},
"force": {}
}
]
}
Примеры запуска и вызова:
# Запуск
FLAGS_FILE=flags.json go run main.go
# Проверка
curl 'http://localhost:8080/eval?flag=checkout_new&user=user-1'
# Принудительная перезагрузка конфига
curl -X POST 'http://localhost:8080/reload'
Как использовать в сервисе: перед выполнением рискованной ветки кода запрашиваем решение у локального клиента или используем тот же алгоритм локально с кешируемым конфигом. Для чувствительных флагов заранее определяем политику: если сервис не может получить конфиг, действуем по безопасному умолчанию.
Производственная версия дополняется: RBAC и аудит, панель управления, распространение обновлений через стримы, интеграция с метриками и тревогами.
Итог: флаги фич — недорогой по внедрению инструмент, который добавляет управляемость изменениям и делает бизнес предсказуемее.