<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          如何讓你的 Python 項目架構(gòu)更整潔?

          共 13581字,需瀏覽 28分鐘

           ·

          2023-11-10 16:57

          入門教程、案例源碼、學(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.client foo_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.client    foo_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.py
          from 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.py
          class 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_CHECKING
          if 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 Protocol
          class 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.py
          def 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.py
          SendMsgFunc = Callable[[str], None]# 全局變量,用來保存當(dāng)前的“短信發(fā)送器”函數(shù)實現(xiàn)_send_sms_func: Optional[SendMsgFunc] = None
          def 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.py
          def 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.py
          from user import set_send_sms_func
          def _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.py
          from typing import Protocol
          class SmsSenderPlugin(Protocol): """由其他模塊實現(xiàn)并注冊的插件類型"""
          def __call__(self, message: str): ...
          class SmsSenderPluginCenter: """管理所有“短信發(fā)送器”插件"""
          @classmethod    def register(cls, name: str, plugin: SmsSenderPlugin): """注冊一個插件""" # ...
          @classmethod    def call(cls, name: str): """調(diào)用某個插件""" # ...

          在其他模塊中,調(diào)用 SmsSenderPluginCenter.register 來注冊具體的插件實現(xiàn):

          # file: marketing.py
          from user import SmsSenderPluginCenter
          SmsSenderPluginCenter.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.py
          from settings import SEND_SMS_FUNC
          def 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.py
          from 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.py
          from networking.signals import custom_domain_updated
          custom_domain_updated.send(sender=app)

          第二步,是在 applications 模塊中新增事件監(jiān)聽代碼,完成資源更新操作:

          # file: applications/main.py
          from applications.utils import deploy_networkingfrom networking.signals import custom_domain_updated
          @receiver(custom_domain_updated)def 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

          來源:piglei



          Crossin的第2本書碼上行動:利用Python與ChatGPT高效搞定Excel數(shù)據(jù)分析已經(jīng)上市了

          點此查看上一本《碼上行動:零基礎(chǔ)學(xué)會Python編程》介紹

          本書從 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會為你開啟陪讀模式,解答你在閱讀本書時的一切疑問。


          感謝轉(zhuǎn)發(fā)點贊的各位~

          _往期文章推薦_

          如何將Python「羊了個羊」打包成exe
          一段奇葩的1024代碼



          【教程】: python

          【答疑】: 666
          更多資源點擊閱讀原文
          瀏覽 1197
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  欧美日韩在线看 | 国产日产久久高清欧美一区 | 成人黄色在线看 | 亚洲中文视频 | 国产视频黄片 |