TLDR(AI Claude 的總結(jié))
本文來自一位 Python 開發(fā)者對(duì)一個(gè)龐大的 Python 項(xiàng)目的代碼結(jié)構(gòu)的總結(jié)。
該項(xiàng)目包含近 3 萬個(gè) Python 文件,由全球 400 多名開發(fā)者共同維護(hù)。為了應(yīng)對(duì)代碼日益增長(zhǎng)的復(fù)雜性,項(xiàng)目采用了分層架構(gòu)的設(shè)計(jì)。即將代碼庫劃分為多個(gè)層級(jí),并限制不同層級(jí)之間的依賴關(guān)系,依賴只能從上層流向下層。
文章詳細(xì)介紹了該項(xiàng)目的分層結(jié)構(gòu),以及如何利用 Import Linter 工具來強(qiáng)制執(zhí)行分層規(guī)則。通過追蹤被忽略的非法 import 語句數(shù)量,可以衡量分層結(jié)構(gòu)實(shí)現(xiàn)的進(jìn)度。
分層架構(gòu)確實(shí)能夠有效降低大型項(xiàng)目的復(fù)雜度,方便獨(dú)立開發(fā)。但也存在一些缺點(diǎn),比如容易在高層產(chǎn)生過多代碼,完全實(shí)施分層需要花費(fèi)時(shí)間等。總體來說,盡早引入分層架構(gòu),能夠減少后期的重構(gòu)工作量,是管理大型 Python 項(xiàng)目的一個(gè)有效方式。
本文通過一個(gè)真實(shí)的大規(guī)模 Python 項(xiàng)目案例,生動(dòng)地介紹了分層架構(gòu)的實(shí)施過程、優(yōu)勢(shì)和不足,對(duì)于管理大型項(xiàng)目很有借鑒作用。
前言
大家好,我是來自 Kraken Technologies 的 Python 開發(fā)者 David。我在的 Kraken 工作是維護(hù)一個(gè) Python 應(yīng)用,根據(jù)最新統(tǒng)計(jì)它擁有 27637 個(gè)模塊的。是的,你沒看錯(cuò),這個(gè)項(xiàng)目擁有近 28K 獨(dú)立的 Python 文件(不包括測(cè)試代碼)。
我與全球其他 400 名開發(fā)人員一同維護(hù)這個(gè)龐然大物,不斷地為它合并新的代碼。任何人只需要在 Github 上獲得一位同事的批準(zhǔn),就能修改代碼文件,并啟動(dòng)軟件的部署,該軟件在 17 家不同的能源和公用事業(yè)公司運(yùn)行著,擁有數(shù)百萬的客戶群體。
看到上面的描述,你大概率會(huì)下意識(shí)地認(rèn)為這個(gè)項(xiàng)目的代碼肯定無比的混亂。坦白講,我也會(huì)這么想。但事實(shí)是,至少在我工作的領(lǐng)域,大量的開發(fā)人員可以在一個(gè)大型的 Python 項(xiàng)目上高效地工作。
實(shí)現(xiàn)這個(gè)目標(biāo)的要素有很多,其中許多要素來自文化與規(guī)則而非技術(shù),在本篇博文中,我想著重講一下我們是如何通過優(yōu)化代碼組織結(jié)構(gòu)來實(shí)現(xiàn)這一目標(biāo)的。
分層架構(gòu)
如果你已經(jīng)負(fù)責(zé)維護(hù)某個(gè)應(yīng)用的代碼倉庫一段時(shí)間,肯定會(huì)感受到隨著時(shí)間的推移代碼復(fù)雜度越來越高。在不斷開發(fā)與維護(hù)的過程中,應(yīng)用中各部分的邏輯代碼混合在一起,獨(dú)立地分析應(yīng)用中的某個(gè)模塊變得越來越困難。
這也是我們?cè)缙诰S護(hù)代碼倉庫時(shí)遇到的問題,經(jīng)過研究后我們決定采用分層架構(gòu)(即將代碼庫劃分成多個(gè)組件(也就是層級(jí),后面不再注釋),并限制各組件間的引用關(guān)系)來應(yīng)對(duì)這一問題。
分層(Layering)是一種較為常見的軟件架構(gòu)模式,在這種模式下不同的組件(即層級(jí),后面不在重復(fù)注釋)會(huì)被以(概念上)棧的形式組織起來。在這個(gè)棧中,下層組件不能依賴(引入)其上層組件。
依賴向下關(guān)系流動(dòng)的分層架構(gòu)
例如,在上圖中,C 可以依賴 B 和 A,但不能依賴 D。
分層架構(gòu)的應(yīng)用很寬泛,你可以自由地定義組件。例如:你可以將多個(gè)可獨(dú)立部署的服務(wù)視作多個(gè)組件,也可以直接將項(xiàng)目中不同部分的源碼文件視作不同的組件。
依賴關(guān)系的定義也很寬泛。通常,只要兩個(gè)組件間存在直接交叉(即使只發(fā)生在概念層級(jí)上),我們就認(rèn)為它們之間存在依賴關(guān)系。間接交叉(例如通過配置傳遞)通常不被視為依賴關(guān)系。
如何在 Python 項(xiàng)目中應(yīng)用分層架構(gòu)
分層架構(gòu)在 Python 項(xiàng)目中的最佳實(shí)踐是:將 Python 模塊作為分層依據(jù),將導(dǎo)入語句視為依賴依據(jù)。
以如下項(xiàng)目倉庫目錄舉例:
?
myproject ?__init__.py ?payments/ ??__init__.py ??api.py ??vendor.py ?products.py ?shopping_cart.py
?
目錄中模塊之間的嵌套關(guān)系是分層的最佳依據(jù)。假設(shè),我們決定按照一下順序進(jìn)行分層:
?
#?依賴關(guān)系向下流動(dòng)(即上層可以依賴下層) shopping_cart payments products
?
為了滿足上述架構(gòu)的要求,我們需要禁止?payments?中的模塊從?shopping_cart?模塊中引入內(nèi)容,但可以從?products?模塊中引入內(nèi)容(參考圖 1)。
分層也可以嵌套,因此我們可以在 payments 模塊中繼續(xù)分層,例如:
?
api vendor
?
設(shè)置多少分層以及以什么順序進(jìn)行排列沒有唯一正確的答案,需要我們不斷的在實(shí)踐中總結(jié)。但是合理的運(yùn)用分層架構(gòu)確實(shí)能夠有效地降低項(xiàng)目結(jié)構(gòu)的復(fù)雜度,使其能夠更易于理解和修改。
我們是如何在 Kraken 的項(xiàng)目中實(shí)踐分層架構(gòu)的
在我編寫這邊文章的時(shí)候,已經(jīng)有 17 家不同的能源和公共事業(yè)相關(guān)的企業(yè)購買了 Kraken 的許可證。我們?cè)趦?nèi)部稱呼這些企業(yè)為 client,并為每一家企業(yè)都運(yùn)行了一個(gè)獨(dú)立的實(shí)例。也正因如此,Kraken 的不同實(shí)例間形成了一種「同根不同枝」的特點(diǎn)。
通俗地講就是不同實(shí)例間的很多行為其實(shí)是共享的,但是每個(gè) client 也都有屬于自己的定制代碼,以滿足他們特定的需求。從地域?qū)用鎭碇v也如此,在英國運(yùn)行的所有 client 之間存在一定的共性(他們屬于同類的能源行業(yè)),而日本的 Octopus Energy 則不共享這些的共性。
隨著 Kraken 平臺(tái)的成長(zhǎng),我們也在不斷地優(yōu)化著我們的分成架構(gòu),來幫助我們更好地滿足不同客戶的需求。目前的分層的頂層結(jié)構(gòu)大致如下:
?
#?依賴關(guān)系向下流動(dòng)(即上層可以依賴下層) kraken/ ????__init__.py ???? ????client/ ????????__init__.py ????????oede/ ????????oegb/ ????????oejp/ ????????... ???? ????territories/ ????????__init__.py ????????deu/ ????????gbr/ ????????jpn/ ????????... ???? ????core/
?
client 組件在結(jié)構(gòu)的頂部。每一個(gè) client 在該層都有一個(gè)專屬的子包(例如,oede 對(duì)應(yīng) Octopus Energy Germany)。在此之下的是 territories 組件,用于滿足不用國家所需的特定行為,同樣為不同地區(qū)設(shè)置了不同的子包。最底層是 core 組件,包含了所用 client 都會(huì)用到的通用代碼。
我們還制定了一個(gè)特別的規(guī)則:client 組件下的子包必須是獨(dú)立的(即不能被其他 client 引用),territories 組件下的子包也是如此。
將 Kraken 以這種分層結(jié)構(gòu)構(gòu)建之后,我們可以在有限的區(qū)域內(nèi)(例如一個(gè)組件的子包)便捷地進(jìn)行代碼的更新和維護(hù)。由于 client 組件位于結(jié)構(gòu)的頂部,因此不會(huì)有任何其他組件會(huì)直接依賴于它,這樣我們就能更方便地更改特定 client 有關(guān)的內(nèi)容,而且不必但因會(huì)影響到其他 client 的行為。
同樣,只更改 territories 組件內(nèi)的一個(gè)子包也不會(huì)影響到其他的子包。這樣,我們就可以快速、獨(dú)立地進(jìn)行跨團(tuán)隊(duì)開發(fā),尤其是當(dāng)我們進(jìn)行的更改只影響少量 Kraken 實(shí)例的時(shí)候。
通過 Import Linter 確保項(xiàng)目中的分層實(shí)現(xiàn)
雖然引入了分層結(jié)構(gòu),但我們很快發(fā)現(xiàn),僅僅在理論上論述分層是不夠的。開發(fā)人員經(jīng)常會(huì)不小心進(jìn)行分層間的違規(guī)引入。我們需要以某種方式確保分層結(jié)構(gòu)的理論能夠在代碼結(jié)構(gòu)中被遵循,為了達(dá)到此目的我們?cè)陧?xiàng)目中引入了第三方庫 Import Linter。
Import Linter 是一款開源工具,用于檢查項(xiàng)目中的引用邏輯是否遵循了指定的結(jié)構(gòu)。首先,我們需要在一個(gè) INI 文件中定義一個(gè)描述目標(biāo)需求的配置,類似這樣:
?
[importlintertop-level] name?=?Top?level?layers type?=?layers layers?= ????kraken.clients ????kraken.territories ????Kraken.core
?
我們還可以使用另外兩個(gè)配置文件強(qiáng)制不同的 clients、territories 之間相互獨(dú)立。類似這樣:
?
#?文件?1 [importlinterclient-independence] name?=?Client?independence type?=?independence layers?= ????kraken.clients.oede ????kraken.clients.oegb ????kraken.clients.oejp ????... #?文件?2 [importlinterterritory-independence] name?=?Territory?independence type?=?independence layers?= ????kraken.territories.deu ????kraken.territories.gbr ????kraken.territories.jpn ????...
?
然后,你可以在命令行運(yùn)行?lint-import,它會(huì)告訴你項(xiàng)目中是否有任何導(dǎo)入行為違反了我們配置中的要求。我們會(huì)在每次拉取代碼的時(shí)候運(yùn)行此功能,因此如果有人使用了不合規(guī)的導(dǎo)入,檢查就會(huì)失敗,代碼也就不會(huì)被合并。
上面展示的并不是我們項(xiàng)目全部的配置文件。團(tuán)隊(duì)成員可以在應(yīng)用程序的更深處添加自己的分層,例如:kranken.ritories.jpn 本身就是分層。我們目前擁有超過 40 個(gè)配置文件用于規(guī)定我們的分層結(jié)構(gòu)。
消除技術(shù)債
我們沒有辦法在確定是由分層架構(gòu)的第一時(shí)間就使整個(gè)項(xiàng)目符合架構(gòu)需求。因此,我們使用了 Import Linter 中的一項(xiàng)特性,該功能允許您在檢查非法導(dǎo)入之前忽略對(duì)某些導(dǎo)入的檢查。
?
[importlintermy-layers-contract] name?=?My?contract type?=?layers layers?= ????kraken.clients ????kraken.territories ????kraken.core ignore_imports?= ????kraken.core.customers?-> ????kraken.territories.gbr.customers.views ????kraken.territories.jpn.payments?->?kraken.utils.urls ????(and?so?on...)
?
此后,我們使用項(xiàng)目構(gòu)建時(shí)被 Import Linter 忽略的導(dǎo)入語句的數(shù)量作為跟蹤技術(shù)債完成度的指標(biāo)。這樣,我們就能觀察到隨著時(shí)間的推移技術(shù)債的情況是否有所改善,以及改善的速度如何。
Ignored imports since 1 May 2022
上圖是我們過去一年多的時(shí)間里被我們忽略的有問題的引入語句數(shù)量的變化。我會(huì)定期分享這張圖,想大家展示我們最新的工作進(jìn)度,并鼓勵(lì)我們的開發(fā)者努力做到完全遵守分層結(jié)構(gòu)的約定。我們對(duì)其他幾個(gè)技術(shù)債也使用了這種燃盡圖的方法去展示。
沒有銀彈,談?wù)劮謱蛹軜?gòu)的缺點(diǎn)
復(fù)雜現(xiàn)實(shí)
現(xiàn)實(shí)世界無比的復(fù)雜,依賴關(guān)系遍布在項(xiàng)目的各個(gè)角落。在采用分層架構(gòu)后,你會(huì)經(jīng)常遇到想要打破現(xiàn)有層級(jí)關(guān)系的情況,會(huì)經(jīng)常在不經(jīng)意間從低層級(jí)的組件中調(diào)用高層級(jí)的組件。
幸運(yùn)的是,總有辦法解決這類問題,那就是所謂的 控制反轉(zhuǎn)(Ioc),在 Python 中你可以很容易地做到這一點(diǎn),只是需要轉(zhuǎn)換一下思維方式。不過使用這個(gè)方法會(huì)增加「局部復(fù)雜性」,但為了讓項(xiàng)目整體變得更加簡(jiǎn)單,這點(diǎn)代價(jià)還是值得的。
結(jié)構(gòu)中高層代碼過多
在分層結(jié)構(gòu)中,層數(shù)越高的組件天然地越容易更改。正因如此,我們特地簡(jiǎn)化了修改特定 clinents 或 territories 的代碼流程。另一方面,core 是一切其他代碼的基礎(chǔ),修改它就成為了一件高成本、高風(fēng)險(xiǎn)的事情。
高成本、高風(fēng)險(xiǎn)的底層代碼修改行為讓我們望而卻步,促使我們編寫更多針對(duì)特定客戶或地區(qū)的高層級(jí)代碼。最終的結(jié)果就是,高層的代碼比我們想象中要多的多的多。我們?nèi)栽趯W(xué)習(xí)如何解決這個(gè)問題。
目前為止我們?nèi)晕赐耆瓿?/p>
還記得之前提到過的被設(shè)置在 Import Linter 特殊配置文件中被忽略的 import 嗎?多年過去了,它仍未被全部解決,根據(jù)統(tǒng)計(jì)還有最少 15 個(gè)。最后的這幾個(gè) import 也是最頑固、最難以被優(yōu)化的。
我們需要付出很多的時(shí)間才能重構(gòu)完一個(gè)現(xiàn)有項(xiàng)目,所以,越早分層需要面對(duì)的麻煩就越少。
總結(jié)
Kraken 的分層結(jié)構(gòu)使我們?cè)谌绱她嫶蟮拇a體量下仍舊保持著健康的開發(fā)和維護(hù),而且操作難度相對(duì)較小,特別是在考慮到它的規(guī)模的情況下。如果不對(duì)數(shù)以萬計(jì)的模塊之間的依賴關(guān)系加以限制,我們的項(xiàng)目倉庫很可能會(huì)像揉亂的線團(tuán)一樣復(fù)雜。
但是我們選擇的代碼架構(gòu)順利的幫助我們?cè)趩我坏?Python 代碼庫中進(jìn)行大量工作??此撇豢赡?,但這就是事實(shí)。
如果你正在開發(fā)一個(gè)大型的 Python 項(xiàng)目,或者哪怕是一個(gè)相對(duì)較小的項(xiàng)目,不發(fā)試試分層結(jié)構(gòu),還是那句話:越早分層需要面對(duì)的麻煩就越少。
評(píng)論
查看更多