每特17劃

及時當勉勵 2004/06/07

從零星的 copy fork 演進為 upstream 持續更新模式 — 2015-07-17

從零星的 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 註解到,重新排列,或是增加減少,都相對容易處理。

Ask, Do, Commit —

Ask, Do, Commit

受到姿勢跑法(Pose Method)將跑步簡化為 Fall,Pose,Pull 三個動作的啓發,我在想,寫程式是否也能有這樣的簡化?

回想了一下,自己寫程式的過程,似乎也可簡單分成幾個:

1. Ask

先問自己一個問題,或是從 issue tracker 裡挑一個問題。

2. Do

在問題的驅使下,開始去作一些事,如寫程式、調整設定,或是研究資料之類的。

3. Commit

作到某個階段就作個 commit ,可能是版本控制的 commit ,或是筆記的儲存。有點像是玩 RPG 遊戲時"遊戲進度存檔"的功能。

備註:

其中,我覺得第一步是最困難的,很多時候不知那根筋不對,腦筋就是無法進入專注。我發現到,我自己似乎比較常在被問問題,(尤其是有趣的問題),而腦袋開始想東西時,那瞬間好像有什麼開關被打開了一樣。

git 分支運作模式之我見 —

git 分支運作模式之我見

tag: merge / patch / 上游 / 下游 / 順流 / 逆流

特性: merge strategy

git 的 merge 有多種不同的 strategy 模式,可透過

git merge [-s ] [-X ]

比較常用的有

git merge -s ours # 以我方 branch 為主
git merge -s recursive -X ours # 如遇衝突以我方 branch 為主
git merge -s recursive -X theirs # 如遇衝突以對方 branch 為主

其中, 最重要的是預設模式為

git merge -s recursive

特性: merge base

git 在作 merge 時,除了參考兩方 branch 的 HEAD ,還會參考兩個 branch 的共同 ancestor 。

以上圖為例,R 是 A,B 分支共同的 ancestor ,也是 A3, B2 的 merge base。

要如何找出 merge base 呢? 除了手算外,也可以下指令找出 merge base:

git merge-base [branch A] [branch B]

特性: git 下不會影響 merge base 的 merge 方式

git 還有其他不會影響 merge base 的 merge 方式,如下:

  • git cherry-pick
  • git merge --squash
  • 以 patches 形式匯入

特性: merge 的累積性

當我們持續跟隨 merge 某個分支時,會發現新一次的 merge 會參考前一次 merge 的資訊,而只處理前一次 merge 完之後的新變更。 也就是說,之前已經處理的 merge conflict ,不會再重複出現,也不需要重複處理。 這個聰明特性讓 git merge 變得很好用。其示意圖大致如下:

上圖中,黃色記號的點,是兩個 branch 在 merge 時,所納入計算的 merge base。 我們可以注意到,merge base 會依新的 merge 成果,往前累積紀錄點。

另一個值得注意的地方是,雖然是 B 分支 merge A 分支,但 merge base 是在 A 分支上,而上圖的 merge 過程,merge base 都是保持在 A 分支上。

特性: 交叉 merge 的變化

當 git merge 的方向改變時, merge base 也會隨之變化。示例圖如下:

上圖中,黃色記號所註記的 merge base 點,隨著 B merge A 切換成 A merge B 時,merge base 也跟著從 A 分支切換到 B 分支上。 而這個變化,正是藏在細節裡的魔鬼,因為這個切換,使先前累積一致的紀錄點中斷了。 在中斷之後,再一次回到 B merge A 時,會發現先前已經處理過的舊 conflict 又再一次重複出現。

也就是說,交叉 merge 會破壞掉原先 merge 的累積紀錄,不管是 A 分支或是 B 分支,都會失去 merge 的累積性。

在交叉 merge 的特性下,只會有兩個結果,一個是兩分支漸趨一致,最後合而為一,或是保持獨立分支,但一直不斷的處理先前舊的 merge conflict。

特性: merge base 俱有傳遞性

舉例來說,當 B merge A ,而 C 又 merge B 時,C 的 merge base 會分別紀錄在 A, B 分支上。 接著,當 A merge C 時,merge base 切換紀錄在 C 分支上。 則當下一次 C 再次 merge B 之時,會發現之前累積在 A, B 分支上的 merge base 紀錄中斷了,而切換到 C 分支上。

觀察: 順流發佈與逆流整合

在分支的使用過程中,我觀察到兩種類型的使用情境。

其一,順流發佈。

以 Linux Kernel 為例,主要的版本會在最上游的 kernel mainline 發佈,然後各個 Linux distribution 主分支再 merge mainline 新發佈的程式碼。而再更下游的 distribution 分支,會再 merge 上述的主分支的程式。

這個過程主要是從功能開發的上游往下游遞延,跟產品製造從生產、經銷、商店的發佈流程類似。

這個模式在 git branch 模式中,很容易實現,只要以 fork 的方式從上游衍生下游分支,並持續單向 merge 即可。

其二,逆流整合

商品出現問題時,回饋的方向則跟上述的流程是相反的。舉例來說,消費者可能先反應給商店,商店反應給經銷,經銷再回饋給製造的上游廠商,或是消費者也有管道可直接反應給經銷商、製造商。不論經過幾層,這個方向跟順流發佈是相反的。

程式也是一樣,可能有 bug 或是新功能,是散佈在下游的分支們之間。為了讓這些成果能夠發佈到更多地方使用,我們會嘗試將這些成果整合到上游的分支,以利下一波發佈時能一併分佈到其他更多下游分支。

一般來說,我們在 git 下實行的方式,大概是從主要版本中成立一個獨立開發分支,然後用這個分支去一一 merge 那些想要整合的下游分支。

順流發佈與逆流整合的衝突

我在嘗試建立分支之間的發佈與整合流程時,遭遇了大挫敗。 情況大概是,我在處理完近幾個 merge conflict ,建立順流發佈的分支架構後,卻在一次逆流整合的小小 merge 之後,先前已經處理過的舊 conflict 又重新冒了出來。

為什麼?

這問題我卡了很久,後來研究發現,其實原因就是上述提及的兩個:

  • 交叉 merge 的變化
  • merge base 俱有傳遞性

也就是說,我在順流發佈與逆流整合的分支流程中,構成了迴路,使得整個 merge base 的累積性紀錄被破壞了。

重構 git 分支結構的兩個原則

要解決上述的問題,唯一的方法就是避免產生 merge base 的迴路。根據上述的種種特性與觀察,我想出了兩個原則來克服這個問題:

  1. 在 git 分支結構中,區別出上游與下游的順序關係
  2. 逆流方向必須使用不影響 merge base 的 merge 方式(ex: patches/cherry-pick/merge –squash/…)

重新審視 git workflow

以最常被引用的 nvie 的經典文章 A successful Git branching model 為例:

從上圖的開發流程中,大略可分類為 develop + feature branches => release branches => master + hotfixes,可劃分為 upstream , midstream, downstream。 其中,標上星號的是我認為是逆流方向的 merge的部份。

以 tag=1.0 這個節點為例,在 merge 產生 tag=1.0 時,參與 merge 計算的有,tag=0.2 節點跟綠色的 release branch 節點,還有作為 merge base 參考點的 hotfixed 紅色節點。 但如果 hotfixes 往 develop 分支的 merge 是斷開的話,那麼 merge base 參考點,就會是 tag=0.1 節點了。 也就是說圖中的 hotfixes 分支往 develop 分支的 merge ,破壞了原先累積性的 workflow 設計。 雖然此示例圖比較簡單,看起來影響不是很明顯,但在更複雜的案例中,很可能就因某次的逆流 merge 而導致整體 workflow 衝突中斷。

這也是為什麼,這麼多人依這一篇文章建立 git workflow 運作一段時間之後,卻遇到一些難纏的 merge conflict 的問題的原因了。 會出現奇怪的 conflict 其實不是你的錯,而這個 git workflow 本身就漏了這個細節中的魔鬼。

延伸思考

仔細觀察 Linux Kernel 的 workflow,你會發現 upstream 沒有在收 Pull Request 的,而是以 patch / squash merge 的方式在整合的。

為什麼 upstream 不收 Pull Request 呢? 又 Pull Request 適用在那些 workflow ,又不適合那些 workflow呢? 這些問題,從上述提出的觀點下,都有了解釋與答案了。

WPML 基礎架構與切換機制分析 —
Config First and Logger Driver Design —