首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >系统设计:功能开关(又名功能标志)Feature Toggles (aka Feature Flags)

系统设计:功能开关(又名功能标志)Feature Toggles (aka Feature Flags)

作者头像
崔认知
发布2025-06-23 13:21:56
发布2025-06-23 13:21:56
15700
代码可运行
举报
文章被收录于专栏:nobodynobody
运行总次数:0
代码可运行

认知科技技术团队

认知科技技术团队,定期提供最新IT类资讯、原创内容、编程开发的教程与经验分享,福利天天在等你!

292篇原创内容

公众号

翻译:https://martinfowler.com/articles/feature-toggles.html

“功能切换”(Feature Toggles (often also refered to as Feature Flags) )是一组模式,可以帮助团队快速且安全地向用户交付新功能。在这篇关于功能切换的文章中,我们将首先通过一个简短的故事来展示功能切换在一些典型场景中的作用。然后,我们将深入探讨具体的模式和实践,这些将帮助团队成功地使用功能切换。

功能切换(Feature Toggles)也被称为功能标志(Feature Flags)功能位(Feature Bits)功能切换器(Feature Flippers)。这些术语都是指同一组技术。在本文中,我将交替使用功能切换和功能标志。

一个切换的故事

想象一下这个场景。你正在参与一个复杂的城镇规划模拟游戏的开发,负责核心模拟引擎。你的团队被分配了一个任务,即提高样条曲线重构算法的效率。你清楚地知道,这将需要对现有实现进行一次大规模的重构,预计需要花费数周时间。与此同时,团队中的其他成员还需要继续在代码库的相关部分进行一些正在进行的工作。

如果可能的话,你希望避免为此工作创建分支,因为过去合并长期分支的经历非常痛苦。相反,你决定整个团队继续在主干上工作,但从事样条曲线重构改进的开发人员将使用功能切换来防止他们的工作影响其他团队成员或破坏代码库的稳定性。

功能标志的诞生

这是从事算法工作的两人组引入的第一个更改:

更改前

代码语言:javascript
代码运行次数:0
运行
复制
function reticulateSplines(){
    // 当前实现代码在此
}

更改后

代码语言:javascript
代码运行次数:0
运行
复制
function reticulateSplines(){
    var useNewAlgorithm = false;
    // useNewAlgorithm = true; // 如果你正在开发新的样条曲线重构算法,请取消此行注释

    if( useNewAlgorithm ){
        return enhancedSplineReticulation();
    }else{
        return oldFashionedSplineReticulation();
    }
}

function oldFashionedSplineReticulation(){
    // 当前实现代码在此
}

function enhancedSplineReticulation(){
    // TODO: 实现更好的样条曲线重构算法
}

两人组将当前算法的实现移动到了一个名为oldFashionedSplineReticulation的函数中,并将reticulateSplines变成了一个切换点。现在,如果有人正在开发新的算法,他们可以通过取消useNewAlgorithm = true这一行的注释来启用“使用新算法”这一功能

使标志动态化

几个小时后,两人组准备将他们的新算法通过模拟引擎的一些集成测试。他们还想在同一次集成测试运行中测试旧算法。这就需要能够动态地启用或禁用该功能,这意味着他们需要放弃通过注释或取消注释useNewAlgorithm = true这一行的笨拙机制:

代码语言:javascript
代码运行次数:0
运行
复制
function reticulateSplines(){
    if( featureIsEnabled("use-new-SR-algorithm") ){
        return enhancedSplineReticulation();
    }else{
        return oldFashionedSplineReticulation();
    }
}

我们现在已经引入了一个featureIsEnabled函数,这是一个切换路由器,可以用来动态控制哪条代码路径处于活动状态。切换路由器的实现方式多种多样,从简单的内存存储到复杂的分布式系统,甚至带有华丽的用户界面。现在,我们先从一个非常简单的系统开始:

代码语言:javascript
代码运行次数:0
运行
复制
function createToggleRouter(featureConfig){
    return {
        setFeature(featureName,isEnabled){
            featureConfig[featureName] = isEnabled;
        },
        featureIsEnabled(featureName){
            return featureConfig[featureName];
        }
    };
}

我们可以基于一些默认配置创建一个新的切换路由器——也许是从配置文件中读取的——但我们也可以动态地启用或禁用一个功能。这使得自动化测试能够验证一个被切换的功能的两个方面:

代码语言:javascript
代码运行次数:0
运行
复制
describe( '样条曲线重构', function(){
    let toggleRouter;
    let simulationEngine;

    beforeEach(function(){
        toggleRouter = createToggleRouter();
        simulationEngine = createSimulationEngine({toggleRouter:toggleRouter});
    });

    it('使用旧算法时可以正确工作', function(){
        // 给定
        toggleRouter.setFeature("use-new-SR-algorithm",false);

        // 当
        const result = simulationEngine.doSomethingWhichInvolvesSplineReticulation();

        // 那么
        verifySplineReticulation(result);
    });

    it('使用新算法时可以正确工作', function(){
        // 给定
        toggleRouter.setFeature("use-new-SR-algorithm",true);

        // 当
        const result = simulationEngine.doSomethingWhichInvolvesSplineReticulation();

        // 那么
        verifySplineReticulation(result);
    });
});

准备发布

时间又过去了一些,团队相信他们的新算法已经具备了发布所需的所有功能。为了确认这一点,他们一直在修改更高层次的自动化测试,以便在功能关闭和启用的情况下都对系统进行测试。团队还希望进行一些手动探索性测试,以确保一切都能按预期工作——毕竟,样条曲线重构是系统行为的关键部分。

为了在生产环境中手动测试一个尚未被验证为可以普遍使用的功能,我们需要能够为普通用户关闭该功能,但为内部用户启用它。实现这一目标的方法有很多种:

  • 让切换路由器根据切换配置做出决策,并使该配置特定于环境。只在预生产环境中启用新功能。
  • 允许通过某种管理用户界面在运行时修改切换配置。使用该管理用户界面在测试环境中启用新功能。
  • 教会切换路由器如何根据切换上下文做出动态的、按请求切换的决策。这些决策会考虑请求的上下文,例如通过查找特殊的 cookie 或 HTTP 头。通常,切换上下文被用作识别发起请求的用户的代理。

(我们稍后会更详细地探讨这些方法,所以如果你对这些概念还不熟悉,不用担心。)

图片
图片

img

团队决定采用按请求切换的路由器,因为它给他们带来了很大的灵活性。团队特别欣赏的是,这将使他们能够在生产环境中测试新算法,而无需单独的测试环境。相反,他们只需在生产环境中启用该算法,但仅限于内部用户(通过特殊 cookie 检测)。团队现在可以为自己启用该 cookie,并验证新功能是否按预期工作。

灰度发布

根据到目前为止进行的探索性测试,新的样条曲线重构算法看起来不错。然而,由于它是游戏模拟引擎的关键部分,团队仍然有些不愿意为所有用户启用这个功能。团队决定使用他们的功能标志基础设施进行一次灰度发布,只在他们的总用户群中为一小部分用户——一个“金丝雀”群体——启用新功能。

团队通过增强切换路由器,使其理解用户群体的概念——一组始终一致地体验到一个功能始终处于开启或关闭状态的用户。通过随机抽样的方式创建了一个金丝雀用户群体,占用户总数的 1%——也许使用用户 ID 的模数。这个金丝雀群体将始终启用该功能,而其他 99% 的用户群体仍然使用旧算法。团队将监控两组用户的关键业务指标(用户参与度、总收入等),以确保新算法不会对用户行为产生负面影响。一旦团队确信新功能没有不良影响,他们就会修改切换配置,为整个用户群体启用该功能。

A/B 测试

团队的产品经理了解到这种方法后,非常兴奋。她建议团队使用类似机制进行一些 A/B 测试。关于是否修改犯罪率算法以考虑污染水平是否会增加或减少游戏的可玩性,一直存在争议。他们现在有能力通过数据来解决这一争议。他们计划推出一个能够体现该想法本质的低成本实现,并通过功能标志进行控制。他们将为相当大一部分用户启用该功能,然后研究这些用户的行为与“对照”群体相比如何变化。这种方法将使团队能够基于数据,而不是 HiPPOs,解决有争议的产品问题。

这个简短的场景旨在说明功能切换的基本概念,同时也突出这一核心能力可以有多种不同的应用。现在我们已经看到了这些应用的例子,让我们深入探讨一下。我们将探索不同类别的切换,并看看它们之间的区别。我们将介绍如何编写可维护的切换代码,最后分享一些避免功能切换系统陷阱的实践。

切换的类别

我们已经看到了功能切换提供的基本功能——在一个可部署单元中发布替代代码路径,并在运行时选择其中一条。上述场景还表明,这种功能可以在各种不同的情境中以多种方式使用。将所有功能切换归为一类可能会很有诱惑力,但这是一条危险的道路。不同类别切换所面临的设计力量相当不同,以相同的方式管理它们可能会在未来带来痛苦。

功能切换可以根据两个主要维度进行分类:功能切换将存在多长时间,以及切换决策需要有多动态。还有其他因素需要考虑——例如谁将管理功能切换——但我认为长期性和动态性是两个重要的因素,可以帮助指导我们如何管理切换。

让我们通过这两个维度来考虑各种类别的切换,并看看它们的归属。

发布切换

发布切换允许将不完整且未经测试的代码路径作为潜在代码部署到生产环境中,这些代码可能永远不会被启用。

这些功能标志用于实践持续交付的团队实现基于主干的开发。它们允许正在进行的功能被签入共享集成分支(例如 master 或主干),同时仍然允许该分支随时部署到生产环境中。发布切换允许将不完整且未经测试的代码路径作为潜在代码部署到生产环境中,这些代码可能永远不会被启用。

产品经理也可能出于产品中心的原因使用相同的方法来防止半成品功能被终端用户看到。例如,一个电子商务站点的产品经理可能不希望用户看到一个仅对其中一个运输合作伙伴有效的“预计运输日期”功能,宁愿等到该功能对所有运输合作伙伴都实现后再发布。产品经理可能还有其他原因不希望暴露一个功能,即使它已经完全实现和测试。功能发布可能会与营销活动协调进行,例如。以这种方式使用发布切换是实现持续交付原则“将功能发布与代码部署分离”的最常见方式。

图片
图片

img

发布切换本质上是过渡性的。它们通常不应该存在超过一到两周,尽管以产品为中心的切换可能需要保留更长时间。发布切换的切换决策通常是静态的。对于给定的发布版本,每个切换决策都是相同的,通常通过发布新版本并更改切换配置来改变该切换决策是完全可以接受的。

实验切换

实验切换用于进行多变量或 A/B 测试。系统的每个用户被分配到一个群体中,在运行时,切换路由器将始终将给定用户路由到一条代码路径或另一条路径,基于他们所在的群体。通过跟踪不同群体的聚合行为,我们可以比较不同代码路径的效果。这种技术通常用于对电子商务系统中的购买流程或按钮上的号召性用语等进行数据驱动的优化。

图片
图片

img

实验切换需要保留足够长的时间以生成具有统计显著性的结果。根据流量模式,这可能意味着几小时或几周的生命周期。更长时间的保留不太可能有用,因为系统中的其他更改可能会使实验结果失效。根据其本质,实验切换是高度动态的——每个传入请求可能代表不同的用户,因此可能与上一个请求被路由到不同的路径。

运营切换

这些标志用于控制系统的运营行为。我们可能会在推出一个具有不确定性能影响的新功能时引入运营切换,以便系统操作人员如果需要,可以快速在生产环境中禁用或降级该功能。

大多数运营切换将是相对短期的——一旦对新功能的运营方面获得信心,该标志就应该被退役。然而,许多系统会保留少量的长期“杀戮开关”,允许生产环境的操作人员在系统承受异常高负载时优雅地降级非关键系统功能。例如,当系统负载很高时,我们可能希望禁用首页上相对昂贵的推荐面板。我曾咨询过一家在线零售商,他们维护的运营切换可以在高需求产品发布之前故意禁用其主要购买流程中的许多非关键功能。这些长期存在的运营切换可以被视为手动管理的断路器。

图片
图片

img

正如前面提到的,许多这些标志只存在很短的时间,但一些关键控制可能会被操作人员几乎无限期地保留下来。由于这些标志的目的是允许操作人员快速响应生产问题,因此它们需要能够极其快速地重新配置——需要发布新版本才能转运营切换的操作人员可能不会感到满意。

权限切换

为一组内部用户启用新功能是一个香槟早午餐——一个早期机会,可以“品尝自己的香槟”。

这些标志用于更改某些用户收到的功能或产品体验。例如,我们可能有一组“高级”功能,只对付费客户启用。或者,我们可能有一组“alpha”功能,仅对内部用户可用,另一组“beta”功能,对内部用户和 beta 用户可用。我将这种为一组内部或 beta 用户启用新功能的技术称为香槟早午餐——一个早期机会,可以“品尝自己的香槟”。

香槟早午餐在许多方面与金丝雀发布相似。两者的区别在于,金丝雀发布的功能是暴露给随机选择的用户群体,而香槟早午餐的功能是暴露给特定的用户组。

图片
图片

img

当用作管理仅对高级用户暴露的功能时,权限切换可能比其他类别的功能切换存在更长时间——以多年为单位。由于权限是用户特定的,权限切换的切换决策将始终是按请求的,使其成为一个非常动态的切换。

管理不同类别的切换

现在我们有了一个切换分类方案,我们可以讨论动态性和长期性这两个维度如何影响我们如何处理不同类别的功能标志。

静态与动态切换
图片
图片

img

在运行时做出路由决策的切换必然需要更复杂的切换路由器,以及这些路由器的更复杂的配置。

对于简单的静态路由决策,切换配置可以是一个简单的每个功能的开启/关闭状态,切换路由器只需负责将该静态开启/关闭状态传递给切换点。正如我们之前讨论的,其他类别的切换更具动态性,需要更复杂的切换路由器。例如,实验切换的路由器会为给定用户动态做出路由决策,可能使用某种基于用户 ID 的一致群体算法。与从配置中读取静态切换状态不同,这种切换路由器需要读取某种群体配置,定义实验群体和对照群体的大小。该配置将被用作群体算法的输入。

我们将更详细地讨论不同方式来管理这种切换配置。

长期切换与临时切换

我们还可以将切换类别分为本质上是临时性的与那些可能长期存在甚至数年的。这种区分应该强烈影响我们实现功能的切换点的方式。如果我们添加一个几天内就会被移除的发布切换,那么我们可能可以接受一个简单的 if/else 检查切换路由器的切换点。这就是我们在样条曲线重构示例中所做的:

代码语言:javascript
代码运行次数:0
运行
复制
function reticulateSplines(){
    if( featureIsEnabled("use-new-SR-algorithm") ){
        return enhancedSplineReticulation();
    }else{
        return oldFashionedSplineReticulation();
    }
}

然而,如果我们正在创建一个新的权限切换,其切换点预计会保留很长时间,那么我们肯定不想通过随意散布 if/else 检查来实现这些切换点。我们需要使用更可维护的实现技术。

实现技术

功能标志似乎会导致相当混乱的切换点代码,这些切换点还有在代码库中扩散的倾向。控制这种倾向对于代码库中的任何功能标志都是很重要的,特别是如果标志将长期存在的话。有一些实现模式和实践可以帮助减少这个问题。

解耦决策点与决策逻辑

功能切换的一个常见错误是将做出切换决策的地方(切换点)与决策背后的逻辑(切换路由器)耦合在一起。让我们看一个例子。我们正在开发下一代电子商务系统。我们的一项新功能将允许用户通过点击订单确认邮件(即发票邮件)中的链接轻松取消订单。我们正在使用功能标志来管理所有下一代功能的发布。我们最初的功能标志实现如下:

invoiceEmailer.js

代码语言:javascript
代码运行次数:0
运行
复制
    const features = fetchFeatureTogglesFromSomewhere();

    function generateInvoiceEmail(){
        const baseEmail = buildEmailForInvoice(this.invoice);
        if( features.isEnabled("next-gen-ecomm") ){
            return addOrderCancellationContentToEmail(baseEmail);
        }else{
            return baseEmail;
        }
    }

在生成发票邮件时,我们的 InvoiceEmailler 检查“next-gen-ecomm”功能是否启用。如果是,则邮件中会添加一些额外的订单取消内容。

虽然这看起来是一个合理的方法,但它非常脆弱。关于是否在发票邮件中包含订单取消功能的决策直接与该相当宽泛的“next-gen-ecomm”功能耦合在一起——甚至使用了一个魔术字符串。为什么发票邮件代码需要知道订单取消内容是下一代功能集的一部分?如果我们希望启用下一代的部分功能而不暴露订单取消功能怎么办?反之亦然?如果我们决定只想对某些用户发布订单取消功能呢?在功能开发过程中,这种“切换范围”变化是很常见的。还要记住,这些切换点往往会在代码库中扩散。由于我们当前的方法将切换决策逻辑作为切换点的一部分,任何对该决策逻辑的更改都需要遍历整个代码库中扩散的所有切换点。

幸运的是,任何软件问题都可以通过增加一层间接性来解决。我们可以通过以下方式将切换决策点与该决策背后的逻辑解耦:

featureDecisions.js

代码语言:javascript
代码运行次数:0
运行
复制
    function createFeatureDecisions(features){
        return {
            includeOrderCancellationInEmail(){
                return features.isEnabled("next-gen-ecomm");
            }
            // ... 其他决策函数也住在这里 ...
        };
    }

invoiceEmailer.js

代码语言:javascript
代码运行次数:0
运行
复制
    const 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.js

代码语言:javascript
代码运行次数:0
运行
复制
    function createInvoiceEmailler(config){
        return {
            generateInvoiceEmail(){
                const baseEmail = buildEmailForInvoice(this.invoice);
                if( config.includeOrderCancellationInEmail ){
                    return addOrderCancellationContentToEmail(email);
                }else{
                    return baseEmail;
                }
            },

            // ... 其他发票邮件生成器方法 ...
        };
    }

featureAwareFactory.js

代码语言:javascript
代码运行次数:0
运行
复制
    function createFeatureAwareFactoryBasedOn(featureDecisions){
        return {
            invoiceEmailler(){
                return createInvoiceEmailler({
                    includeOrderCancellationInEmail: featureDecisions.includeOrderCancellationInEmail()
                });
            },

            // ... 其他工厂方法 ...
        };
    }

现在,我们的 InvoiceEmailler 不再向外请求 FeatureDecisions,而是在构造时通过 config 对象注入这些决策。InvoiceEmailler 现在完全不了解功能标志。它只知道其某些行为可以在运行时配置。这还使得测试 InvoiceEmailler 的行为变得更容易——我们可以通过在测试中传递不同的配置选项来测试它在配置为包含或不包含订单取消内容时如何生成邮件:

代码语言:javascript
代码运行次数:0
运行
复制
describe( '发票邮件发送', function(){
    it( '在配置为包含订单取消内容时应包含此类内容', function(){
        // 给定
        const emailler = createInvoiceEmailler({includeOrderCancellationInEmail:true});

        // 当
        const email = emailler.generateInvoiceEmail();

        // 那么
        verifyEmailContainsOrderCancellationContent(email);
    });

    it( '在配置为不包含订单取消内容时不应包含此类内容', function(){
        // 给定
        const emailler = createInvoiceEmailler({includeOrderCancellationInEmail:false});

        // 当
        const email = emailler.generateInvoiceEmail();

        // 那么
        verifyEmailDoesNotContainOrderCancellationContent(email);
    });
});

我们还引入了一个 FeatureAwareFactory 来集中创建这些注入决策的对象。这是对依赖注入模式的一般应用。如果我们的代码库中有一个 DI 系统,那么我们可能用它来实现这种方法。

避免条件判断

在我们到目前为止的例子中,我们的切换点是用 if 语句实现的。这对于一个简单、短期的切换可能有意义。然而,在任何需要多个切换点或预期切换点将长期存在的功能中,不建议使用条件判断。一个更可维护的替代方案是使用某种策略模式来实现替代代码路径:

invoiceEmailler.js

代码语言:javascript
代码运行次数:0
运行
复制
    function createInvoiceEmailler(additionalContentEnhancer){
        return {
            generateInvoiceEmail(){
                const baseEmail = buildEmailForInvoice(this.invoice);
                return additionalContentEnhancer(baseEmail);
            },
            // ... 其他发票邮件生成器方法 ...

        };
    }

featureAwareFactory.js

代码语言:javascript
代码运行次数:0
运行
复制
    function identityFn(x){ return x; }

    function createFeatureAwareFactoryBasedOn(featureDecisions){
        return {
            invoiceEmailler(){
                if( featureDecisions.includeOrderCancellationInEmail() ){
                    return createInvoiceEmailler(addOrderCancellationContentToEmail);
                }else{
                    return createInvoiceEmailler(identityFn);
                }
            },

            // ... 其他工厂方法 ...
        };
    }

在这里,我们通过允许我们的发票邮件生成器被配置为一个内容增强函数来应用策略模式。FeatureAwareFactory 在创建发票邮件生成器时,根据其 FeatureDecision 选择一个策略。如果订单取消功能应该出现在邮件中,它将传入一个增强器函数,该函数将该内容添加到邮件中。否则,它会传入一个 identityFn 增强器——一个没有效果的函数,只是将邮件原样返回。

切换配置

动态路由与动态配置

早些时候,我们将功能标志分为那些在给定代码部署中切换路由决策基本是静态的,以及那些在运行时变化的。需要注意的是,一个标志的决策可能以两种方式在运行时变化。首先,像运营切换这样的标志可能会在系统故障期间从开启动态地重新配置为关闭。其次,某些类别的切换(如权限切换和实验切换)会根据诸如请求用户等请求上下文为每个请求做出动态路由决策。前者是通过重新配置动态的,而后者是内在动态的。这些内在动态的切换可能做出高度动态的决策,但它们的配置可能相当静态,可能只能通过重新部署进行更改。实验切换就是这种功能标志的一个例子——我们实际上不需要在运行时修改实验的参数。事实上这样做可能会使实验在统计上无效。

优先使用静态配置

如果功能标志的性质允许,通过源控制和重新部署来管理切换配置是更可取的。通过源控制管理切换配置可以为我们提供使用源控制管理基础设施即代码的相同好处。它允许切换配置与被切换的代码库一起存在,这带来了一个巨大的优势:切换配置将与代码更改或基础设施更改以完全相同的方式通过持续交付管道移动。这实现了 CD 的全部好处——可重复的构建,以一致的方式在各个环境中进行验证。它还大大减少了功能标志的测试负担。对于不太动态的标志,几乎不需要验证发布在切换关闭和开启时的表现,因为该状态被烘焙到发布中,不会更改(至少对于较不动态的标志)。另一个好处是,切换配置与代码库并排存储,我们可以轻松查看以前发布中的切换状态,并在需要时轻松重现以前的发布。

管理切换配置的方法

尽管静态配置是更可取的,但在某些情况下,例如运营切换,需要更动态的方法。让我们看看一些管理切换配置的方法,从简单但不太动态的方法到复杂但功能强大的方法。

硬编码切换配置

最基础的技术——可能基础到不被视为功能标志——是简单地注释或取消注释代码块。例如:

代码语言:javascript
代码运行次数:0
运行
复制
function reticulateSplines(){
    //return oldFashionedSplineReticulation();
    return enhancedSplineReticulation();
}

比注释方法稍复杂的是使用可用预处理器的 #ifdef 功能。

由于这种硬编码方法不允许动态重新配置切换,因此它只适用于我们愿意通过部署代码来重新配置标志的功能标志。

参数化切换配置

构建时配置提供的硬编码配置对于许多用例来说不够灵活,包括许多测试场景。一种允许在不重新构建应用程序或服务的情况下重新配置功能标志的简单方法是通过命令行参数或环境变量指定切换配置。这种方法已经存在了很长时间,早在人们将这种技术称为功能切换或功能标志之前。然而,它有局限性。在协调大量进程的配置方面可能会变得繁琐,更改切换配置需要重新部署或至少重新启动进程(并且可能需要服务器的特权访问才能重新配置切换)。

切换配置文件

另一个选项是从某种结构化文件中读取切换配置。这种方法通常开始于作为更通用的应用程序配置文件的一部分。

有了切换配置文件,现在你可以通过更改该文件而不是重新构建应用程序代码本身来重新配置功能标志。然而,在大多数情况下,你仍然可能需要重新部署应用程序来重新配置标志,尽管不需要重新构建。

应用程序 DB 中的切换配置

使用静态文件管理切换配置一旦达到一定规模就会变得繁琐。通过文件修改配置相对麻烦。跨一组服务器确保一致性和修改的一致性成为挑战。为了应对这一问题,许多组织将切换配置移动到某种类型的集中存储中,通常是现有的应用程序数据库。这通常伴随着构建某种管理界面,允许系统操作人员、测试人员和产品经理查看和修改功能标志及其配置。

分布式切换配置

使用通用数据库存储切换配置是非常常见的;它是功能标志引入并开始流行后的一个显而易见的去处。然而,现在有一些专用的分层键值存储服务更适合管理应用程序配置——如 Zookeeper、etcd 或 Consul。这些服务形成一个分布式集群,为所有连接到集群的节点提供共享的环境配置。可以动态修改配置,所有集群中的节点都会自动被告知更改——这是一个非常方便的附加功能。使用这些系统管理切换配置意味着每个节点上的切换路由器可以基于跨整个集群协调的切换配置做出决策。

这些系统中的一些(如 Consul)附带一个基本的管理界面,用于管理切换配置。然而,通常会创建一个小的自定义应用程序来管理切换配置。

覆盖配置

到目前为止,我们的讨论假设所有配置都由单一机制提供。对于许多系统来说,现实情况更为复杂,具有来自各种来源的覆盖配置层。对于切换配置,通常有一个默认配置以及来自简单附加配置文件或 Zookeeper 集群等复杂系统的环境特定的覆盖。要意识到,任何环境特定的覆盖都与持续交付的理想相悖,即在你的交付管道中有一个完全相同的位和配置流。通常,实用主义要求使用一些环境特定的覆盖,但努力保持你的可部署单元和配置尽可能环境不可知将导致更简单、更安全的管道。我们稍后在讨论测试功能切换系统时会再次讨论这个话题。

每次请求的覆盖

与环境特定的配置覆盖的替代方法是允许通过特殊的 cookie、查询参数或 HTTP 标头在每次请求的基础上覆盖切换的开启/关闭状态。这种方法有几个优点。如果服务是负载均衡的,你可以确信覆盖将应用于你正在访问的任何服务实例。你还可以在不影响其他用户的情况下在生产环境中覆盖功能标志,并且不太可能意外地留下覆盖。如果每次请求的覆盖机制使用持久 cookie,那么测试你的系统的人可以配置自己的自定义切换覆盖集,这些覆盖将一致地应用于他们的浏览器。

这种方法的缺点是它引入了好奇或恶意最终用户可能自行修改功能切换状态的风险。一些组织可能对未发布功能可能被足够有决心的用户公开访问的想法感到不适。对称加密你的覆盖配置是一种缓解这种担忧的选项,但无论如何,这种方法会增加功能切换系统的复杂性——以及攻击面。我在一篇博客文章中详细讨论了这种基于 cookie 的覆盖技术,并与 Thoughtworks 的一位同事开源了一个 ruby 实现。

使用功能标志系统的工作

尽管功能切换绝对是一种有帮助的技术,但它确实带来了额外的复杂性。以下是一些可以使使用功能标志系统变得更容易的技术。

暴露当前功能标志配置

长期以来,将构建/版本号嵌入到部署工件中并在某处公开这些元数据一直是一个有益的做法,以便开发人员、测试人员或操作人员可以了解某个环境中正在运行的具体代码。功能标志系统也应该应用相同的思路。任何使用功能标志的系统都应该提供一种方式,让操作人员可以发现切换配置的当前状态。在基于 HTTP 的 SOA 系统中,这通常通过某种元数据 API 端点或端点来实现。例如,Spring Boot 的 Actuator 端点。

利用结构化的切换配置文件

将基础切换配置存储在某种结构化、可读的文件(通常是 YAML 格式)中并通过源控制管理是很常见的。我们可以从这个文件中获得一些额外的好处。为每个切换包含一个人类可读的描述是非常有用的,特别是对于由核心交付团队之外的人管理的切换。当尝试决定在生产故障事件中启用运营切换时,你会更希望看到什么:basic-rec-algo 还是 “使用简单的推荐算法。这很快,对后端系统的负载更小,但比我们的标准算法准确性差得多。”?一些团队还选择在切换配置文件中包含其他元数据,如创建日期、主要开发人员联系方式,甚至为那些预期短期存在的切换设置过期日期。

不同的切换需要不同的管理方式

正如前面讨论的,有各种类别的功能标志,具有不同的特征。这些差异应该被拥抱,即使所有各种切换可能使用相同的技术机制,也应该以不同的方式管理不同的切换。

让我们回顾一下我们之前提到的电子商务站点的例子,该站点在首页上有一个推荐产品部分。最初,我们可能将该部分置于开发中的发布切换之后。然后,我们可能将其移至实验切换之后,以验证它是否有助于推动收入。最后,我们可能将其移至运营切换之后,以便在极端负载下可以将其关闭。如果我们遵循前面关于将决策逻辑与切换点解耦的建议,那么这些切换类别之间的转变对切换点代码本身不会有任何影响。

然而,从功能标志管理的角度来看,这些转变绝对应该产生影响。作为从发布切换转变为实验切换的一部分,切换的配置方式将会改变,并且可能移动到一个不同的区域——可能是源控制中的 YAML 文件而不是管理界面。产品人员现在可能管理配置,而不是开发人员。同样,从实验切换转变为运营切换将意味着如何配置切换、配置存储的位置以及谁管理配置的再次变化。

功能标志引入了验证的复杂性

使用功能标志系统,我们的持续交付过程变得更加复杂,特别是在测试方面。我们通常需要为同一个工件在 CD 流水线中测试多个代码路径。为了说明为什么,想象我们正在发布一个系统,如果切换开启则可以使用新的优化税计算算法,否则继续使用现有的算法。在给定的可部署工件通过我们的 CD 流水线时,我们无法知道该切换在生产环境中是否会开启或关闭——这正是功能标志的全部意义所在。因此,为了验证所有可能进入生产环境的代码路径,我们必须在两种状态下测试我们的工件:切换开启和关闭。

我们可以看到,当有一个切换在使用时,这引入了一个需求,即至少要倍进行一些测试。当有多个切换在使用时,我们可能会遇到可能的切换状态的组合爆炸。验证所有这些状态的行为将是一项艰巨的任务。这可能导致一些以测试为重点的人对功能标志持健康的怀疑态度。

幸运的是,情况并不像一些测试人员最初想象的那么糟糕。虽然一个具有功能标志的发布候选版本确实需要在几个切换配置下进行测试,但没有必要测试每一个可能的组合。大多数功能标志不会相互交互,并且大多数发布不会涉及超过一个功能标志的配置更改。

一个好的惯例是,当功能标志关闭时启用现有或遗留行为,当功能标志开启时启用新或未来行为。

那么,一个团队应该测试哪些功能标志配置呢?最重要的是测试你期望在生产环境中生效的切换配置,这意味着当前生产切换配置加上你打算发布开启的任何切换。同时,测试那些你打算发布的切换关闭的回退配置也是明智的。为了避免未来发布的意外回归,许多团队还会执行一些所有切换都开启的测试。请注意,这只有在你遵循切换语义的惯例时才有意义,即当功能标志关闭时启用现有或遗留行为,当开启时启用新或未来行为。

如果你的功能标志系统不支持运行时配置,那么你可能需要重新启动你正在测试的进程以转切换,或者更糟糕的是重新将工件部署到测试环境中。这会严重损害你的验证过程的周期时间,从而影响 CI/CD 提供的极其重要的反馈循环。为了避免这个问题,考虑暴露一个端点,允许动态地在内存中重新配置功能标志。这种类型的覆盖在用于实验切换时变得更加必要,因为要测试切换的两个路径更加繁琐。

这种动态重新配置特定服务实例的能力是一个非常锋利的工具。如果使用不当,它会在共享环境中造成很多痛苦和混乱。这种设施只应被自动化测试使用,可能还可以用于手动探索性测试和调试。如果在生产环境中需要一个更通用的切换控制机制,则最好使用上面讨论的分布式配置系统来构建。

切换放置的位置

边缘处的切换

对于需要每个请求上下文(实验切换、权限切换)的切换类别,将切换点放置在系统的边缘服务中是有意义的,即向最终用户呈现功能的公开 Web 应用。这是用户请求首次进入你的领域的入口点,因此这是切换路由器可以利用最大上下文做出切换决策的地方,基于用户及其请求。将切换点放置在系统的边缘还有一个好处,即可以将复杂的条件切换逻辑保留在系统核心之外。在许多情况下,你可以将切换点放置在呈现 HTML 的地方,如这个 Rails 示例:

someFile.erb

代码语言:javascript
代码运行次数:0
运行
复制
<%= if featureDecisions.showRecommendationsSection? %>
  <%= render 'recommendations_section' %>
<% end %>

将切换点放置在边缘也适用于控制对尚未准备好发布的新用户功能的访问。在这种情况下,你可以在 UI 层使用一个简单的功能切换来隐藏“使用 Facebook 登录”按钮。

有趣的是,对于这些类型的功能标志,未发布的功能本身可能实际上已经公开,但位于用户无法发现的 URL 上。

核心中的切换

还有其他类型低级别的切换,必须放置在你的架构更深处。这些切换通常是技术性的,控制功能的内部实现方式。例如,一个发布切换控制是否在第三方 API 前使用新的缓存基础设施,或者直接将请求路由到该 API。在这种情况下,将这些切换决策本地化到所切换功能的服务中是唯一合理的选择。

管理功能标志的携带成本

功能标志有迅速增加的趋势,特别是在最初引入时。它们是有用的,创建成本低,因此通常会产生很多。然而,切换确实带来了携带成本。它们需要你引入新的抽象或条件逻辑到你的代码中。它们还引入了显著的测试负担。Knight Capital Group 的 4.6 亿美元错误是一个警告,说明不正确管理功能标志(以及其他因素)可能会导致什么后果。

聪明的团队将他们的功能标志视为一种库存,这种库存带来了携带成本,并努力将这种库存保持在最低限度。

聪明的团队将他们代码库中的功能标志视为一种库存,这种库存带来了携带成本,并努力将这种库存保持在最低限度。为了保持功能标志的数量在可控范围内,团队必须积极地移除不再需要的功能标志。一些团队有一个规则,每当引入发布切换时,就在团队的待办事项列表中添加一个切换移除任务。其他团队会给切换设置“到期日期”。还有一些团队甚至创建了“定时炸弹”,如果功能标志在到期后仍然存在,就会导致测试失败(甚至拒绝启动应用程序)。我们还可以应用精益方法来减少库存,对系统在任何时间允许拥有的功能标志数量设置一个上限。一旦达到这个上限,如果有人想要添加一个新切换,他们首先需要进行工作以移除一个现有的标志。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-06-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 认知科技技术团队 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一个切换的故事
    • 功能标志的诞生
    • 使标志动态化
    • 准备发布
    • 灰度发布
    • A/B 测试
  • 切换的类别
    • 发布切换
    • 实验切换
    • 运营切换
    • 权限切换
    • 管理不同类别的切换
      • 静态与动态切换
      • 长期切换与临时切换
  • 实现技术
    • 解耦决策点与决策逻辑
    • 决策反转
    • 避免条件判断
  • 切换配置
    • 动态路由与动态配置
    • 优先使用静态配置
    • 管理切换配置的方法
    • 硬编码切换配置
    • 参数化切换配置
    • 切换配置文件
    • 应用程序 DB 中的切换配置
    • 分布式切换配置
    • 覆盖配置
      • 每次请求的覆盖
  • 使用功能标志系统的工作
    • 暴露当前功能标志配置
    • 利用结构化的切换配置文件
    • 不同的切换需要不同的管理方式
    • 功能标志引入了验证的复杂性
    • 切换放置的位置
      • 边缘处的切换
      • 核心中的切换
    • 管理功能标志的携带成本
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档