GIT 存儲格式與運用 頂 原 薦

GIT 存儲格式與運用

在 GIT 的實現規範中,存儲格式是非常簡單而且高效的,一個代碼託管平臺通常需要 基於這些特性實現一非常有意思的功能。在本文中,將介紹基於 GIT 存儲庫格式實現的 倉庫體積限制與大文件檢查。

存儲庫的佈局

正常的 GIT 存儲庫佈局應當遵循 GIT 規範 Git Repository Layout 一個 GIT 倉庫包括如下兩種風格:

  • .git 目錄存在於工作目錄的根目錄中。
  • <project>.git 這種是一個裸倉庫,沒有工作目錄,服務器上存儲的就是這種。

特別注意的是,如果是一個 子模塊 (submodule) .git 回是一個文件,文件內容爲 gitdir:/path/to/gitdir

下文是一個表格,關於目錄結構和描述信息。

路徑目錄(D)\ 文件 (F)描述
objectsD鬆散對象和包文件
refsD引用,包括頭引用,標籤引用,和遠程引用
packed-refsF打包的引用,通常運行 git gc 後產生
HEADF當前指向的引用或者 oid,例如 ref: refs/heads/master
configF存儲庫的配置,可以覆蓋全局配置
branchesD-
hooksD請查看 Documentation/githooks.txt
indexFgit index file, Documentation/technical/index-format.txt
sharedindex.<SHA-1>F-
infoD存儲庫信息,啞協議依賴 info/refs
remotesD-
logsD運行 git log 可以查看提交記錄
shallowF-
commondirD-
modulesD子模塊的 git 目錄
worktreesD工作目錄,更新後的文檔與 git 多個工作目錄有關

對於一些實際上使用非常少的路徑,我就沒有添加說明了。

鬆散文件格式

在 objects 目錄中,有 00,01,...ff 這樣的目錄,目錄下存儲着文件名長度爲38的二進制文件,這些文件就是鬆散文件, git 創建提交時,修改的文件,更新的目錄樹,以及提交內容會被壓縮後寫入到這些目錄中,成爲一個個鬆散文件,當需要傳輸 或者運行 gc 時,這些文件就會被寫入到 pack 文件中。

對象文件使用的壓縮算法是 deflate,增加一個新的對象文件時,先要計算這個文件的 hash 值(原始長度 20,16 進制長度 40 個字符), 這個根據這個值查找文件是否存在這些個目錄或者 pack 文件中,不存在則創建前綴目錄(hash 值 16 進制字符串前兩個),然後、 將原始文件的類型以及長度信息以及內容一起壓縮,寫入到磁盤,文件名是 hash 值的 16 進制的後 38 個字符。

通過解壓縮可以得到符合下面格式的文件:

type SP digest NUL body

其中類型是 commit blob tree 而長度則是 10 進制的數字,以字節爲單位。後面的 body 就是各種類型文件的內容。

commit 內容是純文本的,有 tree ,這個 tree 也就是根的 tree,然後有 一個 到多個 parent,這與 git merge 方向有關, 還有作者,提交者,以及提交信息。這裏的 oid 是 16 進制的。

tree bbe101c40b962d8b8977b34d0eb8bf12bb9e9679
parent 789808fe48670f2fce59da45a82a2a18f489e300
author Junio C Hamano <[email protected]> 1467837778 -0700
committer Junio C Hamano <[email protected]> 1467837778 -0700

Third batch of topics for 2.10

Signed-off-by: Junio C Hamano <[email protected]>

blob 就是真實的文件。

而 tree 就是將文件按目錄結構和屬性組織起來,在 tree 中每一個 tree entry 可能是 blob, 也可能是 tree,也有可能是 commit,在有 submodule 的情況下就有 commit。commit 指向的是一個提交, 僅通過此 commit 並不能獲取完整的資源,在工作目錄的根下,當項目存在 submodule 時,會有一個 .gitmodules git submodule 在先要註冊到 git config 中,然後克隆到 .git/modules 目錄,然後 git 依據 tree 中的 commit 檢出。 如果是新增的 submodule 將會檢出對應倉庫的 HEAD 指向的引用。

這裏的 oid 是原始的。可執行的 blob 和普通的 blob 存儲時並無大的差別,主要的差別體現在 tree 的 unix 目錄項。

100644 blob 33d07c06bd90833ce56bc64c13bdc08c1997c3fb    .gitattributes
100644 blob 6483b21cbfc73601602d628a2c609d3ca84f9e53    .gitignore
100755 blob a88b6824b908d89ee185b84ed92b9c122b0118dd    GIT-VERSION-GEN
100644 blob 4f00bdd3d69babe8a58c4989406eaa6fb5f36a50    Makefile
100755 blob 4277f30c4116faf2788243af4ec23f1d077698e8    git-gui--askpass
100755 blob 11048c7a0e94f598b168de98d18fda9aea420c7d    git-gui.sh
040000 tree 1ead6a96af286100752067ea1849d49b35ce1d35    lib
040000 tree 452280d7fa4a155bd311a7cce7e327964267b792    macosx
040000 tree 2294a6a975b861ecdb5b03d091877c14ec696621    po
040000 tree ae99a38593d127e47f956d96abf3d6a40d3aff66    windows

在 Git-SCM 中,有例圖顯示了這種結構:

Data-Model

如何解壓對象文件?大多數語言都綁定了 zlib,放心去使用即可。

比如 C# 有 System.IO.Compression 有 System.IO.Compression.DeflateStream 類,就可以拿過來使用。 如果是 C++ 直接使用 zlib 中 z_stream 即可,Linux Unix 都帶了,然後 Visual Studio 可以使用 NuGet 安裝到項目中,可以使用。

那麼計算 HASH 呢?OpenSSL 提供了 hash 函數,大多數 Linux 和 Unix 都帶了,可以使用,在 Windows 中也可以使用 OpenSSL, 當然也可以使用 Windows 自帶的加密算法動態庫 bcrypt.dll,你也可以在網上找到一個 SHA-1 算法的實現。

Pack 文件格式

Pack 文件的設計使得 git 倉庫可以更好的節省磁盤空間,有利於服務器之間傳輸數據。

Pack 文件

文檔地址:Git pack format

pack 文件的第一部分是簽名 {'P','A','C','K'} 4 字節,正如 zip 文件帶有 PK 一樣。

第二部分是 4 字節(網絡字節序)版本號,這裏需要使用 ntohl 來轉成本機的,x86\amd64 是小端的。

第三部分是 4 字節(網絡字節序)對象數目。

然後就是對象條目,3 bit 類型,然後根據類型判斷長度字符串的 bit 長度。然後計算長度。 其中類型包括鬆散對象的所有類型,還包括

 (undeltified representation)
 n-byte type and length (3-bit type, (n-1)*7+4-bit length)
 compressed data

 (deltified representation)
 n-byte type and length (3-bit type, (n-1)*7+4-bit length)
 20-byte base object name if OBJ_REF_DELTA or a negative relative
 offset from the delta object's position in the pack if this
 is an OBJ_OFS_DELTA object
 compressed delta data

最後,是 20-byte SHA-1 校驗碼。

Idx 文件

如果直接去解析 pack 文件是很麻煩的一件事,而我們只需要將大文件掃描出來,並不需要做其他工作, 所以,我們可以瞭解 idx 文件格式,然後做出一些取捨。

idx 文件的格式也在 pack 文件格式文檔中。

idx 文件有兩個版本,第一版基本不怎麼使用了,所以這裏講的是第二版。

第一部分是 魔數 \377tOc 4-byte

第二部分是 版本號 網絡字節序,目前是 2。

第三部分是 256 個扇出表 (fan-out table),這個與版本 1 一致。每一個是 4 byte 網絡字節序。 比如第一個代表 前綴 00 的 對象有多少個, 前 255 個都是對應的對象序號有多少個,並沒有 ff 對應的 有多少個對象,最後一項表示所有的對象數目。扇出表總共佔用 256*4 字節。

第四部分是 按順序排列的 20 字節對象 sha-1,每一個 佔用 20-byte,共計 total*20-byte。

第五部分是 按順序排列的 4 字節 crc 校驗馬,總共 total*4-byte。

第六部分是 按順序排列的 4 字節 pack 偏移(網絡字節序),總共 total*4-byte 這就意味着普通的 pack 文件 無法存儲超過 4 GB 大小的文件。

第七部分是 8 字節偏移條目,大多數不要參照此文件。

最後依然是校驗碼。

倉庫大小限制和文件大小檢測

首先講的是倉庫大小,對於 git 而言,最重要的數據是 objects 和 refs,只要擁有這些數據,就可以恢復出一個完整的倉庫。 而對倉庫做大小限制,則只需要檢測 objects 目錄大小即可。

通常來說在 linux 中,可以使用 du -sh 查看目錄佔用磁盤空間大小。在 Windows 中有多種方式,可以使用 Sysinternals 的 du 工 具,運行 du -sh 一樣 OK。 (Sysinternals 創始人之一 Mark Russinovich 現是 Microsoft Azure CTO)

這裏值得注意的是在 Linux 中,目錄同樣佔用空間,4096 字節,無論是目錄還是文件,佔用的的大小一定是塊大小的整倍數。 即 S_BLKSIZE,這裏是 512。

下面有兩段代碼,分別是 unix like 和 Windows 掃描目錄大小。

在 Unix/Linux 或者 Bash On Windows 中,可以使用下面這個例子

class ScanningFolder {
public:
  bool FolderSizeResolve(const std::string &dir__) {
    DIR *dir = opendir(dir__.c_str());
    if (dir == nullptr) {
      fprintf(stderr, "opendir: %s\n", strerror(errno));
      return false;
    }
    // folder self
    size_ += 4096;
    dirent *dirent_ = nullptr;
    while ((dirent_ = readdir(dir))) {
      if (dirent_->d_type & DT_REG) {
        std::string file = dir__ + "/" + dirent_->d_name;
        struct stat stat_;
        if (stat(file.c_str(), &stat_) != 0) {
          fprintf(stderr, "ERROR: %s\n", strerror(errno));
          closedir(dir);
          return false;
        } else {
          // S_BLKSIZE 512
          size_ += stat_.st_blocks * S_BLKSIZE;
        }
      } else if (dirent_->d_type & DT_DIR) {
        if (strcmp(dirent_->d_name, ".") == 0 ||
            strcmp(dirent_->d_name, "..") == 0) {
          continue;
        }
        std::string newdir = dir__ + "/" + dirent_->d_name;
        if (!FolderSizeResolve(newdir)) {
          closedir(dir);
          return false;
        }
      }
    }
    closedir(dir);
    return true;
  }
  uint64_t Size() const { return this->size_; }

private:
  uint64_t size_ = 0;
};

當然也可以使用 ftw 這樣的函數,不過並不一定高效,比如 libc musl 就是使用 opendir 來 實現的 ftw。這樣一來性能反而下降了。

在 Windows 中,遍歷目錄可以使用 FindFirstFile/FindNextFile 這個兩個 API。

class FolderSize {
public:
  FolderSize(const std::wstring &dir) : size_(-1) {}
  int64_t Size() const { return size_; }

private:
  bool TraverseFolder(const std::wstring &dir) {
    WIN32_FIND_DATAW find_data;
    HANDLE hFind = FindFirstFileW(dir.c_str(), &find_data);
    if (hFind == INVALID_HANDLE_VALUE) {
      return false;
    }
    while (true) {
      if (find_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
        if (wcscmp(find_data.cFileName, L".") != 0) {
          std::wstring xdir = dir;
          xdir.push_back('\\');
          xdir.append(find_data.cFileName);
          TraverseFolder(xdir);
        }
      } else {
        size_ +=
            ((int64_t)find_data.nFileSizeHigh << 32 + find_data.nFileSizeLow);
      }
      if (!::FindNextFileW(hFind, &find_data)) {
        break;
      }
    }
    FindClose(hFind);
    return true;
  }
  int64_t size_;
};

然後就是文件大小檢測,通常有兩個指標,一個是壓縮前的大小,一個是壓縮後的大小。

對於鬆散文件,不依賴第三方庫,我們可以使用 zlib 去查看鬆散對象的大小。如果只要檢測壓縮後的大小,實際上在遍歷目錄的 時候就可以使用 stat 的 st_size 參數獲得實際大小。

對於 pack 文件中的大小,通常計算起來比較麻煩,由於我們對文件大小的差異容忍度很高,我們實際上可以使用 idx 偏移值去計算。 先取得 pack 文件大小,然後讀取 idx 中所有的 sha 值與偏移值。然後對偏移值使用 sort 排序,由大到小,最後使用 前一個偏移值 減去後一個偏移值即可得到近似大小。其中,第一個要使用 (packsize-20) 去減。比如碼雲限制文件大小,警告是 50M,錯誤時 100 M, 由於 pack 得到的時壓縮後的大小,實際上誤差也就可以忽略不計了。如果需要使用原始大小可以使用 libgit2 去實現。

偏移值計算時,數據結構的使用非常重要,在 fan-out table 的最後一箇中,已經得知所有對象的數目,便可以使用 vector 之類的容器, 與 list 相比,筆者在掃描 2 GB 的 FreeBSD 倉庫對象,共計 300 W 個對象,其中使用 list 是 7s,而 vector 是 3s,運行環境是 12 年筆記本。 i3 處理器,機械硬盤。Windows(Bash On Windows)。

關於實際大小和壓縮後的大小,zlib 的壓縮可大可小,一般而言傳輸和存儲時都是壓縮後的文件,所以在實現代碼託管業務時, 限制大小的策略應當側重於壓縮後的大小。

最後

關於 GIT 存儲的研究是作爲 Native-Hook 的一部分,與鉤子相關的內容本次就沒有寫了。

關於本文的 git pages: http://ipvb.oschina.io/git/2016/07/10/GitStorage/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章