Resurrecting a Dead Library: Part One - Resuscitation

Michael Lynch

复活一个已死的库:第一篇——复苏

原文由 Michael Lynch 发布,订阅该博客

我到场的时候,眼前的景象可不怎么好看。

我看到那些曾经活跃、灵动的 Python 类,如今已萎缩得不成样子,多年没有得到任何锻炼。各个抽象层级的函数被不人道地挤在 utils 这个标签之下。我试着去读界面代码,却发现有东西挡在前面。凑近一看,顿时感到一阵恶心——堵在视图层里的,竟是大块血淋淋的业务逻辑。

代码已经死了。

在这个三篇系列中,我将向你展示我是如何让它起死回生,并以此做出一项业务的:

  • 第一篇:复苏(本文)——把代码救活,让它能在任何现代系统上运行
  • 第二篇:稳定化——在修复代码的同时防止功能回退
  • 第三篇:康复——开始重构代码

熊医生正在抢救蟒蛇

这个库

这个库是 ingredient-phrase-tagger,是《纽约时报》发布的一个开源库。它能把食谱中的食材描述解析成结构化数据。

几年前,《时报》决定将其庞大的历史食谱档案数字化。他们雇佣了数据录入人员,让他们查看这些食谱中的原始食材文本,把其中蕴含的数据一条条拆解出来。最终得到的数据库看起来是这样的:

原始食材数量单位名称备注
3 tablespoons flour3.0tablespoonflour
2 1/2 cups of finely chopped red onions2.5cupred onionsfinely chopped
2 dried pasilla chilies2.0pasilla chiliesdried

在向这个数据库持续添加了六年数据后,他们意识到已经积累了足够多的数据,可以训练一个机器学习模型来模拟人工录入时的判断。项目取得了成功,于是他们把所有的源代码和数据都公开了。

这跟我有什么关系?

我遇到了和《时报》一样的问题。我的项目 KetoHub 会从全网聚合食谱,并让用户按食材进行搜索。食谱网站通常不会以结构化的形式发布食材清单,所以我得自己把结构拆解出来。

KetoHub 截图

KetoHub 上搜索“avocado”的结果

正则表达式实现的截图

我那套令人作呕的正则解析实现节选

在我偶然发现 ingredient-phrase-tagger 的时候,我正用一种丑陋、凑合的方式解析食材:正则表达式

这根本不可持续。每当我为 KetoHub 的索引新增一个食谱网站,就得去修改那一长串正则表达式来处理新的边界情况。久而久之,食材解析代码变得极其纠缠复杂,开始以各种莫名其妙的方式崩溃。

我的正则表达式维护和调试起来都极其繁琐。我感觉自己像是蒙着眼睛、用电锯在剁食材。而《时报》的那个库,看起来却能像外科手术一样干净利落地剖开食材。我迫切想要它。

但首先,我得想办法让他们的代码跑起来。

为什么这会很难?

《时报》为了黑客周活动搭建了这个库,所以它缺少专业软件项目应有的许多要素,比如自动化测试或详尽的文档。README 里虽然有安装说明,却只在 Mac OS X 上有效。没有测试,也没有持续集成配置,根本搞不清楚到底该怎么把代码跑起来。

OS X 安装说明

ingredient-phrase-tagger 库的安装说明

当然,注意到这些问题的不止我一个。就在发布之时,《时报》还受到了著名 Python 开发者 D. John Trump 的严厉批评:

关于代码的 Trump 推文

用 Docker 来构建

我想以一种能保证无论在什么操作系统上行为都一致的方式来构建这个库。这听起来正是 Docker 的用武之地。

Docker 能让开发者为应用构建出随处可运行的自包含环境。我只用一条命令就拉起了一个用于构建的 Ubuntu 基础环境:

$ docker run -it --rm ubuntu:16.04 /bin/bash
root@164475279c95:/# cat /etc/*release | head -n 2
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=16.04

这个食材解析库的第一个依赖是它的机器学习引擎:一个叫 CRF++ 的 C++ 应用。

CRF++ 安装说明

CRF++ 安装说明

CRF++ 的构建说明看起来足够简单,于是我在 Ubuntu 容器里执行了这些命令:

$ apt-get update && apt-get install git build-essential -y
...
$ git clone https://github.com/taku910/crfpp.git
$ cd crfpp
$ ./configure
...
$ make
...
g++ -DHAVE_CONFIG_H -I.     -O3 -Wall -c -o crf_learn.o crf_learn.cpp
crf_learn.cpp:9:21: fatal error: winmain.h: No such file or directory
compilation terminated.

哎呀,make 失败了,报错说缺少 Windows 头文件。

这个项目还有人在维护吗?

CRF++ 提交历史

CRF++ 变更历史,显示最后一次提交在 2015 年

糟了!又是一个已死的仓库?我已经在复活一个库了,可不想再背上另一个。

稍稍绕个弯

关于 winmain.h 的报错是个不祥之兆,但既然《时报》的开发者能在 OS X 上运行 CRF++,我就知道在非 Windows 环境下跑通它是可行的。

也许已经有人修好了这个问题,只是维护者一直没合进来。我去看了仓库里尚未合并的 pull request。其中有一个看起来特别有希望:

CRF++ 的 pull request

提交到 CRF++ 的待合并 pull request

这个 pull request 的标题简直就像在说:“嘿,Michael,看!我正好解决了你遇到的那个问题”,于是我应用了 @humem 的补丁:

$ git remote add humem https://github.com/humem/crfpp.git
$ git checkout -b patch
Switched to a new branch 'patch'
$ git pull humem patch
remote: Counting objects: 2, done.
remote: Total 2 (delta 1), reused 1 (delta 1), pack-reused 1
Unpacking objects: 100% (2/2), done.
From https://github.com/humem/crfpp
 * branch            patch      -> FETCH_HEAD
 * [new branch]      patch      -> humem/patch
Updating 1dc92a6..37ec31f
Fast-forward
 winmain.h | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 69 insertions(+)
 create mode 100644 winmain.h

……然后再次尝试构建:

$ make
make  all-am
make[1]: Entering directory '/crfpp'
g++ -DHAVE_CONFIG_H -I.     -O3 -Wall -c -o crf_learn.o crf_learn.cpp
/bin/bash ./libtool --tag=CXX   --mode=link g++  -O3 -Wall   -o crf_learn crf_learn.o libcrfpp.la -lpthread -lpthread -lm -lm -lm
libtool: link: g++ -O3 -Wall -o .libs/crf_learn crf_learn.o  ./.libs/libcrfpp.so -lpthread -lm
g++ -DHAVE_CONFIG_H -I.     -O3 -Wall -c -o crf_test.o crf_test.cpp
/bin/bash ./libtool --tag=CXX   --mode=link g++  -O3 -Wall   -o crf_test crf_test.o libcrfpp.la  -lpthread -lpthread -lm -lm -lm
libtool: link: g++ -O3 -Wall -o .libs/crf_test crf_test.o  ./.libs/libcrfpp.so -lpthread -lm
make[1]: Leaving directory '/crfpp'

太好了!make 成功了。

现在,我只需要 make install 然后运行 CRF++:

$ make install
...
$ crf_test --version
crf_test: error while loading shared libraries: libcrfpp.so.0: cannot open shared object file: No such file or directory

不!安装倒是成功了,但 CRF++ 立刻就报错崩掉了,说缺少某个库。

我把报错信息拿去 Google,找到了一个 StackOverflow 答案,让我运行 ldconfig 命令。我试了一下……

$ ldconfig
$ crf_test --version
CRF++ of 0.59

成功了!跑通了!

真正用 Docker 来构建

哦,等等。这好像不是我本来要做的事。

这一通“剃牦牛”式的折腾让我完全跑偏了,居然把最初的目标给忘了:在 Docker 容器里跑通 ingredient-phrase-tagger。

不过,我还是抱有希望,觉得最糟糕的部分已经过去了。剩下的唯一安装步骤就是运行这个库的 setuptools 安装程序,而我用 setuptools 向来都挺顺利的:

$ apt-get install python python-pip -y
...
$ git clone https://github.com/NYTimes/ingredient-phrase-tagger.git
$ cd ingredient-phrase-tagger
$ python setup.py install
...
Installed /usr/local/lib/python2.7/dist-packages/six-1.11.0-py2.7.egg
Finished processing dependencies for ingredient-phrase-tagger==0.0.0.dev0

呼!这一步倒是没出问题。看起来所有东西都装好了。

拿去试跑一下

仓库里的“快速开始”说明提到了一个叫 roundtrip.sh 的 shell 脚本,它能端到端地演练这个库的功能:

$ ./roundtrip.sh
...
visualizing...
./roundtrip.sh: 18: ./roundtrip.sh: ruby: not found

嗯,这个 Python 库居然还需要 Ruby,不知道为什么。好吧,再试一次:

$ apt-get install ruby -y
...
$ ./roundtrip.sh

它跑了大约五分钟,吐出了一大堆输出,最后以这些内容收尾:

Sentence-Level Stats:
        correct:  1487
        total:  1999
        % correct:  74.3871935968

Word-Level Stats:
        correct: 10391
        total: 11450
        % correct: 90.7510917031

成功了!

哦,等等。它到底干了什么?

用我的食材来测试

这个库确实在做事,但我完全看不出它到底在做什么。文档里提到了两个用于解析任意食材的脚本,parse-ingredients.pyconvert-to-json.py,于是我试了试它们:

$ echo "1 pinch Garlic Powder" >> input.txt
$ echo "1 Cup Mozzarella, shredded" >> input.txt
$ echo "6 slices cooked bacon" >> input.txt

$ python bin/parse-ingredients.py input.txt > results.txt
$ python bin/convert-to-json.py results.txt
[
    {
        "input": "1 pinch Garlic Powder",
        "display": "<span class='qty'>1</span><span class='unit'>pinch</span><span class='name'>Garlic Powder</span>",
        "name": "Garlic Powder",
        "unit": "pinch",
        "qty": "1"
    },
    {
        "comment": "shredded",
        "name": "Cup Mozzarella",
        "qty": "1",
        "other": ",",
        "input": "1 Cup Mozzarella, shredded",
        "display": "<span class='qty'>1</span><span class='name'>Cup Mozzarella</span><span class='other'>,</span><span class='comment'>shredded</span>"
    },
    {
        "comment": "cooked",
        "name": "bacon",
        "qty": "6",
        "input": "6 slices cooked bacon",
        "display": "<span class='qty'>6</span><span class='unit'>slices</span><span class='comment'>cooked</span><span class='name'>bacon</span>",
        "unit": "slice"
    }
]

跑通了!

好吧,算是基本跑通了。模型没能把“1 Cup Mozzarella, shredded”里的“Cup”识别为计量单位,机器学习模型似乎以为有一种叫“Cup Mozzarella”的产品,食谱需要来上一份。

Cup Mozzarella 产品的图片

机器学习模型臆想出来的一个产品

让它更省事

我可不想每次运行这个库都要重走一遍这些步骤,所以得想办法加快安装过程。

首先,我 fork 了 CRF++ 仓库,把 @humem 的修复包含进去。这样就得到了一份能在 Linux 上干净构建的 CRF++ 源码副本。然后,我把之前执行过的所有 shell 命令都收进了一个 Dockerfile

FROM ubuntu:16.04

RUN apt-get update -y && apt-get upgrade -y
RUN apt-get install -y build-essential git python2.7 python-pip ruby

# Install CRF++.
RUN git clone https://github.com/mtlynch/crfpp.git && \
    cd crfpp && \
    ./configure && \
    make && \
    make install && \
    ldconfig && \
    cd ..

# Install ingredient-phrase-tagger.
RUN git clone https://github.com/NYTimes/ingredient-phrase-tagger && \
    cd ingredient-phrase-tagger && \
    python setup.py install

WORKDIR /ingredient-phrase-tagger

下载 Dockerfile

以后如果我想得到一个带这个库的环境,只需要在放有 Dockerfile 的目录里运行这两条命令就行:

docker build --tag phrase-tagger .
docker run -it --rm phrase-tagger /bin/bash

这两条命令会创建一个满足食材解析库所有依赖的 Docker 容器。在这个环境里,我可以毫无报错地运行 roundtrip.sh 脚本:

$ ./roundtrip.sh
...
Done!434.32 s

testing...
visualizing...
evaluating...

Sentence-Level Stats:
        correct:  1487
        total:  1999
        % correct:  74.3871935968

Word-Level Stats:
        correct: 10391
        total: 11450
        % correct: 90.7510917031

为了更方便,我还把容器镜像上传到了 Docker Hub,这样大家在家也能用一条命令直接使用我的容器:

docker run -it --rm mtlynch/ingredient-phrase-tagger:nyt-untouched /bin/bash

下面的演示就是在一台除了 Docker 什么都没装的 Ubuntu 系统上录制的。我用它从 Docker Hub 拉取了我的定制镜像,训练了一个机器学习模型,并解析了一条新的食材:

接下去

代码成功跑起来了,而且我的成果在任何支持 Docker 的系统上都能复现。接下来该做什么?

我还没深入看源码,但从运行脚本的过程中已经发现了一些奇怪的地方。最明显的是,那些使用脚本显得不透明又僵硬——训练数据、文件路径和模型参数全都被硬编码、埋在了 shell 脚本里。我希望用户能自由调整这些值,以优化模型的准确率。

另外,你注意到解析输出里的这个了吗?

"display": "<span class='qty'>2</span><span class='unit'>tablespoons</span><span class='name'>lemon juice</span>",

为什么一个机器学习模型既要负责把食材数据结构化,又要负责生成 HTML?这就好比让神经外科医生既要做开颅手术,又要去组装医院的家具。

我本想一头扎进代码里大刀阔斧地改功能,但在此之前必须先完成一个关键步骤:稳定化。我需要把库现有的行为固化下来,这样之后对功能的任何改动才会是明确而审慎的。

这一点我会在本系列的第二篇中讲述,其中包括:

  • 我是如何加入端到端测试,以避免不小心弄坏现有功能
  • 如何配置测试,使其在对代码做任何改动前自动运行
  • 如何为代码库引入我常用的工具集,以便后续维护

封面插图由 Loraine Yow 绘制。我 fork 的 ingredient-phrase-tagger 库可在 GitHub 上获取。我基于该库提供一项名为 Zestful 的托管服务。

本文章由 muse-spark-1.2-contributor 进行翻译

评论