Resurrecting a Dead Library: Part Three - Rehabilitation

Michael Lynch

復活已死的函式庫:第三部——重整

原文由 Michael Lynch 發布,訂閱此部落格

我熱愛重構。沒有什麼比把糾結的義大利麵式程式碼梳理開來,讓其底層邏輯以清晰、直觀的方式呈現更讓我滿足的事了。

我學到重構需要審慎。年輕時比較莽撞,我會一頭衝進老舊的程式碼庫,不顧什麼可控的變更就把程式碼拆得七零八落。結果難免在幾天或幾週後才發現,自己刪掉了一段看似無關緊要、實際上卻對某個冷門情境至關重要的微妙程式碼,導致程式壞掉了。

在這篇文章中,我會示範如何謹慎地重構。我會說明我在一個真實的老舊 Python 函式庫上所應用的技巧,包括我用來減少錯誤的開發工具鏈,以及我加入單元測試來鎖定既有行為的流程。

這是三部曲系列的最後一篇,講述我如何讓 ingredient-phrase-tagger 起死回生——這是一套利用機器學習將烹飪食材描述(例如「2 cups milk」)解析為結構化資料的函式庫。完整的來龍去脈請看第一部,簡單來說,我發現了一個被棄置的函式庫,並讓它重獲新生,以便為我的 SaaS 事業提供動力:

  • 第一部:復甦 - 在其中我把程式碼搶救回來,讓它能在任何現代系統上執行
  • 第二部:穩定化 - 在其中我防止功能在修復過程中退化
  • 第三部:重整(本文) - 在其中我開始重構程式碼

寄居蟹被從殼中拉出

我們目前在哪裡?

在前兩篇文章中,我建立了一個客製化的 Docker 映像檔,讓我能在任何地方使用這個函式庫,並加入了端對端測試來保留高層次的行為。每當程式碼庫有任何變更,Travis 持續整合就會建置所有相依套件,並在受控的環境中執行測試

到目前為止,我還沒有動到程式碼本身。我只是在既有程式碼之上加入工具與腳本來驗證其行為。現在,安全地修改程式碼所需的所有機制都已就位,我終於可以開始重構了。

強制統一空白格式

開發者永遠不該把心力浪費在空白上。每當我展開一個新的軟體專案,我都會盡早將空白格式自動化。

在 Python 專案中,我用 YAPF(Yet Another Python Formatter)來達成這點。我在這個專案中的第一個程式碼變更,就是將所有檔案重新格式化以符合我偏好的標準——Google Python 風格指南

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

這帶來了大量的程式碼異動,但我很有信心這是安全的變更,因為 YAPF 是一套成熟的工具,而且我的端對端測試仍然通過。

我很小心地將這次 pull request限制為只有空白的變更,以免在雜訊中埋藏其他東西,讓 pull request 難以讓其他開發者審查。

YAPF 變更產生的 Diff

用 YAPF 修正空白後的 Diff

為了確保未來的變更都能遵守相同的風格規範,我在建置腳本中加入了一項新的檢查:

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

它和前面的指令相同,只是把 --in-place 旗標換成 --diff。如果 YAPF 偵測到違反空白規範的情況,就會將其印出,然後回傳失敗的結束代碼,導致建置腳本以失敗收場。

加入靜態分析

pyflakes 是我一定會加入 Python 工具鏈的另一個實用工具。它利用靜態分析來找出諸如未初始化的變數或未使用的 import 等粗心錯誤。

把它加到 ingredient-phrase-tagger 的建置腳本中,它馬上就抓到一個未使用的 import:

$ 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 類別和這個新模組之間找到一個最小的介面。

我發現 Cli 是在 generate_data 的迴圈本體中呼叫其他所有函式的。如果我把那段程式碼抽取成一個新函式,Cli 就只需要存取這個新函式,而不需要它原本的那些方法。

YAPF 變更產生的 Diff

generate_data 中的迴圈本體抽取為名為 translate_row 的新函式

這個改動讓 Cli 類別變得更精簡、邏輯上也更內聚。它現在只剩下兩個公開方法和一個私有方法:

  • run
  • generate_data
  • _parse_args

它還不算完美,但已經比先前臃腫的介面好多了。我當然還有許多做的改動,但那些只能先等等。

為了盡量降低出錯的機率,我在重構時讓每個 pull request 的範圍都保持得很小。在檔案之間搬移程式碼時,盡量減少變更是特別重要的,因為搬移本身就很難讓人注意到逐行的細微修改。

我的端對端測試通過了,這表示我在搬移過程中沒有破壞什麼重要的東西,但我的工作還沒完成。這次重構產生了一個新函式,意味著我需要一個新的單元測試來涵蓋它。

我的第一個單元測試

建立單元測試並不難。我暫時在 translator.translate_row 的開頭與結尾加入除錯用的 log 敘述來印出輸入與輸出。那些數值就成了我第一個單元測試的輸入與預期輸出:

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

它回傳一組以 tab 分隔的數值,那是函式庫的機器學習引擎能理解的格式。

又加入了幾個單元測試來涵蓋不同類型的食材:一種帶有分數的食材("1 1/2 teaspoons salt"),以及一種帶有備註的食材("Half a vanilla bean, split lengthwise, seeds scraped")。

將單元測試整合到建置流程中

單元測試如果沒有整合到建置流程中,就沒什麼樂趣可言,所以我更新了建置腳本把它們納入:

在建置腳本中加入單元測試指令的 Diff 截圖

在建置腳本中加入執行單元測試的指令

因為 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 環境中。

這很容易修正。我只需要在 Docker 容器中把 .coverage 檔案取出來,加上一道指令即可:

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 容器對檔案系統的視圖。/app 這個路徑在 Travis 的檔案系統中根本不存在。

我該如何彌合這兩個對同一批檔案有著不同視圖的環境之間的落差?我找到了一個解法,但有點迂迴。

迂迴的路徑轉換方法

coverage 的文件中,我注意到它支援一個paths 選項,其中討論了如何合併來自多個檔案系統的路徑:

paths 文件的截圖

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 終於顯示了程式碼涵蓋率資訊。

我宣告這個函式庫已重生

在整合了程式碼涵蓋率追蹤之後,我覺得這個函式庫又活了過來。它不會因為品質而得獎,但基礎設施已經就位,讓我或任何其他開發者都能帶著高度的信心持續迭代程式碼。

在這一系列的部落格文章中,我描述了如何以小而離散的步驟來改進這個函式庫。這將出現錯誤的可能性降到最低,但或許也讓全貌變得不那麼清晰。為了提供一些宏觀的視角,請容我回顧一下在讓它重生的過程中,我對函式庫所做的高層次改進:

之前之後
只能在 OS X 上建置在任何支援 Docker 的環境中皆可建置
沒有端對端測試擁有完整的端對端測試
沒有單元測試擁有少量的單元測試,以及易於新增更多測試的機制
沒有程式碼涵蓋率資訊在每次提交時測量程式碼涵蓋率,並長期維護涵蓋率歷史紀錄
沒有自動化建置在每次提交時自動建置並測試程式碼
程式碼風格不一致透過自動化工具強制統一風格規範
開發者必須手動找出未使用的 import 和未初始化的變數套用靜態分析自動捕捉粗心錯誤

重構一個準備丟棄的版本

鑑於我對這些改進感到如此自豪,你可能會驚訝地發現,在又花了幾週改進程式碼之後,我最終還是放棄了它,轉而選擇完全重寫。

……計畫把其中一個丟掉;反正你最後都會這麼做。

-Fred Brooks,The Mythical Man-Month: Essays on Software Engineering

我越是重構程式碼,就越意識到其基礎架構上的問題。這並不代表我改進程式碼的努力是白費的——我需要親自動手,才能培養出深刻的理解。一旦我完全理解了一切,就能安心地從頭重寫,以獲得更好的可維護性與效能。

成果就是一個名為 Zestful 的服務。它提供與 ingredient-phrase-tagger 類似的功能,但以託管 API 的形式提供。它讓客戶可以立即解析食材,而不需要像我當初為了讓原始函式庫能運作那樣經歷重重關卡。

如果你想看看 Zestful 的實際運作,請參考線上展示

Zestful 食材解析展示的截圖


封面插圖由 Loraine Yow 繪製。我 fork 的 ingredient-phrase-tagger 函式庫可在 GitHub 上取得。我基於這個函式庫提供名為 Zestful 的代管服務。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言