昨晚看完 Linus 第一次提交的 Git 代碼后,我失眠了!
學(xué)習(xí) Git 的內(nèi)部實現(xiàn),最好的辦法是看 Linus 最初的代碼提交,checkout 出 Git 項目的第一次提交節(jié)點,可以看到代碼庫中只有幾個文件:一個 README,一個構(gòu)建腳本 Makefile,剩下幾個 C 源文件。這次 commit 的備注寫的也非常特別:
commit?e83c5163316f89bfbde7d9ab23ca2e25604af290
Author:?Linus?Torvalds?
Date:???Thu?Apr?7?15:13:13?2005?-0700
????Initial?revision?of?"git",?the?information?manager?from?hell
為了把 Git 這條線學(xué)好,我勸你最好把前面兩個章節(jié)回顧一下:
由于公眾號的文章發(fā)布后不能修改,也沒辦法加個統(tǒng)一的目錄作為索引頁,所以二哥就把《Java 程序員進階之路》的系列文章開源到了 GitHub(點擊閱讀原文可以直接跳轉(zhuǎn)):
https://github.com/itwanger/toBeBetterJavaer
每天看著 star 數(shù)(目前已有 741 個 star)的上漲我心里非常的開心,希望越來越多的 Java 愛好者能因為這個開源項目而受益,而越來越多人的 star,也會激勵我繼續(xù)更新下去~
在 README 中,Linus 詳細描述了 Git 的設(shè)計思路??此茝?fù)雜的 Git 工作,在 Linus 的設(shè)計里,只有兩種對象抽象:
對象數(shù)據(jù)庫(“object database”); 當(dāng)前目錄緩存(“current directory cache”)。
Git 的本質(zhì)就是一系列的文件對象集合,代碼文件是對象、文件目錄樹是對象、commit 也是對象。這些文件對象的名稱即內(nèi)容的 SHA1 值,SHA1 哈希算法的值為 40 位。Linus 將前二位作為文件夾、后 38 位作為文件名。大家可以在 .git 目錄里的 objects 里看到有很多兩位字母/數(shù)字名稱的目錄,里面存儲了很多 38 位 hash 值名稱的文件,這就是 Git 的所有信息。
Linus 在設(shè)計對象的數(shù)據(jù)結(jié)構(gòu)時按照 <標(biāo)簽ascii碼表示>(blob/tree/commit) + <空格> + <長度ascii碼表示> + <\0> + <二進制數(shù)據(jù)內(nèi)容> 來定義,大家可以用 xxd 命令看下 objects 目錄里的對象文件(需 zlib 解壓),比如一個 tree 對象文件內(nèi)容如下:
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??????
對象有三種:BLOB、TREE、CHANGESET。
BLOB: 即二進制對象,這就是 Git 存儲的文件,Git 不像某些 VCS (如 SVN)那樣存儲變更 delta 信息,而是存儲文件在每一個版本的完全信息。
比如先提交了一份 hello.c 進入了 Git 庫,會生成一個 BLOB 文件完整記錄 hello.c 的內(nèi)容;對 hello.c 修改后,再提交 commit,會再生成一個新的 BLOB 文件記錄修改后的 hello.c 全部內(nèi)容。
Linus 在設(shè)計時,BLOB 中僅記錄文件的內(nèi)容,而不包含文件名、文件屬性等元數(shù)據(jù)信息,這些信息被記錄在第二種對象 TREE 里。
TREE: 目錄樹對象。在 Linus 的設(shè)計里,TREE 對象就是一個時間切片中的目錄樹信息抽象,包含了文件名、文件屬性及 BLOB 對象的 SHA1 值信息,但沒有歷史信息。這樣的設(shè)計好處是可以快速比較兩個歷史記錄的 TREE 對象,不能讀取內(nèi)容,而根據(jù) SHA1 值顯示一致和差異的文件。
另外,由于 TREE 上記錄文件名及屬性信息,對于修改文件屬性或修改文件名、移動目錄而不修改文件內(nèi)容的情況,可以復(fù)用 BLOB 對象,節(jié)省存儲資源。而 Git 在后來的開發(fā)演進中又優(yōu)化了 TREE 的設(shè)計,變成了某一時間點文件夾信息的抽象,TREE 包含其子目錄的 TREE 的對象信息(SHA1)。這樣,對于目錄結(jié)構(gòu)很復(fù)雜或?qū)蛹壿^深的 Git 庫 可以節(jié)約存儲資源。歷史信息被記錄在第三種對象 CHANGESET 里。

CHANGESET:即 Commit 對象。一個 CHANGESET 對象中記錄了該次提交的 TREE 對象信息(SHA1),以及提交者(committer)、提交備注(commit message)等信息。
跟其他 SCM(軟件配置管理)工具所不同的是,Git 的 CHANGESET 對象不記錄文件重命名和屬性修改操作,也不會記錄文件修改的 Delta 信息等,CHANGESET 中會記錄父節(jié)點 CHANGESET 對象的 SHA1 值,通過比較本節(jié)點和父節(jié)點的 TREE 信息來獲取差異。
Linus 在設(shè)計 CHANGESET 父節(jié)點時允許一個節(jié)點最多有 16 個父節(jié)點,雖然超過兩個父節(jié)點的合并是很奇怪的事情,但實際上,Git 是支持超過兩個分支的多頭合并的。
Linus 在三種對象的設(shè)計解釋后著重闡述了可信(TRUST):雖然 Git 在設(shè)計上沒有涉及可信的范疇,但 Git 作為配置管理工具是可以做到可信的。原因是所有的對象都以 SHA1 編碼(Google 實現(xiàn) SHA1 碰撞攻擊是后話,且 Git 社區(qū)也準(zhǔn)備使用更高可靠性的 SHA256 編碼來代替),而簽入對象的過程可信靠簽名工具保證,如 GPG 工具等。
理解了 Git 的三種基本對象,那么對于 Linus 對于 Git 初始設(shè)計的“對象數(shù)據(jù)庫”和“當(dāng)前目錄緩存”這兩層抽象就很好理解了。加上原本的工作目錄,Git 有三層抽象,如下圖示:一個是當(dāng)前工作區(qū)(Working Directory),也就是我們查看/編寫代碼的地方,一個是 Git 倉庫(Repository),即 Linus 說的對象數(shù)據(jù)庫,我們在 Git 倉看到的 .git 文件夾中存儲的內(nèi)容,Linus 在第一版設(shè)計時命名為 .dircache,在這兩個存儲抽象中還有一層中間的緩存區(qū)(Staging Area),即 .git/index 里存儲的信息,我們在執(zhí)行 git add 命令時,便是將當(dāng)前修改加入到了緩存區(qū)。
Linus 解釋了“當(dāng)前目錄緩存”的設(shè)計,該緩存就是一個二進制文件,內(nèi)容結(jié)構(gòu)很像 TREE 對象,與 TREE 對象不同的是 index 不會再包含嵌套 index 對象,即當(dāng)前修改目錄樹內(nèi)容都在一個 index 文件里。這樣設(shè)計有兩個好處:
能夠快速的復(fù)原緩存的完整內(nèi)容,即使不小心把當(dāng)前工作區(qū)的文件刪除了,也可以從緩存中恢復(fù)所有文件;
能夠快速找出緩存中和當(dāng)前工作區(qū)內(nèi)容不一致的文件。

Linus 在 Git 的第一次代碼提交里便完成了 Git 的最基礎(chǔ)功能,并可以編譯使用。代碼極為簡潔,加上 Makefile 一共只有 848 行。感興趣的話可以通過上一段所述方法 checkout Git 最早的 commit 上手編譯玩玩,只要有 Linux 環(huán)境即可。
因為依賴庫版本的問題,需要對原始 Makefile 腳本做些小修改。Git 第一個版本依賴 openssl 和 zlib 兩個庫,需要手工安裝這兩個開發(fā)庫。在 ubuntu 上執(zhí)行:sudo apt install libssl-dev libz-dev ;然后修改 makefile 在 LIBS= -lssl 行 中的 -lssl 改成 -lcrypto 并增加 -lz ;最后執(zhí)行 make,忽略編譯告警,會發(fā)現(xiàn)編出了7個可執(zhí)行程序文件:init-db, update-cache, write-tree, commit-tree, cat-file, show-diff 和 read-tree。
下面分別簡要介紹下這些可執(zhí)行程序的實現(xiàn):
init-db: 初始化一個 git 本地倉庫,這也就是我們現(xiàn)在每次初始化建立 git 庫式敲擊的 git init 命令。只不過一開始 Linus 建立的倉庫及 cache 文件夾名稱叫 .dircache,而不是我們現(xiàn)在所熟知的 .git 文件夾。 update-cache: 輸入文件路徑,將該文件(或多個文件)加入緩沖區(qū)中。具體實現(xiàn)是:校驗路徑合法性,然后將文件計算 SHA1值,將文件內(nèi)容加上 blob 頭信息進行 zlib 壓縮后寫入到對象數(shù)據(jù)庫(.dircache/objects)中;最后將文件路徑、文件屬性及 blob sha1 值更新到 .dircache/index 緩存文件中。 write-tree: 將緩存的目錄樹信息生成 TREE 對象,并寫入對象數(shù)據(jù)庫中。TREE 對象的數(shù)據(jù)結(jié)構(gòu)為:‘tree ‘ + 長度 + \0 + 文件樹列表。文件樹列表中按照 文件屬性 + 文件名 + \0 + SHA1 值結(jié)構(gòu)存儲。寫入對象成功后,返回該 TREE 對象的 SHA1 值。 commit-tree: 將 TREE 對象信息生成 commit 節(jié)點對象并提交到版本歷史中。具體實現(xiàn)是輸入要提交的 TREE 對象 SHA1 值,并選擇輸入父 commit 節(jié)點(最多 16個),commit 對象信息中包含 TREE、父節(jié)點、committer 及作者的 name、email及日期信息,最后寫入新的 commit 節(jié)點對象文件,并返回 commit 節(jié)點的 SHA1 值。 cat-file: 由于所有的對象文件都經(jīng)過 zlib 壓縮,因此想要查看文件內(nèi)容的話需要使用這個工具來解壓生成臨時文件,以便查看對象文件的內(nèi)容。 show-diff: 快速比較當(dāng)前緩存與當(dāng)前工作區(qū)的差異,因為文件的屬性信息(包括修改時間、長度等)也保存在緩存的數(shù)據(jù)結(jié)構(gòu)中,因此可以快速比較文件是否有修改,并展示差異部分。 read-tree: 根據(jù)輸入的 TREE 對象 SHA1 值輸出打印 TREE 的內(nèi)容信息。
這就是第一個可用版本的 Git 的全部七個子程序,可能用過 Git 的小伙伴會說:這怎么跟我常用的 Git 命令不一樣呢?Git add, git commit 呢?是的,在最初的 Git 設(shè)計中是沒有我們這些平常所使用的 git 命令的。
在 Git 的設(shè)計中,有兩種命令:分別是底層命令(Plumbing commands)和高層命令(Porcelain commands)。一開始,Linus 就設(shè)計了這些給開源社區(qū)黑客使用的符合 Unix KISS 原則的命令,因為黑客們本身就是動手高手,水管壞了就擼起袖子去修理,因此這些命令被稱為 plumbing commands。
后來接手 Git 的 Junio Hamano 覺得這些命令對于普通用戶不太友好,因此在此之上,封裝了更易于使用、接口更精美的高層命令,也就是我們今天每天使用的 git add, git commit 之類。Git add 就是封裝了 update-cache 命令,而 git commit 就是封裝了 write-tree, commit-tree 命令。
這是《Java 程序員進階之路》專欄的第 74 篇(記得點擊「閱讀原文」鏈接去點個 star 哦)。該專欄風(fēng)趣幽默、通俗易懂,對 Java 愛好者極度友好和舒適??,內(nèi)容包括但不限于 Java 基礎(chǔ)、Java 集合框架、Java IO、Java 并發(fā)編程、Java 虛擬機、Java 企業(yè)級開發(fā)(Maven、Git、SSM、Spring Boot)等核心知識點。
點擊上方名片,發(fā)送消息「03」 就可以獲取最新版《Java 程序員進階之路》PDF 版了,讓我們一起成為更好的 Java 工程師吧,沖!
