Resurrecting a Dead Library: Part Three - Rehabilitation

Michael Lynch

让一座沉寂的库重获新生:第三部分——康复

我热爱重构(refactoring)。没有什么比理清一团乱麻般的代码、以清晰直观的方式揭示其底层逻辑更让我满足。

我逐渐认识到,重构需要勤勉。在年轻且更鲁莽的时候,我会冲进遗留代码库,不顾变更是否受控就把代码拆得七零八落。结果往往是,几天或几周后,我才发现自己因为删掉了一段看似无关、实际上却对某个罕见场景至关重要的代码而破坏了程序。

在这篇文章中,我将展示如何谨慎地进行重构。我会介绍自己重构一个真实遗留 Python 库时采用的技术,包括我使用的、用于尽量减少错误的开发工具链,以及我为添加单元测试(unit test)而采用的、用于锁定现有行为的流程。

这是关于我如何让 ingredient-phrase-tagger 重获新生的三篇系列文章中的最后一篇。这个库利用机器学习将烹饪配料(例如“2 cups milk”)解析为结构化数据。要了解完整背景,请阅读第一部分;简而言之,我发现了一个被遗弃的库,并让它重新运行起来,使它能够为我的 SaaS 业务提供支持:

  • 第一部分:复苏——我让代码恢复健康,使其能够在任何现代系统上运行
  • 第二部分:稳定——我在恢复代码的同时,防止其功能发生倒退
  • 第三部分:康复(本文)——我开始重构代码

被从壳中拉出的寄居蟹

我们现在处于什么状态?

在前两篇博客文章中,我创建了一个自定义 Docker 镜像,这样就能在任何地方使用这个库;还添加了一个端到端测试(end-to-end test),用于保留高层行为。每次代码库发生变更时,Travis 持续集成(continuous integration)都会构建所有依赖,并在受控环境中执行测试

直到这一步,我还没有修改代码本身。我只是在现有代码之上添加工具和脚本,以验证其行为。现在,修改代码所需的各种安全机制都已就位,我终于可以开始重构了。

强制执行空白字符规范

开发者绝不应该把脑力浪费在空白字符上。每当我开始一个新的软件项目时,都会尽早将空白字符格式化自动化。

对于 Python 项目,我使用 YAPF(Yet Another Python Formatter)来实现这一点。在这个项目中,我做的第一次代码变更,就是按照Google 的 Python Style Guide——我偏好的标准——重新格式化所有文件:

yapf \
  --in-place \
  --recursive \
  --style google \
  ./ \
  --exclude="third_party/*" \
  --exclude="build/*"

这引入了大量代码变动,但我确信这是一次安全的变更,因为 YAPF 是一个成熟的工具,而且我的端到端测试仍然通过了。

我很谨慎地将这个拉取请求限制为只有空白字符变更,以免其他内容淹没在噪声中,让其他开发者难以审查这个拉取请求。

YAPF 变更后的差异

使用 YAPF 修复空白字符后的差异

为了确保今后的变更都遵循同样的风格规范,我在构建脚本中添加了一项新检查:

yapf \
  --diff \
  --recursive \
  --style google \
  ./ \
  --exclude="third_party/*" \
  --exclude="build/*"

它与前面的命令相同,只是将 --in-place 标志换成了 --diff 标志。如果 YAPF 检测到空白字符违规,就会将它们打印出来,然后返回失败的退出码,从而导致构建脚本以失败告终。

添加静态分析(static analysis)

pyflakes 是我总会添加到 Python 工具链中的另一个实用组件。它使用静态分析来识别未初始化变量或未使用导入等粗心错误。

将它添加到了 ingredient-phrase-tagger 的构建脚本中,它立即发现了一个未使用的导入:

$ pyflakes \
    bin/ \
    ingredient_phrase_tagger/
ingredient_phrase_tagger/training/utils.py:3: 'string' imported but unused

该读代码了

你可能已经注意到,在整个过程中,我一直避免尝试理解代码。我只是对这个库的行为有一个粗略的认识,就这样一路应付了过来。

我发现,阅读代码的最佳方式是边重构边测试。著名软件专家 Martin Fowler(马丁·福勒)对这一过程的描述最为到位:

当我查看不熟悉的代码时,我必须努力理解它的作用。我看几行代码,然后对自己说,哦,没错,这段代码就是在做这个。进行重构时,我不会止步于脑中的记录。我会实际修改代码,使其更好地反映我的理解,然后通过重新运行代码来验证这种理解,看看它是否仍能正常工作。

——Martin Fowler,Refactoring: Improving the Design of Existing Code(《重构:改善既有代码的设计》)

解决糟糕的代码组织

这个库中 80% 的代码都集中在两个文件里:cli.py(命令行界面)和 utils.py(实用工具)。换句话说,作者把代码分成了两个桶:“用户界面”和“其他所有东西”。但即使这样,划分也并不清晰。

cli.py 中很少有代码与从命令行读取或向命令行写入有关。它由一个名为 Cli 的类组成,包含以下方法:

  • run
  • generate_data
  • parseNumbers
  • matchUp
  • addPrefixes
  • bestTag
  • _parse_args

我的第一要务是精简 Cli 类,使它形成对命令行界面更合乎逻辑的抽象。

剖析 Cli

要拆分 Cli 类,我需要一个切入点。generate_data 显然不像是属于一个负责管理用户界面的类,但我无法立即将它移走。generate_data 通过 self 参数调用 Cli 的其他方法,这意味着它与类的其他部分共享状态。

真的是这样吗?cli.py 中的每个函数都是 Cli 类的成员方法,但它们真的共享实例变量吗?

我检查了 Cli 的构造函数:

def __init__(self, argv):
      self.opts = self._parse_args(argv)
      self._upstream_cursor = None

构造函数给 self._upstream_cursor 赋了一个值,但从来没有任何代码引用这个变量。它是死代码,因此删掉它很容易。

另一个成员变量 self.opts 并不是死代码,但只有两个方法引用了它:rungenerate_data

既然不存在共享状态,Cli 的其他公共方法就没有理由必须是方法。它们完全可以作为模块级自由函数存在。更好的是,我可以把它们移到一个全新的模块中,用比 cli 更能描述其用途的名称来命名。

形成清晰的抽象

发现 Cli 的大多数方法都可以放到另一个模块后,我就必须设计这个新模块。当然,我可以把每个函数都移过去,并把它们全部设为公共函数,但我想在 Cli 类与这个新模块之间找到一个最小接口。

我意识到,Cligenerate_data 的循环体中调用了所有其他函数。如果我把这段代码提取到一个新函数中,Cli 就只需要访问这个新函数,不再需要之前的任何方法。

从 generate_data 中提取循环体后的差异

generate_data 的循环体提取到名为 translate_row 的新函数中

这项变更让 Cli 类更加精简,逻辑内聚性也更好。现在它只包含两个公共方法和一个私有方法:

  • run
  • generate_data
  • _parse_args

它仍不完美,但比之前臃肿的接口好得多。当然还有许多我想要进行的变更,但那些只能以后再说。

为了尽量降低出错的概率,我让重构中的每个拉取请求都保持严格的范围。移动文件之间的代码时,尤其要尽量减少变更,因为代码移动本身就会让人难以注意到逐行的修改。

我的端到端测试通过了,这说明我没有在移动代码时破坏任何重要功能,但工作还没有完成。我的重构创建了一个新函数,这意味着我需要一个新的单元测试来执行它。

我的第一个单元测试

创建单元测试很容易。我在 translator.translate_row 的开头和结尾临时添加了调试日志语句,用来打印输入和输出。这些值就成了我的第一个单元测试的输入和预期输出:

def test_translates_row_with_simple_phrase(self):
    row = {
        'index': 162,
        'input': '2 cups flour',
        'name': 'flour',
        'qty': 2.0,
        'range_end': 0.0,
        'unit': 'cup',
        'comment': '',
    }
     self.assertMultiLineEqual("""
2\tI1\tL4\tNoCAP\tNoPAREN\tB-QTY
cups\tI2\tL4\tNoCAP\tNoPAREN\tB-UNIT
flour\tI3\tL4\tNoCAP\tNoPAREN\tB-NAME
""".strip(),
                              translator.translate_row(row).strip())

我仍然没有完全理解这个函数的作用,但单元测试让我更接近真相。我发现它处理的是这个库的训练数据,这些数据存储在一个 CSV 文件中,形式如下:

indexinputnameqtyrange_endunitcomment
1622 cups flourflour2.00.0cup

它返回一组以制表符分隔的值,而这个库的机器学习引擎能够理解这些值。

我又添加了几个单元测试,用于覆盖不同类型的配料:含分数的配料("1 1/2 teaspoons salt"),以及附带评论的配料("Half a vanilla bean, split lengthwise, seeds scraped")。

将单元测试集成到构建中

单元测试如果不集成到构建流程中,就没多大意思,所以我更新了构建脚本,将它们包含进去:

在构建脚本中添加单元测试命令的差异截图

将单元测试执行添加到构建脚本中

由于 Travis 持续集成已经会在每次代码变更时运行我的构建脚本,我在下一次 Travis 构建中看到了单元测试输出:

单元测试日志输出

Travis 构建输出中的单元测试日志

添加代码覆盖率

重构时,随着越来越多的代码受到测试覆盖,我喜欢看着代码覆盖率百分比不断上升。在 Python 项目中,我使用 coverage 模块收集覆盖率信息,再使用 Coveralls 将结果呈现在网页仪表板中。

要从 Python 原生的单元测试运行器切换到 coverage,我只需对构建脚本做一个很小的改动:

-python -m unittest discover
+coverage run -m unittest discover

接着,我在 Travis 配置中添加了一个 after_success 键,使 Travis 能够将代码覆盖率信息上传到 Coveralls。

after_success:
  - pip install pyyaml coveralls
  - coveralls

我迫不及待地查看 Coveralls,想看看代码覆盖率统计数据,结果……

Coveralls 显示没有结果的截图

Coveralls 没有显示任何代码覆盖率信息

什么都没有。

我的代码覆盖率去哪儿了?

过去我在几十个项目中使用过 Coveralls,所以不明白它为什么什么都不显示。这明明只是一个简单的 Python 项目。coverage 命令应该会创建一个名为 .coverage 的文件,其中包含代码覆盖率信息,而 coveralls 命令应该会将它上传到 Coveralls 仪表板。

哦,问题就在这里!coverage 命令是在我的 Docker 容器中运行的,但 coveralls 二进制文件是在标准的 Travis 环境中运行的,因此找不到 .coverage 文件。我从未把它从 Docker 容器复制到外部的 Travis 环境中。

这很容易修复。我只需要添加一条命令,将 .coverage 文件从 Docker 容器中提取出来:

after_success:
  - pip install pyyaml coveralls
  - docker cp ingredient-phrase-tagger-container:/app/.coverage ./
  - coveralls

然而,Coveralls 仪表板仍然什么都没有显示:

Coveralls 再次显示没有结果的截图

Coveralls仍然没有显示任何代码覆盖率信息

不过,Travis 构建打印出了之前构建中没有出现的输出:

$ coveralls
Submitting coverage to coveralls.io...
No source for /app/ingredient_phrase_tagger/__init__.py
No source for /app/ingredient_phrase_tagger/training/__init__.py
No source for /app/ingredient_phrase_tagger/training/cli.py
No source for /app/ingredient_phrase_tagger/training/translator.py
No source for /app/ingredient_phrase_tagger/training/utils.py
Coverage submitted!
Job #177.1
https://coveralls.io/jobs/39259674

这时我意识到,还有另一个问题。

Travis 和 Docker 对文件系统的看法互相冲突。例如,下面是它们各自看到的 cli.py 文件:

环境文件路径
Docker 容器/app/ingredient_phrase_tagger/training/cli.py
Travis/home/travis/ingredient_phrase_tagger/training/cli.py

这样一来,coveralls 在 Travis 中打印的错误信息就更容易理解了:

No source for /app/ingredient_phrase_tagger/training/cli.py

Coveralls 找不到该文件,是因为 .coverage 中的路径基于 Docker 容器所看到的文件系统。Travis 的文件系统中并不存在 /app 路径。

我该如何在这两个对同一组文件有着不兼容视图的环境之间搭起桥梁?我找到了一个解决方案,但过程有点曲折。

一种迂回的路径转换方式

coverage 的文档中,我注意到它支持一个paths 选项,专门讨论如何合并来自多个文件系统的路径:

paths 文档截图

coverage 命令的 paths 选项文档

为了使用这些选项,我创建了以下 .coveragerc 文件:

[run]
source = ingredient_phrase_tagger

; Run in parallel mode so that coverage can canonicalize the source paths
; regardless of whether it runs locally or within a Docker container.
parallel = True

[paths]
; the first path is the path on the local filesystem
; the second path is the path as it appears within the Docker container
source =
  ingredient_phrase_tagger/
  /app/ingredient_phrase_tagger/

我的新方案是在 Docker 容器中运行 coverage 命令,然后在 Travis 环境中执行 coverage combine 功能,将所有路径规范化为 Travis 文件系统中的路径。

应用这个方案后,我的 Travis 配置中的 after_success 部分如下:

after_success:
  - pip install pyyaml coveralls
  # Copy the .coverage.* file from the Docker container to the local filesystem.
  - docker cp ingredient-phrase-tagger-container:/app/$(docker exec -it ingredient-phrase-tagger-container bash -c "ls -a .coverage.*" | tr -d '\r') ./
  # Use coverage combine to canonicalize the source paths.
  - coverage combine
  # Upload coverage information to Coveralls.
  - coveralls

终于有代码覆盖率了

我检验了完整的解决方案。最后,Coveralls 收到了结果,并显示了我的代码覆盖率数据

Coveralls 显示代码覆盖率统计数据的截图

Coveralls 终于显示了代码覆盖率信息。

我宣布这个库已经重获新生

集成代码覆盖率跟踪后,我感觉这个库又活过来了。它不会因为质量而赢得任何奖项,但基础设施已经就绪,我或其他任何开发者都可以满怀信心地继续迭代这份代码。

在这一系列博客文章中,我描述了自己如何通过一系列小而独立的步骤改进这个库。这种做法将潜在的 bug 降到了最低,但或许也掩盖了全貌。为了让大家获得一些整体视角,请允许我回顾一下自己在让这个库重获新生的过程中所做的高层改进:

之前之后
只能在 OS X 上构建可在任何支持 Docker 的环境中构建
没有端到端测试拥有完善的端到端测试
没有单元测试拥有少量单元测试,并且很容易添加更多测试
没有代码覆盖率信息每次提交都会测量代码覆盖率,并持续维护覆盖率历史记录
没有自动化构建每次提交都会自动构建和测试代码
代码风格不一致通过自动化工具强制执行风格规范
开发者必须手动找出未使用的导入和未初始化的变量应用静态分析,自动捕获粗心错误

重构一个然后丢掉

考虑到我对这些变更感到多么自豪,你可能会惊讶地得知,在又花了几周改进代码之后,我还是放弃了它,转而彻底重写。

……计划好扔掉一个;反正你最终都会这么做。

——Fred Brooks(弗雷德·布鲁克斯),The Mythical Man-Month: Essays on Software Engineering(《人月神话:软件工程的随笔》)

我越是重构代码,就越能看出其根本架构存在的问题。这并不意味着我改进代码所付出的努力白费了——我需要亲自深入其中,才能形成深刻理解。在完全理解一切之后,我才有信心从头重写,以获得更好的可维护性和性能。

最终的成果是一项名为 Zestful 的服务。它提供与 ingredient-phrase-tagger 类似的功能,但形式是托管 API。客户可以立即解析配料,而不必像我当初让原始库运行起来那样,经历重重繁琐步骤。

如果你想看看 Zestful 的实际效果,可以试试在线演示

Zestful 配料解析演示的截图


封面插图由 Loraine Yow(洛兰·尤)绘制。我的 ingredient-phrase-tagger 库分支可在 GitHub 上获取。我提供一项基于这个库的托管服务,名为 Zestful

原文由 Michael Lynch 发布

本文章由 openai/gpt-5.6-luna 进行翻译