功能开关(Feature Toggle,也常称为 Feature Flag,功能标志)是一项非常实用的技术。它允许团队在不修改代码的情况下改变系统行为。功能开关常用于持续交付、基于主干的开发、灰度发布、金丝雀发布、A/B 测试和权限控制等场景。由于不同场景下的功能开关有不同的生命周期、动态性和管理方式,因此在设计、实现和管理功能开关时,必须区分其类型,并采用不同的管理策略。
需要注意的是,功能开关会增加系统复杂度。我们可以通过合理的实现方式,以及合适的配置管理工具来控制这种复杂度;但与此同时,也应尽可能限制系统中的功能开关数量,避免它们无节制地增长。
“功能开关”并不是单一技术,而是一组模式。它们可以帮助团队更快速、更安全地向用户交付新功能。本文将先通过一个简短的故事,展示功能开关的一些典型使用场景;随后再深入讨论相关模式和实践,说明团队应如何有效使用功能开关。

功能开关也被称为功能标志、功能位或功能翻转器。这些说法本质上指的是同一组技术。本文中会交替使用“功能开关”和“功能标志”这两个术语。
一个关于功能开关的故事
想象这样一个场景:你的团队正在开发一款复杂的城镇规划模拟游戏,而你们负责其中的核心模拟引擎。你当前的任务是提升样条曲线网格化算法的效率。你知道,这会涉及较大的实现改动,可能需要数周时间。与此同时,团队中的其他成员仍然需要继续在同一个代码库中开发其他相关功能。
由于过去合并长期分支的经历并不愉快,你希望尽量避免为这项工作创建长期分支。因此,你决定让整个团队继续在主干上工作;而负责改进样条曲线算法的开发人员,则通过功能开关来隔离尚未完成的工作,避免影响团队其他成员,也避免破坏代码库的稳定性。
第一个功能标志的诞生
负责算法改造的两位开发人员引入了第一个改动。
修改前:function reticulateSplines() { // 当前实现位于此处 }
本文示例均使用 JavaScript ES2015。
修改后:function reticulateSplines() { var useNewAlgorithm = false; // useNewAlgorithm = true; // 如果你正在开发新的 SR 算法,请取消注释这一行 if (useNewAlgorithm) { return enhancedSplineReticulation(); } else { return oldFashionedSplineReticulation(); } } function oldFashionedSplineReticulation() { // 当前实现位于此处 } function enhancedSplineReticulation() { // TODO:实现更好的 SR 算法 }
他们将当前算法的实现移到了 oldFashionedSplineReticulation 函数中,并把 reticulateSplines 改造成了一个开关点。现在,如果有人正在开发新算法,只需要取消注释 useNewAlgorithm = true 这一行,就可以启用“使用新算法”这一功能。
让功能标志动态化
几个小时后,两位开发人员准备在模拟引擎的一些集成测试中运行新算法。他们还希望在同一组集成测试中测试旧算法。此时,他们需要能够动态启用或禁用该功能。也就是说,是时候抛弃那种通过注释或取消注释 useNewAlgorithm = true 来控制开关的笨拙做法了。function reticulateSplines() { if (featureIsEnabled(“use-new-SR-algorithm”)) { return enhancedSplineReticulation(); } else { return oldFashionedSplineReticulation(); } }
现在,我们引入了一个 featureIsEnabled 函数。它是一个开关路由器(Toggle Router),用于动态决定当前应启用哪一条代码路径。开关路由器的实现方式有很多:可以是简单的内存存储,也可以是带有管理界面的复杂分布式系统。这里我们先从一个非常简单的版本开始:function createToggleRouter(featureConfig) { return { setFeature(featureName, isEnabled) { featureConfig[featureName] = isEnabled; }, featureIsEnabled(featureName) { return featureConfig[featureName]; } }; }
请注意,这里使用的是 ES2015 的方法简写语法。
我们可以根据某个默认配置创建新的开关路由器,例如从配置文件中读取配置;也可以动态启用或禁用某个功能。这使自动化测试能够验证同一个功能在两种开关状态下的表现:describe(“样条曲线网格化”, function() { let toggleRouter; let simulationEngine; beforeEach(function() { toggleRouter = createToggleRouter(); simulationEngine = createSimulationEngine({ toggleRouter: toggleRouter }); }); it(“使用旧算法时运行正常”, function() { // given toggleRouter.setFeature(“use-new-SR-algorithm”, false); // when const result = simulationEngine.doSomethingWhichInvolvesSplineReticulation(); // then verifySplineReticulation(result); }); it(“使用新算法时运行正常”, function() { // given toggleRouter.setFeature(“use-new-SR-algorithm”, true); // when const result = simulationEngine.doSomethingWhichInvolvesSplineReticulation(); // then verifySplineReticulation(result); }); });
准备发布
随着时间推移,团队认为新算法已经趋于完善。为了验证这一点,他们修改了更高层级的自动化测试,使系统能够在启用和禁用该功能的两种情况下接受测试。团队还希望进行一些手动探索式测试,确保一切运行正常。毕竟,样条曲线网格化是系统行为中的关键部分。
为了对尚未确认可以公开发布的功能进行手动测试,我们需要一种机制:在生产环境中对普通用户禁用该功能,但允许内部用户启用它。实现这一目标有多种方式:
- 让开关路由器根据开关配置做出决策,而该配置与环境相关。例如,仅在预生产环境中启用新功能。
- 允许通过某种管理界面在运行时修改开关配置。例如,在测试环境中通过管理界面启用新功能。
- 让开关路由器根据请求动态做出决策。这类决策会考虑开关上下文,例如检查特定的 Cookie 或 HTTP 头。通常,开关上下文会被用作识别请求用户身份的代理。
后文会更详细地讨论这些方法。因此,即使你现在对其中某些概念还不熟悉,也不必担心。

团队最终决定采用按请求动态切换的路由方式,因为它具有很强的灵活性。团队尤其看重的一点是,这种方式使他们无需单独搭建测试环境,也能测试新算法。他们可以在生产环境中启用该算法,但仅对内部用户开放,并通过特殊 Cookie 识别这些用户。这样,团队成员只需启用该 Cookie,就能验证新功能是否按预期运行。
金丝雀发布:用功能开关控制灰度范围
根据目前的探索式测试结果,新的样条曲线网格化算法表现良好。然而,由于它是游戏模拟引擎的关键组成部分,开发团队仍不愿立即向所有用户启用该功能。于是,他们决定利用功能开关机制进行金丝雀发布:仅向一小部分用户,也就是“金丝雀用户群”,开放新功能。
团队通过引入“用户群组”的概念来增强开关路由器。用户群组指的是一组始终体验到某个功能处于开启或关闭状态的用户。团队随机抽取 1% 的用户,例如通过用户 ID 取模,创建了一个“金丝雀用户群”。该用户群始终启用新算法,而其余 99% 的用户继续使用旧算法。
随后,团队会分别监控这两个用户群的关键业务指标,例如用户参与度、总收入等,以确保新算法不会对用户行为产生负面影响。一旦团队确认新功能没有不良影响,就会修改开关配置,将该功能面向所有用户启用。
A/B 测试:用功能标志验证产品假设
团队的产品经理了解到这种做法后非常兴奋。她建议团队使用类似机制来进行 A/B 测试。长期以来,团队一直在争论一个问题:如果修改犯罪率算法,将污染水平纳入考量,究竟,团队一直在争论一个问题:如果修改犯罪率算法,将污染水平纳入考量,究竟会提升还是降低游戏的可玩性?
现在,他们可以用数据来解决这个争论。团队计划推出一个低成本实现,捕捉这一想法的核心,并通过一个功能开关控制它。他们会面向相当一部分用户启用该功能,然后研究这些用户与“对照组”用户在行为上的差异。这样,团队就能基于数据,而不是基于最高薪资者的个人意见(HiPPO,Highest Paid Person’s Opinion)来解决产品争议。
这个简短场景展示了功能开关的基本概念,也突出了这项核心能力在多种场景中的潜力。接下来,我们将进一步深入:讨论不同类型的功能开关,分析它们之间的区别;介绍如何编写易于维护的开关代码;最后分享一些实践经验,帮助团队避免功能开关系统中的常见陷阱。
功能开关的类别
我们已经看到,功能开关提供了一项基本能力:在同一个可部署单元中包含不同的代码路径,并在运行时选择其中一条执行。前面的场景也表明,这项能力可以在许多不同场景中以不同方式使用。
把所有功能开关都视为同一种东西,表面上看似合理,但实际上很危险。不同类型的开关有截然不同的设计驱动因素。如果用同一种方式管理所有开关,后续很可能会带来问题。
功能开关可以从两个主要维度进行分类:开关的生命周期,以及开关决策的动态程度。当然,还有其他因素也值得考虑,例如由谁来管理功能开关。但在我看来,生命周期和动态性是指导开关管理的两个关键维度。
下面,我们就从这两个维度出发,看看不同类型的功能开关分别属于哪一类。
发布开关
发布开关用于帮助践行持续交付的团队开展基于主干的开发。它们允许团队将仍在开发中的功能提交到共享集成分支,例如 master 或 trunk,同时仍然保证该分支可以随时部署到生产环境。发布开关使团队能够把尚未完成、尚未经过验证的代码路径作为潜在代码发布到生产环境中,而这些代码路径可能永远不会被启用。
产品经理也可以采用类似的、以产品为中心的方式,避免尚未完成的产品功能被最终用户看到。例如,电商网站的产品经理可能不希望用户看到一个仅适用于某个物流合作伙伴的“预计发货日期”功能,而是希望等到该功能支持所有物流合作伙伴后再发布。产品经理也可能出于其他原因,不希望立即发布某个已经完全实现并通过测试的功能。例如,功能发布可能需要配合营销活动的节奏。
以这种方式使用发布开关,是实现持续交付中“将功能发布与代码部署解耦”这一原则的常见方法。

发布开关本质上是临时性的。它们通常不应保留超过一两周,但以产品发布为中心的开关可能会存在更长时间。发布开关的启用或禁用决策通常比较固定。对于某个给定版本,所有用户通常都会看到相同的开关状态。因此,通过发布一个包含开关配置变更的新版本来改变开关状态,通常是完全可以接受的。
实验开关
实验开关用于执行多变量测试或 A/B 测试。系统中的每个用户会被分配到某个用户群组中,运行时的开关路由会根据用户所属的群组,将其引导到不同的代码路径。通过跟踪不同群组的总体行为,我们可以比较不同代码路径带来的效果。这项技术常用于对电商系统的购买流程、按钮上的行动号召文案等进行数据驱动的优化。

实验开关需要在足够长的时间内保持同一配置,才能产生具有统计意义的结果。根据系统流量的不同,这可能意味着数小时,也可能意味着数周。但实验持续时间也不宜过长,因为系统中的其他变更可能会使实验结果失效。
实验开关本质上高度动态。每一个传入请求都可能来自不同用户,因此其路由结果可能与上一个请求不同。
运维开关
运维开关用于控制系统运行层面的行为。例如,当我们推出某项新功能,但尚不确定它对性能的影响时,可以引入一个运维开关,使系统运维人员在必要时能够迅速在生产环境中禁用该功能,或降低该功能的运行成本。
大多数运维开关的生命周期都相对较短。一旦我们对新功能的运行表现有了足够信心,就应移除这些开关。不过,系统中通常也会保留少量长期存在的“熔断开关”或“终止开关”,以便生产运维人员在系统承受异常高负载时,能够优雅地降级非关键功能。
例如,当系统负载过高时,我们可能需要禁用首页上一个开销较大的“推荐”面板。我曾接触过海外某些在线零售团队,他们维护着一些运维开关,可以在热门促销活动开始前,有意关闭网站核心购买流程中的许多非关键功能。这类长期存在的运维开关,可以被视为一种手动管理的熔断机制。

如前所述,许多运维开关只会短期存在,但一些关键控制项可能会长期保留,方便运维人员使用。由于这类开关的目的,是让运维人员能够快速响应生产问题,因此它们必须能够被非常迅速地重新配置。如果仅仅为了切换一个运维开关就需要发布一个新版本,运维人员一定不会满意。
权限开关
权限开关用于改变特定用户可以获得的功能或产品体验。例如,我们可能有一组“高级功能”,仅对付费客户启用;也可能有一组“Alpha 功能”,仅供内部用户使用;还可能有一组“Beta 功能”,仅向内部用户和 Beta 用户开放。
我把这种面向部分内部用户或 Beta 用户提前启用新功能的方式称为“香槟早午餐”——也就是让他们提前“享用香槟”。
香槟早午餐在很多方面与金丝雀发布类似。两者的区别在于:金丝雀发布会将功能开放给随机选择的用户群体,而香槟早午餐则只向特定用户群体开放功能。

当权限开关用于管理仅对高级用户开放的功能时,它的生命周期可能远长于其他类型的功能开关,甚至可以持续数年。由于权限通常与具体用户相关,因此权限开关的决策往往基于每一个请求,是一种非常动态的开关。
管理不同类别的功能开关
有了上述分类之后,我们就可以讨论“动态性”和“生命周期”这两个维度如何影响我们管理不同类型功能标志的方式。
静态开关与动态开关

需要在运行时做出路由决策的开关,必然需要更复杂的开关路由器,以及更复杂的路由器配置。
对于简单的静态路由决策,每个功能的开关配置可以只是一个简单的“开”或“关”。开关路由器只需将这个静态状态传递给开关点即可。
但正如前文所说,其他类型的开关更加动态,需要更复杂的开关路由器。例如,实验开关的路由器会根据给定用户做出动态路由决策,可能会使用基于用户 ID 的某种一致性分组算法。这种开关路由器不再只是从配置中读取静态的开关状态,而是需要读取某种分组配置,例如实验组和对照组各自的规模。随后,该配置会作为分组算法的输入。
后文我们会进一步讨论管理这类开关配置的不同方法。
长期存在的开关与临时开关

我们还可以将功能开关分为两类:一类本质上是短暂的,另一类则是长期存在的,甚至可能持续数年。这种差异应该显著影响我们实现开关点的方式。
如果我们添加的是一个几天后就会移除的发布开关,那么在开关点中简单地对开关路由器做一次 if/else 检查,通常就足够了。前面样条曲线网格化的例子就是这样:function reticulateSplines() { if (featureIsEnabled(“use-new-SR-algorithm”)) { return enhancedSplineReticulation(); } else { return oldFashionedSplineReticulation(); } }
但如果我们创建的是一个全新的权限开关,而且预计它会长期存在,那么显然不应该随意在代码中堆叠大量 if/else 语句来实现开关点。对于长期存在的开关,我们需要采用更易维护的实现方式。
功能开关的实现技术
功能开关很容易让开关点代码变得混乱,也容易让开关点在整个代码库中蔓延。无论是哪类功能开关,我们都应控制这种趋势;对于长期存在的开关尤其如此。下面介绍一些实现模式和实践,它们有助于缓解这一问题。
将决策点与决策逻辑解耦
使用功能开关时,一个常见错误是把做出开关决策的位置,也就是开关点,与决策背后的逻辑,也就是开关路由,耦合在一起。
来看一个例子。我们正在开发下一代电商系统。其中一项新功能允许用户通过点击订单确认邮件,也就是发票邮件中的链接,轻松取消订单。我们使用功能标志管理所有下一代功能的推出。最初的功能标志实现如下:
invoiceEmailer.jsconst features = fetchFeatureTogglesFromSomewhere(); function generateInvoiceEmail() { const baseEmail = buildEmailForInvoice(this.invoice); if (features.isEnabled(“next-gen-ecomm”)) { return addOrderCancellationContentToEmail(baseEmail); } else { return baseEmail; } }
在生成发票邮件时,发票邮件发送器会检查 next-gen-ecomm 功能是否已经启用。如果已启用,它就在邮件中添加额外的订单取消内容。
这个方案看起来似乎合理,但其实很脆弱。“是否在发票邮件中包含订单取消功能”这一决策,被直接绑定到了一个相当宽泛的 next-gen-ecomm 功能上,而且还是通过一个“魔法字符串”实现的。为什么发票邮件发送代码需要知道订单取消内容属于下一代功能集?如果我们想启用下一代功能的某些部分,但暂时不开放订单取消功能,该怎么办?反过来又该怎么办?如果我们决定只向特定用户推出订单取消功能,又该怎么办?
在功能开发过程中,这类“开关范围”的变化非常常见。还要注意,开关点往往会散布在整个代码库中。如果开关决策逻辑被写在开关点里,那么一旦要修改决策逻辑,就必须在代码库中逐一查找并修改所有相关开关点。
幸运的是,软件中的许多问题都可以通过增加一层间接层来解决。我们可以这样将开关决策点与其背后的逻辑解耦:
featureDecisions.jsfunction createFeatureDecisions(features) { return { includeOrderCancellationInEmail() { return features.isEnabled(“next-gen-ecomm”); } // 其他决策函数…… }; }
invoiceEmailer.jsconst features = fetchFeatureTogglesFromSomewhere(); const featureDecisions = createFeatureDecisions(features); function generateInvoiceEmail() { const baseEmail = buildEmailForInvoice(this.invoice); if (featureDecisions.includeOrderCancellationInEmail()) { return addOrderCancellationContentToEmail(baseEmail); } else { return baseEmail; } }
我们引入了一个 FeatureDecisions 对象,作为所有功能开关决策逻辑的集中位置。我们为代码中的每一个具体开关决策创建一个决策方法。在本例中,“是否在发票邮件中包含订单取消功能”由 includeOrderCancellationInEmail 方法表示。
目前,这个决策逻辑只是简单地检查 next-gen-ecomm 功能的状态。但随着逻辑演进,我们已经有了一个集中管理的位置。每当我们想修改某个具体开关决策的逻辑时,只需要修改一个地方。我们可能需要调整决策范围,例如由哪个具体功能标志控制该决策;也可能需要调整决策依据,例如从静态开关配置改为 A/B 测试,或者改为基于运维状况的判断,例如订单取消基础设施发生故障。
无论是哪种情况,发票邮件发送器都不需要知道这个开关决策是如何做出的,也不需要知道它为什么这样做。
决策反转
在前面的例子中,发票邮件发送器负责询问功能标志机制自己应该如何运行。这意味着发票邮件发送器需要了解一个额外概念——功能标志,还需要耦合到一个额外模块。这会让发票邮件发送器更难独立理解、独立使用和独立测试。
随着时间推移,功能标志在系统中会越来越普遍,越来越多模块会把功能标志系统作为全局依赖。这并不是理想状态。
在软件设计中,我们通常可以通过控制反转来解决这类耦合问题。这里也是如此。下面是一种将发票邮件发送器与功能标志机制解耦的方式:
invoiceEmailer.jsfunction createInvoiceEmailler(config) { return { generateInvoiceEmail() { const baseEmail = buildEmailForInvoice(this.invoice); if (config.includeOrderCancellationInEmail) { return addOrderCancellationContentToEmail(baseEmail); } else { return baseEmail; } } // 其他发票邮件发送方法…… }; }
featureAwareFactory.jsfunction createFeatureAwareFactoryBasedOn(featureDecisions) { return { invoiceEmailler() { return createInvoiceEmailler({ includeOrderCancellationInEmail: featureDecisions.includeOrderCancellationInEmail() }); } // 其他工厂方法…… }; }
现在,InvoiceEmailler 不再主动调用 FeatureDecisions,而是在构建时通过 config 对象接收这些决策。这样,InvoiceEmailler 就完全不需要知道功能标志的存在。它只知道自己行为的某些方面可以在运行时配置。
这也让测试 InvoiceEmailler 的行为变得更容易。我们只需要在测试中传入不同配置,就能分别验证它在包含和不包含订单取消内容时的表现:describe(“发票邮件发送器”, function() { it(“当配置要求包含订单取消内容时,应包含该内容”, function() { // given const emailler = createInvoiceEmailler({ includeOrderCancellationInEmail: true }); // when const email = emailler.generateInvoiceEmail(); // then verifyEmailContainsOrderCancellationContent(email); }); it(“当配置要求不包含订单取消内容时,不应包含该内容”, function() { // given const emailler = createInvoiceEmailler({ includeOrderCancellationInEmail: false }); // when const email = emailler.generateInvoiceEmail(); // then verifyEmailDoesNotContainOrderCancellationContent(email); }); });
我们还引入了一个 FeatureAwareFactory,用于集中创建这些注入了功能决策的对象。这是依赖注入模式的一种应用。如果代码库中已经使用了依赖注入系统,那么很可能可以直接使用现有系统来实现这种做法。
避免条件语句
到目前为止,我们的示例都使用 if 语句来实现开关点。对于简单、短期存在的开关,这样做或许可以接受。但如果某个功能需要多个开关点,或者你预计开关点会长期存在,那么就不建议继续使用条件语句。
一种更易维护的替代方案,是使用某种策略模式来实现不同的代码路径:
invoiceEmailer.jsfunction createInvoiceEmailler(additionalContentEnhancer) { return { generateInvoiceEmail() { const baseEmail = buildEmailForInvoice(this.invoice); return additionalContentEnhancer(baseEmail); } // 其他发票邮件发送方法…… }; }
featureAwareFactory.jsfunction identityFn(x) { return x; } function createFeatureAwareFactoryBasedOn(featureDecisions) { return { invoiceEmailler() { if (featureDecisions.includeOrderCancellationInEmail()) { return createInvoiceEmailler(addOrderCancellationContentToEmail); } else { return createInvoiceEmailler(identityFn); } } // 其他工厂方法…… }; }
这里,我们使用了策略模式,为发票邮件发送器配置一个“内容增强函数”。FeatureAwareFactory 创建发票邮件发送器时,会根据 FeatureDecision 的结果选择具体策略。
如果邮件需要包含订单取消内容,就传入一个会添加订单取消信息的增强函数;否则,就传入一个无操作的 identityFn,它会直接原样返回邮件内容。
功能开关配置
动态路由与动态配置
前面我们将功能开关分为两类:一类是在给定代码部署中路由决策基本保持不变的开关;另一类是在运行时动态变化的开关。
需要注意的是,开关决策在运行时发生变化,实际上有两种方式。
第一种方式是通过重新配置改变决策。例如,运维开关可能会因为系统故障而从“开启”动态切换为“关闭”。
第二种方式是开关本身就具有动态性。例如,权限开关和实验开关会根据请求上下文,也就是当前请求对应的用户,为每个请求做出不同的路由决策。
前者是通过重新配置实现动态变化;后者本质上就是动态的。对于这些本质上动态的开关来说,它们的决策可能非常动态,但它们的配置仍然可以相当静态,甚至只能通过重新部署来修改。实验开关就是一个例子:我们通常并不需要在运行时修改实验参数。事实上,这样做很可能会让实验在统计意义上失效。
优先选择静态配置
如果功能标志的特性允许,最好通过源代码控制和重新部署来管理开关配置。
通过源代码控制管理开关配置,可以带来与使用源代码控制管理基础设施即代码(Infrastructure as Code,IaC)类似的优势。它允许开关配置与被切换的代码库共存。这样一来,开关配置就能像代码变更或基础设施变更一样,在持续交付流水线中流动。
这充分发挥了持续交付的优势:构建可重复,并且可以在不同环境中以一致方式进行验证。它也能大大降低功能标志带来的测试负担。因为开关状态已经嵌入发布版本中,并且不会在运行时改变,至少对于动态性较低的功能标志而言是这样,所以我们不需要同时验证同一个版本在开关关闭和开启状态下的表现。
开关配置与代码库一起保存在源代码控制中的另一个好处是:我们可以很容易地查看历史版本中的开关状态,并在需要时重新创建某个历史版本。
管理开关配置的方式
虽然静态配置是首选,但在某些场景下,例如运维开关,我们需要更动态的管理方式。下面我们来看几种管理开关配置的方法,从简单但动态性较低的方式,到复杂但更灵活的方式。
硬编码开关配置
最基础的技巧——甚至简单到不一定能称为功能标志——就是直接注释或取消注释代码块。例如:function reticulateSplines() { // return oldFashionedSplineReticulation(); return enhancedSplineReticulation(); }
比注释代码稍微复杂一点的方式,是使用预处理器提供的 #ifdef 功能,如果你的技术栈支持的话。
由于这种硬编码方式不支持动态重新配置开关,因此它只适用于那些可以通过重新部署代码来改变状态的功能标志。
参数化开关配置
硬编码配置所提供的是构建时配置,对于许多用例而言并不够灵活,尤其是大量测试场景。
一种简单方法是通过命令行参数或环境变量指定开关配置。这样,至少可以在不重新构建应用程序或服务的情况下重新配置功能标志。这是一种简单且历史悠久的开关方式,早在“功能开关”或“功能标志”这些术语流行之前,人们就已经在使用这种技术。
不过,它也有一些局限。跨大量进程协调配置会变得非常繁琐;而且更改开关配置通常仍然需要重新部署,或者至少需要重启进程。此外,负责重新配置开关的人可能还需要拥有服务器上的特权访问权限。
开关配置文件
另一种方法是从某种结构化文件中读取开关配置。这类开关配置通常源自更通用的应用程序配置文件。
使用开关配置文件后,只需修改配置文件就能重新配置功能标志,而不需要重新构建应用程序代码。不过,虽然大多数情况下不必重新构建应用程序,你可能仍然需要重新部署,才能让新的标志配置生效。
在应用程序数据库中存储开关配置
当系统规模达到一定程度后,使用静态文件管理开关配置会变得非常繁琐。通过文件修改配置相对麻烦,确保服务器集群中的配置保持一致本身就是一个挑战,而保持一致地修改配置则更加困难。
为了应对这种情况,许多组织会将开关配置迁移到某种集中式存储中,通常是现有的应用程序数据库。这通常还会伴随某种管理用户界面的建设,使系统运维人员、测试人员和产品经理能够查看和修改功能标志及其配置。
分布式开关配置
在系统已有的通用数据库中存储开关配置是一种很常见的做法。随着功能标志被引入并逐渐普及,这种做法往往会自然成为首选方案。
不过,近年来出现了一系列专用的分层键值存储或配置管理系统,它们更适合管理应用程序配置。这些系统通常会组成一个分布式集群,为集群中的所有节点提供共享的环境配置源。配置可以按需动态修改,集群中的所有节点也会自动收到变更通知——这是一项非常有用的附加能力。
使用这些系统管理开关配置,意味着我们可以在集群中的每个节点上部署开关路由器,并让它们基于整个集群协调一致的开关配置做出决策。
有些系统自带管理界面,可以提供一种基本的开关配置管理方式。不过在实际使用中,团队通常最终仍会创建一个小型的定制应用程序来管理开关配置。
覆盖配置
到目前为止,我们一直假设所有配置都由单一机制提供。但在实际系统中,情况往往更复杂:配置可能来自多个来源,并通过多层覆盖共同生效。
对于开关配置,常见做法是拥有一份默认配置,再叠加特定环境的覆盖配置。这些覆盖配置可能来自简单的附加配置文件,也可能来自复杂的分布式配置系统。
需要注意的是,任何特定于环境的覆盖配置,都在一定程度上违背了持续交付的理念:在整个交付流水线中保持完全相同的组件和配置流程。出于实用考虑,我们通常会使用一些特定于环境的覆盖配置;但如果我们努力让可部署单元和配置尽可能与环境无关,就能构建出更简单、更安全的交付流水线。
后文在讨论如何测试功能开关系统时,我们还会再次回到这个话题。
按请求覆盖
除了环境级别的配置覆盖之外,还有一种方式是允许通过特殊 Cookie、查询参数或 HTTP 头,在每个请求的层面覆盖开关的开启或关闭状态。
相比完整的配置覆盖,这种方式有几个优势。如果服务经过负载均衡,你可以确信无论请求落到哪个服务实例上,覆盖都会生效。你也可以在生产环境中覆盖某个功能标志,而不会影响其他用户,并且不太可能意外保留某个全局覆盖设置。
如果按请求覆盖机制使用的是持久 Cookie,那么测试人员可以配置自己的一组开关覆盖,这些覆盖会在他们自己的浏览器中持续生效。
这种按请求配置方式的缺点是,它引入了一个风险:好奇或恶意用户可能自行修改功能开关状态。一些组织可能不愿意看到某些未发布功能被足够执着的第三方访问到。对覆盖配置进行加密签名,是缓解这类担忧的一种方式。但无论如何,这种方法都会增加功能开关系统的复杂度,也会扩大系统的攻击面。
我曾在另一篇文章中详细介绍过这种基于 Cookie 的覆盖技术,也描述过一个与海外某技术咨询团队成员共同开源的实现。
在系统中使用功能标志
功能开关确实是一种非常有用的技术,但它也会带来额外复杂度。下面这些实践可以帮助我们更轻松地使用功能开关系统。
显示当前功能开关配置
将构建号或版本号嵌入已部署制品,并暴露这些元数据,使开发人员、测试人员或运维人员能够了解某个环境中正在运行的代码版本,这一直是一项非常有用的实践。同样的理念也适用于功能标志。
任何使用功能标志的系统,都应提供某种方式,让运维人员能够查看当前开关配置的状态。在面向 HTTP 的 SOA 系统中,这通常可以通过某种元数据 API 端点实现。例如,可以参考某些主流 Web 框架提供的应用管理与健康检查端点。
使用结构化的开关配置文件
通常的做法是将基础开关配置保存在某种结构化、易读的文件中,通常是 YAML 文件,并通过源代码控制系统进行管理。
这类文件还有一些额外好处。为每个开关添加易读说明非常有用,尤其是那些由核心交付团队之外的人员管理的开关。生产环境出现故障时,如果你需要决定是否启用某个运维开关,你更希望看到哪一种描述?
是“基础推荐算法”?
还是“使用简单推荐算法。该算法速度快,对后端系统负载较小,但准确率远低于标准算法”?
显然,后者更有助于决策。
一些团队还会选择在开关配置文件中添加其他元数据,例如创建日期、主要开发联系人,甚至是短期功能开关的过期日期。
以不同方式管理不同开关
如前所述,功能开关有多种类型,它们各有不同特性。即使所有开关都可能通过同一种技术机制控制,也应该重视这些差异,并以不同方式管理它们。
我们回到前面那个电商网站首页“推荐产品”模块的例子。最初,在开发阶段,我们可能会把它放在发布开关之后。之后,我们可能会将它迁移到实验开关之后,以验证它是否有助于提升收入。最后,我们又可能将它放到运维开关之后,以便在网站负载过高时关闭它。
如果我们遵循了前文关于“将决策逻辑与开关点解耦”的建议,那么这些开关类别的变化,应该完全不会影响开关点代码。
不过,从功能开关管理的角度来看,这些变化确实会产生影响。从发布开关过渡到实验开关时,开关的配置方式会发生变化,而且很可能迁移到不同位置,例如从源代码控制系统中的 YAML 文件迁移到管理界面。此时,产品经理和开发人员很可能都要参与配置管理。对于需要把需求评审、开发排期、测试验证和发布上线串联起来的研发团队,也可以借助 PingCode 这类智能化研发管理工具,把功能开关相关的需求、任务、缺陷、测试与发布记录统一管理,减少信息在不同环节之间断裂的风险。
同样,从实验开关过渡到运维开关,也意味着开关配置方式、配置位置以及配置管理者都会发生变化。
功能开关会带来验证复杂度
使用功能开关系统后,持续交付流程会变得更加复杂,尤其是在测试方面。我们经常需要在持续交付流水线中,对同一个可部署制品测试多条代码路径。
举例来说,假设我们正在交付一个系统,它可以根据某个开关的状态启用新的优化税收计算算法;如果开关关闭,则继续使用现有算法。当某个可部署制品流经持续交付流水线时,我们无法预知该开关最终在生产环境中会处于启用还是禁用状态——这正是功能开关存在的意义。因此,为了验证所有可能进入生产环境的代码路径,我们必须在两种状态下测试该制品:开关开启和开关关闭。

可以看到,即使只有一个开关生效,至少一部分测试也需要执行两次。而当多个开关同时生效时,可能的状态数量会呈指数级增长。验证每一种状态下的行为,将是一项极其艰巨的任务。这也会让测试人员对功能开关产生合理的担忧。
值得庆幸的是,情况并没有一些测试人员最初想象得那么糟。虽然带有功能标志的候选版本确实需要测试若干开关配置,但并不需要测试每一种可能组合。大多数功能标志之间不会相互影响,而且大多数版本发布也不会同时涉及多个功能标志配置的变更。
那么,团队应该测试哪些功能开关配置呢?
最重要的是测试那些预计会在生产环境中生效的功能开关配置。这意味着要测试当前生产环境中的功能开关配置,以及所有计划发布的功能开关都设置为“开启”时的配置。
同时,最好也测试一下备用配置,也就是所有计划发布的功能开关都设置为“关闭”时的情况。为了避免在未来版本中出现意外回归,许多团队还会进行一些所有功能开关都设置为“开启”的测试。
请注意,最后这条建议只有在团队遵循某种开关语义约定时才有意义:当功能开关处于“关闭”状态时,启用现有或旧版功能;当功能开关处于“开启”状态时,启用新的或未来的功能。
如果你的功能标志系统不支持运行时配置,那么为了切换开关,你可能需要重启正在测试的进程;更糟的是,甚至可能需要将制品重新部署到测试环境。这会严重拉长验证流程的周期时间,进而影响 CI/CD 所提供的关键反馈循环。
为了避免这个问题,可以考虑提供一个端点,允许在内存中动态重新配置功能标志。当你使用实验开关这类功能时,这种覆盖机制尤其必要,因为要同时测试开关的两条路径会更加繁琐。
动态重新配置特定服务实例的能力非常强大。但如果使用不当,它也可能在共享环境中造成许多问题和混乱。该能力应仅用于自动化测试,并在必要时用于手动探索式测试和调试。如果生产环境需要更通用的开关控制机制,最好使用前文“开关配置”部分讨论过的真正分布式配置系统来构建。
功能开关应该放在哪里
在系统边缘放置开关
对于需要每个请求上下文的开关类型,例如实验开关和权限开关,把开关点放在系统边缘服务中通常是合理的。这里所说的边缘服务,是指直接向最终用户提供功能的公开 Web 应用程序。
用户的单个请求首先从这里进入系统边界,因此开关路由器可以获得最丰富的上下文信息,从而基于用户及其请求做出开关决策。将开关点放在系统边缘的另一个好处是,它可以把烦琐的条件切换逻辑隔离在系统核心之外。
在许多情况下,你可以直接把开关点放在渲染 HTML 的位置。例如下面这个模板示例:
someFile.erb<% if featureDecisions.showRecommendationsSection? %> <%= render “recommendations_section” %> <% end %>
在界面边缘放置开关点,也非常适合控制尚未准备好发布的新功能的访问权限。在这种情况下,你可以使用一个简单的功能开关来控制访问入口,例如显示或隐藏某个 UI 元素。
举例来说,你可能正在开发“使用第三方账号登录应用”的功能,但尚未准备好向用户发布。这个功能的实现可能需要改动架构中的多个部分,但你可以通过 UI 层的一个简单功能开关来控制它是否对用户可见,例如隐藏“使用第三方账号登录”按钮。
值得注意的是,对于某些类型的功能标志,大部分未发布功能实际上可能已经暴露在系统中,只是位于用户无法发现的 URL 之后。
在系统核心放置开关
还有一些更底层的开关,必须放在架构更深处。这些开关通常更偏技术性,用于控制某项功能的内部实现方式。
例如,一个发布开关可能控制系统在访问第三方 API 之前,是否先使用新的缓存基础设施,还是直接将请求路由到该 API。在这类场景下,把开关决策放在被切换功能所在的服务内部,才是唯一合理的选择。
管理功能开关的持有成本
功能开关往往会迅速增多,尤其是在刚被引入时。它们创建起来方便又便宜,因此很容易被大量创建。然而,功能开关也会带来维护成本。它们要求你在代码中引入新的抽象或条件逻辑,还会显著增加测试负担。
海外某金融机构曾因功能开关管理不当并叠加其他操作问题,造成巨额损失。这一事件是一个警示:如果功能开关管理不当,后果可能极其严重。
成熟的团队会把代码库中的功能开关视为一种“库存”。库存会产生持有成本,因此他们会努力将库存维持在尽可能低的水平。
为了控制功能开关数量,团队必须主动移除不再需要的开关。有些团队规定,每当引入新的发布开关时,都必须同时在团队待办事项列表中添加一个移除该开关的任务。还有些团队会为开关设置“过期日期”。也有团队会创建“定时炸弹”:如果某个功能开关在过期后仍然存在,测试就会失败,甚至应用程序会拒绝启动。对于跨职能团队而言,也可以使用 Worktile 这类通用项目协作系统,将开关清理任务、负责人、截止日期和相关文档纳入日常协作流程,避免临时开关长期滞留。
我们也可以借鉴精益思想来减少库存,限制系统在任意时刻允许存在的功能开关数量。一旦达到上限,如果有人想添加新的开关,就必须先移除一个现有开关。
文章包含AI辅助创作:功能开关(Feature Toggle)与功能标志(Feature Flag):类型、实现方式与最佳实践,发布者:su,转载请注明出处:https://worktile.com/kb/p/3973931
微信扫一扫
支付宝扫一扫