在生产环境中推过新功能的人,几乎都遇到过同一个问题:“这个功能怎么在不开完整服务的前提下,先让一小部分人用起来?”

Feature Toggle(特性开关)是回答这个问题的一种常见思路。但在 Spring Boot 微服务体系里,怎么实现一套不绑定具体供应商、能随代码一起版本管理、还能在生产环境动态切换的特性开关,仍然是很多团队需要摸索的事情。

本文结合 shop 项目中的 shop-starter-feature-toggle 模块,完整讲一遍从实现到部署的 Feature Toggle 落地路径。


为什么不用 LaunchDarkly / Unleash 直接上手#

在开始之前,先明确一个关键问题:为什么不用现成的 SaaS Feature Flag 平台?

LaunchDarkly、Unleash、Split 等确实是成熟的产品,但在实际落地时往往面临几个门槛:

  • 引入外部依赖:需要在生产环境部署或购买 SaaS,增加运维和成本复杂度
  • 供应商锁定:一旦业务代码深度耦合某个平台的 SDK,切换供应商的成本很高
  • 对于 POC 和中小项目来说过度工程化:很多团队只需要"开/关"级别的开关,不需要复杂的受众定位、A/B 测试等功能

OpenFeature 提供了第三条路。


OpenFeature:供应商无关的特性开关标准#

OpenFeature 定义了一套供应商无关的特性 flag API。它的架构很简单:

flowchart LR App["Spring Boot 应用"] SDK["OpenFeature SDK
(供应商无关 API)"] Provider["Property Provider
(从 Spring Config 读取)"] Config["K8s ConfigMap
或 YAML 文件"] App --> SDK SDK --> Provider Provider --> Config

核心原则:

  1. 业务代码只依赖 OpenFeature SDK,不依赖任何具体 Provider
  2. Provider 可替换:当前用 Spring Property Provider,未来可以换成 Unleash、LaunchDarkly、flagd 等,业务代码改动通常可以控制在较小范围
  3. 标准化评估结果ProviderEvaluation 统一返回 value、variant、reason、errorCode,便于监控和调试

参考:OpenFeature 规范OpenFeature ConceptsOpenFeature Java SDK


shop-starter-feature-toggle 模块架构#

shop 项目的 shop-starter-feature-toggle 是一个 Spring Boot 自动配置模块,所有服务只需要添加依赖即可使用。

三个核心类#

shop-starter-feature-toggle/
├── FeatureToggleAutoConfiguration.java    # 自动装配入口
├── FeatureToggleProperties.java           # @ConfigurationProperties 绑定
├── OpenFeaturePropertyProvider.java       # 实现 OpenFeature FeatureProvider 接口
└── FeatureToggleService.java              # 业务层友好的封装

1. FeatureToggleProperties:配置绑定#

@ConfigurationProperties(prefix = "shop.features")
public class FeatureToggleProperties {

    private Map<String, Boolean> flags = new LinkedHashMap<>();

    public Map<String, Boolean> getFlags() { return flags; }
    public void setFlags(Map<String, Boolean> flags) {
        this.flags = flags == null ? new LinkedHashMap<>() : new LinkedHashMap<>(flags);
    }
    public boolean contains(String key) { return flags.containsKey(key); }
    public Boolean get(String key) { return flags.get(key); }
}

通过 @ConfigurationProperties 绑定 shop.features.flags 下的所有 key-value 对。Spring Cloud Kubernetes ConfigMap 可以动态更新这些属性。

2. OpenFeaturePropertyProvider:实现 FeatureProvider 接口#

这是整个模块最核心的类。它实现了 OpenFeature 的 FeatureProvider 接口,把 Spring Config 中的 flag 暴露给 OpenFeature SDK:

public class OpenFeaturePropertyProvider implements FeatureProvider {

    private static final Metadata METADATA = () -> "shop-k8s-property-provider";

    private final FeatureToggleProperties properties;

    @Override
    public Metadata getMetadata() { return METADATA; }

    @Override
    public ProviderEvaluation<Boolean> getBooleanEvaluation(
            String key, Boolean defaultValue, EvaluationContext context) {
        if (!properties.contains(key)) {
            return missing(key, defaultValue);
        }
        Boolean value = properties.get(key);
        return ProviderEvaluation.<Boolean>builder()
                .value(value)
                .variant(Boolean.TRUE.equals(value) ? "enabled" : "disabled")
                .reason("STATIC")
                .flagMetadata(ImmutableMetadata.builder()
                        .addString("source", "spring-config")
                        .addString("key", key)
                        .build())
                .build();
    }

    @Override
    public ProviderEvaluation<String> getStringEvaluation(...) {
        return typeMismatch(key, defaultValue);
    }
    // ... getIntegerEvaluation / getDoubleEvaluation / getObjectEvaluation
}

关键设计:

  • 只支持 Boolean 类型:其他类型(String、Integer、Double、Object)都返回 TYPE_MISMATCH。这是因为当前场景只需要"开/关"级别的开关,不需要复杂的动态值
  • 未定义 flag 返回默认值:如果 key 不存在,返回 defaultValue + FLAG_NOT_FOUND,尽量避免因为 flag 缺失直接阻断服务
  • 元数据标记来源:每次评估都携带 source: spring-config,方便在监控中追溯 flag 的来源

参考:OpenFeature Java SDK 规范OpenFeature Java SDK GitHub

3. FeatureToggleAutoConfiguration:自动装配#

@AutoConfiguration
@EnableConfigurationProperties(FeatureToggleProperties.class)
public class FeatureToggleAutoConfiguration {

    static final String OPENFEATURE_DOMAIN = "shop-platform";

    @Bean
    OpenFeaturePropertyProvider openFeaturePropertyProvider(FeatureToggleProperties properties) {
        return new OpenFeaturePropertyProvider(properties);
    }

    @Bean
    Client featureToggleClient(OpenFeaturePropertyProvider provider) {
        OpenFeatureAPI api = OpenFeatureAPI.getInstance();
        api.setProvider(OPENFEATURE_DOMAIN, provider);
        return api.getClient(OPENFEATURE_DOMAIN);
    }

    @Bean
    FeatureToggleService featureToggleService(Client client) {
        return new FeatureToggleService(client);
    }

    @Bean
    DisposableBean openFeatureShutdownHook() {
        return () -> OpenFeatureAPI.getInstance().shutdown();
    }
}

通过 @AutoConfiguration + META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,任何引入了 shop-starter-feature-toggle 依赖的服务都会自动获得 Feature Toggle 能力,不需要额外配置。

4. FeatureToggleService:业务层封装#

public class FeatureToggleService {

    private final Client client;

    public FeatureToggleService(Client client) {
        this.client = client;
    }

    public boolean isEnabled(String flagKey, boolean defaultValue) {
        return client.getBooleanValue(flagKey, defaultValue);
    }

    public void requireEnabled(String flagKey, boolean defaultValue, String message) {
        if (!isEnabled(flagKey, defaultValue)) {
            throw new BusinessException(CommonErrorCode.FEATURE_DISABLED, message);
        }
    }
}

对业务层暴露两个方法:

  • isEnabled():条件判断,适合做降级分支
  • requireEnabled():强制校验,适合做 API 级别的开关

Flag 常量管理:SearchFeatureFlags#

flag key 是字符串,散落在代码中容易出错。shop 项目在每个服务中定义常量类来管理:

public final class SearchFeatureFlags {

    public static final String AUTOCOMPLETE = "search-autocomplete";
    public static final String TRENDING = "search-trending";
    public static final String LOCALE_AWARE_SEARCH = "search-locale-aware";

    private SearchFeatureFlags() {}
}

好处:

  • 编译期检查,拼写错误通常更容易被提早发现
  • IDE 自动补全,不需要记 key 名
  • 一个服务的所有 flag 集中管理

Controller 层的两种使用方式#

方式一:requireEnabled() — 强制开关#

用于"功能不存在时应直接返回错误"的场景:

@GetMapping(SearchApi.SEARCH_PRODUCT_SUGGESTIONS)
public ApiResponse<SearchApi.SearchSuggestionsResponse> suggestProducts(
        @RequestParam String q,
        @RequestParam(defaultValue = "8") int limit,
        @RequestParam(required = false) List<String> locales) {
    featureToggleService.requireEnabled(
            SearchFeatureFlags.AUTOCOMPLETE,
            true,
            "Search autocomplete is disabled");
    return ApiResponse.success(searchService.suggest(q, limit, locales));
}

@GetMapping(SearchApi.TRENDING_QUERIES)
public ApiResponse<SearchApi.TrendingQueriesResponse> trendingQueries(
        @RequestParam(defaultValue = "10") int limit) {
    featureToggleService.requireEnabled(
            SearchFeatureFlags.TRENDING,
            true,
            "Trending search queries are disabled");
    return ApiResponse.success(searchService.trending(limit));
}

当 flag 关闭时,直接返回 SC_FEATURE_DISABLED(400),不调用底层搜索服务。这适合资源消耗大、需要额外基础设施支撑的功能(如 autocomplete 需要额外计算资源)。

方式二:isEnabled() — 条件降级#

用于"功能不可用时优雅降级"的场景:

@GetMapping(SearchApi.SEARCH_PRODUCTS)
public ApiResponse<SearchApi.SearchProductsResponse> searchProducts(
        @RequestParam(required = false) String q,
        @RequestParam(required = false) String categoryId,
        @RequestParam(required = false) String sort,
        @RequestParam(defaultValue = "1") int page,
        @RequestParam(defaultValue = "20") int hitsPerPage,
        @RequestParam(required = false) List<String> locales) {
    List<String> effectiveLocales = locales;
    if (locales != null && !locales.isEmpty()
            && !featureToggleService.isEnabled(
                    SearchFeatureFlags.LOCALE_AWARE_SEARCH, true)) {
        effectiveLocales = List.of();
    }
    var request = new SearchApi.SearchProductsRequest(
            q, categoryId, sort, page, hitsPerPage, effectiveLocales);
    return ApiResponse.success(searchService.search(request));
}

search-locale-aware flag 关闭时,搜索仍然正常工作,只是不携带 locale 参数——降级而不阻断。这适合功能增强型特性,关闭时核心功能不受影响。


K8s ConfigMap 挂载 + informer 热更新#

ConfigMap 定义#

在 shop 项目的 K8s 清单中,Feature Toggle 配置通过 ConfigMap 管理:

apiVersion: v1
kind: ConfigMap
metadata:
  name: search-service-feature-toggles
  namespace: shop
  labels:
    spring.cloud.kubernetes.config: "true"
  annotations:
    spring.cloud.kubernetes.configmap.apps: search-service
data:
  feature-toggles.yaml: |
    shop:
      features:
        flags:
          search-autocomplete: true
          search-trending: true
          search-locale-aware: true    

Deployment 挂载#

search-service 的 Deployment 将这个 ConfigMap 挂载为 volume:

spec:
  containers:
    - name: search-service
      volumeMounts:
        - name: search-feature-toggles
          mountPath: /workspace/config/feature-toggles
          readOnly: true
  volumes:
    - name: search-feature-toggles
      configMap:
        name: search-service-feature-toggles

热更新链路#

Spring Cloud Kubernetes 通过 ConfigMap informer 实现热更新:

flowchart LR Admin["运维/开发修改 YAML"] Git["git push"] Apply["kubectl apply / ArgoCD sync"] CM["ConfigMap 更新"] Informer["Spring Cloud Kubernetes informer"] Watcher["Configuration Watcher"] Refresh["/actuator/refresh"] App["应用 flag 生效(无需重启)"] Admin --> Git --> Apply --> CM CM --> Informer --> Watcher --> Refresh --> App

具体流程:

  1. 运维/开发修改 ConfigMap YAML
  2. kubectl apply 或 ArgoCD 同步到集群
  3. Spring Cloud Kubernetes informer 检测到 ConfigMap 变更
  4. Configuration Watcher 触发 @RefreshScope Bean 刷新
  5. FeatureToggleProperties 重新绑定新的 flag 值
  6. 下一次 flag 评估时生效——整个过程不需要重启 Pod

参考:Spring Cloud Kubernetes Configuration WatcherSpring Cloud Kubernetes PropertySource ReloadKubernetes ConfigMap

已知注意事项#

ConfigMap volume 更新是 eventual 的:官方文档明确提到,挂载到 volume 的 ConfigMap 更新不会在同一个时刻“瞬时切换”。因此如果你依赖 spring.config.import 读取文件,最好同时评估 SPRING_CLOUD_KUBERNETES_CONFIGURATION_WATCHER_REFRESHDELAY,不要假设改完 YAML 就能零延迟生效。

要打标签才会被 watcher 关注:当前仓库里 search-service-feature-toggles 同时打了 spring.cloud.kubernetes.config=truespring.cloud.kubernetes.config.informer.enabled=true。如果漏了这些标签,Configuration Watcher 不会主动发 refresh 事件。


从 Property Provider 到完整 Feature Management 的演进路径#

当前实现是一个轻量起点,适合 POC 和中小规模团队。随着 Flag 数量增长和运维需求变化,可以沿着以下路径演进:

flowchart LR P1["阶段 1
Static Property Provider
(当前实现)"] P2["阶段 2
flagd (本地进程)"] P3["阶段 3
Unleash / LaunchDarkly
(生产级)"] P1 --> P2 --> P3

阶段 1:Static Property Provider(当前)#

  • flag 值来自 application.yml 或 K8s ConfigMap
  • 适合开发、测试、小规模生产
  • 优势:不依赖专有 Feature Flag 平台、K8s 原生、版本管理清晰

阶段 2:flagd(本地进程评估)#

flagd 是 OpenFeature 官方提供的本地 flag 评估守护进程,从文件或远程端点加载 flag 定义:

# 只需替换 Provider 实现,大部分业务代码不动
@Bean
Client flagdClient() {
    OpenFeatureAPI api = OpenFeatureAPI.getInstance();
    api.setProvider(new FlagdProvider());  // 替代 OpenFeaturePropertyProvider
    return api.getClient();
}

flagd 支持从文件、gRPC 端点、K8s ConfigMap 等来源加载 flag,并提供缓存、评估日志等能力。

阶段 3:Unleash / LaunchDarkly(生产级)#

当需要受众定位、A/B 测试、flag 审计等高级功能时,可以引入 Unleash 或 LaunchDarkly 的 OpenFeature Provider:

// 业务调用面通常不变,主要替换 Provider
api.setProvider(new UnleashProvider(unleashConfig));
// 或
api.setProvider(new LaunchDarklyProvider(ldClient));

由于业务代码只依赖 OpenFeature SDK,切换供应商时主要是替换 Provider wiring 与运维配置;Controller / Service 的调用面通常可以保持不变。

参考:flagd 官方文档Unleash OpenFeature ProviderLaunchDarkly OpenFeature Provider for Java


与其他 Feature Toggle 方案的对比#

维度 OpenFeaturePropertyProvider(当前) Spring Boot @ConditionalOnProperty Unleash / LaunchDarkly
运行时切换 ✅ ConfigMap 热更新 ❌ 需要重启 ✅ 管理控制台实时切换
供应商锁定 ✅ 无 ✅ 无 ⚠️ 有
受众定位 ❌ 不支持 ❌ 不支持 ✅ 支持
A/B 测试 ❌ 不支持 ❌ 不支持 ✅ 支持
外部依赖 需要部署 SaaS 或自托管
适用规模 POC / 中小项目 简单的开/关场景 大规模生产

对于 shop 项目当前的 POC 定位,OpenFeaturePropertyProvider + K8s ConfigMap 的组合当前已经能满足需求。未来如果需要更精细的 flag 管理,再替换 Provider 会更稳妥。

参考:OpenFeature ConceptsSpring Boot Conditional Property


踩坑记录#

1. Configuration Watcher / informer 需要 RBAC 权限#

informer 模式需要 ServiceAccount 有 configmapsgetlistwatch 权限:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: configmap-reader
  namespace: shop
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: configmap-reader-binding
  namespace: shop
subjects:
  - kind: ServiceAccount
    name: search-service
    namespace: shop
roleRef:
  kind: Role
  name: configmap-reader
  apiGroup: rbac.authorization.k8s.io

没有这些权限时,informer 会持续报错 Forbidden,flag 无法热更新。

2. 不要把 @ConfigurationProperties 刷新机制想得太理所当然#

Spring Cloud Kubernetes 的 Configuration Watcher 默认是通过 /actuator/refresh 驱动刷新。当前仓库采用的是 spring.config.import 读取挂载目录下的 feature-toggles.yaml,再由 watcher 发出 refresh 事件,这条链路已经在 search-service 里跑通。

按我这次的经验,不要只凭“我加了 @ConfigurationProperties”就假设热更新一定成立,而是更适合用实际 smoke test 去验证刷新后的行为。

3. flag key 命名约定#

建议统一使用 <service>-<feature> 格式(如 search-autocomplete),避免不同服务的 flag 互相冲突。SearchFeatureFlags 常量类就是一个好的实践。


参考与实现位置#


小结#

Feature Toggle 在 Shop Platform 中的落地路径:

  • OpenFeature SDK:供应商无关的特性 flag API,业务代码只依赖标准接口
  • OpenFeaturePropertyProvider:自定义 Provider 实现,从 Spring Config 读取 flag,不依赖专有 Feature Flag 平台
  • FeatureToggleServiceisEnabled()(条件降级)+ requireEnabled()(强制开关)两种模式
  • K8s ConfigMap + Configuration Watcher:运维通过 YAML 管理 flag,热更新无需重启 Pod
  • Flag 常量类SearchFeatureFlags 集中管理 key,编译期检查
  • 可演进:从 Static Provider → flagd → Unleash/LaunchDarkly,业务代码改动通常最小

项目仓库:github.com/meirongdev/shop