What Is Version Control?

版本控制系統是程序開發中存放各種修訂版本的地方。基本上它們是非常簡單的系統。不幸的是,在這幾年,人們對版本控制的各個組件使用了各種各樣不同的名稱,這使得情況越趨混亂。因此,我們首先爲將要使用的一些組件進行定義。
 
 

2.1 Repository

你可能已經留意到我們忽略了些什麽;我們說過,“版本控制系統是...存放...的地方,”但我們從來沒有說過這些東東究竟都放在哪裏。實際上,它們全都儲存在 Repository中。
 
在大多數的版本控制系統中,Repository 是項目文件所有主版本的存儲中心。有些版本控制系統使用數據庫做爲 Repository,有些使用普通的文件,還有些是兩者兼有。總之,很明顯的,Repository 是版本控制策略的核心。你必須把它設置在一臺安全可靠的機器上。毫無疑問地,這玩意必須進行常規的備份。
 
在過去,Repository 以及它所有的用戶都不得不共同一臺機器(或者至少是共用著一個文件系統)。結果限制多多;

不同的網絡鏈接


版本控制系統的作者有時對“連網”有著不同的定義。有時它的意思是通過共享的網絡驅動器存取 Repository 裏的文件(就像 Windows 的共享或者 NFS 掛載)。有時它又代表著一個客戶端可以通過網絡與服務器端的 Repository 互動的 client-server 架構。兩者行得通(盡管如果支撐文件共享的裝置不支持鎖定機制的話前者難以進行正確設計)。無論如何,你還是可以找到一些關於部署和安全設置的資料,它們可以幫助你決定所需的系統。
 
如果版本控制系統需要連接到共享的驅動器,而你又需要在內部網絡以外連接它,你得先確認你的組織是不是允許你這麽幹。虛擬私有網絡(VPN)包允許你這麽做,但並不是所有公司都在跑 VPN。
 
CVS 使用 client-server 模式來進行遠程鏈接。
 
這就很難讓開發人員在不同的地方或是使用不同的機器、操作系統進行工作。結果就是,現在絕大多數的版本控制系統都支持網絡作業;作爲開發人員,你可以使用網絡連接 Repository ,有了 Repository,客戶端版本控制工具與服務器端的作用都是一樣的。巨爽。現在無論開發人員在哪裏;只要他們能夠通過網絡連接到 Repository,他們就可以安全的進行工作;你完全可以不讓煩人的對手看到你那珍貴的源代碼。Andy 和我就常常在路上通過 Internet 存取我們的源代碼。
 
然而這又引出了一個有趣的問題。如果沒有網絡可以連接到 Repository,而你又需要進行開發時該怎麽辦?很簡單,“看情況。”有些版本控制系統是僅爲連接到 Repository 的應用而設計的;也就是說你必須一直在線,如果不先連接到中央 Repository,你就不能對源碼進行改動。其它系統則寬鬆得多。CVS 系統就屬於後者,也是本書使用的系統。我們可以在 35,000 英尺的高空用筆記本電腦進行編輯,回到酒店房間時再進行同步。在線/離線是選擇版本控制系統時一項很重要的標准,無論選擇什麽産品都要先確認它是否支持你的工作方式。
 
 

2.2 我們應該儲存什麽?

你的項目中的所有東西都儲存在 Repository 裏,但是,我們所說的這些東西究竟是什麽呢?
 
顯然你需要程序的源文件來建立項目:Java,或者是 C#VB,又或者是任何你正在使用的語言。事實上,有些人認爲這裏所指的“源碼”是版本控制裏一項非常重要的組件,所以他們將版本控制系統稱爲“源代碼控制系統”。
 
源碼固然重要,但許多人都忘了還有其它的東西需要儲存在版本控制下。例如:假設你是一個 Java 程序員,你可能使用 Ant 來編譯你的源代碼。Ant 通過一個腳本(通常是 build.xml)來控制編譯。這個腳本是構建過程中的一部分,沒有它你就不能建立程序。所以,它也應該被儲存在版本控制系統內。
 
類似的,有很多項目使用 Metadata 來進行它們的配置。這些 Metadata 也應當儲存在 Repository 裏。同理於各種用於創建發佈版本光盤的腳本、QA 使用的測試數據等。
 
事實上,有個簡單的測試可以決定文件是否應該儲存於 Repository 中。只需要問下自己“如果我們沒有一個最新版本的 xx,那麽我們還可以建立和交付我們的程序嗎?”如果答案是 no,那麽 xx 就應該放在 Repository 裏。
 

Joe 問到...

生成的文件要怎樣?


如果我們需要儲存建立項目所需的所有東西,那麽這是不是也意味著我們也要儲存所有生成的文件呢?例如,我們可能需要運行 JavaDoc 來爲我們的源碼樹生成 API 文檔。那麽這份文檔是否也應該儲存在版本控制系統的 Repository 裏呢?
 
非常簡單,no。如果一份生成的文件可以從其它文件中重建,那麽存儲它就是重複勞動。爲什麽這樣不好?我們並不是擔心浪費磁盤空間,而是因爲不想讓它脫離了應有的步驟。假設我們儲存了源碼和文檔,而當我們更改源碼後,這份文檔也就失效了。如果我們忘記更新然後提交了,那麽在 Repository 裏就有了一份讓人誤解的文檔。所以在這種情況下,我們只保留原始資料:源代碼。同理於所有生成的文件。
 
可實際上,有些文件是很難重建的。譬如說,生成文件的工具可能只有一個 single license,而文件卻是所有開發人員都需要的,又或者是這個文件需要幾個小時來生成。在這種情況下,把它們放在 Repository 就比較合理了。有 license 的開發人員可以創建這個文件,或者是用某台比較好的機器生成這費時的文件。這樣可以被提交到 Repository 裏,這樣其它的開發人員就可以直接使用了。
 
除了創建軟件所需的所有文件外,所有非代碼的項目文件也應當存儲在版本控制系統內(任何將會用得上的東西),包括項目文檔(內部和外部的都要)、其它也可能包含在內的如重要郵件的文本,會議記錄,網上找到的信息-總之是所有對項目有幫助的東西。
 
 

2.3 Workspace 和操控文件

Repository 存儲著項目的所有文件,但如果我們需要爲應用加入一些很棒的新功能的話它並不能給予很多的幫助;我們需要可以存取這些文件的地方,也就是我們本地的 workspaceWorkspace 是一份屬於我們工作部分的所有文件的本地副本,它從 Repository 獲得。對於中小型項目來說,workspace 可能只是項目中所有文件的一份副本。而對於大型項目,你可安排開發人員在項目代碼的一個子集下工作,這樣構建的時候可以節省他們的時間也有助於分離出系統的子系統。Workspace 有時也稱爲 working directory 或是代碼的 working copy
 
爲了初始化 workspace,我們需要從 Repository 中導出相關的文件。不同的版本控制系統對這個過程有著不同的名稱,但最常用(也是 CVS 所使用的)的名稱是 checking out。當從 Repository check out 時,你的 workspace 就得到了一份本地副本(即使 Repository 就存儲在你工作的電腦內,在使用文件之前你仍然需要將它們 check out 出來;Repository 應當被看做是黑盒。)。Check out 的過程確保你得到的是最新的文件,這些文件的目錄結構與 Repository 一一對應。
 
工作的時候,你會在本地的 workspace 對項目的代碼進行改動,不時的將改動保存回 Repository。這個過程稱爲 committing;你在把所做的改動 committing(注:提交)回 Repository。
 
當然了,你在改動代碼的時候,組內的其它成員也是,他們同樣也在提交改動。然而這些改動並不會影響你本地的 workspace;它不會因爲別人將改動提交就突然改變了。你反而要通知版本控制系統更新你本地的 workspace。在更新的時候,你將會從 Repository 得到最新的文件集。而當你的同事進行更新時,他們也會得到你最新的改動。(可讓人困惑的是,有些人從 Repository 導出最新的改動時也用 "check out" 來表示更新。但因爲這是一個通用的習慣用語,我們將在本書裏使用更新一詞來表示。)CVS 的交互見下圖(Figure 2.1):
 
Figure 2.1 - Clients and a Repository
 
當然,這裏有一個潛在的問題:如果你和一個同事要同時對同一個源文件進行修改時會發生什麽事情呢?這取決於你所使用的版本控制系統,但它們都有辦法來處理這種情況。詳細的我們將會在 2.9 Locking Options 裏談到。
 

2.4 项目,模块和文件

迄今爲止,我們已經談了存儲文檔,但還沒有談到它們是怎樣組織起來的。
 
大多數的版本控制系統在底層使用單獨的文件進行處理(有一些類 IDE 的系統使用函數來進行版本控制,但這些系統極爲罕見)。項目中的每個文件都按名存儲於 Repository 中;如果你添加了一個叫 Panel.java 的文件到 Repository,那麽其它的組員就可以將 Panel.java 導入到他們自己的 workspace 中。
 
然而,這樣做是非常低級的。一個典型的項目可能有成百或是上千的文件,而一個公司也許有成打的項目。幸運的是,絕大多數的版本控制系統允許你對 Repository 進行結構化處理。在最上層,它們通常會將你的工作劃分爲項目。而每個項目中你所面對的是一組組的模塊(或是子模塊)。舉個例子,可能你正在爲 Orinoco(一個基於網絡的圖書訂購應用程序)。所有構建應用所需的文件可能都存儲在 Repository 中名爲 Orinoco 的項目下。如果需要,你可以把它們全都導入到你本地的磁盤上。
 
Orinoco 項目自身可能被劃分成大量獨立的模塊。例如,可能有一組人在作信用卡處理而另一組則在做訂購模塊。可以的話,信用卡子項目的人並不需要項目的所有源代碼;他們的代碼應當被清晰的劃分開來。事實上,他們 check out 時只是想要看到項目中他們在工作的那部分。
 
CVS 允許 Repository 管理者將項目拆分成模塊。一個模塊是一組可以按名導出的文件(通常包含一個或多個文件系統目錄樹)。模塊可以是層次結構,但卻不是必須的;同樣的文件或文件集可以出現在許多不同的模塊內。模塊甚至可以讓你在項目之間共享代碼(只需將文件放到一個共享的模塊中然後讓其它組按名引用即可)。
 
模塊爲你帶來 Repository 的許多不同的視圖,允許組員僅面對他們所需要的文件。我們會在第九章談到它。
 
 

2.5 版本从何而来?

本書是關於版本控制系統的,但至今爲止我們所談到是在 Repository 中存取文件。版本從何而來呢?
 
其實版本控制系統的 Repository 是相當聰明的。它不仅是照管着当前文件的副本,而是保存着每个 check in 进来的版本。如果你导出一个文件,编辑,然后提交,Repository 将会保留着原来的版本和包含你的改動的版本(實際上,大多數的版本控制系統保存的是文件間差異的版本而不是每個修正的完整版本)。大多數的系統使用一種簡單的編號方式爲文件版本進行命名。在 CVS 中,文件的第一個版本被賦值爲修訂版本號 1.1。如果有一個改動過的版本被提交,這個改動的版本就是 1.2。下一個改動是 1.3,以此類推。(稍後我們會談到更複雜的編號方式)。與這些修訂版本關聯的是文件被提交的日期和時間,連同一項可選的來自開發人員爲改動進行描述的注釋。
 
這個存儲修訂版本的系統功能異常的強大,使用它,版本控制系統可以做到:
  • 取回文件的指定修訂版本。
  •  
  • 導出系統的所有源代碼,如同它在兩個月前出現的那樣。
  •  
  • 告別你某個特定文件 1.3 和 1.5 版本間的改動。
你也可以使用這個修訂版本系統來取消錯誤。如果你在將近週末的時候才發現自己已經走進了一條死胡同,你可以收回所做的所有改動,將代碼恢複到週一早上的狀態。
 
修訂版本的編號有許多種的方式。有些版本控制系統將單一的修訂版本號用在所有文件上,有些則給每個文件一個唯一的修訂版本號碼序列。CVS 採用的是後者。舉個例子,我們從 Repository 中導出三個文件,得到下列的版本號碼:
 
File1.java    1.10
File2.java    1.7
File3.java    1.9
 
編輯 File1.java 和 File3.java,但不去碰 File2.java。如果我們將這些改動提交會 Repository,系統將會對那些我們修改過的文件的修訂版本號碼進行增值:
 
File1.java    1.11
File2.java    1.7
File3.java    1.10
 
這意味著你不能靠單獨的文件版本號碼來了解事情,譬如項目發佈版本(例如,Orinoco 的 1.3a 版本)。因爲這點常常給剛開始使用 CVS 的團隊帶來災難,我們重複一下吧。CVS 賦於文件的獨立的修訂版本號碼不應該被用作外部的版本號碼。作爲替代,版本控制系統爲你提供了 tags(或者它們的等價物)。
 
 

2.6 Tags

所有的這些修訂版本號碼都很棒,但比起像 1.47 這樣的號碼,人們似乎對如 "PreRelease2" 這樣的名稱更感冒一些。由不同文件組成的軟件的某個發佈版本有著不同的修訂版本號碼時同樣也存在著問題。在之前的例子中,我們可能准備要將由 File1.java,File2.java 以及 File3.java 構建的軟件交付使用了,但是每個文件都有自己的修訂版本號碼。那麽你要怎樣將這些不同的版本號碼結合起來呢?
 
Tags 來解決你的難題了。版本控制系統讓你可以在某個時間點爲一組文件(或是模塊、一個完整的項目)命名。假設你將剛才三個文件的這一組定名爲 "PreRelease2" ,隨後你就可以使用相同的 tag 將它們導出了。你將得到 1.11 的 File1.java,1.7 的 File2.java 和 1.10 的 File3.java。
 
Tags 是跟蹤項目代碼歷史記錄中的重要事件的一個很好的方法。我們將在稍後廣泛的使用 tags。事實上,tags 和 branches(下一節的主題)有著它們各自的章節。
 
 

2.7 Branches

常規的開發過程中,多數人都工作在共有的代碼上(盡管他們可能工作在不同的部分)。他們會導出、修改,然後提交,每個人都會參與到這份工作中。這條代碼流常常被稱爲 mainline。(見下圖 2.2)圖中的時間順序從左到右,較粗的那根水平線代表著代碼的進度;也就是 mainline。每個開發人員都從 mainline 上將代碼導出、提交到他們自己的 workspaces中。
 
Figure 2.2 - A Simple Mainline
 
但考慮到新的發佈版本即將交付。可以有一組由開發人員組成的小的子團隊爲此做准備,修正最後的一些 bugs,和發佈工程師一起工作,幫助 QA 小組。在這個緊要關頭,他們需要的是穩定;其它開發人員繼續編輯代碼、准備爲下一個發佈版本加入功能都將會影響到他們的工作。
 
其中一個選擇就是在産生發佈版本時暫停新的開發,但這就意味著其它的組員只能呆坐著了。
 
另一個選擇就是將軟件複制到一臺備份機器上然後發佈小組就使用這台機器。但如果我們這麽做,他們複制後又進行了改動會怎樣呢?我們怎麽進行追蹤?如果他們在這些代碼中找到的 bugs 同樣存在於 mainline 中,我們怎樣才能有效、可靠的將這些修正應用到 mainline 中呢?一旦他們將軟件發佈,我們如何對客戶報告的 bugs 進行修正;如何能保證可以在交付的時候找到相同狀況的代碼?
 
一個更好的選擇就是使用版本控制系統內建的 branching 功能。
 
Branching 有些像常出現在科幻小說中可以拆分時空的東東。也就是說有兩個並行的未來。一些事發生了,其中一個未來也就被拆分了。很快你就要面對一連串充滿選擇的世界(當你沒有頭緒的時候這將會是解釋這個故事的好東東)。
 
版本控制系統中的 branching 同樣允許你創建多個並行的未來,但裏面住的不是外星人和太空牛仔,它們包含的是源碼和版本信息。
 
回到小組發佈産品新版的案例上。迄今爲止,所有的小組都工作在 mainline 上(見 Figure 2.2),但是發佈小組想要將發佈版本從 mainline 中分離出來。爲此他們在 Repository 中創建了一個 branch。從現在開始直到他們完工,發佈小組將會從這個 branch 中導出和提交。即使應用發佈了,這個 branch 仍然是有效的;如果用戶提交 bugs,小組會在這個發佈版本的 branch 中進行修正。見下圖:
 
Figure 2.3 - Mainline with a Release Branch
 
一個 branch 幾乎就像一個完全獨立的 Repository:
 
人們使用這個 branch 查看源碼、在其它的 branches 或 mainline 上進行獨立的操作。每個 branch 都有它自己的歷史記錄以及追蹤著人們創建的各個獨立的版本(雖然很明顯,如果你回顧創建 branch 的那一刻,branch 和 mainline 其實是一回事)。
 
這正是你創建發佈版本時想要的。發佈小組改善和交付的工作會基於一份穩定的代碼。與此同時,主開發組可以繼續對 mainline 中的代碼進行改動而無需在發佈期間暫停開發。當用戶提交發佈版本的問題時,發佈小組可以對發佈的 branch 存取,這樣他們就可以進行修正然後交付不包含任何 mainline 中新近的開發代碼的已更新版本。
 
Branches 由 tags 標識,branch 內文件的修訂版本號碼有著它們額外的級別號碼。因此如果 File1.java 的修訂版本是 1.14 然後你創建一個 branch,你會發現在 branch 中修訂版本號碼是 1.14.2.1 而 mainline 中仍然是 1.14。在 mainline 中編輯這個文件你會得到修訂版本 1.15;而在 branch 中將會是 1.14.2.2。
 
你可以從其它的 branches 中創建 branches,但你通常不會這麽幹。我們遇到過很多因爲項目中的分支過於複雜而最終棄用 branching 的開發人員。我們將會在本書中介紹一個你所需要的簡單方案,但會避免不必要的複雜性。
 
 

2.8 Merging

回到那個有著多個未來的科幻小說裏。爲了增添些有趣的情節,作者常常會允許他們的人物使用 wormholes(譯注:類似於黑洞的物理空間)穿梭於時空之間,多相發散陰極射線示波管,又或者只是一杯熱茶。
 
你同樣可以在版本控制系統的各個 branches 間穿梭(要不要茶都無所謂)。雖然每個導出的版本都來自於特定的 branch,然後又會提交回給這個 branch,但還是可以很容易的把多個 branches 導出到一臺開發機器上(當然要在硬盤不同的目錄或文件夾裏)。這樣的話 mainline 上的工作和發佈版本的 bug 的修正就可以同時進行了。
 
不僅如此,版本控制系統還支持 merging。譬如說你在發佈版本裏修正了一個 bug 然後意識到同樣的 bug 也將在出現在 mainline 的代碼中。你可以讓版本控制系統記錄下你在修正這個 bug 時對源代碼所做的改動,然後將它們應用到 mainline 的代碼上。這在很大程度上剔除了複制和拷貝以及來回於系統的不同版本間的需要。關於 merging 稍後我們會談到很多。
 
 

2.9 Locking 選項

想像一下有兩個開發人員,Fred 和 Wilma,工作在同一個項目上。他們都已經將項目文件導出到各自的硬盤上,然後都要編輯他們本地的 File1.java 副本。如果他們提交這個文件時會發生些什麽?
 
一個壞的狀況就是版本控制系統接收了 Fred 的改動,然後又接受了 Wilma 的同一個文件。因爲 Wilma 的副本裏不會包含 Fred 所做的改動,所以存儲 Wilma 的副本到 Repository 時實際上也就丟失了 Fred 的工作。
 
爲了阻止這個發生,版本控制系統實現了某些類型的衝突解決系統(可能在 Fred 和 Wilma 這種情形下是好事)。有兩個常用的衝突解決方案。
 
第一個方案叫 strict locking。在一個 strict locking 的版本控制系統裏,所有導出的文件會被初始標識爲“只讀”。你可以查看它們,用它們來構建你的應用程序,但不可以進行編輯或改動。如果要編輯或改動,你得先請求 Repository 的許可:“請問我是否可以編輯 File1.java?”如果沒有別的人在編輯相同的文件,你就會得到 Repository 的許可,本地的文件副本會被改爲“讀/寫”。然後你就可以進行編輯了。如果當你標識著這個文件時有人請求對其進行編輯,他們將會被拒絕。在你完成改動並提交之後,你本地的副本將會恢複爲只讀的狀態,其它的人就可以進行編輯了。
 
第二個解決衝突的方案叫 optimistic locking,雖然實際上它並不鎖定任何東西。在這,開發人員可以編輯任何導出的文件:導出的文件被設爲可讀/可寫的狀態。然而,當你上次導出的文件已被更新後,Repository 將不會允許你提交這個文件。它會請求你在提交之前將該 Repository 裏最新的改動應用到你本地的副本中。這就是它在聰明之處:不是使用 Repository 裏的最新版本對本地的副本進行簡單的覆蓋,而是嚐試著將 Repository 的變更和你所做的改動合併。例如,讓我們看看 File1.java:
 
public class File1 {
    public String getName() {
        return "Wibble";
    }
    public int getSize() {
        return 42;
    }
}
 
Wilma 和 Fred 都導出了這個文件。Fred 修改了第三行:
 
    return "WIBBLE";
 
然後他將這個文件提交。這就意味著 Wilma 的副本過時了。Wilma 並不知道這事,她修改了第 6 行,返回 99 而不是 42。提交時,她被告知她的副本已經過時了;她需要合併 Repository 中的改動。詳見 Figure 2.4。
 
Figure 2.4 - Conflict Handled by Merge
 
當 Wilma 將改動合併時,版本控制系統可以准確的定位 Fred 的改動而不會與 Wilma 的重疊,因此只需對她的副本更新第三行即可,不會影響其它的改動。當提交時,Wilma 和 Fred 所做的改動都會原封不動的存儲回 Repository 中。
 
當 Fred 和 Wilma 都更新了第三行,但做出的卻是不同的改動時又會怎樣呢?假定 Fred 先提交,那他的改動將會被接納。當 Wilma 提交的時候,她將再被告知她的副本已經過時了。然而這次她合併時版本控制系統就會留意到她和 Repository 都對一行代碼進行了。這是個衝突。在這個情況下,Wilma 會看到警告的信息,然後她源文件的副本中的衝突會被標出。她將不得不手工解決這個問題(可能得和 Fred 談談爲什麽他們都在修改同一行代碼)。
 
有了上面的描述後你可能會認爲使用 optimistic locking 開發系統多少有些魯莽;多人同時編輯著相同的文件。人們常常是還沒試就說這行不通,然後堅持使用 strict locking 的版本控制系統。
 
然而在現實中 strict locking 卻引起了大量毫无意义的争论。如果你试试采用 optimistic locking 的系统(譬如 CVS),你会很驚訝的发现它几乎没有冲突。......
 
 

2.10 配置管理

有時你會聽到一些人在談論配置管理或軟件配置管理系統(常簡稱爲 CM 或 SCM)。乍聽之下他們像是在談版本控制。沒錯;實際上 CM 嚴重依賴於好的版本控制。但版本控制只是配置管理中會用到的一個工具而已。