目結(jié)構(gòu)組織方式)
你曾經(jīng)打開自己的Python項(xiàng)目盯著二十多個(gè)散落.py文件突然想退出重寫嗎這種沖動(dòng)很普遍但問題的根源不是代碼質(zhì)量而是項(xiàng)目結(jié)構(gòu)本身在傳達(dá)一種無序感。當(dāng)目錄結(jié)構(gòu)無法回答“這段邏輯該放哪”時(shí)每一次新增功能都是一次架構(gòu)賭博。很多人把結(jié)構(gòu)問題歸咎于不用Django或者Flask但真正值得嘗試的結(jié)構(gòu)方式很少被討論。結(jié)構(gòu)不是目錄樹而是代碼之間依賴關(guān)系的可視化。創(chuàng)建一個(gè)空目錄比創(chuàng)建有效邊界容易得多而大多數(shù)學(xué)到的“最佳實(shí)踐”都在教我們創(chuàng)建目錄卻沒有教我們?nèi)绾巫屇夸浘邆湔嬲募s束力。下面這些組織方式不是銀彈但至少能讓你在下一個(gè)項(xiàng)目里少一些猶豫。別急著建目錄先學(xué)會(huì)在扁平中生存成熟開發(fā)者往往能容忍一個(gè)很扁平的目錄結(jié)構(gòu)。當(dāng)項(xiàng)目只有十幾個(gè)模塊時(shí)強(qiáng)行按lib/、utils/、core/分割只會(huì)制造虛假的復(fù)雜度。扁平結(jié)構(gòu)最大的好處是讓依賴關(guān)系一目了然——你不需要在五個(gè)不同深度的目錄里尋找一個(gè)函數(shù)。我見過不少項(xiàng)目一個(gè)utils.py就有2000行但是把它拆成20個(gè)文件放進(jìn)utils/包不會(huì)讓結(jié)構(gòu)變得更好。拆分的標(biāo)準(zhǔn)不是文件大小而是“是否可能被獨(dú)立復(fù)用”。如果一個(gè)函數(shù)只被一個(gè)模塊使用就應(yīng)該定義在模塊內(nèi)部而不是放到底層的公共工具包里。當(dāng)你需要添加一個(gè)新的服務(wù)看看現(xiàn)有的頂層文件。如果新代碼能用一句“從某個(gè)模塊導(dǎo)入”說清楚就無需新建子目錄。結(jié)構(gòu)的復(fù)雜度必須由真實(shí)的依賴約束來支撐而不是由美學(xué)的沖動(dòng)來驅(qū)動(dòng)。這里的關(guān)鍵是抗拒“規(guī)范化”的誘惑——等出現(xiàn)第三個(gè)需要相同邏輯的地方再提取共享模塊往往比提前設(shè)計(jì)更精準(zhǔn)。模塊邊界是對(duì)未來變化的預(yù)測當(dāng)一個(gè)扁平目錄變得擁擠你會(huì)自然想要?jiǎng)澐职?。但在建包之前先回答一個(gè)問題哪些代碼會(huì)因?yàn)橥粋€(gè)業(yè)務(wù)原因而變化這是劃分模塊邊界的唯一可靠標(biāo)準(zhǔn)。比如支付相關(guān)的邏輯無論是支付接口、支付回調(diào)還是支付狀態(tài)機(jī)它們會(huì)因?yàn)樯嫌沃Ц肚赖恼{(diào)整而一起變化應(yīng)該放在同一個(gè)包內(nèi)。相反User模型和EmailSender可能不會(huì)因?yàn)橥粭l業(yè)務(wù)規(guī)則而變化它們不應(yīng)該被放在同一個(gè)“業(yè)務(wù)實(shí)體”包里。有一種很常見的結(jié)構(gòu)錯(cuò)位是把所有數(shù)據(jù)模型放進(jìn)models.py所有服務(wù)放進(jìn)service.py。當(dāng)models.py必須依賴service.py去實(shí)現(xiàn)某些業(yè)務(wù)規(guī)則你就知道斷層已經(jīng)出現(xiàn)。一個(gè)值得一試的結(jié)構(gòu)是讓每個(gè)功能包內(nèi)部自帶它的模型、服務(wù)、接口和存儲(chǔ)實(shí)現(xiàn)。也就是說包不是按技術(shù)層來建的而是按業(yè)務(wù)能力來建的。這種組織方式讓內(nèi)聚性有了具體的邊界當(dāng)你要修改訂單功能你走進(jìn)一個(gè)特定的目錄而不會(huì)在整個(gè)代碼庫的矩陣?yán)锎┧蟆W尅肮δ堋背蔀楸取皩印备叩慕M織單位層級(jí)結(jié)構(gòu)如controllers、services、repositories在許多框架中被固化下來但它容易導(dǎo)致“跨層依賴”的泥潭。按功能組織意味著每個(gè)功能包都像一個(gè)微型的系統(tǒng)對(duì)外只暴露一個(gè)清晰的入口。例如在電商項(xiàng)目里checkout/目錄里可以包含cart.py、payment.py、order.py以及這些模塊獨(dú)有的異常和自定義類型。這樣一來目錄本身就在說“這些代碼屬于同一個(gè)業(yè)務(wù)故事”。一個(gè)更具體的做法是使用“端口-適配器”或“六邊形架構(gòu)”的思路。讓領(lǐng)域邏輯處于核心數(shù)據(jù)存儲(chǔ)和外部API都是可以替換的適配器。但這不是要你復(fù)制一堆抽象接口只需要在功能包內(nèi)部定義好“這個(gè)包需要什么”和“這個(gè)包對(duì)外提供什么”。當(dāng)包之間的依賴變成“包A依賴包B的接口”而不是“A被包里的某個(gè)類直接實(shí)例化”結(jié)構(gòu)就獲得了呼吸感。雖然這會(huì)帶來些許抽象成本但換來的是當(dāng)你替換ORM或切換數(shù)據(jù)庫時(shí)不需要重寫業(yè)務(wù)規(guī)則。依賴關(guān)系真正的結(jié)構(gòu)是流動(dòng)的方向檢查項(xiàng)目結(jié)構(gòu)是否健康最快的方法是看一張依賴圖。如果依賴箭頭指向的方向和你的直覺相反結(jié)構(gòu)就有問題。比如業(yè)務(wù)層不應(yīng)該依賴框架細(xì)節(jié)而應(yīng)該反過來。但Python的松耦合語法讓隱形依賴很容易產(chǎn)生——一個(gè)頂層的import可能來自任何地方。一種值得嘗試的約束是在包內(nèi)部使用相對(duì)導(dǎo)入并且明確禁止跨功能包的深層導(dǎo)入。做不到這個(gè)至少要在文檔中畫出依賴方向。另一個(gè)簡單的技術(shù)是“只允許向下依賴”。這里的“向下”指的是相對(duì)穩(wěn)定的底層比如標(biāo)準(zhǔn)庫、第三方庫、你定義的數(shù)據(jù)結(jié)構(gòu)和常量而不是另一個(gè)正在快速變化的功能包。當(dāng)兩個(gè)功能包需要互相調(diào)用通常說明它們應(yīng)該合并或者有一個(gè)共同的下層模塊需要抽出來。在動(dòng)手寫代碼前先畫一畫邊界找出哪些依賴是容易破碎的。如果你發(fā)現(xiàn)某個(gè)包被十個(gè)其他包導(dǎo)入它自身卻依賴了其中三個(gè)那么你很可能已經(jīng)踩進(jìn)了循環(huán)依賴的泥潭——即使暫時(shí)沒有報(bào)錯(cuò)也說明邊界已經(jīng)模糊。配置不該是一堆變量而是一個(gè)決策點(diǎn)很多項(xiàng)目把配置散落在環(huán)境變量、config.py和settings.py里然后讓每個(gè)模塊自己去讀取。這種結(jié)構(gòu)讓配置變得不可追蹤。一個(gè)值得一試的組織方式是將配置提升為一個(gè)獨(dú)立的決策點(diǎn)用一個(gè)模塊專門負(fù)責(zé)收集、校驗(yàn)和聚合配置然后在進(jìn)程啟動(dòng)時(shí)通過構(gòu)造器注入到需要的地方。這樣你的代碼不需要到處依賴os.environ而是顯式地接收參數(shù)或配置對(duì)象。進(jìn)一步說配置也應(yīng)該分層框架配置、應(yīng)用配置、部署配置??蚣芘渲梅旁诳蚣艿募s定位置應(yīng)用配置放在與業(yè)務(wù)代碼分離的配置文件里部署配置則完全交給環(huán)境變量。項(xiàng)目結(jié)構(gòu)應(yīng)該體現(xiàn)出這種分層而不是把所有東西都塞進(jìn)同一個(gè)settings.py。當(dāng)你把配置當(dāng)成一個(gè)“決策點(diǎn)”來看待你會(huì)發(fā)現(xiàn)很多看似必要的全局對(duì)象其實(shí)可以變成局部依賴。測試結(jié)構(gòu)決定你敢于重構(gòu)的程度測試文件的組織方式直接反映了代碼的可測性。如果測試需要復(fù)制生產(chǎn)環(huán)境的目錄結(jié)構(gòu)才能找到待測模塊那么被測模塊本身已經(jīng)過度耦合。一種值得嘗試的做法是每個(gè)功能包內(nèi)部自帶tests/目錄測試與被測代碼放在同一個(gè)邊界內(nèi)。這樣可以確保測試不會(huì)脫離業(yè)務(wù)上下文也能在重構(gòu)時(shí)立刻知道哪些測試會(huì)受影響。更重要的是測試結(jié)構(gòu)應(yīng)該是“行為契約”的可視化。當(dāng)你用一個(gè)測試來驗(yàn)證“創(chuàng)建訂單后發(fā)送郵件”這個(gè)行為測試應(yīng)該直接通過功能包的公開入口來驅(qū)動(dòng)而不是通過內(nèi)部函數(shù)或數(shù)據(jù)庫狀態(tài)。一旦測試開始導(dǎo)入包內(nèi)部的私有函數(shù)它就不再是行為測試而是實(shí)現(xiàn)測試這會(huì)鎖死你的內(nèi)部結(jié)構(gòu)。因此我推薦在每個(gè)功能包的__init__.py中顯式導(dǎo)出公開接口測試只依賴公開接口而不是包內(nèi)部的任意模塊。大項(xiàng)目如何在不犧牲可理解性的前提下組織當(dāng)項(xiàng)目規(guī)模變大比如超過50萬行單純的功能拆分仍然會(huì)遭遇“橫向爆炸”——功能數(shù)量太多瀏覽起來仍然困難。這時(shí)需要用“模塊”與“場景”雙重維度來組織。先按主業(yè)務(wù)劃分領(lǐng)域包再在領(lǐng)域包內(nèi)建立“場景編排層”。場景編排層只負(fù)責(zé)調(diào)用各個(gè)功能包不包含業(yè)務(wù)規(guī)則。這樣做的目的是讓新成員能夠從“用戶故事”出發(fā)找到代碼所在的入口而不是在一個(gè)巨大的服務(wù)類里迷失。另一個(gè)值得嘗試的實(shí)踐是用“依賴注入容器”給結(jié)構(gòu)做一個(gè)顯式映射。容器不是可有可無的裝飾品而是項(xiàng)目結(jié)構(gòu)的一張地圖。當(dāng)你在容器里看到OrderService依賴PaymentGateway你立刻知道它們之間存在端口關(guān)系。但這種映射要有節(jié)制否則容器會(huì)變成無所不能的God Class。最好的程度是只需要在啟動(dòng)階段組裝依賴業(yè)務(wù)代碼里依然使用顯式的構(gòu)造器傳入。別忘了文檔也是結(jié)構(gòu)的一部分。如果一張目錄圖能讓人在半分鐘內(nèi)理解項(xiàng)目的層次就勝過了冗長的架構(gòu)文檔。所以把README.md里的項(xiàng)目結(jié)構(gòu)說明維護(hù)到和代碼同樣重要的程度。當(dāng)這部分開始失真意味著你實(shí)際的結(jié)構(gòu)已經(jīng)偏離了初衷。一個(gè)可落地的結(jié)構(gòu)模板如果你厭倦了空談下面這個(gè)模板可以作為一個(gè)起點(diǎn)。項(xiàng)目頂層只有三樣?xùn)|西src/、tests/和pyproject.toml。在src里your_app/的下一層是各個(gè)功能包例如checkout/、inventory/、member/。每個(gè)功能包內(nèi)部都遵循“入口→接口→實(shí)現(xiàn)→存儲(chǔ)”的約束。入口是包目錄下的__init__.py它只導(dǎo)出該包對(duì)其他包可用的函數(shù)接口定義在contracts.py中實(shí)現(xiàn)放在services.py存儲(chǔ)放在repository.py。同時(shí)每個(gè)功能包下都有tests/目錄測試文件和它測試的模塊一一對(duì)應(yīng)。而share/目錄存放那些確實(shí)被多個(gè)功能包復(fù)用的純工具比如金額計(jì)算、日期格式化——共享目錄里不能導(dǎo)入任何功能包。config.py作為唯一的配置入口在應(yīng)用啟動(dòng)時(shí)讀取環(huán)境變量然后通過依賴注入把配置對(duì)象傳給各功能包。這個(gè)模板的核心價(jià)值在于所有跨包的依賴都必須通過公開入口而不是深入到別人包的內(nèi)部文件。當(dāng)你需要從checkout中獲取訂單你應(yīng)該寫from checkout import get_order而不是from checkout.services.order_service import OrderService。這保證了每個(gè)包的可替換性。當(dāng)功能開發(fā)變快結(jié)構(gòu)不會(huì)成為阻力因?yàn)槟阒恍枰趕hare/中添加新的輔助函數(shù)或者在某個(gè)包內(nèi)部進(jìn)行重構(gòu)。測試也遵循同樣的規(guī)則只能經(jīng)由公開入口訪問其他包的功能。結(jié)構(gòu)不是終局而是一個(gè)持續(xù)演化的過程最后談一個(gè)容易被忽略的時(shí)間維度。項(xiàng)目結(jié)構(gòu)是一種解決“當(dāng)前已知問題”的策略而不是對(duì)未知未來的承諾。你需要允許自己在一次迭代后大膽重構(gòu)目錄而不是把目錄當(dāng)作神圣的契約。比較健康的節(jié)奏是每次功能迭代結(jié)束后花15分鐘觀察是否產(chǎn)生了“新模塊正在被多個(gè)包引用”的跡象如果有就自然地抽取成獨(dú)立包。不值得一試的是追求“完美結(jié)構(gòu)”的心態(tài)那只會(huì)讓你在抽象上過度投資。架構(gòu)的意義在于降低認(rèn)知負(fù)擔(dān)而認(rèn)知負(fù)擔(dān)是高度主觀的。每個(gè)團(tuán)隊(duì)都應(yīng)該形成自己的“結(jié)構(gòu)感覺”并通過代碼評(píng)審把這種感覺沉淀下來。只要依賴方向清晰內(nèi)聚邊界明確測試能陪著你自由重組這個(gè)項(xiàng)目結(jié)構(gòu)就值得你繼續(xù)下去。不要被工具、模板、框架的默認(rèn)布局束縛——?jiǎng)e人建議的結(jié)構(gòu)只需要把它當(dāng)作一次可以修改的起點(diǎn)。你的項(xiàng)目會(huì)告訴你它需要怎樣的空間而你的責(zé)任是傾聽并作出權(quán)衡。