如何向100万用户推广危险的重构?


电影《飞机》,1980年。

当我在产品上添加另一个重构时,我就是这样。即使您用度量标准和日志覆盖了整个代码,也要在所有环境中测试功能-部署后,这并不会节省100%的时间。

第一法卡普


我们以某种方式重构了与Google表格的集成处理。对于用户来说,这是非常有价值的功能,因为他们同时使用许多需要链接在一起的工具-将联系人发送到表格,上传问题答案,导出用户等。

集成代码没有从第一个版本进行重构,因此维护变得越来越困难。这开始影响我们的用户-由于代码的复杂性,我们发现了一些老错误,我们不敢编辑。现在该采取措施了。不应进行逻辑上的更改-只需编写测试,移动类和梳齿名称。当然,我们在开发环境上测试了功能,然后进行了部署。

20分钟后,用户写道该集成无效。将数据发送到Google Sheet的功能下降了-事实证明,对于调试,我们以销售和本地环境的不同格式发送数据。重构时,我们按格式进行销售。

我们修复了整合问题,但是仍然保留了星期五快乐的夜晚(您想到了!)的沉积物。回想起来(与团队完成冲刺会议),我们开始考虑将来如何避免这种情况-我们需要改进手动测试,自动测试,使用指标和警报的做法,除此之外,我们想到了使用功能标记来测试重构实际上,将对此进行讨论。

实作


该方案很简单:如果用户启用了标记,则转到具有新版本的代码,否则,转到具有旧版本的代码:

if ($user->hasFeature(UserFeatures::FEATURE_1)) {
  // new version
} else {
  // old version
}

通过这种方法,我们有机会先在自己身上测试产品上的重构,然后再将其倾倒在用户身上。

几乎从项目一开始,我们就对flags功能进行了原始实现。在两个基本实体(用户和帐户)的数据库中,添加了功能字段,这些字段是位掩码在代码中,我们注册了要素的新常量,如果特定的要素对用户可用,则将其添加到掩码中。

public const ALLOW_FEATURE_1 = 0b0000001;
public const ALLOW_FEATURE_2 = 0b0000010;
public const ALLOW_FEATURE_3 = 0b0000100;

代码中的用法如下所示:

If ($user->hasFeature(UserFeatures::ALLOW_FEATURE_1)) {
  // feature 1 logic
}

重构时,我们通常首先向团队开放测试的标志,然后向积极使用该功能的多个用户开放,最后向所有人开放,但有时会出现更复杂的方案,下面将详细介绍。

重载的地方重构


我们的系统之一接受Facebook Webhook,并通过队列对其进行处理。队列处理不再应付,用户开始延迟接收某些消息,这可能严重影响机器人订阅者的体验。我们开始通过将处理转移到更复杂的队列方案来重构这个地方。这个位置很关键-在所有服务器上倒入新逻辑很危险,因此我们在该标志下关闭了新逻辑,并能够在产品上对其进行测试。但是,当我们完全打开此标志时会发生什么?我们的基础架构将如何运作?这次我们将标志的开头部署在服务器上,并遵循指标。

我们已将所有关键数据处理划分为集群。每个群集都有一个ID。我们决定通过仅在某些服务器上打开标志功能来简化这种复杂重构的测试,代码中的检查如下所示:

If ($user->hasFeature(UserFeatures::CGT_REFACTORING) ||
    \in_array($cluster, Configurator::get('cgt_refactoring_cluster_ids'))) {
  // new version
} else {
  // old version
}

首先,我们进行了重构,并向团队开放了旗帜。然后,我们找到了几个积极使用cgt功能,向他们打开标志并查看是否一切对他们有用的用户。最后,他们开始在服务器上打开标志并遵循指标。

可以通过管理面板更改cgt_refactoring_cluster_ids标志。最初,我们将值cgt_refactoring_cluster_ids分配给一个空数组,然后一次添加一个群集-[1],查看一段时间的指标,然后添加另一个群集-[1、2],直到测试整个系统。

配置器实施


我将讨论什么是配置器以及如何实现。它被编写为能够在不进行部署的情况下更改逻辑,例如,如上所述,当我们需要急剧回滚逻辑时。我们还将它用于动态配置,例如,当您需要测试不同的缓存时间时,可以将其取出进行快速测试。对于开发人员来说,这看起来像一个具有管理员值的字段列表,可以更改。我们将所有这些存储在数据库中,在Redis中缓存,并在工作人员的静态变量中缓存。

重构过时的位置


在下一季度,我们重构了注册逻辑,为通过几种服务过渡到注册的可能性做准备。在我们的条件下,不可能对注册逻辑进行聚类,以使某个用户绑定到某个逻辑,并且我们没有提出比测试该逻辑更好的任何方法,从而推出了所有注册请求的一部分。使用标志很容易以类似的方式进行操作:

If (Configurator::get('auth_refactoring_percentage') > \random_int(0, 99)) {
  // new version
} else {
  // old version
}

因此,我们在管理面板中将auth_refactoring_percentage的值设置为0到100。当然,我们使用指标“涂抹”了整个授权逻辑,以了解最终并没有减少转换。

指标


为了告诉我们在打开标志的过程中如何遵循指标,我们将更详细地考虑另一种情况。当订户向Facebook Messenger发送消息时,ManyChat接受来自Facebook的Facebook挂钩。我们必须根据业务逻辑处理每条消息。对于cgt功能,我们需要确定订户是否通过在Facebook上的评论开始对话,以便向他发送相关消息作为响应。在代码中,看起来就像确定当前订户的上下文,如果我们可以确定widgetId,则可以从中确定响应消息。

有关功能的更多信息
Facebook api. — . Widget, :

—> —> —> Facebook:



:
—> —>



“ , !” , . , “ !” id , — , id.

之前,我们以3种方式定义了上下文,它看起来像这样:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  //      
  if (null !== $user->gt_widget_id_context) {
    $watcher->logTick('cgt_match_processor_matched_via_context');

    return $user->gt_widget_id_context;
  }

  //      
  if (null !== $user->name) {
    $widgetId = $this->cgtMatchByThread($user);
    if (null !== $widgetId) {
      $watcher->logTick('cgt_match_processor_matched_via_thread');

      return $widgetId;
    }

    $widgetId = $this->cgtMatchByConversation($user);
    if (null !== $widgetId) {
      $watcher->logTick('cgt_match_processor_matched_via_conversation');

      return $widgetId;
    }
  }

  return null;
}

观察者服务在匹配时分别发送了分析数据,我们针对所有三种情况制定了指标:


通过不同的及时链接方法找到上下文的次数

接下来,我们发现了另一种匹配方法,该方法应替换所有旧选项。为了测试这一点,我们获得了另一个指标:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  //    
  $widgetId = $this->cgtMatchByEcho($user);
  
  if (null !== $widgetId) {
      $watcher->logTick('cgt_match_processor_matched_via_echo_message');
  }

  //    
  // ...
}

在此阶段,我们要确保新匹配的数量等于旧匹配的总和,因此只需编写指标而不返回$ widgetId:


新方法发现的上下文数量完全覆盖旧方法的绑定总和,

但这不能保证我们在所有情况下都具有正确的匹配逻辑。下一步是通过打开标志逐步进行测试:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  //    
  $widgetId = $this->cgtMatchByEcho($user);
  
  if (null !== $widgetId) {
    $watcher->logTick('cgt_match_processor_matched_by_echo_message');
  
    //    ,   
    If ($this->allowMatchingByEcho($user)) {
      return $widgetId;
    }
  }

  // ...
}

function allowMatchingByEcho(User $user): bool
{
  //    
  If ($user->hasFeature(UserFeatures::ALLOW_CGT_MATCHING_BY_ECHO)) {
    return true;
  }
  //     
  If (\in_array($this->clusterId, Configurator::get('cgt_matching_by_echo_cluster_ids'))) {
    return true;
  }

  return false;
}

然后开始测试过程:首先,我们通过打开标志UserFeatures :: ALLOW_CGT_MATCHING_BY_ECHO在所有环境和经常使用匹配的随机用户上单独测试新功能。在此阶段,我们发现了一些比赛不正确并修复的情况。然后,他们开始部署到服务器:平均而言,我们在一周内的1天内部署了一台服务器。在测试之前,我们警告支持人员,他们会仔细研究与功能相关的标签,并向我们发送任何奇特的东西。多亏了支持和用户,一些固定的情况才得以解决。最后,最后一步是无条件地发现所有东西:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  $widgetId = $this->cgtMatchByEcho($user);
  
  if (null !== $widgetId) {
    $watcher->logTick('cgt_match_processor_matched_by_echo_message');
  
    return $widgetId;
  }

  return null;
}

新标志功能实现


本文开头描述的标志功能的实现为我们服务了大约3年,但是随着团队的增长,它变得不舒服-我们在创建每个标志时必须部署并且不要忘记清除标志的值(我们将常数值重用于不同的功能)。最近,该组件已被重写,现在我们可以通过管理面板灵活地管理标志。标志从位掩码中解开并存储在单独的表中-这使创建新标志变得容易。每个条目还具有描述和所有者,标志管理变得更加透明。

这种方法的缺点


这种方法有很大的缺点-代码有两个版本,需要同时对其进行支持。测试时,您必须考虑逻辑的两个分支,并且需要全部检查它们,这非常痛苦。在开发过程中,有些情况下我们将修补程序引入一种逻辑,但却忘记了另一种逻辑,并在某个时候成功了。因此,我们仅在关键位置应用此方法,并尝试尽快摆脱​​旧版本的代码。我们尝试在小的迭代中完成其余的重构。


当前过程如下所示:首先,我们在标志的条件下关闭逻辑,然后部署并开始逐渐打开标志。扩展标志时,一旦出现问题,我们将密切监视错误和指标-立即回滚标志并处理问题。加号的是,打开/关闭标志的速度非常快-只是管理面板中值的更改。一段时间后,我们切出了旧版本的代码,这应该是防止更改两个版本的代码的最短时间。警告同事有关此类重构很重要。我们通过github进行审查,并在重构期间使用代码所有者,以使更改不会在没有重构作者的情况下进入代码。

最近,我推出了新版本的Facebook Graph API。一秒钟内,我们向API发出了3000多个请求,任何错误对我们来说都是很昂贵的。因此,我以最小的影响在该标志下推出了更改-我设法捕获了一个令人不快的错误,测试了新版本并最终完全不用担心就切换到了该版本。

All Articles