如何讓你的 Python 項目架構(gòu)更整潔?
入門教程、案例源碼、學(xué)習(xí)資料、讀者群
請訪問: python666.cn
大家好,歡迎來到 Crossin的編程教室 !
對于活躍的大型 Python 項目而言,維持架構(gòu)的整潔性是一件頗具挑戰(zhàn)的事情,這主要體現(xiàn)在包與包、模塊與模塊之間,難以保持簡單而清晰的依賴關(guān)系。
一個大型項目,通常包含數(shù)以百記的子模塊,各自實現(xiàn)特定的功能,互相依賴。如果在架構(gòu)層面上缺少設(shè)計,開發(fā)實踐上沒有約束,這些模塊間的依賴關(guān)系,常常會發(fā)展成為一個胡亂纏繞的線團,讓人難以理清頭緒。

這會帶來以下問題:
?架構(gòu)理解成本高:當(dāng)新人加入項目時,會有許多關(guān)于架構(gòu)的疑問,比方說:既然 common.validators 是一個低層的通用工具模塊,為何要引用高層模塊 workloads 中的 ConfigVar 模型??影響開發(fā)效率:想要開發(fā)新功能時,開發(fā)者難以判斷代碼應(yīng)放在哪個包的哪個模塊中,而且不同的人可能會有不同的看法,很難達成共識?模塊職責(zé)混亂:依賴關(guān)系很亂,基本等同于模塊的職責(zé)也很亂,這意味著部分模塊可能承擔(dān)太多,關(guān)注了不應(yīng)該關(guān)注的抽象
如果把依賴關(guān)系畫成一張圖,一個架構(gòu)健康的項目的圖,看上去應(yīng)該層次分明,圖中所有依賴都是單向流動,不存在環(huán)形依賴。健康的依賴關(guān)系,有助于各個模塊達成“關(guān)注點分離”的狀態(tài),維持職責(zé)單一。

本文介紹一個治理模塊間依賴關(guān)系的工具:import-linter[1] 。
import-linter 簡介
import-linter[2] 是由 seddonym[3] 開發(fā)的一個開源代碼 Linter 工具。
要使用 import-linter 檢查依賴關(guān)系,首先需要在配置文件中定義一些“契約(contract)”。舉個例子,下面是一份 import-linter 的配置文件:
# file: .importlinter[importlinter]root_packages =foo_projinclude_external_packages = True[importlinter:contract:layers-main]name=the main layerstype=layerslayers =foo_proj.clientfoo_proj.lib
其中的 [importlinter:contract:layers-main] 部分,定義了一個名為 the main layers 的“分層(layers)”類型的契約,分層契約意味著高層模塊可以隨意導(dǎo)入低層模塊中的內(nèi)容,反之就不行。
the main layers 設(shè)定了一個分層關(guān)系: foo_proj.client 模塊屬于高層,foo_proj.lib 屬于低層。
運行命令 lint-imports,工具會掃描當(dāng)前項目中的所有 import 語句,構(gòu)建出模塊依賴關(guān)系圖,并判斷依賴關(guān)系是否符合配置中的所有契約。
假如在項目中的 foo_proj/lib.py 文件里,存在以下內(nèi)容:
from foo_proj.client import Client
則會導(dǎo)致 lint-imports 命令報錯:
$ lint-imports# ...Broken contracts----------------the main layers---------------foo_proj.lib is not allowed to import foo_proj.client:- foo_proj.lib -> foo_proj.client (l.1)
只有當(dāng)我們刪除這條 import 語句后,代碼才能通過檢查。
除了“分層”類型的契約以外,import-linter 還內(nèi)置了兩種契約:
?禁止(forbidden)[4]:禁止一些模塊導(dǎo)入你所指定的其他模塊?獨立(independence)[5]:標(biāo)記一個列表中的模塊互相獨立,不會導(dǎo)入其他模塊中的任何內(nèi)容
如果這些內(nèi)置契約不能滿足你的需求,你還可以編寫自定義契約,詳情可查閱 官方文檔[6]。
在項目中引入 import-linter
要在項目中引入 import-linter 工具,首先需要編寫好你所期望的所有契約。你可以試著從以下幾個關(guān)鍵問題開始:
?從頂層觀察項目,它由哪幾個關(guān)鍵分層構(gòu)成,之間的關(guān)系如何?許多項目中都存在類似 application -> services -> common -> utils 這種分層結(jié)構(gòu),將它們記錄為對應(yīng)契約?對于某些復(fù)雜的子模塊,其內(nèi)部是否存在清晰的分層?如果能找到 views -> models -> utils 這種分層,將其記錄為對應(yīng)契約?有哪些子模塊滿足“禁止(forbidden)”或“獨立(independence)”契約?如果存在,將其記錄下來
將這些契約寫入到配置文件中以后,執(zhí)行 lint-imports,你會看到海量的報錯信息(如果沒有任何報錯,那么恭喜你,項目很整潔,關(guān)掉文章去干點其他事情吧!)。它們展示了哪些導(dǎo)入關(guān)系違反了你所配置的契約。
逐個分析這些報錯信息,將其中不合理的導(dǎo)入關(guān)系添加到各契約的 ignore_imports 配置中:
[importlinter:contract:layers-main]name=the main layerstype=layerslayers =foo_proj.clientfoo_proj.libignore_imports =# 暫時忽略從 lib 模塊中導(dǎo)入 client,使其不報錯foo_proj.lib -> foo_proj.client
處理完全部的報錯信息以后,配置文件中的 ignore_imports 可能會包含上百條必須被忽略的導(dǎo)入信息,此時,再次執(zhí)行 lint-imports 應(yīng)該不再輸出任何報錯(一種虛假的整潔)。
接下來便是重頭戲,我們需要真正修復(fù)這些導(dǎo)入關(guān)系。
飯要一口一口吃,修復(fù)依賴關(guān)系也需要一條一條來。首先,試著從 ignore_imports 中刪除一條記錄,然后執(zhí)行 lint-imports,觀察并分析該報錯信息,嘗試找到最合理的方式修復(fù)它。
不斷重復(fù)這個過程,最后就能完成整個項目的依賴關(guān)系治理。
Tip:在刪減
ignore_imports配置的過程中,你會發(fā)現(xiàn)有些導(dǎo)入關(guān)系會比其他的更難修復(fù),這很正常。修復(fù)依賴關(guān)系常常是一個漫長的過程,需要整個團隊的持續(xù)投入。
修復(fù)依賴關(guān)系的常見方式
下面介紹幾種常見的修復(fù)依賴關(guān)系的方式。
為了方便描述,我們假設(shè)在以下所有場景中,項目定義了一個“分層”類型的契約,而低層模塊違反契約,反過來依賴了高層模塊。
1. 合并與拆分模塊
調(diào)整依賴關(guān)系最直接的辦法是合并模塊。
假設(shè)有一個低層模塊 clusters,違規(guī)導(dǎo)入了高層模塊 resources 的子模塊 cluster_utils 里的部分代碼。考慮到這些代碼本身就和 clusters 有一定關(guān)聯(lián)性,因此,你其實可以把它們直接挪到 clusters.utils 子模塊里,從而消除這個依賴關(guān)系。
如下所示:
# 分層:resources -> clusters# 調(diào)整前resources -> clustersclusters -> resources.cluster_utils # 違反契約!# 調(diào)整后resources -> clustersclusters -> clusters.utils
如果被依賴的代碼與所有模塊間的關(guān)聯(lián)都不太密切,你也可以選擇將它拆分成一個新模塊。
比方說,一個低層模塊 users 依賴了高層模塊 marketing 中發(fā)送短信相關(guān)的代碼,違反了契約。你可以選擇把代碼從 marketing 中拆分出來,置入一個處于更低層級的新模塊 utils.messaging 中。
# 分層:marketing -> users# 調(diào)整前marketing -> usersusers -> marketing # 違反契約!# 分層:marketing -> users -> utils# 調(diào)整后marketing -> usersusers -> utils.messaging
這樣做以后,不健康的依賴關(guān)系便能得到解決。
2. 依賴注入
依賴注入(Dependency injection)是一種常見的解耦依賴關(guān)系的技巧。
舉個例子,項目中設(shè)置了一個分層契約:marketing -> users, 但 users 模塊卻直接導(dǎo)入了 marketing 模塊中的短信發(fā)送器 SmsSender 類,違反了契約。
# file: users.pyfrom marketing import SmsSender # 違反契約!class User:"""簡單的用戶對象"""def __init__(self):self.sms_sender = SmsSender()def add_notification(self, message: str, send_sms: bool):"""向用戶發(fā)送新通知"""# ...if send_sms:self.sms_sender.send(message)
要通過“依賴注入”修復(fù)該問題,我們可以直接刪除代碼中對 SmsSender 的依賴,改為要求調(diào)用方必須在實例化 User 時,主動傳入一個“代碼通知器(sms_sender)”對象。
# file: users.pyclass User:"""簡單的用戶對象:param sms_sender: 用于發(fā)送短信通知的通知器對象"""def __init__(self, sms_sender):self.sms_sender = sms_sender
這樣做以后,User 對“短信通知器”的依賴就變?nèi)趿耍辉龠`反分層契約。
添加類型注解
但是,前面的依賴注入方案并不完美。當(dāng)你想給 sms_sender 參數(shù)添加類型注解時,很快會發(fā)現(xiàn)自己開始重蹈覆轍:不能寫 def __init__(self, sms_sender: SmsSender),那樣得把刪掉的 import 語句找回來。
# file: users.pyfrom typing import TYPE_CHECKINGif TYPE_CHECKING:# 因為類型注解找回高層模塊的 SmsSender,違反契約!from marketing import SmsSender
即使像上面這樣,把 import 語句放在 TYPE_CHECKING 分支中,import-linter 仍會將其當(dāng)做普通導(dǎo)入對待(注:該行為可能會在未來發(fā)生改動,詳見 Add support for detecting whether an import is only made during type checking · Issue #64[7]),將其視為對契約的一種違反。
為了讓類型注解正常工作,我們需要在 users 模塊中引入一個新的抽象:SmsSenderProtocol 協(xié)議(Protocol),替代實體 SmsSender 類型。
from typing import Protocolclass SmsSenderProtocol(Protocol):def send(message: str):...class User:def __init__(self, sms_sender: SmsSenderProtocol):self.sms_sender = sms_sender
這樣便解決了類型注解的問題。
Tip:通過引入 Protocol 來解耦依賴關(guān)系,其實上是對依賴倒置原則(Dependency Inversion Principle)的一種應(yīng)用。依賴倒置原則認為:高層模塊不應(yīng)該依賴低層模塊,二者都應(yīng)該依賴抽象。
關(guān)于它的更多介紹,推薦閱讀我的另一篇文章:《Python 工匠:寫好面向?qū)ο蟠a的原則(下) 》[8]。
3. 簡化依賴數(shù)據(jù)類型
在以下代碼中,低層模塊 monitoring 依賴了高層模塊 processes 中的 ProcService 類型:
# file:monitoring.pyfrom processes import ProcService # 違反契約!def build_monitor_config(service: ProcService):"""構(gòu)造應(yīng)用監(jiān)控相關(guān)配置:param service: 進程服務(wù)對象"""# ...# 基于 service.port 和 service.host 完成構(gòu)造# ...
經(jīng)過分析后,可以發(fā)現(xiàn) build_monitor_config 函數(shù)實際上只使用了 service 對象的兩個字段:host 和 port,不依賴它的任何其他屬性和方法。所以,我們完全可以調(diào)整函數(shù)簽名,將其改為僅接受兩個必要的簡單參數(shù):
# file:monitoring.pydef build_monitor_config(host: str, port: int):"""構(gòu)造監(jiān)控相關(guān)配置:param host: 主機域名:param port: 端口號"""# ...
調(diào)用方的代碼也需要進行相應(yīng)修改:
# 調(diào)整前build_monitor_config(svc)# 調(diào)整后build_monitor_config(svc.host, svc.port)
通過簡化函數(shù)所接收的參數(shù)類型,我們消除了模塊間的不合理依賴。
4. 延遲提供函數(shù)實現(xiàn)
Python 是一門非常動態(tài)的編程語言,我們可以利用這種動態(tài),延遲提供某些函數(shù)的具體實現(xiàn),從而扭轉(zhuǎn)模塊間的依賴關(guān)系。
假設(shè)低層模塊 users 目前違反了契約,直接依賴了高層模塊 marketing 中的 send_sms 函數(shù)。要扭轉(zhuǎn)該依賴關(guān)系,第一步是在低層模塊 users 中定義一個用來保存函數(shù)的全局變量,并提供一個配置入口。
代碼如下所示:
# file: users.pySendMsgFunc = Callable[[str], None]# 全局變量,用來保存當(dāng)前的“短信發(fā)送器”函數(shù)實現(xiàn)_send_sms_func: Optional[SendMsgFunc] = Nonedef set_send_sms_func(func: SendMsgFunc):global _send_sms_func_send_sms_func = func
調(diào)用 send_sms 函數(shù)時,判斷當(dāng)前是否已提供具體實現(xiàn):
# file: users.pydef send_sms(message: str):"""發(fā)送短信通知"""if not _send_sms_func:raise RuntimeError("Must set the send_sms function")_send_sms_func(message)
完成以上修改后,users 不再需要從 marketing 中導(dǎo)入“短信發(fā)送器”的具體實現(xiàn)。而是可以由高層模塊 marketing 來一波“反向操作”,主動調(diào)用 set_send_sms_func,將實現(xiàn)注入到低層模塊 users 中:
# file: marketing.pyfrom user import set_send_sms_funcdef _send_msg(message: str):"""發(fā)送短信的具體實現(xiàn)函數(shù)"""...set_send_sms_func(_send_msg)
這樣便完成了依賴關(guān)系的扭轉(zhuǎn)。
變種:簡單的插件機制
除了用一個全局變量來保存函數(shù)的具體實現(xiàn)以外,你還可以將 API 設(shè)計得更復(fù)雜一些,實現(xiàn)一種類似于“插件”的注冊與調(diào)用機制,滿足更豐富的需求場景。
舉個簡單的例子,在低層模塊中,實現(xiàn)“插件”的抽象定義以及用來保存具體插件的注冊器:
# file: users.pyfrom typing import Protocolclass SmsSenderPlugin(Protocol):"""由其他模塊實現(xiàn)并注冊的插件類型"""def __call__(self, message: str):...class SmsSenderPluginCenter:"""管理所有“短信發(fā)送器”插件"""def register(cls, name: str, plugin: SmsSenderPlugin):"""注冊一個插件"""# ...def call(cls, name: str):"""調(diào)用某個插件"""# ...
在其他模塊中,調(diào)用 SmsSenderPluginCenter.register 來注冊具體的插件實現(xiàn):
# file: marketing.pyfrom user import SmsSenderPluginCenterSmsSenderPluginCenter.register('default', DefaultSender())SmsSenderPluginCenter.register('free', FreeSmsSender())
和使用全局變量一樣,插件機制同樣是對依賴倒置原則的一種具體應(yīng)用。上面的代碼僅包含最簡單的原理示意,真實的代碼實現(xiàn)會更復(fù)雜一些,不在此文中贅述。
5. 由配置文件驅(qū)動
假設(shè)低層模塊 users 違規(guī)依賴了高層模塊 marketing 中的一個工具函數(shù) send_sms。除了使用上面介紹的方式以外,我們也可以選擇將工具函數(shù)的導(dǎo)入路徑定義成一個配置項,置入配置文件中。
# file:settings.py# 用于發(fā)送短信通知的函數(shù)導(dǎo)入路徑SEND_SMS_FUNC = 'marketing.send_sms'
在 users 模塊中,不再直接引用 marketing 模塊,而是通過動態(tài)導(dǎo)入配置中的工具函數(shù)的方式,來使用 send_sms 函數(shù)。
# file: users.pyfrom settings import SEND_SMS_FUNCdef send_sms(message: str):func = import_string(SEND_SMS_FUNC)return func(message)
這樣也可以完成依賴關(guān)系的解耦。
Tip:關(guān)于 import_string 函數(shù)的具體實現(xiàn),可以參考 Django 框架[9]。
6. 用事件驅(qū)動代替函數(shù)調(diào)用
對于那些耦合關(guān)系本身較弱的模塊,你也可以選擇用事件驅(qū)動的方式來代替函數(shù)調(diào)用。
舉個例子,低層模塊 networking 每次變獨立域名數(shù)據(jù)時,均需要調(diào)用高層模塊 applications 中的 deploy_networking 函數(shù),更新對應(yīng)的資源,這違反了分層契約。
# file: networking/main.pyfrom applications.utils import deploy_networking # 導(dǎo)入高層模塊,違反契約!deploy_networking(app)
該問題很適合用事件驅(qū)動來解決(以下代碼基于 Django 框架的信號機制[10] 編寫)。
引入事件驅(qū)動的第一步是發(fā)送事件。我們需要修改 networking 模塊,刪除其中的函數(shù)調(diào)用代碼,改為發(fā)送一個類型為 custom_domain_updated 的信號:
# file: networking/main.pyfrom networking.signals import custom_domain_updatedcustom_domain_updated.send(sender=app)
第二步,是在 applications 模塊中新增事件監(jiān)聽代碼,完成資源更新操作:
# file: applications/main.pyfrom applications.utils import deploy_networkingfrom networking.signals import custom_domain_updateddef on_custom_domain_updated(sender, **kwargs):"""觸發(fā)資源更新操作"""deploy_networking(sender)
這樣便完成了解耦工作。
總結(jié)
在依賴關(guān)系治理方面,import-linter[11] 是一個非常有用的工具。它通過提供各種類型的“契約”,讓我們得以將項目內(nèi)隱式的復(fù)雜依賴關(guān)系,通過配置文件顯式的表達出來。再配合 CI 等工程實踐,能有效地幫助我們維持項目架構(gòu)的整潔。
如果你想在項目中引入 import-linter,最重要的工作是修復(fù)已有的不合理的依賴關(guān)系。常見的修復(fù)方式包括合并與拆分、依賴注入、事件驅(qū)動,等等。雖然手法多種多樣,但最重要的事用一句話便可概括:把每行代碼放在最恰當(dāng)?shù)哪K中,必要時在當(dāng)前模塊引入新的抽象,借助它的力量來反轉(zhuǎn)模塊間的依賴關(guān)系。
愿你的項目架構(gòu)永遠保持整潔!
References
[1] import-linter: https://github.com/seddonym/import-linter[2] import-linter: https://github.com/seddonym/import-linter[3] seddonym: https://github.com/seddonym[4] 禁止(forbidden): https://import-linter.readthedocs.io/en/stable/contract_types.html#forbidden-modules[5] 獨立(independence): https://import-linter.readthedocs.io/en/stable/contract_types.html#independence[6] 官方文檔: https://import-linter.readthedocs.io/en/stable/custom_contract_types.html[7] Add support for detecting whether an import is only made during type checking · Issue #64: https://github.com/seddonym/grimp/issues/64[8] 《Python 工匠:寫好面向?qū)ο蟠a的原則(下) 》: https://www.piglei.com/articles/write-solid-python-codes-part-3/[9] Django 框架: https://github.com/django/django/blob/main/django/utils/module_loading.py#L19[10] Django 框架的信號機制: https://docs.djangoproject.com/en/4.2/topics/signals/[11] import-linter: https://github.com/seddonym/import-linter
作者:piglei

本書從 Python 和 Excel 結(jié)合使用的角度講解處理分析數(shù)據(jù)的思路、方法與實戰(zhàn)應(yīng)用。不論是希望從事數(shù)據(jù)分析崗位的學(xué)習(xí)者,還是其他職業(yè)的辦公人員,都可以通過本書的學(xué)習(xí)掌握 Python 分析數(shù)據(jù)的技能。書中創(chuàng)新性地將 ChatGPT 引入到教學(xué)當(dāng)中,用 ChatGPT 答疑并提供實訓(xùn)代碼,并介紹了使用 ChatGPT 輔助學(xué)習(xí)的一些實用技巧,給學(xué)習(xí)者帶來全新的學(xué)習(xí)方式
京東雙十一5折優(yōu)惠進行中
讀者朋友們購買后可在后臺聯(lián)系我,加入讀者交流群,Crossin會為你開啟陪讀模式,解答你在閱讀本書時的一切疑問。
_往期文章推薦_
【教程】: python
