Git 必知必会:原理剖析(一)
作为程序员,最常用的版本管理工具便是 Git。但我相信大多数人和我一样,从没有认真了解过其具体实现的原理。但了解 Git 的原理,能有助于我们工作更好的使用 Git。下面,让我们一起来了解 Git 中的一些概念,以及实现。
Git 是一个分布式版本控制软件,在使用的过程中,与 CVS 类不同,不需要使用服务端,就可以实现版本控制。但在我们常用的使用过程中,依然会一个中间服务器作为 original
来实现代码的托管
、控制
等,整个流程如下图所示:
从图中可以看到,虽然有一个远程仓库 ,但它仅仅是提供托管以及作为权威库存在,所有程序员仓库节点都可以不依赖这个远程仓库独立存在并使用。
在进行开发工作的时候,我们对代码的源文件进行修改,然后通过 commit
命令提交到本地仓库,在通过 push
命令将代码同步到远程仓库中。那图中的 Git 本地仓库
以及 工作区
指的分别是哪些东西呢?
相信熟悉 Git 的同学都知道,我们克隆到本地的项目中,都会有一个 .git
的隐藏文件夹,这个文件夹中的内容就是 Git 本地仓库
, 而工作区就是我们看到的其它原代码。所以,对一个 Git 仓库
来说,最重要的便是 .git
文件夹里面的内容了。
刚提到,在本地工程中,.git
文件夹为 Git 本地仓库,库目录结构如下:
remote url
、用户信息
在这些目录和文件中,其中最重要的为 objects
文件夹。在 Git 的设计中,所有的核心对象都会往里装。这些对象又分为:
commit
, 存储信息中包含 顶层源目录的 tree hash值 、时间戳、commit 日志信息、0个或多个父commit hash值我在本地建了一个项目,添加了几个文件,以及两次提交。在 objects 目录下,存储的文件类容如下:
初看这个会一脸懵,其实很简单,以 objects/78/ace89700a69e490c86f54fbe9d12f0cfb2dbdb
这个文件为例,其中 78ace89700a69e490c86f54fbe9d12f0cfb2dbdb
这一串为这个文件的 Hash 值,计算时使用的是 SHA-1 的算法,其计算结果为 20 个字节组成,通常表示成 40 个 16 进制的形式的字符。对比 Hash 与文件结构可以看出,Git 使用的 Hash 前两个字符作为文件夹名称,后 38 个字符作为文件名,即表示为 hash[0,2]/hash[2:]
格式。在 git 中,也有相应的工具查看 Hash 文件存储的数据类型以及数据内容,可以使用如下命令进行查看:
# 查看文件类型
git cat-file -t 78ace89700a69e490c86f54fbe9d12f0cfb2dbdb
# 查看文件内容
git cat-file -p 78ace89700a69e490c86f54fbe9d12f0cfb2dbdb
下面在来看一下各 Hash 值的计算:
在 Git 中, Blob 的文件中,存储的内容格式如下:
blob <content length><NUL><content>
其中 content length 指的是源文件内容的长度,NUL 为 \0
,而 content 为源文件的内容。举个例子, 我在一个空仓库中,添加了一个文件 Main.java
,其内容如下:
public class Main {
public static void main(String [] args) {
System.out.println("This is Main.java");
}
}
因此,存储到 objcets 中的文件内容为:
blob 122\0public class Main {
public static void main(String [] args) {
System.out.println("This is Main.java");
}
}
为了更好的验证这个逻辑,我写了一段 Java
记算此 Hash
的测试代码,代码如下:
public static HashData getBlobHash(File file) throws Exception {
String content = new String(FileUtil.read(file));
String header = "blob " + content.length() + "\0";
return getHash(header + content);
}
运行出来的结果正好为 78ace89700a69e490c86f54fbe9d12f0cfb2dbdb
, 与 Git 中记算的结果一致。
在存储 Main.java
文件类容中,并没有将文件名存储进去,那 Git 是在哪儿存储的呢? 对,就是现在要讲的 tree 的结构。 先来看一下,我写的示例中的文件结构:
和 blob 类似, tree 也有对应的内容存储结构:
tree <content length><NUL><file mode> <file name><NUL><file hash>
其中 content length 指的后面<file mode> <file name><NUL><file hash>
的长度,NUL 为 \0
, file mode 指的是文件类型, 列举其中几种:
0100000000000000
(040000
): 文件夹1000000110100100
(100644
): 常规非执行文件1000000110110100
(100664
): 常规不可执行组可写文件1000000111101101
(100755
): 常规可执行文件1010000000000000
(120000
): 软链文件file name 指的是文件名称, file hash 为 blob
或者 tree
的 hash 值,使用的是 20 位二进制值,非 40 位的 16 进制字符串。
具体来看上面例子中的信息,我们从最下面的 blob 文件往上看,通过上面的脚本, 可以计算出 Main.java
的 hash 为:78ace89700a69e490c86f54fbe9d12f0cfb2dbdb
, new.txt
的 hash 为: fa49b077972391ad58037050f2a75f74e3671e92
,因此,针对 example
这一级目录的文件类容如下:
tree 72<NUL>
100644 Main.java<NUL>78ace89700a69e490c86f54fbe9d12f0cfb2dbdb
100644 new.txt<NUL>fa49b077972391ad58037050f2a75f74e3671e92
PS:为了更好的阅读,上面的内容进行了换行处理,实际文件中并没有换行。格式中提到的 file hash 在此处为了便于展示,直接放了对应的 40 位的 16 进制字符串。
得到文件内容后,可以很方便的计算出 hash 为:f6e2e8e5243c07191d0c1f4353448bd57785c39d
。也可以用 git 命令去验证:
➜ git cat-file -p f6e2e8e5243c07191d0c1f4353448bd57785c39d
100644 blob 78ace89700a69e490c86f54fbe9d12f0cfb2dbdb Main.java
100644 blob fa49b077972391ad58037050f2a75f74e3671e92 new.txt
➜ git cat-file -t f6e2e8e5243c07191d0c1f4353448bd57785c39d
tree
当然,计算这个,我也用 Java 实现了一个简单的计算。看一下代码的实现:
File[] allFiles = dir.listFiles();
//按文件名进行字典排序
SortedMap<File, HashData> allHash = new TreeMap<>(Comparator.comparing(File::getName));
for (File file : allFiles) {
if (file.getName().equals(".git") || file.getName().equals(".DS_Store")) {
continue;
}
// 计算每一个文件的 hash
allHash.put(file, calcHash(file));
}
// 拼接文件子文件的 hash
byte[] allContent = new byte[0];
for (Map.Entry<File, HashData> item : allHash.entrySet()) {
String header = getFileMode(item.getKey()) + " " + item.getKey().getName() + "\0";
byte[] content = merge(header, item.getValue().originalData);
byte[] tempContent = merge(allContent, content);
allContent = tempContent;
}
String header = "tree " + allContent.length + "\0";
byte[] mergedArray = merge(header, allContent);
HashData hash = getHash(mergedArray, "SHA-1");
与前面的 tree 和 blob 的相似,按照固定格式进行拼装即可:
commit <content length><NUL>tree <tree hash>
parent <parent hash>
author <username> <email> <timestamp>
committer <username> <email> <timestamp>
<commit message>
需要注意的是,此格式中的 tree hash 与 parent hash 是 40 位的 16 进制值, 换行也是真实的换行。按照上面的格式进行拼装后,使用 SHA-1 可以很方便的计算出 Hash 值。
通过前面 blob 、tree、以及 commit 计算后,通过 commit 作为入口,就可以将所有的文件夹以及文件内容进行关联起来,构建一个树目录结构。还是前面提到的那个例子,一共执行了三次提交:
git commit -am "first commit"
进行第一次提交git commit -am "add new file"
进行第二次提交git commit -am "modify Main.java"
进行第三次提交在本例中, .git/HEAD
中指向的 .git/refs/heads/main
文件,此文件中存储了 commit
的 hash 值, 作为入口,可以将刚的三次提交,构建出一个树结构,如下图所示:
从上面的例子中,可以看到,Main.java 经过第一次的创建与后续的修改,创建了两个 blob ,虽然使用了 zlib 进行了压缩,但针对二进制文件文件,压缩率可以说很微小,这就会导致 objects 中存储的 blob 文件大小会快速增长。当我们首次克隆代码时,会把这些二进制的所有版本文件都下载下来,导致下载速度很慢。并且这些历史版本的文件,你可能永远也用不到,但是却不得不下载它。
基于此,Git LFS 应运而生,它通过将大文件替换为一个很小的指针文件,并且在使用中,只会将当前需要使用到的二进制文件下载到本地即可。
在 mac 上,我喜欢使用 Homebrew
进行安装,使用如下命令就可以安装好 git-lfs
:
brew install git-lfs
安装好后,就可以对项目进行配置,在不支持 git-lfs 的项目目录下,执行如下命令:
git lfs install
此命令会更新 .git/hooks/
下的脚本,注入 git
命令的处理流程
可以看到,在 hooks
文件夹中,多了 post-checkout
、post-commit
、pre-push
等脚本,根据这些脚本名称就知道其执行时间点。
下一步便是将我们要跟踪的二进制文件添加到跟踪目录中,使用如下命令:
git lfs track <file-type>
# 例如
# git lfs track "*.db"
执行后,会在项目根目录下创建 .gitattributes
文件,注意,需要将此文件上传到远程库中去。这个文件中的内容格式如下:
*.db filter=lfs diff=lfs merge=lfs -text
在这里,我随机生成了一个无序的二进制文件 test.db
, 添加到项目中,并且执行 git add .
命令,会将此文件加入进去,从文件目录中可以看到,lfs 目录下新增了一个文件:
而在f2bb0511bd0ce670c75a1ce4d52ecea91bad219b
这个文件中存储的便是 lfs 的文件指针:
version https://git-lfs.github.com/spec/v1
oid sha256:d1ccb0fe6d165aab8c40c6b0aa796bf4f80011c050e168a625d3d2ab46b07306
size 41943040
通过此种方式,进行关联,降低代码库大小。最后,在看看官方图:
创建于 2005 年的 Git 中,有很多知识点值得学习,建议有时间同学深入研究。最后,如果文中有错误和遗漏,欢迎与我联系。