"""Feature Flag 存储实现。 支持两种后端: - RedisFeatureFlagStore:生产环境使用,支持多实例共享、热更新 - InMemoryFeatureFlagStore:测试/开发环境使用,纯内存 支持的 Flag 类型: - 全局开关(enabled: bool) - 白名单(whitelist: Set[str],如 user_id 列表) - 百分比切流(percentage: 0-100,基于标识符哈希取模) 判定优先级:白名单 > 百分比 > 全局开关 """ from __future__ import annotations import hashlib import json import logging import threading import time from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Optional, Set logger = logging.getLogger(__name__) # Redis key 前缀 FEATURE_FLAG_REDIS_PREFIX = "feature_flag:" @dataclass class FeatureFlagConfig: """单个 Feature Flag 的配置。""" name: str enabled: bool = False percentage: int = 0 # 0-100 whitelist: Set[str] = field(default_factory=set) def to_dict(self) -> dict: return { "name": self.name, "enabled": self.enabled, "percentage": self.percentage, "whitelist": sorted(self.whitelist), } @classmethod def from_dict(cls, data: dict) -> "FeatureFlagConfig": return cls( name=data["name"], enabled=bool(data.get("enabled", False)), percentage=int(data.get("percentage", 0)), whitelist=set(data.get("whitelist", [])), ) def is_active(self, identifier: Optional[str] = None) -> bool: """判断当前 flag 是否激活。 判定优先级: 1. 全局关闭 → False 2. 白名单匹配 → True 3. 百分比命中 → True 4. 其他 → False Args: identifier: 用于白名单匹配和百分比哈希的标识符(如 user_id)。 传 None 时只看全局开关 + 百分比(百分比用随机值)。 """ if not self.enabled: return False # 白名单:精确匹配 if identifier and identifier in self.whitelist: return True # 百分比:0 直接 False,100 直接 True if self.percentage <= 0: # 没有白名单且百分比为0 → 未启用 return False if self.percentage >= 100: return True # 基于 identifier 做哈希取模,确保同一用户始终落在同一侧 if identifier: hash_val = int( hashlib.md5(f"{self.name}:{identifier}".encode("utf-8")).hexdigest(), 16 # nosec B324 ) # nosec B324 - 用于哈希取模做百分比切流,非安全用途 return (hash_val % 100) < self.percentage # 无 identifier 且百分比在 0-100 之间 → 按比例随机(不保证一致性) import random return random.randint(0, 99) < self.percentage class FeatureFlagStore(ABC): """Feature Flag 存储抽象接口。""" @abstractmethod def get(self, name: str) -> FeatureFlagConfig: """获取指定 flag 的配置,不存在则返回默认配置(关闭状态)。""" ... @abstractmethod def set(self, config: FeatureFlagConfig) -> None: """设置 flag 配置。""" ... @abstractmethod def delete(self, name: str) -> bool: """删除 flag,返回是否成功删除。""" ... @abstractmethod def list_all(self) -> dict[str, FeatureFlagConfig]: """列出所有 flag。""" ... def is_active(self, name: str, identifier: Optional[str] = None) -> bool: """便捷方法:判断 flag 是否激活。""" return self.get(name).is_active(identifier) class InMemoryFeatureFlagStore(FeatureFlagStore): """内存实现,用于测试和本地开发。""" def __init__(self) -> None: self._flags: dict[str, FeatureFlagConfig] = {} self._lock = threading.Lock() def get(self, name: str) -> FeatureFlagConfig: with self._lock: return self._flags.get(name, FeatureFlagConfig(name=name, enabled=False)) def set(self, config: FeatureFlagConfig) -> None: with self._lock: self._flags[config.name] = config def delete(self, name: str) -> bool: with self._lock: if name in self._flags: del self._flags[name] return True return False def list_all(self) -> dict[str, FeatureFlagConfig]: with self._lock: return dict(self._flags) class RedisFeatureFlagStore(FeatureFlagStore): """Redis 实现,支持多实例共享配置。 每个 flag 存在一个独立的 Redis hash key 中: Key: feature_flag:{name} Fields: enabled, percentage, whitelist(JSON array) """ def __init__(self, redis_url: str, key_prefix: str = FEATURE_FLAG_REDIS_PREFIX) -> None: import redis as redis_lib self._redis = redis_lib.from_url(redis_url, decode_responses=True) self._key_prefix = key_prefix # 本地缓存 + TTL,减少 Redis 调用 self._cache: dict[str, tuple[FeatureFlagConfig, float]] = {} self._cache_ttl = 5.0 # 秒,默认5秒本地缓存 self._lock = threading.Lock() def _redis_key(self, name: str) -> str: return f"{self._key_prefix}{name}" def _parse_whitelist(self, raw: Optional[str]) -> Set[str]: if not raw: return set() try: data = json.loads(raw) return set(data) if isinstance(data, list) else set() except (json.JSONDecodeError, TypeError): return set() def get(self, name: str) -> FeatureFlagConfig: now = time.time() # 先查本地缓存 with self._lock: cached = self._cache.get(name) if cached and now - cached[1] < self._cache_ttl: return cached[0] # 从 Redis 读取 try: key = self._redis_key(name) data = self._redis.hgetall(key) if not data: config = FeatureFlagConfig(name=name, enabled=False) else: config = FeatureFlagConfig( name=name, enabled=(data.get("enabled", "0") in ("1", "true", "True")), percentage=int(data.get("percentage", 0)), whitelist=self._parse_whitelist(data.get("whitelist")), ) # 写入本地缓存 with self._lock: self._cache[name] = (config, now) return config except Exception as exc: logger.warning("Failed to get feature flag %s from Redis: %s", name, exc) # Redis 不可用时返回默认值(关闭),不影响业务 return FeatureFlagConfig(name=name, enabled=False) def set(self, config: FeatureFlagConfig) -> None: key = self._redis_key(config.name) self._redis.hset( key, mapping={ "enabled": "1" if config.enabled else "0", "percentage": str(config.percentage), "whitelist": json.dumps(sorted(config.whitelist), ensure_ascii=False), }, ) # 失效本地缓存 with self._lock: self._cache.pop(config.name, None) def delete(self, name: str) -> bool: key = self._redis_key(name) result = self._redis.delete(key) with self._lock: self._cache.pop(name, None) return bool(result) def list_all(self) -> dict[str, FeatureFlagConfig]: pattern = f"{self._key_prefix}*" result: dict[str, FeatureFlagConfig] = {} try: cursor = 0 while True: cursor, keys = self._redis.scan(cursor=cursor, match=pattern, count=100) for key in keys: name = key[len(self._key_prefix) :] result[name] = self.get(name) if cursor == 0: break except Exception as exc: logger.warning("Failed to list feature flags from Redis: %s", exc) return result def invalidate_cache(self, name: Optional[str] = None) -> None: """手动失效本地缓存。""" with self._lock: if name: self._cache.pop(name, None) else: self._cache.clear()