Linux:改變世界的一次程式碼提交

華為雲開發者社群發表於2020-09-24
摘要:如果選Linux社群歷史上最偉大的一次 Git 程式碼提交,那一定是 Git 工具專案本身的第一次程式碼提交。
吾詩已成。無論大神的震怒,還是山崩地裂,都不能把它化為無形!
—— 奧維德《變形記》

背景

Linux 作為最大也是最成功的開源專案,吸引了全球程式設計師的貢獻,到目前為止,共有兩萬多名開發者給 Linux Kernel 提交過程式碼。

令人驚訝的是,在專案的前十年(1991 ~ 2002)中,Linus 作為專案管理員並沒有藉助任何配置管理工具,而是以手工方式通過 patch 來合併大家提交的程式碼。倒不是說 Linus 喜歡手工處理,而是因為他對於軟體配置管理工具(SCM)非常挑剔,無論是商用的 clearcase 還是開源的 cvs、svn 等都不能入他的法眼。

在他看來,一個能夠滿足 Linux 核心專案開發使用的版本控制系統需要滿足幾個條件: 1) 快 2)支援多分支場景(幾千個分支並行開發場景) 3) 分散式 4) 能夠支援大型專案。直到2002年,Linus 終於找到了一款基本滿足他要求的工具——BitKeeper, 而 BitKeeper 是商業工具,他們願意給 Linux 社群免費使用,但是需要保證遵守不得進行反編譯等條款。BitKeeper 提供的預設介面顯然不能滿足社群使用者的全部需要,一位社群開發者反編譯 BitKeeper 並利用了未公開介面,這讓 BitKeeper 公司撤回了免費使用的 License。不得已,Linus 利用假期十天時間,實現一款 DVCS —— Git,並推送給社群開發者們使用。

設計

Git 已經稱為全球軟體開發者的標配,關於 Git 的介紹和用法不需多說,我今天想要談談 Git 的內部實現。不過在看本文之前,我先給大家提一個問題:如果是你來設計 git(或者重新設計 git),你打算怎麼設計?第一個版本釋出準備實現哪些功能?看完本文,再對照自己的想法做個比較。歡迎留言討論。

學習 Git 的內部實現,最好的辦法是看 Linus 最初的程式碼提交,checkout 出 git 專案的第一次提交節點(方法參見部落格:《閱讀開原始碼小技巧》),可以看到程式碼庫中只有幾個檔案:一個 README,一個構建指令碼Makefile,剩下幾個 C 原始檔。這次 commit 的備註寫的也非常特別: Initial revision of “git”, the information manager from hell.

commit e83c5163316f89bfbde7d9ab23ca2e25604af290
Author: Linus Torvalds <torvalds@ppc970.osdl.org>
Date:   Thu Apr 7 15:13:13 2005 -0700

    Initial revision of "git", the information manager from hell

在 README 中,Linus 詳細描述了 Git 的設計思路。看似複雜的 Git 工作,在 Linus 的設計裡,只有兩種物件抽象:1) 物件資料庫(“object database”); 2) 當前目錄快取(“current directory cache”)。

Git 的本質就是一系列的檔案物件集合,程式碼檔案是物件、檔案目錄樹是物件、commit 也是物件。這些檔案物件的名稱即內容的 SHA1 值,SHA1 雜湊演算法的值為40位。Linus 將前二位作為資料夾、後38位作為檔名。大家可以在 .git 目錄裡的 objects 裡看到有很多兩位字母/數字名稱的目錄,裡面儲存了很多38位hash值名稱的檔案,這就是 Git 的所有資訊。

Linus 在設計物件的資料結構時按照 <標籤ascii碼錶示>(blob/tree/commit) + <空格> + <長度ascii碼錶示> + <\0> + <二進位制資料內容> 來定義,大家可以用 xxd 命令看下 objects 目錄裡的物件檔案(需 zlib 解壓),比如一個 tree 物件檔案內容如下:

00000000: 7472 6565 2033 3700 3130 3036 3434 2068  tree 37.100644 h
00000010: 656c 6c6f 2e74 7874 0027 0c61 1ee7 2c56  ello.txt.'.a..,V
00000020: 7bc1 b2ab ec4c bc34 5bab 9f15 ba         {....L.4[....

物件有三種:BLOB、TREE、CHANGESET。

BLOB: 即二進位制物件,這就是 Git 儲存的檔案,Git 不像某些 VCS (如 SVN)那樣儲存變更 delta 資訊,而是儲存檔案在每一個版本的完全資訊。比如先提交了一份 hello.c 進入了 Git 庫,會生成一個 BLOB 檔案完整記錄 hello.c 的內容;對 hello.c 修改後,再提交 commit,會再生成一個新的 BLOB 檔案記錄修改後的 hello.c 全部內容。Linus 在設計時,BLOB 中僅記錄檔案的內容,而不包含檔名、檔案屬性等後設資料資訊,這些資訊被記錄在第二種物件 TREE 裡。

TREE: 目錄樹物件。在 Linus 的設計裡 TREE 物件就是一個時間切片中的目錄樹資訊抽象,包含了檔名、檔案屬性及BLOB物件的SHA1值資訊,但沒有歷史資訊。這樣的設計好處是可以快速比較兩個歷史記錄的 TREE 物件,不能讀取內容,而根據 SHA1 值顯示一致和差異的檔案。

另外,由於 TREE 上記錄檔名及屬性資訊,對於修改檔案屬性或修改檔名、移動目錄而不修改檔案內容的情況,可以複用 BLOB 物件,節省儲存資源。而 Git 在後來的開發演進中又優化了 TREE 的設計,變成了某一時間點資料夾資訊的抽象,TREE 包含其子目錄的 TREE 的物件資訊(SHA1)。這樣,對於目錄結構很複雜或層級較深的 Git庫 可以節約儲存資源。歷史資訊被記錄在第三種物件 CHANGESET 裡。

Linux:改變世界的一次程式碼提交

圖片摘自 Pro Git 1

CHANGESET: 即 Commit 物件。一個 CHANGESET 物件中記錄了該次提交的 TREE 物件資訊(SHA1),以及提交者(committer)、提交備註(commit message)等資訊。跟其他SCM(軟體配置管理)工具所不同的是,Git 的 CHANGESET 物件不記錄檔案重新命名和屬性修改操作,也不會記錄檔案修改的 Delta 資訊等,CHANGESET 中會記錄父節點 CHANGESET 物件的 SHA1 值,通過比較本節點和父節點的 TREE 資訊來獲取差異。

Linus 在設計 CHANGESET 父節點時允許一個節點最多有 16 個父節點,雖然超過兩個父節點的合併是很奇怪的事情,但實際上,Git 是支援超過兩個分支的多頭合併的。

Linus 在三種物件的設計解釋後著重闡述了可信(TRUST):雖然 Git 在設計上沒有涉及可信的範疇,但 Git 作為配置管理工具是可以做到可信的。原因是所有的物件都以SHA1編碼(Google 實現 SHA1 碰撞攻擊是後話,且 Git 社群也準備使用更高可靠性的 SHA256 編碼來代替),而簽入物件的過程可信靠簽名工具保證,如 GPG 工具等。

理解了Git 的三種基本物件,那麼對於 Linus 對於 Git 初始設計的“物件資料庫”和“當前目錄快取”這兩層抽象就很好理解了。加上原本的工作目錄,Git 有三層抽象,如下圖示:一個是當前工作區(Working Directory),也就是我們檢視/編寫程式碼的地方,一個是 Git 倉庫(Repository),即 Linus 說的物件資料庫,我們在 Git 倉看到的 .git 資料夾中儲存的內容,Linus 在第一版設計時命名為 .dircache,在這兩個儲存抽象中還有一層中間的快取區(Staging Area),即 .git/index 裡儲存的資訊,我們在執行 git add 命令時,便是將當前修改加入到了快取區。

Linus 解釋了“當前目錄快取”的設計,該快取就是一個二進位制檔案,內容結構很像 TREE 物件,與 TREE 物件不同的是 index 不會再包含巢狀 index 物件,即當前修改目錄樹內容都在一個 index 檔案裡。這樣設計有兩個好處:1. 能夠快速的復原快取的完整內容,即使不小心把當前工作區的檔案刪除了,也可以從快取中恢復所有檔案;2. 能夠快速找出快取中和當前工作區內容不一致的檔案。

Linux:改變世界的一次程式碼提交

圖片摘自 Things About Git and Github You Need to Know as Developer 2

實現

Linus 在 Git 的第一次程式碼提交裡便完成了 Git 的最基礎功能,並可以編譯使用。程式碼極為簡潔,加上 Makefile 一共只有 848 行。感興趣的同事可以通過上一段所述方法 checkout Git 最早的 commit 上手編譯玩玩,只要有 Linux 環境即可。

因為依賴庫版本的問題,需要對原始 Makefile 指令碼做些小修改。Git 第一個版本依賴 openssl 和 zlib 兩個庫,需要手工安裝這兩個開發庫。在 ubuntu 上執行: sudo apt install libssl-dev libz-dev ; 然後修改 makefile 在 LIBS= -lssl 行 中的 -lssl 改成 -lcrypto 並增加 -lz ;最後執行 make,忽略編譯告警,會發現編出了7個可執行程式檔案: init-db, update-cache, write-tree, commit-tree, cat-file, show-diff 和 read-tree.

下面分別簡要介紹下這些可執行程式的實現:

(1)init-db: 初始化一個 git 本地倉庫,這也就是我們現在每次初始化建立 git 庫式敲擊的 git init 命令。只不過一開始 Linus 建立的 倉庫及 cache 資料夾名稱叫 .dircache, 而不是我們現在所熟知的 .git 資料夾。

(2)update-cache: 輸入檔案路徑,將該檔案(或多個檔案)加入緩衝區中。具體實現是:校驗路徑合法性,然後將檔案計算 SHA1值,將檔案內容加上 blob 頭資訊進行 zlib 壓縮後寫入到物件資料庫(.dircache/objects)中;最後將檔案路徑、檔案屬性及 blob sha1 值更新到 .dircache/index 快取檔案中。

(3)write-tree: 將快取的目錄樹資訊生成 TREE 物件,並寫入物件資料庫中。TREE 物件的資料結構為: ‘tree ‘ + 長度 + \0 + 檔案樹列表。檔案樹列表中按照 檔案屬性 + 檔名 + \0 + SHA1 值結構儲存。寫入物件成功後,飯回該 TREE 物件的 SHA1 值。

(4)commit-tree: 將 TREE 物件資訊生成 commit 節點物件並提交到版本歷史中。具體實現是輸入要提交的 TREE 物件 SHA1 值,並選擇輸入父 commit 節點(最多 16個),commit 物件資訊中包含 TREE、父節點、committer 及作者的 name、email及日期資訊,最後寫入新的 commit 節點物件檔案,並返回 commit 節點的 SHA1 值。

(5)cat-file: 由於所有的物件檔案都經過 zlib 壓縮,因此想要檢視檔案內容的話需要使用這個工具來解壓生成臨時檔案,以便檢視物件檔案的內容。

(6)show-diff: 快速比較當前快取與當前工作區的差異,因為檔案的屬性資訊(包括修改時間、長度等)也儲存在快取的資料結構中,因此可以快速比較檔案是否有修改,並展示差異部分。

(7)read-tree: 根據輸入的 TREE 物件 SHA1 值輸出列印 TREE 的內容資訊。

這就是第一個可用版本的 Git 的全部七個子程式,可能用過 Git 的同事會說:這怎麼跟我常用的 Git 命令不一樣呢? Git add, git commit 呢?是的,在最初的 Git 設計中是沒有我們這些平常所使用的 git 命令的。

在 Git 的設計中,有兩種命令:分別是底層命令(Plumbing commands)和高層命令(Porcelain commands)。一開始,Linus 就設計了這些給開源社群黑客使用的符合 Unix KISS 原則的命令,因為黑客們本身就是動手高手,水管壞了就擼起袖子去修理,因此這些命令被稱為 plumbing commands. 後來接手 Git 的 Junio Hamano 覺得這些命令對於普通的使用者可不太友好,因此在此之上,封裝了更易於使用、介面更精美的高層命令,也就是我們今天每天使用的 git add, git commit 之類。

Git add 就是封裝了 update-cache 命令,而 git commit 就是封裝了 write-tree, commit-tree 命令。關於底層命令的更詳細介紹,大家有興趣的話可以看 Pro Git 中的 Git Internals 章節。

具體的程式碼實現在這裡就不再細述,Linus 的程式碼風格極為簡潔,能一行完成的絕不寫兩行。另外,對於 Linux API 的使用自然無人出其右,我印象最深的是有好多處使用 mmap 建立檔案與記憶體的對映,省去了記憶體申請、檔案讀寫等操作,提升了工具效能。正如一位同事說的:Linus 的程式碼除了不滿足程式設計規範,其他好像真挑不出什麼毛病。順便說一句,Linus 的縮排風格是 Tab 鍵(典故參見《製表符還是空格符,這是個問題》)。

啟示

Linus 在提交了第一個 git commit 後,並向社群釋出了 git 工具。當時,社群中有位叫 Junio Hamano 的開發者覺得這個工具很有意思,便下載了程式碼,結果發現一共才 1244 行程式碼,這更令他驚奇也引發了極大的興趣。Junio 在郵件列表與 Linus 交流並幫助增加了 merge 等功能,而後持續打磨 git,最後 Junio 完全接手了 Git 的維護工作,Linus 則回去繼續維護 Linux Kernel 專案。

如果選歷史上最偉大的一次 Git 程式碼提交,那一定是這 Git 工具專案本身的第一次程式碼提交。這次程式碼提交無疑是開創性的,如果說 Linux 專案促成了開源軟體的成功並改寫了軟體行業的格局,那麼 Git 則是改變了全世界開發者的工作方式和寫作方式。在 Git 誕生後兩年,舊金山的一個小酒館裡坐著三位年輕的程式設計師,決定要用 Git 做點什麼,幾個月後,GitHub 上線。

回到文中開頭提到的問題,如果我來設計 Git 的話,估計還是會從已有工具經驗(如SVN使用)上來延伸設計,甚至在我最早接觸 Git 時候曾膚淺的認為 Git 就是 SVN + 分散式。正是瞭解了 Git 的內部原理乃至閱讀了 Git 的初始程式碼後才感嘆其設計的精妙,Git 的初始設計和實現大概能給(開源)軟體產品如下啟發:

1.解決痛點問題: Git 的緣起便是 Linus 本人及 Linux 社群的訴求,而這些訴求推而廣之是專案協作開發(特別是跨地域專案)的共性訴求。Linus 解決了他本人遇到的痛點問題,順便達成了一項偉大的成就。

2.極簡設計:Linus 在設計 Git 工具時並沒有受傳統 SCM 工具的束縛,考慮檔案差異、版本對比等,而是抽象了幾種基本物件就把 git 的設計思路給理清楚了。

3.MVP (minimum viable product, 最小可用產品):這個概念大家都懂,但實際操作起來卻不容易。一個 MVP 的配置管理工具需要哪些功能?一般來說會想到程式碼提交、歷史追溯、版本比較、分支合併等。但 Linus 卻將它拆解開來,快速實現了底層的基本功能,簡單到只有開源社群黑客才能用。但這就夠了,黑客們因此發現了它的價值,繼續給它添磚加瓦。

4.快速釋出,快速迭代:這也是源於 Linux Kernel 的開發經驗;Linus 在實現了 Git MVP 後,便在 Linux 社群郵件列表中公佈,並徵求意見,迭代完善。

5.找到合適接班人:《大教堂與集市》中也有類似的觀點,它說的是:“如果你對一個專案失去了興趣,你最後的職責就是把它交給一個稱職的繼承者。”不過 Linus 將 Git 交給 Junio 並不是因為失去了興趣,而是因為他發現在 Git 基礎架構建立好之後,Junio 比他更擅長於實現更豐富、對普通使用者介面更友好的功能,因此他就放心的將 Git 交給了 Junio. 為開源專案找到更合適的接班人,這既需要魄力也需要智慧。

  1.  

    1. Pro Git, 10.2 Git Internals - Git Objects: https://git-scm.com/book/en/v2/Git-Internals-Git-Objects ↩

    2. Things About Git and Github You Need to Know as Developer: https://medium.com/swlh/things-about-git-and-github-you-need-to-know-as-developer-907baa0bed79 ↩

     

    點選關注,第一時間瞭解華為雲新鮮技術~

相關文章