本文適合給誰看?若你所在的公司有多條產品線,每條產品線又切分了幾個大同小異的產品,每個產品又維護了標準產品和多個客戶定製版本的程式碼分支,我大概能想象你們的工程師每天面對的是怎樣的場景。
因此為了幫助讀者更好的理解,本文模擬了一個產品從創意產生到產品迭代和多客戶定製版本共存的過程,希望能給你帶來些許的啟發。
1.程式碼版本管理的痛點
一聊起程式碼版本管理,一個老生常談的問題,大家腦海裡可能立馬就對映到了SVN、GIT等現今常用的程式碼版本管理工具,或則是聯想到了Git Flow工作流程等等。這些都沒錯,合理利用好版本管理工具和版本管理工作流是做好程式碼版本管理的基本要素,但僅僅做好這些就能解決下面的問題了嗎?
· 我們的程式碼版本管理就井井有條了嗎?
· 就能管理好公司所有產品的程式碼了嗎?
· 就能提高公司各條產品線之間的通用功能的程式碼複用率了嗎?
· 就能減少產品程式碼和客戶定製化專案程式碼之間的衝突了嗎?
相信大部分讀者心裡還是會有類似的許多問號。作者本人近20年的碼農曆程,經歷了從Source Safe、CVS、SVN到現今的GIT程式碼管理工具變遷史,之前服務過的幾家公司也都有各自的程式碼管理流程和規範,但是很遺憾,程式碼管理永遠還是那一根根的刺,時不時地給我這老碼農的心窩來這麼一下子(請讀者自行腦補程式碼merge衝突N多,版本被強制退回,程式碼段被神一樣的隊友覆蓋等場景)。
可能有讀者會比較納悶,GIT工具功能這麼強健,配合上Vincent Driessen大神舉薦Git Flow工作流,有解決不了的程式碼管理問題?不是你們不會用吧?讓我們一起來模擬一個場景,來實際體驗一下大多數碼農XDJM們的日常吧。(如有雷同,實屬穿越)
Vincent Driessen:https://nvie.com/about/
2.一款前途無量產品的誕生
某天,產品經理X(為什麼是X而不是Y或則Z呢?因為牛X,不是YY的,也不是Zhu隊友)興致匆匆地召集碼農XDJM們宣佈:我們要開發一個炫酷的,前途無量的資訊處理類產品,造福廣大資訊讀者,證明“知識就是財富”不是神話。產品需求呢很簡單,就如下這麼幾個步驟:
· 對接資料來源A,讀取其中的資訊資料
· 對資訊資料做智慧標籤處理(資訊畫像)
· 把處理後的資料送到資料來源B
碼農XDJM們開始了壘磚。2天后,產品雛形(Ver0.0.1)演示提前進行,順便給產品經理也展示了程式碼(沒辦法,我們公司的XDJM們就是這麼任性)。以下用虛擬碼示例:
/* 應用程式啟動類 */ Class Application { function main() { //資料來源對接 call readDataSource() //監聽佇列,讀取資料並處理 call listenQueue() } //傳送到資料佇列中 function readDataSource(){ //從資料來源A中讀取資料 readFromDS_A; //傳送到資料佇列中 sendToQueue; } //監聽佇列,讀取資料並處理 function listenQueue() { //讀取資料項 readDataItem; //標籤處理 call TagEngine.processTags(); //傳送到資料來源B sendToDS_B; }} /* 標籤處理引擎類 */ Class TagEngine { function processTags(){ handleTag; } }
產品經理X看過產品雛形展示後還算滿意,順便又提出了幾個改進意見,最主要一點是標籤需要分類處理,分別為CategoryA、CategoryB、CategoryC、CategoryD。
碼農XDJM們暗喜,so easy,早就摸準你的套路了,這不程式碼結構早就準備好了,專門獨立了標籤處理引擎呢。於是稍作修改後,釋出了Ver0.1.0版本。程式碼結構上主要拆分了標籤處理分類,示例如下:
/* 標籤處理引擎類 */ Class TagEngine { function processTags(){ handleTagCategoryA; handleTagCategoryB; handleTagCategoryC; handleTagCategoryD; } }
之後的一週時間,開發團隊致力於標籤引擎的模型優化和調優,大大小小迭代了多個版本,但程式碼結構還是維持在ver0.1.0打下的基礎上,因此用GIT來管理程式碼相當順利,沒有遇到任何的問題。產品版本順利升級到了ver1.0.0。
3.客製化需求帶來的初級挑戰
由於產品標籤引擎明顯高於市場同類競品的準確率和覆蓋率(這裡必須要贊一下我們的模型演算法團隊),很快該產品就得到了客戶A的關注,以標準產品功能為基礎幫助客戶A構建了標籤系統。但客戶A也提出了一點只適用於客戶A的需求,其中CategoryB的處理邏輯和標準產品不太一致需要客製化, 因此在客戶A那實施的標籤引擎具體程式碼就變成了如下所示:
/* 標籤處理引擎類(客戶A) */ Class TagEngine { function processTags(){ handleTagCategoryA; handleTagCategoryB4ClientA; handleTagCategoryC; handleTagCategoryD; } }
客戶A的標籤系統實施取得巨大成功。與此同時,隨著資料的不斷積累標籤引擎又經歷了數次更新升級,實施團隊也從客戶A那挖掘新的需求需要處理CategoryE類別的標籤。
此時,需要同時維護標準產品程式碼和客戶A定製版本,並需要雙向merge程式碼,即便使強如GitFlow工作流,也開始捉襟見肘了(有同感的同學可以舉手了)。考慮到還有客戶B,客戶C等定製版本的出現,產品經理X和研發組決定把標準版本和客製版本合併共同維護。
在合併程式碼的同時,產品經理X也提出了在只在另一個業務領域Z可用的新的標籤型別F,於是標籤引擎的程式碼ver.1.1.0就變成了如下所示的樣子。(注:此處為便於說明,並沒有採用更符合架構設計的策略模式等常用技巧,原因在於並不會影響本文所要闡述的主旨)
/* 標籤處理引擎類 */ Class TagEngine { function processTags(){ handleTagCategoryA; //isClientA Flag內容讀取自定義檔案 if (isClientA) { handleTagCategoryB4ClientA; } else { handleTagCagegoryB; } handleTagCategoryC; handleTagCategoryD; handleTagCategoryE; //isBusinessZ Flag內容讀取自定義檔案 if (isBusinessZ) { handleTagCategoryF; } } }
如上所示,通過在定義檔案中配置開關,我們可以把標準產品和客製版本的程式碼合併起來一起維護,今後客戶B,客戶C的客製化需求也可以依葫蘆畫瓢。看到這,估計各位讀者大神要開始吐槽了,沒錯,隨著客戶定製版本的愈來愈多,此處的程式碼必然會有很多的分支處理,最後依然還會陷入到醜陋的分支地域陷阱。
但不管怎樣,至少程式碼是work的,我們也通過配置實現了多版本的共同管理。此處提醒各位讀者,若你的專案開始有類似徵兆,是時候考慮重構一下程式碼結構了,否則等這裡的if else多到兩個手都數不過來,且夾雜著多層巢狀的時候,多半你會默唸”神獸“無數次。
4.業務擴充所帶來的挑戰升級
很榮幸,產品在客戶A這邊的大獲成功被客戶B知道了,於是客戶B邀請我們來幫他們實施標籤系統,由於客戶B的資訊量及其龐大,希望我們的產品能夠提高標籤處理的效率來提高系統吞吐量。
經過研究發現,各類標籤的處理事實上是不需要考慮先後順序的,完全可以把多個handleTag的處理做成多執行緒併發來提高整體效率。於是產品程式碼ver1.2.0又演變成了如下所示的樣子。(注:為了簡化虛擬碼的可讀性,此處僅僅用把C,D,E三個類別用於多執行緒處理,也不考慮實際執行緒如何處理,僅作為示例)
/* 標籤處理引擎類 */ Class TagEngine { function processTags(){ handleTagCategoryA; //isClientA Flag內容讀取自定義檔案 if (isClientA) { handleTagCategoryB4ClientA; } else { handleTagCagegoryB; } // 多執行緒處理 call threadPoolHandleTags(); //isBusinessZ Flag內容讀取自定義檔案 if (isBusinessZ) { handleTagCategoryF; } } // 多執行緒處理 function threadPoolHandleTags(){ ThreadPool.add('categoryC') ThreadPool.add('categoryD') ThreadPool.add('categoryE') ThreadPool.run(); } }
數個月過去了,由於標籤引擎絕對領先的準確率,覆蓋率,和高效率,以及在多個客戶那積累的好口碑,很快一傳十,十傳百地在眾多客戶那實施部署並取得極大的成功。標籤引擎的核心功能也在不斷的演化升級,儘管已經升級到了ver1.5.0,但基礎程式碼的組織架構依然和ver1.2.0保持一致。由於標準產品和客戶定製版本在核心上使用同一套程式碼體系,產品的功能迭代和客戶版本的升級維護到現在為止還算可控。
5.跨領域支援引發的變革
終於有一天,另一個業務領域的客戶們紛紛詢問我們的標籤引擎是否可以支援該業務領域的資訊處理。產品經理X和客戶們探討需求後發現,雖然是跨業務領域的,但核心演算法邏輯大差不離,主要的區別點如下:
· 該業務領域的標籤型別是豐富多樣
· 部分標籤的產生是有先後依賴關係的
· 不同客戶對於標籤的先後依賴關係定義是有差異的,比如客戶C這裡是 標籤M->標籤N->標籤O,而客戶D這裡是 標籤N->標籤O->標籤M。
經過整理需求後發現,若我們的產品同時需要支援這個業務領域的需求,則必須滿足如下的基本需求:
· 資料來源是可多選的,可配置的
· 標籤型別不是固定的,需要能隨時新增型別
· 標籤處理需要支援順序不定的前後依賴關係
· 需要能夠支援多執行緒併發,以提高處理效率
到目前為止,雖然標籤引擎的的核心邏輯因為要應對多個客戶,多個業務場景的特殊處理已經演變成從配置檔案讀取Flag來切分多個分支處理的邏輯,但至少還只需要維護一套核心程式碼體系,產品迭代基本無壓力。
因為並沒有切分標準產品版本和客戶定製版本而產生繁瑣的雙向Merge過程,因此用GitFlow工作流可以很好的支援新功能開發,問題修復,新版本釋出等一系列快速迭代(具體如何操作請參考文末參考資料GitFlow)。
顯然,目前的程式碼組織方式,並無法同時滿足上述4個需求,if else的分支方式無法解決邏輯執行順序問題,也很難組織同步和非同步併發的控制問題。在這種情況下,難道我們需要針對每個客戶的不同需求維護不同的程式碼分支嗎?
可以想象,若不得已而這麼做的話,今後必然會陷入程式碼管理的泥沼而不可自拔。每次新功能的釋出,問題修復等都需要同步到每個客戶版本分支,想想都是非常可怕的事情。稍有經驗的開發者這時候肯定會說,我們可以合理利用設計模式,任務排程引擎等技巧來應對諸如此類的困境,來避免切分多個程式碼分支來管理。
6.多版本程式碼管理的基本原則與解決方案
沒錯,在作者看來,程式碼版本管理的核心就在於如何合理組織你的程式碼結構以避免各種特殊場景版本分支(比如客戶分支),原則上來說除了GitFlow所提倡的分支結構外,不應該再出現其他分支。那麼,針對本文的例子,具體怎麼做可以避免切分特殊分支呢,一個支援可配置的任務排程引擎顯然是一個不錯的選擇。
這裡介紹一個輕量級的任務排程引擎liteFlow (參考資料3),該任務排程引擎支援任務節點的配置,任務節點先後順序配置,任務節點的序列/並行執行配,保證多執行緒環境下任務資料流的執行緒安全等,足以滿足大部分的任務排程需求。
稍有遺憾的任務節點尚不支援引數化配置,比如我們的資料來源為kafka的不同topic,若節點支援引數化配置,那麼節點程式碼只需要維護一個,用不同的引數配置來生成不同的節點例項即可,應用的可擴充套件性更加友好。
為彌補這個小小的遺憾,作者fork了liteFlow做了些許的調整。基於調整後的liteFlow任務排程引擎,我們避免了多個客戶分支的程式碼管理泥沼,只需要為每個客戶準備一個任務排程流程配置檔案就可以了。流程配置檔案示例如下:
<!-- 通用配置 --> <?xml version="1.0" encoding="UTF-8"?> <flow> <nodes> <node id="a" class="tech.deepq.training.flow.NodeA"> <param key="kA1" value="vA1"/> <param key="kA2" value="vA2"/> </node> <node id="b" class="tech.deepq.training.flow.NodeB"> <param key="kB1" value="vB1"/> <param key="kB2" value="vB2"/> </node> <node id="c" class="tech.deepq.training.flow.FlowC"> <param key="kC1" value="vC1"/> <param key="kC2" value="vC2"/> </node> </nodes> <chain name="processTagFlow"> <then value="a,b,c"/> <!-- then表示序列 --> </chain> </flow> <!-- 客戶X專用配置 --> <?xml version="1.0" encoding="UTF-8"?> <flow> <nodes> <node id="a" class="tech.deepq.training.flow.NodeA"> <param key="kA1" value="vA1"/> <param key="kA2" value="vA2"/> </node> <node id="b" class="tech.deepq.training.flow.NodeB4ClientX"> <param key="kB1" value="vB1"/> <param key="kB2" value="vB2"/> </node> <node id="c" class="tech.deepq.training.flow.FlowC"> <param key="kC1" value="vC1"/> <param key="kC2" value="vC2"/> </node> </nodes> <chain name="processTagFlow"> <when value="a,b,c"/> <!-- when表示並行 --> </chain> </flow>
參考如上示例配置,通用配置表示順序執行NodeA,NodeB,NodeC 三個節點,而客戶X專用的配置是3個節點並行處理,並且b節點的處理邏輯是客戶定製化。由此可見,藉助任務排程流程引擎,在程式碼層面我們只需要專心實現各個任務節點的業務邏輯,無需用程式碼去控制具體任務流程。既簡化了程式碼邏輯,又解決了程式碼管理上的痛點。
最後,作者再次重申:程式碼版本管理的核心關鍵不在於使用多麼強的程式碼管理工具,而在於如何合理組織程式碼結構來減少程式碼版本分支,來從根本上避免各種版本分支的互相merge過程。從技術層面上來說,我們有很多的手段來做到減少程式碼版本分支,例如設計模式的合理使用,訊息中介軟體解耦,以及本文提到的任務排程流程引擎等等都是很好的選擇。
參考資料
1)GIT:https://git-scm.com/book/en/v2
2)GitFlow:https://nvie.com/posts/a-successful-git-branching-model/
3)liteFlow:https://github.com/thebeastshop/liteFlow
4)liteFlow Fork:https://github.com/muhm21cn/liteFlow
關於作者
穆惠明,深擎科技首席架構師。擁有近20年軟體開發和架構設計經驗,在銀行、保險、證券等金融領域的微服務基礎架構、容器化部署等方面擁有豐富的實戰積累,目前主要負責深擎智慧資訊產品群及資料中臺的架構設計和落地實現等。