Python 專案工程化最佳實踐指南
Python 工程化這件事都沒有統一的規範和專案管理的方案,也許是因為 Python 突然興起的時間較短、用於開發大型專案的公司較少。所以為了幫助內部的同學更好的解決 Python 工程化問題和分享下個人的開發習慣和程式碼管理思路寫下這篇文章。
依賴管理
在 PEP 518 和 pyproject.toml 引入之前。一個專案無法告訴一個像 pip 這樣的工具它需要什麼樣的構建工具來構建。現在 setuptools 有一個 setup_require 引數來指定構建專案所需的東西,但是除非你安裝了 setuptools,否則你無法讀取該設定,這意味著你不能宣告你需要 setuptools 來讀取 setuptools 中的設定。這個雞和蛋的問題就是為什麼像 virtualenv 這樣的工具會預設安裝 setuptools,以及為什麼 pip 在執行一個 setup.py 時總是會注入 setuptools 和wheel,不管你是否顯式地安裝了它。你甚至不要嘗試依賴於 setuptools 的一個特定版本來構建你的專案,因為你沒有辦法來指定版本; 不管使用者碰巧安裝了什麼,你都得將就使用。
在 PEP 518 之後你可以宣告你的構建工具以及要求的版本。
在過去我們可能會經常使用 requirements.txt 之類的檔案來儲存我們專案所需的依賴,但是它並沒有很好的辦法去區分我們在生產環境、開發環境、測試環境所需要的依賴必須要分成多份檔案單獨宣告,通過一些新的構建工具我們就可以解決我們的問題。而且單獨用 requirements.txt 也不能宣告我們需要的 python 版本、系統環境等等。
在這次的內部專案開發中,我選擇的是 PDM,PDM 旨在成為下一代 Python 軟體包管理工具。它最初是為個人興趣而誕生的。如果你覺得 pipenv
或者 poetry
用著非常好,並不想引入一個新的包管理器,那麼繼續使用它們吧;但如果你發現有些東西這些工具不支援,那麼你很可能可以在 pdm
中找到。
Poetry 看起來也是個不錯的選擇,Poetry 和 Pipenv 、PDM 類似,是一個 Python 虛擬環境和依賴管理工具,另外它還提供了包管理功能,比如打包和釋出。你可以把它看做是 Pipenv 和 Flit 這些工具的超集。它可以讓你用 Poetry 來同時管理 Python 庫和 Python 程式。
如果你用的是 PDM 或者 Poetry 請先在專案目錄中,通過 virtualenv 建立一個叫 .venv 或者類似的資料夾。為什麼我推薦 virtualenv 而不是 PEP582 呢?在很多系統下都依賴了一個預設的 python 版本,如果用 PEP582 的話,預設會使用系統自帶的 python 版本,如果不用的話我們又必須額外的要生成一個需要的 python 版本對應的虛擬環境;其次是目前 vscode 並不支援。
我們可以利用這些構建工具,將我們的開發環境、測試環境、生產環境的依賴都區分開來。
我們同樣可以在 pyproject.toml 中增加構建工具的額外命令,比如可以增加用於啟動服務的 start 命令、測試用的 test 命令等等。類似 PDM Scripts 所描述的一樣。通過構建工具啟動服務能很有效解決包所在位置的問題,強制讓所有的包的執行目錄都為專案根目錄。
專案結構
推薦的專案結構如下:
Dockerfile
這個檔案主要用於給 Docker 構建映象使用,建議在生產環境部署時通過 Docker 進行部署。
docs
專門用於儲存文件的資料夾。
LICENSE
如果這個是一個開源專案,那麼這個檔案一般用於放置使用的開源協議。
pyproject.toml
基於 PEP518 規範的配置檔案,儲存了專案介紹、作者聯絡方式、所依賴的包、使用的構建工具等等。
README.md
主要用於放專案介紹、使用說明的 MarkDown 文件。
{project_name}
放實際專案程式碼的資料夾,你可以取任意的名字,但需要確保不跟你所依賴的其它第三方的包名字重複。之所以不用 src 是因為你在編寫測試用例、或者把它作為 python 的包給其它專案用時會更方便。
{project_name}-stubs
{project_name}-stubs中的 project_name需要跟上面那個資料夾的名字一致,然後拼接 -stubs,比如專案叫 andy,那麼這個資料夾叫 andy-stubs。如果你的專案是一個 python 的庫的話,你可以在這個資料夾中存放 mypy 生成的型別描述檔案。mypy 會自動讀取這個專案中的型別描述,方便做型別判斷。詳情見 mypy 文件。
tests
用來存放單元測試等的資料夾。
tox.ini
tox 的配置檔案。
.gitignore
用於告訴 git 應該忽略哪些檔案。
模組引用
Python 模組是最主要的抽象層之一,並且很可能是最自然的一個。抽象層允許將程式碼分為 不同部分,每個部分包含相關的資料與功能。
例如在專案中,一層控制使用者操作相關介面,另一層處理底層資料操作。最自然分開這兩 層的方式是,在一份檔案裡重組所有功能介面,並將所有底層操作封裝到另一個檔案中。 這種情況下,介面檔案需要匯入封裝底層操作的檔案,可通過 import
和 from ... import
語句完成。一旦你使用 import 語句,就可以使用這個模組。 既可以是內建的模組包括 os 和 sys,也可以是已經安裝的第三方的模組,或者專案 內部的模組。
為遵守風格指南中的規定,模組名稱要短、使用小寫,並避免使用特殊符號,比如點(.) 和問號(?)。如 my.spam.py
這樣的名字是必須不能用的!該方式命名將妨礙 Python 的模組查詢功能。就 my.spam.py 來說,Python 認為需要在 my
資料夾 中找到 spam.py
檔案,實際並不是這樣。如果願意你可以將模組命名為 my_spam.py
, 不過並不推薦在模組名中使用下劃線。但是,在模組名稱中使用其他字元(空格或連字號) 將阻止匯入(-是減法運算子),因此請儘量保持模組名稱簡單,以無需分開單詞。 最重要的是,不要使用下劃線名稱空間,而是使用子模組。
```
OK
import library.plugin.foo
not OK
import library.foo_plugin ```
除了以上的命名限制外,Python檔案成為模組沒有其他特殊的要求,但為了合理地使用這 個觀念並避免問題,你需要理解 import 的原理機制。具體來說,import modu
語句將 尋找合適的檔案,即呼叫目錄下的 modu.py
檔案(如果該檔案存在)。如果沒有 找到這份檔案,Python 直譯器遞迴地在 "PYTHONPATH" 環境變數中查詢該檔案,如果仍沒 有找到,將丟擲 ImportError 異常。
一旦找到 modu.py
,Python 直譯器將在隔離的作用域內執行這個模組。所有頂層 語句都會被執行,包括其他的引用。方法與類的定義將會儲存到模組的字典中。然後,這個 模組的變數、方法和類通過名稱空間暴露給呼叫方,這是Python中特別有用和強大的核心概念。
在很多其他語言中,include file
指令被前處理器用來獲取檔案裡的所有程式碼並‘複製’ 到呼叫方的程式碼中。Python 則不一樣:include 程式碼被獨立放在模組名稱空間裡,這意味著您 一般不需要擔心 include 的程式碼可能造成不好的影響,例如過載同名方法。
也可以使用import語句的特殊形式 from modu import *
模擬更標準的行為。但 import *
通常 被認為是不好的做法。使用 from modu import *
的程式碼較難閱讀而且依賴獨立性不足。 使用 from modu import func
能精確定位您想匯入的方法並將其放到全域性名稱空間中。 比 from modu import *
要好些,因為它明確地指明往全域性名稱空間中匯入了什麼方法,它和 import modu
相比唯一的優點是之後使用方法時可以少打點兒字。
import modu
[...]
x = modu.sqrt(4)
其次是如果引用自己專案的的模組時,加入你的專案叫 my,模組叫 modu,那麼不建議使用 from my import modu
來引用,強烈推薦使用 from . import modu
。
其次是建議如果需要給其它模組引用某些類的時候,請在這個模組的 __init__.py
中暴露並且加上 as 代替一些語言中的 export。
from .config import Config as Config
並且完全不建議在 __init__.py
放置大量程式碼,建議只用於代替 export。
往 __init__.py
中加了過多程式碼,隨著專案的複雜度增長, 目錄結構越來越深,子包和更深巢狀的子包可能會出現。在這種情況下,匯入多層巢狀 的子包中的某個部件需要執行所有通過路徑裡碰到的 __init__.py
檔案。如果 包內的模組和子包沒有程式碼共享的需求,使用空白的 __init__.py
檔案是正常甚至好的做法。
型別檢查
Python是一門動態語言,很多時候我們可能不清楚函式引數型別或者返回值型別,很有可能導致一些型別沒有指定方法,在寫完程式碼一段時間後回過頭看程式碼,很可能忘記了自己寫的函式需要傳什麼引數,返回什麼型別的結果,就不得不去閱讀程式碼的具體內容,降低了閱讀的速度,typing 模組可以很好的解決這個問題。
自 python3.5 開始,PEP484 為 python 引入了型別註解 (type hints)。
Mypy 是 Python 中的靜態型別檢查器。Mypy 具有強大且易於使用的型別系統,具有很多優秀的特性,例如型別推斷、泛型、可呼叫型別、元組型別、聯合型別和結構子型別。推薦使用 mypy 作為型別檢查工具並且每個方法必須宣告清楚引數、引數的型別、返回值型別。
``` def register( self, factory: Optional[PooledObjectFactory] = None, name: Optional[str] = None ) -> None:
pass
```
如果這個引數或者返回值可以為空,應當標註 Optional 或者使用 3.11 的語法 型別 | None。如 PooledObjectFactory | None。
在 vscode 中你可以安裝 mypy 的外掛,這樣可以直接在 vscode 中完成型別檢查。
程式碼格式化和風格檢查
為了幫助開發者統一程式碼風格,Python 社群提出了 PEP8 程式碼編碼風格,它並沒有強制要求大家必須遵循,Python 官方同時推出了一個檢查程式碼風格是否符合 PEP8 的工具,名字也叫 pep8。
Black 自稱“零妥協程式碼格式化工具(The uncompromising code formatter)”。
Black 號稱是不妥協的 Python 程式碼格式化工具。之所以成為“不妥協”是因為它檢測到不符合規範的程式碼風格直接就幫你全部格式化好,根本不需要你確定,直接替你做好決定。而作為回報,Black 提供了快速的速度。 Black 通過產生最小的差異來更快地進行程式碼審查。 Black 的使用非常簡單,安裝成功後,和其他系統命令一樣使用,只需在 black 命令後面指定需要格式化的檔案或者目錄即可。
某種意義上來說一個可配置很低的程式碼格式化和檢查工具在團隊中比一個可以大量自定義配置的更好。現代的 IDE 一般都提供了對 Black 的支援。
配置管理
建議將配置放在 {project_name}/{project_name} 資料夾中,使用 yaml 格式進行儲存。之所以不用 toml 之類的格式是因為如果用 k8s 之類的配置對映功能的話就沒法使用了,yaml 則可以很好的與其它系統保持相容。
你可以將配置所在的 yaml 檔案讀取出來並且反序列化成一個配置物件。這個配置物件可以是 python 中的 dataclass 也可以就是一個普通的類,並且上面宣告配置的每個欄位。
配置是一種可能經常會增刪欄位的東西,我們不應該通過類似 dict 的方式進行操作。
異常管理
幾乎所有程式語言中都有異常。異常可以快速指出程式出現的問題,便於排查。開發人員也可以根據情況丟擲自定義異常, 以指示期望的內容和實際不相符。良好的異常設計和使用習慣,可以提高程式的質量。
在邏輯中,可能出現不符合預期的邏輯,會丟擲相關異常。此時在編碼時,為了邏輯的正常執行,需要對邏輯進行處理,捕獲異常。
捕獲異常是,使用 try...except
程式碼塊包裹需要處理異常的程式碼。 expect
捕獲指定的異常型別,如果出現,進入 對應的程式碼邏輯。對於一些不想處理的,通過 raise
丟擲異常。
在捕獲時儘量不要捕獲寬泛的異常基類如 Exception,而是捕獲具體的異常,如 ValueError。
處理異常時,如果沒有繼續丟擲異常,需要輸入日誌資訊。除非你知道不輸出任何資訊不會造成拍錯困難。專案異常要以 ERROR
結尾。和標準異常命名類似。
測試
在 Python 中除了有語言內建的測試框架之外,還有許多第三方測試框架,一些非測試框架內部也會內建測試框架。其目的都是在內建測試框架的基礎上 增加了一些特性,讓編寫測試更加方便,測試過程更加順暢。
為了方便測試框架查詢測試用例,在編寫測試時應遵循一定的規範:
- 測試模組要以
test_
開頭 - 測試方法要以
test_
開頭 - 測試類名要以
Test
開頭
測試都放到 tests 資料夾下面。
Pytest 是在 unittest 的基礎上 增加了大量語法糖,讓測試更加簡便和靈活。並且帶有外掛功能,方便整合其他功能。
由於 Pytest 能相容其他大多數測試框架,而且它也具有強大的功能,所以推薦使用 Pytest 作為主要測試框架使用。
tox 是通用的虛擬環境管理和測試命令列工具。tox 能夠讓我們在同一個 Host 上自定義出多套相互獨立且隔離的 python 環境,如果你的專案需要相容多個 python 版本的話強烈推薦使用它。