從零星的 copy fork 演進為 upstream 持續更新模式
繼上文 git 分支運作模式之我見 的論述後,下文繼續深入軟體開發面臨的問題與解決模式。
一個軟體有很多客戶使用,客戶A要客製化功能 p+x, 客戶B要客製化功能 q+y, 客戶C要客戶 r+x+y, …。 因應不同時期不同的客戶需求,同一套軟體可能因此有多個 fork branches。 每個 branch 主體大致上是相同的,但可能約有 5~10% 左右是依客戶需求而特別訂製的。 這樣的需求趨動下,常常會伴隨幾個特性狀況如下:
特性: copy fork
假設今天多一個客戶想要客製功能 r+y ,那麼可能就會找跟這個需求最接近的客戶C ( r+x+y ) 的程式,複製一份出來用,再去掉 x 功能。這樣子作最快最方便。
特性: 版本交錯
承 copy fork 的特性,因為是取最接近的而不是最新的,所以常常會有新舊版本交錯的問題。很多時候,新版本還有開發中,不甚穩定,所以此時進來的客戶需求,常常改以較穩定較有把握的舊版本來應付,再額外套上一些新開發的功能。此時,產生的分支常常是新舊版本交錯的狀況。
特性: bug重覆出現
承 copy fork 的特性,當複製了某一個功能時,同時也將該功能的 bug 也複製了。今天很可能客戶 A 的功能 x 出問題了,於是開發人員花了點時間將 A 的 x 功能給修正了。但基於種種原因,並未修正到 C 客戶的 x 功能。結果,過一段時間 C 客戶的 x 功能也出問題了,導致開發人員又花了重複的時間去修正 C 客戶的 x 功能。重複的問題,不但重複耗損開發人員的時間,同時也減損了整體的自信心。
特性: 修正更新狀態不一致
承 bug重覆出現 的特性,因為採取遇一個 bug 修一個的策略,導致多個分支之間的版本跟修正程式碼是不一致的。使得程式裡的特例越來越多,比如說,修正客戶 A 的 x 功能時,是以 A 的使用方式考量下設計。而修正客戶 C 的 x 功能時,則依 C 的情境作調整。那麼同樣的 x 功能,就又產生了 2 個分支。
形成狀態
根據上述種種特性,最終整個專案就難以避免的進入,bug散佈交錯,修正更新狀態紊亂不一致,程式碼交錯充斥 workaround,整體的開發複雜度持續增加,開發人員窮於應付不斷冒出的 bug 跟地雷,對於軟體品質的提升只是個偶然振作,而重複的問題一再複發卻束手無策。
落入這個狀況時,很多人第一直覺的反應常常是,“不夠努力”,或是覺得"程式寫得不夠好",所以解決的方向常常是"要求要更努力地將程式寫得更好",而這樣的想法就催生了 “重新開發新版 2.0” 的策略。然後,這樣的策略常常是失敗的。
重新開發新版 2.0 陷入的困境
因為在舊版中,長期累積的 bug 和 workaround hell,常是開發人員的夢魘。 因此,另外成立一個新版本,並宣誓立即擺脫 bug 和 workaround 陰影,往往讓開發人員立刻感受到解放而有希望的感覺。 然而,隨時間演進,卻常常陷入另一種困境,如下:
1. 重新開發的版本,往往不夠成熟,而沒辦法穩定上線
想想,舊版本在過去不斷的測試跟修正,並調整成使用者能接受的程式,中間花了那麼長一段時間。 新版本想要在那麼短的時間內,完成這個過程,談何容易。
2. 因為無法上線,而無法產生收益
在沒有收益下,開發新版本的這條路,是一條以燒錢來爭取未來轉機的道路。 相較於開發新版本的燒錢,改回舊版本提供給客戶,很快就能取得收益,而且時間越長,兩者相差的機會成本越大。 在這樣的情境下,開發新版本受到的支持度越來越低,而回守舊版本的支持度越來越高。
3. 回守舊版本
此消彼長之下,最後仍決定回守舊版本。 專案全體人員士氣大受打擊,因為努力振作一段時間,但最後一無所獲。 更糟的是,開發人員開始意識到,在各項環境條件沒有大改變的情況下,永遠沒有脫離困境的一天。
另一項思路
有兩篇文章給我很大的啟發:
- 約耳趣談軟體 一書的 “CH24|你絕對不應該做的事之一”
- 約耳續談軟體 一書的 “CH30|洗刷刷、洗刷刷”
根據上述的問題種種,我想提出另一個想法。
並不是我們不夠努力,也不是我們程式寫得不夠好,而是,我們的方法錯了。
陷入困境的關鍵因子
讓我們陷入困境的那個因子,其實是來自於 copy fork 那瞬間所產生的"無法一致更新的分支複本"。
也就是說,分支複本隨時間越來越多,版本跟不一致的狀況越來越嚴重,於是不斷增長的複雜度跟層出不窮的狀況,必然最後就壓垮耗盡了開發人員的時間跟腦力。
只要是 “無法一致更新的分支複本” 的問題存在,即使上述所採行的 “2.0新版本” 開發成功,還是很有可能在未來落入相同的困境。
所以,解決方向應該是針對 “無法一致更新的分支複本” 的這個環節來解決。
這個解決方法是存在並真實可行的,目前我得出的解決方法過程如下:
建立更新共同體
step1.
首先,先要考察所有分支的分支祖譜關係。
step2.
選定其中一個開發能量最集中,且跟各分支相容性佳的那個分支作為標竿,訂為 upstream
step3.
讓所有分支和 upstream 建立第一個 merge 關係。 注意, 是用 merge -s ours 來建立第一個 merge 關係,這一點非常重要。 因為,目標是建立連動關係,而不是一開始就要達成一致。
這三個步驟並不需要什麼大決心、大力氣。但已足夠讓所有分支俱備連動關係了。接下來的,就是一步一步的執行小改善的動作如下:
step4-1.
遇到一個 bug , 先假設這個 bug 是 email 寄送程式相關的問題好了。
step4-2.
研究出這個 bug 的根本原因,還有 dependency 狀況 ( 包括函式庫、影響到的程式有那些、那些分支有相同狀況 )
step4-3.
根據上述研究內容,製作一個可徹底解決問題、且能共通於其他分支的修正程式 patch
step4-4.
將這個 patch 提交進 upstream 分支
step4-5.
讓所有分支從 upstream 分支拉新的 merge update。
在各分支拉 upstream 的 update merge 時,有的可能會順利 merge 成功,有的可能會 merge conflict。但預期的難度會在可控制的範圍內。主要原因是:
- a) 因為一開始已經建立了 merge 關係,所以此次 merge 會從前一次的 merge base 參考點出發,也就是說,衝突的部份只會在這個 patch 的修改範圍內,而不會是一大群 conflict 列表
- b) 一般來說, patch 相容性作的好的話,衝突的比例可降低。例如,20個分支,只有3個分支有嚴重衝突(即 15%),此時再針對這3個分支作針對性處理即可。
step4-6.
完成此次連動更新的修正。
在完成上述的動作後,預期所有分支的這個 bug ,並進行了一次"連動一致性"的修正。這個過程可能比往常多花了一些力氣跟時間(ex: 1.5倍或是 2~3倍),但是俱備下述性質:
- a) 雖然多花了點成本,但因修改範圍相對較小,所以可控制在"覺得多作點工,但在可以平常心承受"的範圍內
- b) 單次修正的成本雖然較高,但因為修正的較完整而徹底,這個 bug 可能就一次解決後就永遠的被修正而不再複發了
- c) 修正的效果,重複了 N 個分支。其 impact 是 N 倍放大。
- d) 因為是連動式的全體更新,所以在修正的範圍內,消減了一次複本問題下的複雜度。
繼續重複回到 step4-1.
在上述的流程下,我們進行一回合又一回合的清洗,每次清洗一點,進步一點。慢慢的,就會發現整個專案程式從雜亂無章的複本分支中,漸漸形成出跨越分支的一致性。
標準化、參數設定化、模組化
在上述建立更新共同體之後,已能抑制複雜度的增加,並逐漸收斂出一致性。不過收斂的速度有快有慢,收斂效率的關鍵在於軟體程式本身。
方法之一,參數設定化:
舉例來說,如果一些功能設定是寫死在程式裡面,像是 API Key/URL/… 的值之類的,那麼每次設定值一變更,程式就要修改,改來改去永遠無法固定下來。 其實,解決方法很簡單,只要程式增加讀取設定檔的功能 ( ex: Preference/Setting/Configuration/… 功能 ),那麼,設定有變化時,只要改設定檔就好,程式都不用再改。 簡單來說,就是將會變動的設定跟參數改由外部讀入,不但程式不需要再頻繁更改,也增加了程式的彈性。
方法之二,標準化
不同分支之間雖然各有差異,但仍有許多共同函式庫跟共用程式。然後,這些共用部份,可能因為函式庫的版本差異或是共用程式的不同小修改,而產生不同的邊際效應,而且這些共用程式常常廣泛的散佈在程式之間。若能將這部份的差異去除掉,那麼就能讓分支間的複雜度快速收斂下來。具體的作法,就是利用上述更新共同體的機制,一次更新某一函式庫至同一版本,一回合一回合地的清洗函式庫與共用程式,直到所有分支的基礎核心都同步一致。此時,各分支間的歧異性,就會因為有同步一致的基礎而有效控制下來了。
方法之三,模組化
當主體核心程式的變動越來越少,不免就會想到,客製化的延伸功能,是否能也像設定檔一樣,由外部載入而不需要更動主體程式。這樣的軟體方法就是模組化,舉例來說,外掛/插件就是模組化的設計,他讓軟體主體在不用更改程式下,能以動態外部載入的方式來增加新的功能。這個軟體方法能大大的降低程式主體的分支複雜度,讓主體核心越來越收斂越,並讓客製的複雜度有效的控制在個別的區域範圍內。
撇除上述三者的不同標題名稱,其實本質都是一樣的,就是如何在不更動程式的前提下,能透過動態設定的方式滿足需求。
Syntax sugar
另外,程式語法的微調跟一致性,也有助於複雜度的收斂。
例如,透過一些 coding style 的 policy 或是 linter ,能有效減少 space/whitespace/quoting/… 之類的歧異。
此外,程式的寫法有也差異,比如說:
CFG="aaa bbb ccc"
增加一項 “ddd” 的設定時,會變成
CFG="aaa bbb ccc ddd"
這個變動在 diff 工具的比對下,比較不那麼順手。另一種變形的寫法如下:
CFG="aaa bbb ccc"
CFG="$CFG ddd"
或甚至是一開始就是
CFG=""
CFG="$CFG aaa"
CFG="$CFG bbb"
CFG="$CFG ccc"
CFG="$CFG ddd"
這樣子的寫法,在 diff
工具下,就是呈現一行一行的差異,而且,不易產生 merge conflict 的問題。
不管是 mark 註解到,重新排列,或是增加減少,都相對容易處理。