版本管理三國志 (CVS, Subversion, git)

本文轉載自 博客園,原文鏈接:Click me

作者:Vamei 出處:http://www.cnblogs.com/vamei 歡迎轉載,也請保留這段聲明。謝謝!

最近有兩則和git有關的新聞很火:

12306的搶票插件拖垮了GitHub (GitHub基於git)

陳皓建議阿里共享平臺改用Linux+git的解決方案

git是一款版本控制軟件(VCS,Version Control System)。VCS通常用於管理開發過程中的源代碼文件。VCS是軟件開發的好幫手。當軟件本身在發佈時獲取大量關注時,VCS躲在幕後默默管理和記錄軟件的開發和發佈進程。git頗有戲劇性的借春運搶票火了一把,也讓許多人好奇什麼是git,什麼是VCS。我複習了一下VCS的歷史,忽然有些讀三國時的你方唱罷我登場的感覺,就想寫一個VCS版本的三國志。

現在最常見的VCS軟件(同時也是開源的VCS軟件)有CVS, Subversion和git。CVS曾經雄霸一時,至今還管理着大量的開發項目。Subversion青出於藍,對CVS進行改進,大有取而代之的勢頭。git另闢蹊徑,依仗Linux的名號,並借GitHub的推廣攻城略地。VCS領域激烈的爭鬥正反映了軟件開發項目的紅火勢頭。

 

斬白蛇而起

早期(1970年到1980年代)的軟件開發大部分是愉快的個人創作。比如UNIX下的sed是L. E. McMahon寫的,Python的第一個編譯器是Guido寫的,Linux最初的內核是Linus寫的 (好吧,awk是個例外,它的名字是三位作者的首字母,但也只是三個人)。這些程序員可以用手工的方式進行備份,並以註釋或者新建文本文件來記錄變動。

正如現在普通用戶常做的,當時的程序員常用cp備份:

$cp dev.c dev.bak

更有條理一些的程序員會加上一個時間標記,比如:

$cp dev.c dev.bak.19890908

程序員很可能會用vi創建一個LOG文件來做日誌:

1989-09-08 02:00:00
Old input method is stupid
Add command-line input function

在一個版本發佈的時候,程序員可能做一個tar歸檔,將所有的文件歸爲同一個.tar文件。

$tar -cf project_v1.0.tar project

上面的工具構成了一套人工VCS。上面的這套組合也非常符合UNIX的模塊化理念:讓每個應用專注於一個小的功能,使用者根據需要,將這些功能連接起來。你還可以寫一個shell腳本,將上面的功能都寫在裏面。當需要的時候,調用該腳本就可以了。

(這樣一個shell腳本並不複雜,而且挺有用的,可以作爲學習shell編程的小練習)

 

再說一下早期的合作開發模式。如在Python簡史中看到的,Guido通過電子郵件接收補丁(patch),並將補丁應用到原來的代碼文件。實際上,一個補丁(patch)的主要功能是描述兩個文件的改變(change, or file delta)。 假設我們有兩個文件a.c和b.c內容分別爲:
a.c (有bug的代碼)

int sum(int a, int b)
{
  int c;
  c = a + 1;
  return c;
} 

b.c (修正後的代碼)

int sum(int a, int b)
{
  int c;
  c = a + b;
  return c;
}

 

在UNIX系統下,運行

$diff a b > iss01.patch

iss01.patch就是一個補丁文件,它看起來如下:

4c4
<   c = a + 1;
---
>   c = a + b;

這個補丁表示,更改原文件第四行的c = a + 1;,改爲c = a + b;,更改後的這一行位於新的文件的第四行。

 

使用patch命令將iss01.patch應用到a.c上,相當於將 b.c-a.c 的改變作用在a上,a.c將和b.c有一樣的內容:

$patch a.c < iss01.patch

當我發現a.c的代碼有錯誤時,可以將我修改後的b.c與原來的a.c做diff獲得補丁文件,並將補丁發給Guido,並告訴他該補丁是爲了修正a.c代碼中的加法錯誤。Guido確認之後,就可以使用patch應用該補丁了。在後面我們將看到,這種diff-patch的工作方式被VCS不同程度的採用。

 

東漢末年

早在70年代末80年代初,VCS的概念已經存在,比如UNIX平臺的RCS (Revision Control System)。RCS是由Walter F. Tichy使用C開發。RCS對文件進行集中式管理,主要目的是避免多人合作情況下可能出現的衝突。如果多用戶同時寫入同一個文件,其寫入結果可能相互混合和覆蓋,從而造成結果的混亂。你可以將文件交給RCS管理。RCS允許多個用戶同時讀取文件,但只允許一個用戶鎖定(locking)並寫入文件 (類似於多線程的mutex)。這樣,當一個程序員登出(check-out,見RCS的co命令)某個文件並對文件進行修改的時候。只有在這個程序完成修改,並登入(check-in,見RCS的ci命令)文件時,其他程序員才能登出文件。基本上RCS用戶所需要的,就是co和ci兩個命令。在co和ci之間,用戶可以對原文件進行許多改變(change, or file delta)。一旦重新登入文件,這些改變將保存到RCS系統中。通過check-in將改變永久化的過程叫做提交(commit)。

RCS互斥寫入

RCS的互斥寫入機制避免了多人同時修改同一個文件的可能,但代價是程序員長時間的等待,給團隊合作帶來不便。如果某個程序員登出了某個文件,而忘記登入,那他就要面對隊友的怒火了。(從這個角度上來說,RCS造成的問題甚至大於它所解決的問題……)

文件每次commit都會創造一個新的版本(revision)。RCS給每個文件創建了一個追蹤文檔來記錄版本的歷史。這個文檔的名字通常是原文件名加後綴,v(比如main.c的追蹤文檔爲main.c,v)。追蹤文檔中包括:最新版本的文件內容,每次check-in的發生時間和用戶,每次check-in發生的改變。在最新文檔內容的基礎上,減去歷史上發生的改變,就可以恢復到之前的歷史版本。這樣,RCS就實現了備份歷史和記錄改變的功能。

 

RCS歷史版本追蹤

 

相對與後來的版本管理軟件,RCS純粹線性的開發方式非常不利於團隊合作。但RCS爲多用戶寫入衝突提供了一種有效的解決方案。RCS的版本管理功能逐漸被其他軟件(比如CVS)取代,但時至今日,它依然是常用的系統管理工具。RCS就像是東漢王室,飄搖多年而不倒。

 

挾天子,令諸侯

1986年,Dick Grune寫了一系列的shell腳本用於版本管理,並最終以這些腳本爲基礎,構成了CVS (Concurrent Versions System)。CVS後來用C語言重寫。CVS是開源軟件。在當時,Stallman剛剛舉起GNU的大旗,掀起開源允許的序幕。CVS被包含在GNU的軟件包中,並因此得到廣泛的推廣,最終擊敗諸多商業版本的VCS,呈一統天下之勢。

CVS繼承了RCS的集中管理的理念。在CVS管理下的文件構成一個庫(repository)。與RCS的鎖定文件模式不同,CVS採用複製-修改-合併(copy-modify-merge)的模式,來實現多線開發。CVS引進了分支(branch)的概念。多個用戶可以從主幹(也就是中心庫)創建分支。分支是主幹文件在本地複製的副本。用戶對本地副本進行修改。用戶可以在分支提交(commit)多次修改。用戶在分支的工作結束之後,需要將分支合併到主幹中,以便讓其他人看到自己的改動。所謂的合併,就是CVS將分支上發生的變化應用到主幹的原文件上。比如下面的過程中,我們從r1.1分支出rb1.1.2.*,並最終合併回主幹,構成r1.2

 copy-modify-merge

 

CVS與RCS類似,使用,v文件記錄改變,以便追蹤歷史。在合併的過程中,CVS將兩個change應用於r1.1,就得到了r1.2:

r1.2 = r1.1 + change(rb1.1.2.2 - rb1.1.2.1) + change(rb1.1.2.1-r1.1)

上面的兩個改變都記錄在,v文件中,所以很容易提取。

 

在多用戶情況下,可以創建多個分支進行開發,比如:

在這樣的多分支合併的情況下,有可能出現衝突(colliding)。比如上圖中,第一次合併和第二次合併都對r1.1文件的同一行進行了修改,那麼r1.3將不知道如何去修改這一行 (第二次合併比圖示的要更復雜一些,分支需要先將主幹拉到本地,合併過之後傳回主幹,但這一細節並不影響我們這裏的討論)。CVS要求衝突發生時的用戶手動解決衝突。用戶可以調用編輯器,對文件發生合併衝突的地方進行修改,以決定最終版本(r1.3)的內容。

 

CVS管理下的每個文件都有一系列獨立的版本號(比如上面的r1.1,r1.2,r1.3)。但每個項目中往往包含有許多文件。CVS用標籤(tag)來記錄一個集合,這個集合中的元素是一對(文件名:版本號)。比如我們的項目中有三個文件(file1, file2, file3),我們創建一個v1.0的標籤:

tag v1.0 (file1:r1.3) (file2:r1.1) (file3:r1.5)

v1.0的tag中包括了r1.3版本的文件file1,r1.1版本的file2…… 一個項目在發佈(release)的時候,往往要發佈多個文件。標籤可以用來記錄該次發佈的時候,是哪些版本的文件被髮布。

 

CVS應用在許多重要的開源項目上。在90年代和00年代初,CVS在開源世界幾乎不二選擇 (RCS也是開源的,但正如我們已經提到的,RCS無法與CVS媲美)。CVS就像是官渡之戰後的曹魏,挾開源運動,號令天下。時至今天,儘管CVS已經長達數年沒有發佈新版本,我們依然可以在許多項目中看到CVS的身影。

 

青出於藍

正如曹操的統治富有爭議一樣(比如非漢祚,以臣欺君等等),CVS也有許多常常被人詬病的地方,比如下面幾條:

  • 合並不是原子操作(atomic operation):如果有兩個用戶同時合併,那麼合併結果將是某種錯亂的混合體。如果合併的過程中取消合併,不能撤銷已經應用的改變。
  • 文件的附加信息沒有被追蹤:一旦納入CVS的管理,文件的附加信息(比如上次讀取時間)就被固定了。CVS不追蹤它所管理文件的附加信息的變化。
  • 主要用於管理ASCII文件:不能方便的管理Binary文件和Unicode文件
  • 分支與合併需要耗費大量的時間:CVS的分支和合並非常昂貴。分支需要複製,合併需要計算所有的改變並應用到主幹。因此,CVS鼓勵儘早合併分支。

CVS還有其它一些富有爭議的地方。隨着時間,人們對CVS的一些問題越來越感到不滿 (而且程序員喜歡新鮮的東西),Subversion應運而生。Subversion的開發者Karl Fogel和Jim Blandy是長期的CVS用戶。贊助開發的CollabNet, Inc.希望他們寫一個CVS的替代VCS。這個VCS應該有類似於CVS的工作方式,但對CVS的缺陷進行改進,並提供一些CVS缺失的功能。這就好像劉備從曹營拉出來單幹的劉備一樣。

總體上說,Subversion在許多方面沿襲CVS,也是集中管理庫,通過記錄改變來追蹤歷史,允許分支和合並,但並不鼓勵過多分支。Subversion在一些方面得到改善。Subversion的合併是原子操作。它可以追蹤文件的附加信息,並能夠同樣的管理Binary和Unicode文件。但CVS和Subversion又有許多不同:

  • 與CVS的,v文件存儲模式不同,Subversion採用關係型數據庫來存儲改變集。VCS相關數據變得不透明。
  • CVS中的版本是針對某個文件的,CVS中每次commit生成一個文件的新版本。Subversion中的版本是針對整個文件系統(包含多個文件以及文件組織方式),每次commit生成一個整個項目文件系統樹的新版本。

Subversion依賴類似於硬連接(hard link)的方式來提高效率,避免過多的複製文件本身。Subversion不會從庫下載整個主幹到本地,而只是下載主幹的最新版本。

 

在Subversion剛剛誕生的時候,來自CVS用戶的抱怨不斷。他們覺得在Subversion中有太多的改動,有些改動甚至是相對於CVS的倒退。比如CVS中的tag,在Subversion中被改爲直接複製版本的文件系統樹到一個特殊的文件夾。然而,隨着時間的推移,Subversion逐漸推廣 (Subversion已經是Apache中自帶的一個模塊了,Subversion應用於GCC、SourceForge,新浪APP Engine等項目),並依然有活躍的開發,而CVS則逐漸沉寂。事實上,許多UNIX的參考書的新版本中,都縮減甚至刪除了CVS的內容。

 

別開生面

CVS和Subversion有很多不同的地方。但如果將這兩者和git比較,那麼git看起來就像孫權的碧眼,有一些怪異。

git的作者是Linus Torvald。對,就是寫Linux Kernel的那個Linus Torvald。Linus在貢獻了最初的Linux Kernel源代碼之後,一直領導着Linux Kernel的開發。Linus Torvald本人相當厭惡CVS(以及Subversion)。然而,操作系統內核是複雜而龐大的代碼“怪獸” (2012年的Linux Kernel有1500萬行代碼,Windows的代碼不公開,估計遠遠超過這一數目)。Linux內核小組最初使用.tar文件來管理內核代碼,但這遠遠無法匹配Linux內核代碼的增長速度。Linus轉而使用BitKeeper作爲開發的VCS工具。BitKeeper是一款分佈式的VCS工具,它可以快速的進行分支和合並。然而由於使用證書方面的爭議(BitKeeper是閉源軟件,但給Linux內核開發人員發放免費的使用證書),Linus最終決定寫一款開源的分佈式VCS軟件:git。

git在英文中比喻一個愚蠢或者不愉快的人(a stupid or unpleasant person)。Linus說這個比喻是在說自己:

I'm an egotistical bastard, and I name all my projects after myself. First "Linux", now "git".

(這裏,Linus似乎並不是在貶低自己,見Linus和Eric S. Raymond的爭論: The curse of the gifted)

 

對於一個開發項目,git會保存blob, tree, commit和tag四種對象。

  • 文件被保存爲blob對象。
  • 文件夾被保存爲tree對象。tree對象保存有指向文件或者其他tree對象指針。

上面兩個對象類似於一個UNIX的文件系統,構成了一個文件系統樹。

  • 一個commit對象代表了某次提交,它保存有修改人,修改時間和附加信息,並指向一個文件樹。這一點與Subversion類似,即每次提交爲一個文件系統樹。
  • 一個tag對象包含有tag的名字,並指向一個commit對象。

虛線下面的對象構成了一個文件系統樹。在git中,一次commit實際上就是一次對文件系統樹的快照(snapshot)。

 

每個對象的內容的checksum校驗(checksum校驗可參閱IP頭部的checksum)都經過SHA1算法的HASH轉換。每個對象都對應一個40個字符的HASH值。每個對象對應一個HASH值。兩個內容不同的對象不會有相同的HASH值(SHA1有可能發生碰撞,但概率非常非常非常低)。這樣,git可以隨時識別各個對象。通過HASH值,我們可以知道這個對象是否發生改變。

比如一個文件LOG,它包含一下內容:

aaa

這個文件的HASH碼爲72943a16fb2c8f38f9dde202b7a70ccc19c52f34

如果我們修改這個文件,成爲

aaa

bbb

這個文件的HASH碼變成dbee0265d31298531773537e6e37e4fd1ee71d62

所以,git只需看對象的HASH碼,就可以知道該對象是否發生改變。

 

在整個開發過程中,可能會有許多次提交(commit)。每次commit的時候,git並不總是複製所有的對象。git會檢驗所有對象的HASH值。如果該對象的HASH值已經存在,說明該對象已經保存過,並且沒有發生改變,所以git只需要調整新建tree或者commit中的指針,讓它們指向已經保存過的對象就可以了。git在commit的時候,只會新建HASH值發生改變的對象。如下圖所示,我們創建新的commit的時候,只需要新建一個commit對象,一個tree對象和一個blob對象就足夠了,而不需要新建整個文件系統樹。

 

可以看到,與CVS,Subversion保存改變(file delta)的方式形成對照,git保存的不是改變,而是此時的文件本身。由於不需要遵循改變路徑來計算歷史版本,所以git可以快速的查閱歷史版本。git可以直接提取兩個commit所保存的文件系統樹,並迅速的算出兩個commit之間的改變。

 

同樣由於上面的數據結構,git可以很方便的創建分支(branch)。實際上,git的一個分支是一個指向某個commit的指針。合併時,git檢查兩個分支所指的兩個commit,並找到它們共同的祖先commit。git會分別計算每個commit與祖先發生的改變,然後將兩個改變合併(同樣,針對同一行的兩個改變可能發生衝突,需要手工解決衝突)。整個過程中,不需要複製和遵循路徑計算總的改變,所以效率提高很多。

比如下面的圖1中有兩個分支,一個master和一個develop。我們先沿着develop分支工作,並進行了兩次提交(比如修正bug1),而master分支保持不變。隨後沿着master分支,進行了兩次提交(比如增加輸入功能),develop保持不變。在最終進行圖4中的合併時,我們只需要將C4-C2和C6-C2的兩個改變合併,並作用在C2上,就可以得到合併後的C7。合併之後,兩個分支都指向C7。我們此時可以刪除不需要的分支develop。

由於git創建、合併和刪除分支的成本極爲低廉,所以git鼓勵根據需要創建多個分支。實際上,如果分支位於不同的站點(site),屬於不同的開發者,那麼就構成了分佈式的多分支開發模式。每個開發者都在本地複製有自己的庫,並可以基於本地庫創建多個本地分支工作。開發者可以在需要的時候,選取某個本地分支與遠程分支合併。git可以方便的建立一個分佈式的小型開發團隊。比如我和朋友兩人各有一個庫,各自開發,並相互拉對方的庫到本地庫合併(如果上面master,develop代表了兩個屬於不同用戶的分支,就代表了這一情況)。當然,git也允許集中式的公共倉庫存在,或者多層的公共倉庫,每個倉庫享有不同的優先級。git的優勢不在於引進了某種開發模式,而是給了你設計開發模式的自由。

正如東吳門閥合作的政治模式,git非集中式的開發模式讓git成爲了後起之秀。生子當如孫仲謀,生子當如Git Torvald。

(需要注意的是,GitHub儘管以git爲核心,但並不是Linus創建的。事實上,Linus不接收來自GitHub的Pull Request。Linus本人將此歸罪於GitHub糟糕的Web UI。但有些搞笑的是,正是GitHub的Web頁面讓許多新手熟悉並開始使用git。好吧,Linus大嬸是在鞭策GitHub。)

 

總結

和三國志不同,VCS的三國還沒有決出最終勝負。或許Subversion會繼續在一些重要項目上發揮作用,或許git會最終一統江山,或許CVS可以有新的發展並最終逆襲;又或許,一款新的VCS將取代所有的前輩。VCS激烈的競爭對於程序員來說是好事。一款優秀的VCS可以提高了我們管理項目的能力,降低我們犯錯所可能支付的代價。隨着開發項目越來越龐大和複雜,這一能力變得越來越不可缺少。花一點時間學習VCS,並習慣在工作中使用VCS,將會有意想不到的回報。

(我平時只用git,經驗有限,如果有錯漏,謝謝你的指正)

發佈了10 篇原創文章 · 獲贊 2 · 訪問量 12萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章