Resurrecting a Dead Library: Part Three - Rehabilitation

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 修正空白後的差異

以 YAPF 修正空白後的差異

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

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

它與前一個指令相同,只是將 --in-place 旗標換成了 --diff 旗標。如果 YAPF 偵測到空白違規,就會將其印出,並回傳失敗的結束代碼,導致建置腳本以失敗終止。

加入靜態分析

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

我意識到 Cli 是在 generate_data 的迴圈主體內呼叫其他所有函式。如果我將那段程式碼抽取為一個新函式,Cli 就只需要存取這個新函式,而不需要它先前的任何方法。

從 YAPF 變更產生的差異

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

這項變更讓 Cli 類別更精簡、邏輯上也更內聚。它現在只由兩個公開方法與一個私有方法組成:

  • run
  • generate_data
  • _parse_args

它仍不完美,但已比先前臃腫的介面好多了。誠然,我還想做更多變更,但那些只能先等等。

為了將出錯機率降到最低,我在重構時讓每個 pull request 的範圍保持精簡。在檔案之間搬移程式碼時,盡量減少變更尤其重要,因為搬移動作本身就讓人難以察覺行層級的修改。

我的端對端測試通過了,這告訴我搬移過程中沒有弄壞任何重要東西,但我的工作還沒完成。我的重構產生了一個新函式,這表示我需要一個新的單元測試來涵蓋它。

我的第一個單元測試

建立單元測試很簡單。我暫時在 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

它回傳一組以 Tab 分隔、供函式庫的機器學習引擎理解的值。

又加入了幾個單元測試來涵蓋不同類型的食材:帶有分數的食材("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 容器的檔案系統視圖。/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 的環境中建置
沒有端對端測試擁有完整的端對端測試
沒有單元測試擁有少數單元測試與易於新增更多測試的機制
沒有程式碼涵蓋率資訊在每次提交時測量程式碼涵蓋率並長期維護涵蓋率歷史
沒有自動化建置在每次提交時自動建置並測試程式碼
程式碼風格不一致透過自動化工具強制執行風格規範
開發者必須手動找出未使用的匯入與未初始化的變數套用靜態分析以自動捕捉粗心錯誤

重構一個,然後丟棄

鑑於我對這些變更感到如此自豪,你可能會驚訝地得知,在又花了幾週進一步改善程式碼後,我放棄了它,轉而選擇徹底重寫。

…計畫丟棄一個;反正你終究會這麼做。

-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 發布

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