如何不 Review 每一行代碼,同時保持代碼不被寫亂?

作者:陶文 來源:taowen

本文的讀者

本文的目標是以儘可能濃縮的篇幅提供可模仿的步驟來達成“如何不 Review 每一行代碼,同時保持代碼不被寫亂”的目標。總共三步

第一步:不要拆分代碼倉庫,不要拆微服務

拆分微服務以及代碼倉庫的缺點

拆分微服務和代碼倉庫相比單體架構,最重要的目標是減少分支衝突,控制發佈變更的風險。但是拆分微服務和代碼倉庫並不是最佳的解決方案。Monorepo + Feature Toggle 是更好的解決方案。

使用 Monorepo + Feature Toggle 可以提供所有拆分微服務達成的目標,同時克服以上微服務拆分帶來的缺點

經常聽說的一個說法是最終是要拆分成微服務,多倉庫的。單體應用單倉庫只是一個過渡形態。這會導致我們認爲爲啥 不 一步到位呢。 但事實並非如此,微服務和多倉庫並不一定適合所有人。 你可以用 Monorepo + Feature Toggle 用一輩子。

具體如何實踐 Monorepo + Feature Toggle 按照 https://www.branchbyabstraction.com/ 和 https://trunkbaseddevelopment.com/ 的指導去做就可以了。

第二步:管控集成類需求的代碼審查

當我們把代碼都放一個代碼倉庫裡之後,立即要面臨的問題是代碼不會寫亂麼?你怎麼控制什麼代碼寫在哪裡?每一行代碼寫之前都來問你,每一行代碼寫完了都需要你來 Review 麼?

所以,我們需要一種強制檢查代碼寫在了正確的位置的自動化機制。這個機制就叫“依賴管理”。對應常見的編程語言

當我們把代碼拆分成多個包(或者叫模塊),並使得這些包(模塊)形成特定的依賴關係,就可以通過編譯器檢查控制什麼代碼必須寫在什麼地方,從而不需要靠人去檢查。這個依賴關係如下圖所示

這樣做的好處是可以減少 Review 的負擔。不需要盯着每一行代碼了,只需要重點盯着主板的修改就可以了。實現的步驟是

比如說我們決定有一個團購插件,有一張表 GroupPurchaseCampaign 記錄了團購活動的參與商品和規則。那麼要展示團購活動列表的時候,就會自然優先選擇在團購插件裡來寫,因爲這個插件裡可以訪問這張表。這裡說的“訪問”是指可以 import GroupPurchaseCampaign 這個類型的意思。插件不能 import 另外另外一個插件定義的類型,但是不意味着運行時不能訪問別的插件的數據。運行時的數據都是通的。限制的是編譯器,誰可以 import 誰。

當需要主板進來實現”集成類需求”的時候,應該如何做。分爲以下三類

主板起到的作用和 C 編程裡的“頭文件”的作用是一樣的,就是給模塊之間相互調用提供聲明。主板的代碼要儘可能的少,絕對不要在主板裡提供 CRUD 的裸數據接口,主板裡定義的是界面的槽,流程的槽,而不是直接把數據庫的原始數據暴露出去。

技術上如何實現:在一個包裡提供聲明, 在 另外一個包裡寫實現。 這個有兩類做法:

無論是哪種具體實現技術,都不要實現成如下圖所示這樣

在插件之上 不應該有 一個額外的包(模塊)包含業務邏輯了。插件對主板的插入應該是一個 AutoWire,純機械不含業務的過程。業務編排這樣的概念一定不要出現在依賴關係的最頂層。我們已經在最底下的主板實現了所謂的“業務編排”了。

SaaS 可以把自己的功能拆解到多個插件來實現。但是經常有“按需”組裝,或者付費購買的需求。我們並不需要動態來組裝代碼來獲得“按需”組裝的產品效果。代碼可以是一份,只是通過運行時的開關來控制某些插件是否啓用。這些開關可以是配置文件,也可以是數據庫表來控制。在沒有啓用的時候,界面上完全隱藏相關的組件(就是 if/else 判斷),用戶也察覺不到這個功能的存在。付費購買其實就是付費買這個開關,也不需要像 Apple Store 那樣真的去做什麼代碼下載和安裝。當然給外包公司做二次開發就是完全另外一個話題了,與本主題無關。是否打開某個插件可以是全局性的(給每個商家或者租戶啓用),也可以是“訂單”級別。一個所謂的訂單履約流程,需要組合多個插件的功能。對於每個訂單來說,都有一堆 bit 開關來決定某個插件是否啓用了,以及對應的業務數據是什麼。比如 GroupPurchase + OrderSelfPickup + Order 可以組合成團購自提的訂單。訂單在此處只是一個例子,不同類型的業務有自己的領域概念。

第三步:管控規範型需求的代碼審查

有了主板加插件,Monorepo 已經切分出了多個子目錄了。每個開發者也基本上能夠知道什麼需求寫在什麼目錄下,哪些目錄是自己經常修改的。接下來的問題是,如果每個開發者都各寫各的,那他們之間有重複實現怎麼辦?誰來避免同一個東西,被不同產品經理提出多遍,再由不同的開發者用不同的姿勢實現多變,導致浪費和返工?這個也是一個 Code Review 的問題。不能指望有一個人來 Review 每一行代碼。

解決辦法就是我們希望有一個人來“收口”,然後由這個人來保證收口之後的代碼沒有重複的實現,建立合理的抽象。如下圖所示

所謂“收口”,就是要阻止上圖中這樣的繞過“這層抽象”,去訪問“底層API”的行爲。比如說,所有的編程語言都提供了 Http 調用的能力。但是我們希望封裝一個 Http Restful API 的調用 SDK。在這個 SDK 裡我們統一實現重試,統一實現熔斷摘除故障節點這樣的一些功能。避免每個調用 Restful 接口的地方都重複地 try catch,重複地寫不一致地重試邏輯。那就需要有一個人來封裝這樣的庫,同時強制所有“應該使用這個庫的地方”都使用了這個庫。

實現方案要比管控集成類需求要稍微麻煩一些。集成類需求可以用包之間的依賴關係來約束什麼代碼寫在哪裡,規範型需求的問題是假設一個業務包,比如團購。它依賴了 Http Restful SDK,而 Http Restful SDK 又依賴了 Http 的庫。那麼就意味着團購這個包通過依賴的傳遞性,也依賴了 Http 的庫。在現有的編程語言裡,都無法禁止團購的包通過傳遞性依賴獲得的調用 Http 庫的權利。這個時候我們就需要通過自制 lint 工具的方式,在編譯期額外做更嚴格的依賴關係檢查。通過 lint 檢查,強迫所有訪問 Http 庫的代碼都“收口”到某個目錄裡。然後我們就可以通過 Review 這個目錄的改動,確保重試邏輯只寫了一份,而不是散落到各個地方。

這樣的 lint 規則可以檢查以下類型的訪問

再舉一個例子。通過 lint 檢查,我們可以確保所有包含樣式的前端組件都寫在某個目錄下,比如說 RegularUi 和 SpecialUi。其他目錄中的組件,只能是通過組裝 RegularUi 和 SpecialUi 目錄中的組件來完成自己的設計稿還原。當然這樣就是一種“收口”。我們可以通過 Review 對 RegularUi 和 SpecialUi 這兩個目錄下文件的修改,來發現是不是有兩個開發者在嘗試實現極度類似的頁面組件,也可以促成兩個產品經理互相交流一下,是不是把兩個組件合併成一樣的行爲,避免不必要的實現成本。

“收口”的代價是不可避免會出現很多一次性的需求,個性化的需求。比如優惠券的界面就是要和其他界面不一樣。因爲對樣式做了收口,所以就不能直接寫在優惠券這個包裡面。於是就有了 SpecialUi 這個目錄,用於寫被收口了,但是並不可複用的東西。SpecialUi 裡的組件數量的多寡,體現了 Ui 不一致性的嚴重程度。如果每個頁面都不一樣,都非常有藝術感。那說明這樣的產品並不適合對樣式進行收口,就應該各寫各的,每個頁面都純手工打造。

“收口”的 lint 檢查的關鍵是要去掉對人的主觀判斷的依賴。我們不需要判斷這裡來是不是一定能複用 RegularUi。我們寧願過度收口,導致 SpecialUi 的出現,也要避免人爲主觀判斷的介入。這種“過度的”收口,是規範型需求能實現自動化檢查的關鍵。一旦我們允許酌情出現一些例外的情況下,那麼又變成了需要 Review 每一行代碼了。

“收口”之後的一個風險是強行抽象。明明不適合複用同一個組件的場合,仍然複用了同一個組件。導致組件變得更復雜,導致組件經常被修改。一個對策是控制組件或者函數的參數個數,參數應該儘可能地少。如果某個函數在 Monorepo 中有10處調用,但是其名爲 IsVipUserPriviliged 參數僅僅在 1 處調用有傳值。那麼這個 IsVipUserPriviliged 參數大概率是不應該被添加進來的,是強行抽象的產物。對於 IsVipUserPriviliged 的處理,更適合直接寫在調用的地方,而不是被寫到可複用的目錄裡。

收益

在這三步都完成之後,你獲得了一個“機器人”。它幫你在每個開發者提交代碼的時候檢查代碼是不是寫到了正確的位置。 在 通過了這個機器人檢查的基礎上,你只需要關注重點的一些目錄就可以了,對其他的修改僅僅需要抽查。 這個機器人能夠像拆分了微服務一樣,確保代碼不寫亂。 同時不像微服務那樣,拆分之後就很難調整了。 因爲代碼仍在一個倉庫裡,只是分了目錄,隨時都可以再調整。