文档结构  
翻译进度:已翻译     翻译赏金:0 元 (?)    ¥ 我要打赏

今天我们发布了 Scientist 1.0 帮助你更有信心的进行关键代码的重构。 

随着代码库的成熟以及需求的改变,不可避免的会对部分系统进行替代或重新编写。在Github中,我们很高兴有很多系统的规模已经超出了原有的设计,但是最终当系统达到性能极限或者不能满足新的扩展需求的时候,我们不得不对应用的一部分进行重写或者替代。

问题

几年前我们曾经面临对应用中的关键系统之一进行重写:控制访问权限和库、团队和最值的成员的权限代码,我们寻找一种方法进行如此大的改变并保证它的正确性。

第 1 段(可获 2 积分)

一种常见的进行大规模重构的架构模式是 Branch by Abstraction(抽象分支)。它通过在重构代码周围插入抽象层实现。抽象只是简单的代表原代码的起始位置。一旦开始插入新代码,可以在抽象位置加入开关使得新代码替代原代码。

这种利用抽象层通过阻塞点进行代码替换是一种很好的方法,好处在于可以在需要的时候简单的切换到新代码中,劣势在于它不能保证所有在新代码的行为和原代码中的行为一致——它知识保证了新代码能够取代原代码出现的所有场合。对于我们系统架构的关键部分,这种模式只满足了我们一半的需求。我们不仅需要保证所有原代码出现的位置都被新代码所代替,还需要保证新代码行为的正确性,并且与原代码一致。

第 2 段(可获 2 积分)

为什么测试还不够

进行正确性测试,首先需要为新系统进行测试集对吗?好的,但还不完全。测试是验证新系统正确性的一个很好的起点,但他们是不够的。对于足够复杂的系统,很难建立覆盖所有可能情况的测试集。如果建立了这种测试集,那么它应该也是很大且慢的测试集会减缓开发速度。

此外另一个不完全依赖测试验证正确性的原因是:由于软件存在的bug,给出了足够的时间和空间使得数据同样存在bug。数据质量是对数据存在问题的衡量。代码质量问题会导致系统以意想不到的方式运行,这是规范并没有测试或者明确的。用户会遇到这种有问题的数据,无论他们得到的响应是什么样的,他们都会依靠并相信这些响应是正确的。如果你不确定系统遇到这种有问题的数据会如何工作,很难设计并测试新系统使其产生和原系统相匹配的响应。因此重写系统的测试覆盖率是非常重要的,产品数据作为输入是新系统相对于原系统响应正确性的唯一测试。

第 3 段(可获 2 积分)

嵌入Scientist

Scientist的构建旨在填补如上的缺陷,并通过测试生产数据及其响应保证正确性。通过在待重构代码处创建轻量级的抽象实现,我们称之为experiment (实验)。原代码(控制代码)由实验进行委托,并由实验返回结果。重构代码在执行时作为实验执行的候选。当实验在运行时,原代码和重构代码块都会运行(顺序随机以避免排序问题)。控制代码和候选代码将会进行比较,如果存在不同,会对其进行记录。两个代码快的执行时间也会被记录。之后控制代码的结果由实验给出。

第 4 段(可获 2 积分)

从调用者的角度来看,什么都没有改变。但是通过运行和对比对原系统和新系统,及其不匹配响应和性能差异,你可以使用这些数据作为反馈回路从而修改新系统(也可能是老系统)以修正错误,重新测试直到两个系统完全相同。你甚至可以在重构系统未完全实现前使用Scientist,这时需要通知Scientist由于已知的行为差异,系统需要忽略实验。

第 5 段(可获 2 积分)

下图给出了实验运行的happy path。

scientist control flow

Happy paths 只是系统行为的一部分,Scientist同样可以处理异常。在控制代码块和候选代码块遇到的异常都会在实验观察中进行记录。由于控制代码的异常是该模块的返回值,因此它会在实验的最后给出;由于候选代码的异常会对实验产生不可预估的副作用因此不做输出。如果候选代码和控制代码存在相同的异常,那么两个系统的响应是相同的,此时他们被认为是匹配的。

第 6 段(可获 2 积分)

示例

例如我们有一种方法去确定特定用户pull的版本库是否可以通过:

class Repository
  def pullable_by?(user)
    self.is_collaborator?(user)
  end
end

但是 is_collaborator? 方法是非常低效的并且表现并不是很好,因此你需要重写一个新方法替代它:

class Repository
  def has_access?(user)
    ...
  end
end

声明实验,使用science块对代码进行包裹并对实验进行命名:

def pullable_by?(user)
  science "repository.pullable-by" do |experiment|
    ...
  end
end

声明原方法为控制分支——当方法运行结束时通过整个science块返回分支。

第 7 段(可获 2 积分)
def pullable_by?(user)
  science "repository.pullable-by" do |experiment|
    experiment.use { is_collaborator?(user) }
  end
end

之后指定实验中将尝试的候选分支:

def pullable_by?(user)
  science "repository.pullable-by" do |experiment|
    experiment.use { is_collaborator?(user) }
    experiment.try { has_access?(user) }
  end
end

也许需要为实验添加一些上下文帮助调试潜在的不匹配:

def pullable_by?(user)
  science "repository.pullable-by" do |experiment|
    experiment.context :repo => id, :user => user.id
    experiment.use { is_collaborator?(user) }
    experiment.try { has_access?(user) }
  end
end
第 8 段(可获 2 积分)

启用

默认情况下,所有实验一直是启用的。根据使用Scientist的位置以及应用程序的性能特征,这样也许是不安全的。改变默认设置并且在实验运行过程中能有更多控制,你需要创建自己的实验类并重写 enabled? 方法。下面的代码给出了重写 enabled? 方法的示例,它能够对每个实验按照百分比时间进行启用。

class MyExperiment
  include ActiveModel::Model
  include Scientist::Experiment
  attr_accessor :percentage

  def enabled?
    rand(100) < percentage
  end
end
第 9 段(可获 2 积分)

需要重写 new 方法通知Scientist使用你的类而不是默认实现实验去创建新的实验

module Scientist::Experiment
  def self.new(name)
    MyExperiment.new(name: name)
  end
end

结果发布

Scientist对于对产生的数据的操作不是武断的(译者注:不会直接给出对产生数据的操作);它简单的给出指标以及结果,是否保存需要由你自己下决定。在实验类中实现 publish 方法去记录指标和存储不匹配。Scientist通过这种方法对结果进行传递。 Scientist::Result 包括许多关于实验有用的信息,例如:

第 10 段(可获 2 积分)
  • 实验匹配,不匹配或被忽略
  • 如果控制分支和候选分支的结果存在不同,给出控制分支和候选分支的结果
  • 任何添加到实验中的附加的上下文
  • 候选分支和控制分支的持续时间

在GitHub, 我们使用 Brubeck 和 Graphite记录指标。大部分实验使用Redis保存不匹配数据和附加上下文。下面是我们进行结果生成的示例:

class MyExperiment
  def publish(result)
    name = result.experiment_name

    $stats.increment "science.#{name}.total"
    $stats.timing "science.#{name}.control", result.control.duration
    $stats.timing "science.#{name}.candidate", result.candidates.first.duration

    if result.mismatched?
      $stats.increment "science.#{name}.mismatch"
      store_mismatch_data(result)
    end
  end
 end

 def store_mismatch_data(result)
   payload = {
     :name            => name,
     :context         => context,
     :control         => observation_payload(result.control),
     :candidate       => observation_payload(result.candidates.first),
     :execution_order => result.observations.map(&:name)
   }

   Redis.lpush "science.#{name}.mismatch", payload

   ...
 end
end
第 11 段(可获 2 积分)

通过发布这些数据,我们得到类似下面的图表:

scientist mismatches graph

scientist performance graph

不匹配数据如下:

{
  context: repo: 3
    user: 1
  name: "repository.pullable-by"
  execution_order: ["candidate", "control"]
  candidate: duration: 0.0015689999999999999
    exception: nil
    value: true
  control: duration: 0.000735
    exception: nil
    value: false
}

使用数据纠正系统

一旦产生不匹配数据,可以进行产生不匹配原因的分析调查。通常你会发现新代码中存在bug或这丢失了原代码的部分相应,但是一些时候你会发现原代码或你的数据存在bug。bug得到修改后,可以重新进行实验并重复上述过程直到两种代码路径不存在不匹配为止。

 

第 12 段(可获 2 积分)

完成实验

一旦你有足够的信心保证控制代码和候选代码的响应完全一样,你可以结束这事实验了。结束实验和禁用一样简单,移除science的代码以及控制实现,用候选实现进行代替。

def pullable_by?(user)
  has_access?(user)
end

注意事项

有一些情况Scientist并不使用。尤其需要注意的是Scientist并不意味着能够用于任何代码不产生副作用。写入与控制代码相同的数据库、写入无效缓存或者其他会影响原始行为和生产行为的修改的任何候选代码路径都是危险且不正确的。基于这个原因,我们只在读操作中使用Scientist。

 

第 13 段(可获 2 积分)

此外,需要注意使用Scientist会导致性能下降。在操作中新的实验应该仔细地慢慢添加,他们对性能产生的影响也应该密切监视。做必要的运行保证应用的性能而不是无限期的运行,尤其是对于一些消耗比较大的操作。

总结

Github上大量问题能够自由的使用Scientist。这个开发模式能够被应用于小到单个方法,大到外部系统。 Move Fast and Fix Things帖子给出了使用Scientist进行简单重构的一个很好的例子。在过去几年里,我们也在下面的项目中使用了Scientist:

第 14 段(可获 2 积分)
  • 持续多年的规模较大的重构,清理了权限代码
  • 切换到新的代码搜索集群
  • 优化查询 — 它不仅保证了新查询性能更优,而且查询结果是正确的不会无意中给出或多或少或者不同的数据。
  • 重构代码库的风险部分 —保证没有意外的变化引入

如果你将要对你的Ruby代码库进行高风险的重构,试试Scientist gem吧,看看它是否能够让你的工作变得容易。即时Ruby不是你选择的语言,我们仍然鼓励你将Scientist实验模式应用到你的系统中。当然我们也希望得到你构建的任何开源代码库能够完成这项工作!

第 15 段(可获 2 积分)

文章评论