万字长文 - 解读功能开关 | IDCF

功能开关Feature Toggle(通常也称为功能标志Feature Flag)是一种强大的技术,允许团队在不更改代码的情况下修改系统行为。包括各种使用类别,在实施和管理开关时考虑该方式非常重要。开关引入了复杂性。我们可以通过使用智能开关实现实践和适当的工具来管理我们的开关配置来控制这种复杂性,但我们还应该致力于限制系统中开关的数量。

万字长文 - 解读功能开关 | IDCF_第1张图片

作者 Pete Hodgson是旧金山湾区的一名独立软件交付顾问。他擅长帮助初创工程团队改进他们的工程实践和技术架构。Pete 之前曾在 Thoughtworks 担任过六年的顾问,领导其西海岸业务的技术实践。他还曾在旧金山多家初创公司担任技术主管。

内容

  • 一个开关的故事
  • 开关的类别
  • 管理不同类别的开关
  • 实施技术
  • 开关配置
  • 使用带有特征开关的系统

“功能开关”是一组模式,可以帮助团队快速但安全地向用户提供新功能。在这篇关于功能开关的文章中,我们将从一个简短的故事开始,展示功能开关有用的一些典型场景。然后我们将深入研究细节,涵盖有助于团队通过功能开关取得成功的特定模式和实践。

功能开关也称为功能标志、功能位或功能翻转器。这些都是同一组技术的同义词。在本文中,我将交替使用功能开关和功能标志。

一、一个开关的故事

场景描绘:你隶属于从事复杂城市规划模拟游戏的多个团队之一,你的团队负责核心模拟引擎,你的任务是提高网络渲染算法的效率。你知道这将需要对实施进行相当大的检修,这将需要数周时间。同时,你团队的其他成员将需要在代码库的相关领域继续一些正在进行的工作。

根据过去合并长期存在的分支的痛苦经历,如果可能的话,你希望避免为这项工作打分支。相反,你决定整个团队将继续在主干上工作,但致力于网络渲染算法改进的开发人员将使用功能开关来防止他们的工作影响团队的其他成员或破坏代码库的稳定性。

1.1 功能标志的诞生

以下是研究算法的两人引入的第一个变化:

加入开关之前

  function reticulateSplines () {
     // 当前的实现在这里
  }
这些示例都使用 JavaScript ES2015

加入开关之后

  function reticulateSplines(){
    var useNewAlgorithm = false;
    // useNewAlgorithm = true; // UNCOMMENT IF YOU ARE WORKING ON THE NEW SR ALGORITHM
  
    if( useNewAlgorithm ){
      return enhancedSplineReticulation();
    }else{
      return oldFashionedSplineReticulation();
    }
  }
  
  function oldFashionedSplineReticulation(){
    // current implementation lives here
  }
  
  function enhancedSplineReticulation(){
    // TODO: implement better SR algorithm
  }
  
  function oldFashionedSplineReticulation () {
     // 当前的实现在这里
  }
  
  function enhancedSplineReticulation () {
     // TODO:实现更好的 SR 算法
  }

这对已经将当前的算法实现移动到一个 oldFashionedSplineReticulation 函数中,并将 reticulateSplines设置为一个开关点。现在,如果有人正在研究新算法,他们可以通过取消注释来启用“使用新算法” 功能useNewAlgorithm = true

1.2 使标志动态化

几个小时过去了,这对搭档准备好通过一些模拟引擎的集成测试来运行他们的新算法。他们还想在同一集成测试运行中使用旧算法。他们需要能够动态地启用或禁用该功能,这意味着是时候摆脱注释或取消注释该useNewAlgorithm = true 行的笨拙机制了:

function reticulateSplines () {
   if ( featureIsEnabled( "use-new-SR-algorithm" ) ){
     return enhancedSplineReticulation();
  } else {
    return oldFashionedSplineReticulation();
  }
}

我们现在介绍了一个featureIsEnabled功能,一个开关路由器,它可以用来动态控制哪个代码路径是活动的。实现开关路由器的方法有很多,从简单的内存存储到具有精美 UI 的高度复杂的分布式系统。现在我们将从一个非常简单的系统开始:

function createToggleRouter(featureConfig){
  return {
    setFeature(featureName,isEnabled){
      featureConfig[featureName] = isEnabled;
    },
    featureIsEnabled(featureName){
      return featureConfig[featureName];
    }
  };
}
请注意,我们使用的是 ES2015 的方法简写

我们可以基于一些默认配置创建一个新的开关路由器——也许从配置文件中读取——但我们也可以动态地打开或关闭一个功能。这允许自动化测试来验证开关功能的两侧:

describe( 'spline reticulation', function(){
  let toggleRouter;
  let simulationEngine;

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

  it('works correctly with old algorithm', function(){
    // Given
    toggleRouter.setFeature("use-new-SR-algorithm",false);

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

    // Then
    verifySplineReticulation(result);
  });

  it('works correctly with new algorithm', function(){
    // Given
    toggleRouter.setFeature("use-new-SR-algorithm",true);

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

    // Then
    verifySplineReticulation(result);
  });
});

1.3 准备发布

更多的时间过去了,团队相信他们的新算法功能齐全。为了确认这一点,他们一直在修改他们的更高级别的自动化测试,以便他们在功能关闭和打开的情况下运行系统。该团队还希望进行一些手动探索性测试,以确保一切按预期工作——毕竟,样条网状结构是系统行为的关键部分。

要对尚未被验证为可用于一般用途的功能执行手动测试,我们需要能够为生产中的一般用户群关闭该功能,但能够为内部用户打开它。有很多不同的方法可以实现这个目标:

  • 让开关路由器根据开关设置做出决策,并针对特定环境进行配置。仅在预生产环境中启用新功能。
  • 允许通过某种形式的管理 UI 在运行时修改开关配置。使用该管理 UI 在测试环境中启用新功能。
  • 教开关路由器如何根据请求做出动态的开关决策。这些决策将开关上下文考虑在内,例如通过查找特殊的 cookie 或 HTTP 标头。通常开关上下文用作识别发出请求的用户的代理。

(稍后我们将更详细地研究这些方法,所以如果其中一些概念对你来说是新的,请不要担心。)

万字长文 - 解读功能开关 | IDCF_第2张图片

团队决定使用按请求开关路由器,因为它为他们提供了很大的灵活性。该团队特别感激,这将使他们能够在不需要单独的测试环境的情况下测试他们的新算法。相反,他们可以在生产环境中简单地打开算法,但仅限于内部用户(通过特殊 cookie 检测到)。团队现在可以自己打开该 cookie 并验证新功能是否按预期执行。

1.4 金丝雀发布

基于迄今为止所做的探索性测试,新的网络渲染算法看起来不错。然而,由于它是游戏模拟引擎的重要组成部分,因此仍然有些人不愿意为所有用户启用此功能。团队决定使用他们的功能开关基础设施来执行金丝雀发布,只为他们总用户群的一小部分——“金丝雀”群组开启新功能。

该团队通过向开关路由器传授用户群组的概念来增强开关路由器 - 用户群组始终体验始终开启或关闭的功能。一组金丝雀用户是通过 1% 的用户群的随机抽样创建的——可能使用用户 ID 的模数。这个金丝雀队列将始终启用该功能,而其他 99% 的用户群仍使用旧算法。监控两组的关键业务指标(用户参与度、总收入等),以确保新算法不会对用户行为产生负面影响。一旦团队确信新功能没有不良影响,他们就会修改他们的开关配置,以便为整个用户群启用它。

1.5 A/B 测试

团队的产品经理了解到这种方法,非常兴奋。她建议团队使用类似的机制来执行一些 A/B 测试。关于修改犯罪率算法以考虑污染水平是否会增加或降低游戏的可玩性,一直存在长期争论。他们现在有能力使用数据来解决争论。他们计划推出一个廉价的实现,它可以捕捉到这个想法的本质,并通过一个功能标志来控制。他们将为相当多的用户群启用该功能,然后研究这些用户与“控制”群相比的行为方式。这种方法将允许团队根据数据解决有争议的产品辩论,

这个简短的场景旨在说明功能开关的基本概念,同时也强调该核心功能可以有多少不同的应用程序。现在我们已经看到了这些应用程序的一些示例,让我们更深入地研究一下。我们将探索不同类别的开关,看看是什么让它们与众不同。我们将介绍如何编写可维护的开关代码,最后分享实践以避免功能开关系统的一些陷阱。

二、开关的类别

我们已经看到了功能开关提供的基本功能 - 能够在一个可部署单元中提供替代代码路径并在运行时在它们之间进行选择。上述场景还表明,该工具可以在各种情况下以各种方式使用。将所有功能开关集中到同一个桶中可能很诱人,但这是一条危险的道路。不同类别开关的设计驱动是完全不同的,并且以相同的方式管理它们可能会导致痛苦。

功能开关可以分为两个主要维度:功能开关将存在多长时间,以及开关决策必须有多动态。还有其他因素需要考虑——例如,谁来管理功能开关——但我认为生存周期和动态性是两个重要因素,可以帮助指导如何管理开关。

让我们通过这两个维度的视角考虑各种类别的开关,看看它们适合什么。

2.1 发布开关

发布开关允许将不完整和未经测试的代码路径作为可能永远不会打开的潜在代码交付到生产环境。

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

产品经理也可以使用同样方法的以产品为中心的版本来防止半成品的产品功能暴露给最终用户。例如,电子商务网站的产品经理可能不想让用户看到仅适用于网站的一物流合作伙伴的新预计物流日期功能,而是希望等到该功能已为所有物流合作伙伴实施。产品经理可能有其他原因不想公开功能,即使它们已经完全实现和测试。例如,功能发布可能与营销活动相协调。以这种方式使用发布开关是实现“将 [功能] 发布与 [代码] 部署分开”的持续交付原则的最常见方式。

万字长文 - 解读功能开关 | IDCF_第3张图片

发布开关本质上是过渡的。尽管以产品为中心的开关可能需要保留更长的时间,但它们通常不应停留超过一两周。发布开关的开关决定通常是非常静态的。给定发布版本的每个开关决策都是相同的,通过推出具有开关配置更改的新版本来更改开关决策通常是完全可以接受的。

2.2 实验开关

实验开关用于执行多变量或 A/B 测试。系统的每个用户都被放置在一个群组中,并且在运行时,开关路由器将根据他们所在的群组始终如一地将给定的用户发送到一个或另一个代码路径。通过跟踪不同群组的聚合行为,我们可以比较效果不同的代码路径。这种技术通常用于对电子商务系统的购买流程或按钮上的宣传性用语等事物进行数据驱动的优化。

万字长文 - 解读功能开关 | IDCF_第4张图片

实验开关需要在相同配置下保持足够长的时间以产生具有统计意义的结果。取决于可能意味着生命周期数小时或数周的流量模式。更长的时间不太可能有用,因为对系统的其他更改可能会使实验结果无效。就其性质而言,实验开关是高度动态的 - 每个传入请求都可能代表不同的用户,因此可能会以不同于上一个的方式路由。

2.3 运维开关

这些标志用于控制我们系统行为的操作方面。我们可能会在推出具有不确定性能影响的新功能时引入运维开关,以便系统操作员可以在需要时在生产中快速禁用或降级该功能。

大多数运维开关的寿命都相对较短——一旦对新功能的操作方面获得信心,就应该停用该标志。然而,系统拥有少量长寿命的“终止开关”并不少见,它们允许生产环境的操作员在系统承受异常高负载时优雅地降低非重要系统功能。例如,当我们处于繁重的负载下时,我们可能希望禁用我们主页上的推荐面板,该面板的生成成本相对较高。我咨询了一家维护运维开关的在线零售商,这可能会在高需求产品发布之前故意禁用其网站主要采购流程中的许多非关键功能。 这种长期存在的运维开关可以视作是一种手工管理的断路器。

万字长文 - 解读功能开关 | IDCF_第5张图片

如前所述,这些标志中的许多只存在很短的时间,但一些关键控件可能几乎无限期地保留给操作员。由于这些标志的目的是让操作员对生产问题做出快速反应,因此他们需要非常快速地重新配置 - 需要推出新版本以翻转运维开关 不太可能让操作人员满意。

2.4 许可开关

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

香槟早午餐在很多方面与金丝雀发布相似。两者之间的区别在于,金丝雀发布的功能面向随机选择的一组用户,而香槟早午餐功能面向一组特定用户。

万字长文 - 解读功能开关 | IDCF_第6张图片

当用作管理仅向高级用户公开的功能时,与其他类别的功能开关相比,许可开关的寿命可能非常长 - 以多年为规模。由于权限是特定于用户的,因此许可开关的开关决定将始终按请求进行,因此这是一个非常动态的开关。

三、管理不同类别的开关

现在我们有了一个开关分类方案,我们可以讨论动态性和寿命这两个维度如何影响我们使用不同类别的特征标志的方式。

3.1 静态与动态开关

万字长文 - 解读功能开关 | IDCF_第7张图片

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

对于简单的静态路由决策,开关配置可以是每个功能的简单开或关,开关路由器仅负责将静态开/关状态中继到开关点。正如我们之前所讨论的,其他类别的开关更加动态并且需要更复杂的开关路由器。例如,实验开关的路由器为给定用户动态地做出路由决策,可能使用某种基于该用户 id 的一致群组算法。这个开关路由器不需要从配置中读取静态开关状态,而是需要读取某种队列配置,定义诸如实验队列和控制队列应该有多大。

稍后我们将深入探讨管理此开关配置的不同方法。

3.2 长期开关与瞬态开关

万字长文 - 解读功能开关 | IDCF_第8张图片

我们还可以将开关类别划分为本质上是短暂的类别与长期存在且可能存在多年的类别。这种区别应该对我们实现功能的开关点的方法有很大的影响。如果我们要添加一个将在几天后删除的发布开关,那么我们可能会使用一个开关点,它对 开关路由器进行简单的 if/else 检查。这就是我们之前使用样条网格示例所做的:

function reticulateSplines () {
   if ( featureIsEnabled( "use-new-SR-algorithm" ) ){
     return enhancedSplineReticulation();
  } else {
    return oldFashionedSplineReticulation();
  }
}

但是,如果我们要创建一个带有开关点的新许可开关,我们希望它会持续很长时间,那么我们当然不希望通过不加选择地散布 if/else 检查来实现这些开关点。我们需要使用更易于维护的实现技术。

四、实施技术

功能标志似乎会产生相当混乱的开关点代码,并且这些开关点也有在整个代码库中扩散的趋势。保持这种趋势检查代码库中的任何功能标志很重要,如果标志将长期存在,这一点至关重要。有一些实现模式和实践有助于减少这个问题。

4.1 将决策点与决策逻辑解耦

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

invoiceEmailer.js
  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
  function createFeatureDecisions(features){
    return {
      includeOrderCancellationInEmail(){
        return features.isEnabled("next-gen-ecomm");
      }
      // ... additional decision functions also live here ...
    };
  }
invoiceEmailer.js
  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 实验驱动,或者由操作问题驱动,例如我们的一些订单取消基础设施的中断。在所有情况下,我们的发票电子邮件发送者都可能会很高兴地不知道如何或为什么做出该开关决定。

4.2 反转决定

在前面的示例中,我们的发票电子邮件发送器负责询问功能标记基础设施应该如何执行。这意味着我们的发票电子邮件发送器有一个需要注意的额外概念 - 功能标记 - 以及与之耦合的额外模块。这使得发票电子邮件更难单独使用和思考,包括使其更难测试。随着功能标记在系统中变得越来越普遍,我们将看到越来越多的模块作为全局依赖项与功能标记系统耦合。不是理想的情况。

在软件设计中,我们通常可以通过应用控制反转来解决这些耦合问题。在这种情况下确实如此。以下是我们如何将我们的发票电子邮件与我们的功能标记基础设施分离:

invoiceEmailer.js
  function createInvoiceEmailler(config){
    return {
      generateInvoiceEmail(){
        const baseEmail = buildEmailForInvoice(this.invoice);
        if( config.includeOrderCancellationInEmail ){
          return addOrderCancellationContentToEmail(email);
        }else{
          return baseEmail;
        }
      },
  
      // ... other invoice emailer methods ...
    };
  }
featureAwareFactory.js
  function createFeatureAwareFactoryBasedOn(featureDecisions){
    return {
      invoiceEmailler(){
        return createInvoiceEmailler({
          includeOrderCancellationInEmail: featureDecisions.includeOrderCancellationInEmail()
        });
      },
  
      // ... other factory methods ...
    };
  }

现在,不是我们InvoiceEmailler接触它,而是在构建时通过一个对象 FeatureDecisions将这些决定注入它。现在对功能标记一无所知。它只知道可以在运行时配置其行为的某些方面。这也使得 testing的行为更容易 - 我们可以通过在测试期间传递不同的配置选项来测试它生成带有和不带有订单取消内容的电子邮件的方式:configInvoiceEmaillerInvoiceEmailler

describe( 'invoice emailling', function(){
  it( 'includes order cancellation content when configured to do so', function(){
    // Given 
    const emailler = createInvoiceEmailler({includeOrderCancellationInEmail:true});

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

    // Then
    verifyEmailContainsOrderCancellationContent(email);
  };

  it( 'does not includes order cancellation content when configured to not do so', function(){
    // Given 
    const emailler = createInvoiceEmailler({includeOrderCancellationInEmail:false});

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

    // Then
    verifyEmailDoesNotContainOrderCancellationContent(email);
  };
});

我们还引入了一个FeatureAwareFactory集中创建这些决策注入对象的方法。这是一般依赖注入模式的应用。如果控制反转系统在我们的代码库中发挥作用,那么我们可能会使用该系统来实现这种方法。

4.3 避免条件

到目前为止,在我们的示例中,我们的开关点是使用 if 语句实现的。这对于一个简单的、短暂的开关可能是有意义的。但是,在某个功能需要多个开关点或你希望开关点长期存在的地方,不建议使用点条件。一种更易于维护的替代方法是使用某种策略模式来实现替代代码路径:

invoiceEmailler.js
  function createInvoiceEmailler(additionalContentEnhancer){
    return {
      generateInvoiceEmail(){
        const baseEmail = buildEmailForInvoice(this.invoice);
        return additionalContentEnhancer(baseEmail);
      },
      // ... other invoice emailer methods ...
  
    };
  }
featureAwareFactory.js
  function identityFn(x){ return x; }
  
  function createFeatureAwareFactoryBasedOn(featureDecisions){
    return {
      invoiceEmailler(){
        if( featureDecisions.includeOrderCancellationInEmail() ){
          return createInvoiceEmailler(addOrderCancellationContentToEmail);
        }else{
          return createInvoiceEmailler(identityFn);
        }
      },
  
      // ... other factory methods ...
    };
  }

在这里,我们通过允许我们的发票电子邮件程序配置内容增强功能来应用策略模式。FeatureAwareFactory在创建发票电子邮件时选择一种策略,由FeatureDecision. 如果订单取消应该在电子邮件中,它会传递一个增强功能,将内容添加到电子邮件中。否则,它会传入一个identityFn增强器——它没有任何效果,只是简单地将电子邮件传回而不做任何修改。

五、开关配置

5.1 动态路由与动态配置

早些时候,我们将功能开关分为对于给定的代码部署而言开关路由决策基本上是静态的,与那些决策在运行时动态变化的。需要注意的是,标志的决定可能会在运行时以两种方式发生变化,这一点很重要。

  • 首先,像运维开关这样的东西可能会被动态重新配置从 On 到 Off 以响应系统中断。
  • 其次,某些类别的开关(例如许可开关和实验开关)根据某些请求上下文(例如哪个用户发出请求)为每个请求做出动态路由决策。

前者是通过重新配置动态实现的,而后者则在本质上就是动态的。这些固有的动态开关可能会做出高度动态的决策,但仍然具有配置动作,这是相当静态的,也许只能通过重新部署来改变。实验开关是此类功能标志的一个示例——我们实际上并不需要能够在运行时修改实验的参数。事实上,这样做可能会使实验在统计上无效。

5.2 首选静态配置

如果特性标志的性质允许的话,最好通过源代码控制和重新部署来管理开关配置。通过源代码控制管理开关配置给我们带来的好处,与我们通过将源代码控制用于基础设施即代码之类的东西所获得的好处相同。

它可以允许开关配置与正在开关的代码库一起存在,这提供了一个非常大的好处:开关配置将以与代码更改或基础架构更改完全相同的方式在你的持续交付流水线中移动。这可以充分发挥 CD 的优势 - 可重复的构建,这些构建以一致的方式跨环境进行验证。它还大大减少了功能标志的测试负担。很少需要验证版本将如何通过开关关闭和打开来执行,因为该状态已被打包到版本中并且不会更改(至少对于较少动态的标志)。开关配置在源代码控制中并行存在的另一个好处是,我们可以轻松查看以前版本中开关的状态,并在需要时轻松重新创建以前的版本。

5.3 管理开关配置的方法

虽然静态配置更可取,但在某些情况下,例如运维开关,需要更动态的方法。让我们看一下用于管理开关配置的一些选项,从简单但动态性较低的方法到一些高度复杂但具有许多额外复杂性的方法。

5.4 硬编码开关配置

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

function reticulateSplines(){
  //return oldFashionedSplineReticulation();
  return enhancedSplineReticulation();
}

比注释方法稍微复杂一点的是使用预处理器的#ifdef功能,如果可用的话。

因为这种类型的硬编码不允许动态重新配置开关,它仅适用于我们愿意遵循部署代码模式以重新配置标志的功能标志。

5.5 参数化开关配置

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

5.6 开关配置文件

另一种选择是从某种结构化文件中读取开关配置。这种开关配置的方法很常见,它作为更通用的应用程序配置文件的一部分开始使用。

使用开关配置文件,你现在可以通过简单地更改该文件而不是重新构建应用程序代码本身来重新配置功能标志。但是,尽管在大多数情况下你不需要重新构建应用程序来开关功能,但你可能仍需要执行重新部署以重新配置标志。

5.7 开关应用数据库中的配置

一旦达到一定规模,使用静态文件来管理开关配置可能会变得很麻烦。通过文件修改配置相对繁琐。确保一组服务器的一致性成为一项挑战,使更改始终如一地更是如此。作为对此的回应,许多组织将开关配置转移到某种类型的集中式存储中,通常是现有的应用程序数据库。这通常伴随着某种形式的管理 UI 的构建,它允许系统操作员、测试人员和产品经理查看和修改功能标志及其配置。

5.8 分布式开关配置

使用已经是系统架构一部分的通用数据库来存储开关配置非常普遍;一旦引入功能标志并开始获得牵引力,这是一个明显的方式。然而,现在有一种特殊用途的分层键值存储更适合管理应用程序配置 - 像 Zookeeper、etcd 或 Consul 这样的服务。这些服务形成一个分布式集群,它为连接到集群的所有节点提供环境配置的共享源。可以在需要时动态修改配置,并且集群中的所有节点都会自动收到更改通知——这是一个非常方便的附加功能。

其中一些系统(例如 Consul)带有一个管理 UI,它提供了一种管理开关配置的基本方法。然而,在某些时候,通常会创建一个用于管理开关配置的小型自定义应用程序。

5.9 覆盖配置

到目前为止,我们的讨论假设所有配置都由单一机制提供。许多系统的实际情况更为复杂,配置的覆盖层来自各种来源。使用开关配置,具有默认配置以及特定于环境的覆盖是很常见的。这些覆盖可能来自像附加配置文件这样简单的东西,也可能来自像 Zookeeper 集群这样复杂的东西。

请注意,任何特定于环境的覆盖都与持续交付的理想背道而驰,即在交付管道中始终拥有完全相同的位和配置流。通常实用主义要求使用一些特定于环境的覆盖,但是努力使你的可部署单元和你的配置尽可能与环境无关,这将导致更简单、更安全的管道。当我们谈论测试功能开关系统时,我们将很快重新讨论这个主题。

  • 针对每个请求覆盖

环境特定配置覆盖的另一种方法是允许通过特殊 cookie、查询参数或 HTTP 标头在每个请求的基础上覆盖开关的开/关状态。与完整的配置覆盖相比,这有一些优势。如果服务是负载平衡的,你仍然可以确信无论你点击哪个服务实例,都会应用覆盖。你还可以在生产环境中覆盖功能标志而不影响其他用户,并且你不太可能意外地留下覆盖。

这种按请求方法的缺点是它引入了一种风险,即好奇或恶意的最终用户可能会自己修改功能开关状态。一些组织可能对某些未发布的功能可能对足够坚定的一方公开访问的想法感到不舒服。对覆盖配置进行加密签名是缓解这种担忧的一种选择,但无论如何这种方法都会增加功能开关系统的复杂性和攻击面。

六、使用带有特征标记的系统

虽然功能开关绝对是一种有用的技术,但它也带来了额外的复杂性。在使用带有特征标记的系统时,有一些技术可以帮助简化你的生活。

6.1 公开当前功能开关配置

将构建/版本号嵌入到已部署的工件中并在某处公开该元数据一直是一种有用的做法,以便开发人员、测试人员或操作员可以找出在给定环境中运行的特定代码。相同的想法应该应用于功能标志。任何使用功能标志的系统都应该为操作员提供某种方式来发现开关配置的当前状态。在面向 HTTP 的 SOA 系统中,这通常是通过某种元数据 API 端点或端点来完成的。参见例如 Spring Boot 的 Actuator endpoints。

6.2 利用结构化的开关配置文件

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

6.3 以不同方式管理不同的开关

如前所述,具有不同特征的功能开关有多种类别。应该接受这些差异,并以不同的方式管理不同的开关,即使所有不同的开关都可以使用相同的技术机器进行控制。

让我们回顾一下我们之前的电子商务网站示例,该网站在主页上有一个推荐产品部分。最初,我们可能会在开发时将该部分放在发布开关后面。然后,我们可能会将其移至实验开关的背后,以验证它是否有助于增加收入。最后,我们可能会将它移到运维开关 后面,以便在我们处于极端负载下时可以将其关闭。如果我们遵循先前关于从开关点中解耦决策逻辑的建议,那么开关类别中的这些差异应该对开关点代码没有任何影响。

然而,从功能标志管理的角度来看,这些转换绝对应该产生影响。作为从 发布开关过渡到实验开关的一部分,配置开关的方式会发生变化,并且可能会移动到不同的区域 - 可能会进入管理 UI 而不是源代码管理中的 yaml 文件。产品人员现在可能会管理配置而不是开发人员。同样,从实验开关过渡到运维开关 将意味着开关的配置方式、配置所在的位置以及谁管理配置的另一个变化。

6.4 功能开关引入验证复杂性

使用带有功能标记的系统,我们的持续交付过程变得更加复杂,尤其是在测试方面。当同一个工件通过 CD 管道时,我们经常需要测试多个代码路径。为了说明原因,假设我们正在交付一个系统,该系统可以在启用开关时使用新的优化税收计算算法,或者继续使用我们现有的算法。在给定的可部署工件正在通过我们的 CD 管道移动时,我们无法知道开关是否会在生产中的某个时候打开或关闭 - 毕竟这就是功能标志的全部意义所在。因此,为了验证可能最终在生产中运行的所有代码路径,我们必须在两种状态:开关开关打开并关闭。

万字长文 - 解读功能开关 | IDCF_第9张图片

我们可以看到,通过一个单一的开关,这引入了至少在我们的一些测试中加倍的要求。随着多个开关的发挥,我们有可能开关状态的组合爆炸。验证每个状态的行为将是一项艰巨的任务。这可能会导致以测试为重点的人们对功能标志产生一些健康的怀疑。

令人高兴的是,情况并没有一些测试人员最初想象的那么糟糕。虽然带有功能标记的候选版本确实需要使用一些开关配置进行测试,但没有必要测试“每个”可能的组合。大多数功能标志不会相互交互,并且大多数版本不会涉及对多个功能标志的配置进行更改。

一个好的约定是在功能标志关闭时启用现有的或旧有的行为,而在它打开时启用新的或未来的行为。

那么,团队应该测试哪些功能开关配置?测试你希望在生产中生效的开关配置是最重要的,这意味着当前的生产开关配置加上你打算发布的所有开关都已打开。测试回退配置也是明智之举,你打算释放的那些开关也会被关闭。为了避免在未来的版本中出现任何意外的回归,许多团队还会在所有开关都打开的情况下执行一些测试。请注意,仅当你坚持开关语义的约定时,此建议才有意义,其中在功能关闭时启用现有或旧有行为,而在功能开启时启用新行为或未来行为。

如果你的功能标志系统不支持运行时配置,那么你可能必须重新启动你正在测试的进程才能触发开关,或者更糟糕的是,将工件重新部署到测试环境中。这会对验证过程的周期时间产生非常不利的影响,进而影响 CI/CD 提供的所有重要反馈循环。为避免此问题,请考虑公开一个端点,该端点允许对功能标志进行动态内存重新配置。当你使用诸如实验开关之类的东西时,这些类型的覆盖变得更加必要,在这种情况下,使用开关的两个路径更加繁琐。

这种动态重新配置特定服务实例的能力是一个非常锋利的工具。如果使用不当,可能会在共享环境中造成很多痛苦和混乱。这个工具应该只被自动化测试使用,并且可能作为手动探索性测试和调试的一部分。如果需要在生产环境中使用更通用的开关控制机制,最好使用真正的分布式配置系统构建,如上面开关配置部分所述。

6.5 在哪里放置你的开关

  • 在边缘开关

对于需要每个请求上下文的开关类别(实验开关、许可开关),将开关点放置在系统的边缘服务中是有意义的——即向最终用户展示功能的公开 Web 应用程序。这是你的用户的个人请求首先进入你的域的地方,因此你的开关路由器有最多的上下文可用于根据用户及其请求做出开关决策。将开关点放置在系统边缘的一个附带好处是,它可以将繁琐的条件开关逻辑排除在系统核心之外。在许多情况下,你可以将开关点放置在你正在呈现 HTML 的位置,如以下 Rails 示例所示:

someFile.erb
  <%= if featureDecisions.showRecommendationsSection? %>
    <%= render 'recommendations_section' %>
  <% end %>

当你控制对尚未准备好发布的面向用户的新功能的访问时,将开关点放置在边缘也很有意义。在这种情况下,你可以再次使用简单地显示或隐藏 UI 元素的开关来控制访问。例如,也许你正在构建使用 Facebook 登录应用程序的功能,但还没有准备好将其推广给用户。此功能的实现可能涉及架构各个部分的更改,但你可以通过隐藏“使用 Facebook 登录”按钮的 UI 层的简单功能开关来控制功能的公开。
有趣的是,使用其中一些类型的功能标志,大部分未发布的功能本身可能实际上是公开的,但位于用户无法发现的 url 上。

  • 在核心开关

还有其他类型的较低级别的开关必须放置在你的架构中更深的位置。这些开关通常在本质上是技术性的,并且控制着某些功能如何在内部实现。一个示例是发布开关,它控制是在第三方 API 前使用新的缓存基础设施,还是直接将请求路由到该 API。在这些情况下,在功能被开关的服务中本地化这些开关决策是唯一明智的选择。

6.6 管理功能开关的持有成本

功能标志有快速增加的趋势,特别是在首次引入时。它们有用且创建成本低,因此通常会创建很多。然而,开关确实会带来持有成本。它们要求你在代码中引入新的抽象或条件逻辑。它们还引入了显着的测试负担。骑士资本集团的4.6 亿美元错误是一个警示故事,说明当你没有正确管理功能标志时(除其他外)会出现什么问题。

精明的团队将他们的功能开关视为带有持有成本的库存,并努力将库存保持在尽可能低的水平。

精明的团队将其代码库中的功能开关视为带有持有成本的库存,并寻求将库存保持在尽可能低的水平。为了使功能标志的数量保持可管理,团队必须主动删除不再需要的功能标志。一些团队的规则是,每当首次引入发布开关时,总是将开关删除任务添加到团队的待办事项中。其他团队将“到期日期”放在他们的开关按钮上。有些人甚至会制造“定时炸弹”,如果功能标志在其到期日期之后仍然存在,它将无法通过测试(甚至拒绝启动应用程序!)。

我们还可以采用精益方法来减少库存,对系统在任何时候允许拥有的功能标志的数量进行限制。一旦达到该限制,如果有人想要添加新的开关,他们首先需要完成删除现有开关标识的工作。

原文:https://martinfowler.com/arti...

作者:Pete Hodgson

译者:冬哥

玩乐高,学敏捷,规模化敏捷联合作战沙盘之「乌托邦计划」,2022年3月5-6日登陆深圳,将“多团队敏捷协同”基因内化在研发流程中,为规模化提升研发效能保驾护航!!⛴公众号回复“乌托邦”可参加

你可能感兴趣的:(万字长文 - 解读功能开关 | IDCF)