Git 对象(Git Object)
了解Git, 离不开了解Git对象
提到版本控制, 大家可能都不太陌生, 作为开发者或者软件工程师, 版本控制一直在频繁发生着, Git作为一款优秀的 VCS版本控制管理工具 (Version Control System) , 就是为了解决版本控制问题诞生。 以下是来自维基百科中关于VCS的一段描述:
几乎与文章写作过程一样,对文本的组织和修订的需求一直存在, 只不过逻辑上的完成手段在各个历史时期有所不同, 书籍的 <版本> 和规范修订的 <编号> 的使用可以追溯到发明印刷术的时代。当计算时代来临之后,修订的逻辑诉求变得更加重要和复杂。 如今,最强大或者最复杂的版本控制系统是用于软件开发的系统之中。 复杂体现在通常版本需要多个人员同时读取和并发修改。
— From: https://en.wikipedia.org/wiki/Version_control
如上述描述所言, “版本控制在逻辑上的完成手段在各个历史时期有所不同”, 而现如今Git便是“逻辑上的完成手段”的极佳实现之一, 为何Git会脱颖而出呢? 依照作者看来主要有以下几个原因:
· 免费并开源 : Git是免费的, 并基于GPL-V2协议的开源软件;
· 存储完整性 : Git可以让每台工作的计算机上都存储一个完整的代码存储仓库 (副本), “完整” 意味着每个副本均具备完整的变更历史记录 (一般场景均适用, 但如shallow-clone特性除外, 因为用户可以使用shallow-clone去下载一个指定的、裁剪过的提交历史, 而不下载整个Git仓库);
· 网络独立性 : 因为具备了完整的版本跟踪能力, 存储在任何一个计算机上的Git仓库都可以脱离网络而独立工作 (一般场景均适用, 例如partial-clone情况除外, 因为用户可以使用partial-clone仅仅下载提交的历史, 而不下载任何相关的文本文件, 当在未来需要用到它们的时候“按需下载”, 如果没有网络则可能会造成surprise);
· 协作能力强:
o 合并策略 : Git基于三路合并 (Three-way merge) 算法进行合并, 对于合并双方存在多个不同祖先的情况 (Criss-cross-merge) , Git也可以通过 Recursive three-way merge 支持, 因为其提交历史可以通过作为 有向无环图 (DAG) 的方式被加载、分析和模拟合并等操作 (在Git新版本2.33中引入了新的merge-ort策略, 可以通过缓存的方式解决大型merge-recursive场景下的性能问题, 例如在大量文件rename场景下, 合并性能可提升500倍, Rebase可提升9000倍) ;
o 冲突处理 : 除了简单冲突场景可以支持之外 (双方编辑同一个文件的同一段内容). 一些复杂场景也可以支持, 例如文件在一个版本中被修改内容, 而在另一个版本对文件重命名, 这类操作在一些VCS工具中被视作树冲突 (Tree conflict) 需要人为介入, 而在Git中可以自动帮我们识别完成;
o 线性历史 : 除了合并和冲突之外, 另一个协作的方式是补丁交换 (Patch commutation) 来完成变更合并, 它的特点是可以改变补丁的顺序 (reorder) 或者是修改补丁描述 (reword) , 最终让变更成为一条 线性历史 , 这在Git中也被得以参考实现, 并叫做Rebase. 关于Rebase, 我的同事邢鑫(默翁)会在第7期 B 站视频为大家介绍;
除了以上几个原因, 其实还有例如: Git迁移的能力、Git对大仓的支持、Git传输协议等等一些有优点。
Git经过16年的发展, 已经从最初Linus的第一个提交 (2000多行代码) 演变成目前最流行的VCS工具, 过程中也新增了大量令开发者们惊叹的特性。
不管是“存储完整”还是“网络独立”, 亦或是“协作能力”和“新诞生的特性”, Git的一些最初的设计还是基本保留了下来。
那么这些版本控制中到底有什么不为人知的魔法呢?
认识Git作为VCS机理很好的途径——就是从如何存储版本数据开始,这就是本期要谈的话题 Git对象 (Objects) 和引用 (References) 。
Git Objects
对象类型
Git Objects近乎存储了Git的一切, 了解Git Objects是用好Git基础中的基础。
关于Git对象的分类, 目前有两种不同的“分法”:
· 第一种: 将Git对象分类为4个, 这个也是比较常见的分法, 其中包含 blob/tree/commit/tag 4种;
· 第二种: 在 第一种 基础上, 将 packfile (使用zlib来对其他对象压缩后的文件) 作为第5种对象, packfile在本文章中将不会引申介绍, 后面我的同事韩欣会专门有一期做相关介绍;
Blob
计算BLOB对象
blob是Git中仅用来存储文件文本内容的对象。
之所以上面用了一个 “仅” 这是因为BLOB中并不存储文件的名称, 时间戳, 或者其他的文件元数据 (例如Unix文件系统中attributes的概念, 例如文件权限信息、链接等) , 而只存储文件中的数据. 我们可以使用 git hash-object 子命令来尝试了解blob对象, 确保本机已经安装Git后, 执行如下命令:
➜ git init test.git
➜ cd test.git
➜ echo "some contents" | git hash-object --stdin
f70d6b139823ab30278db23bb547c61e0d4444fb
稍做解释, 首先我们创建了一个 test.git 目录并使用其初始化为一个 Git 仓库, 随后在仓库中执行了 hash-object 子命令. 当成功执行后, 子命令在 stdout 输出了一个 难以记忆的字符串 “f70d6b139823ab30278db23bb547c61e0d4444fb” 。
hash-object 子命令可以让我们方便的计算或生成我们需要的Git对象. 其中, 我们可以在执行前指定 -t 参数, 告诉 hash-object 我们要计算和生成何种类型的对象, 如果没有指定 -t 参数, 默认按照blob类型进行处理。
使用 git hash-object 是使用该子命令最基础的方式之一, 不带任何 option (换言之: 无 -t ), 意味着我们要计算blob类型的对象, 传入 argument[1] 为一个文件名用来读取其文件内部( contents ),之后 hash-object 就可以根据文件文本进行计算或生成对象了。
在上面的执行过程中, 我们没有传入 来指定需要被计算的文件名, 而是使用了 --stdin 参数的方式让 hash_object 从输入流中读取 contents , 这种读取输入流的方式与指定 作为参数在这个场景下并没有本质的不同, 有的话直观的体现在无需在磁盘上多生成一个文件。
Git对象存储本质: key-value
在上面的示例中, 我们只是根据 contents 计算得到了一个 blob object, 这个 object 体现在了一个“难以记忆的字符串”的上面, 似乎除此之外我们对 blob 既看不见, 又摸不着. 那么 “f70d6b139823ab30278db23bb547c61e0d4444fb” 究竟代表了什么含义呢?
这个字符串实际上就是Git对象的key值。
在Git的世界, 不同的对象有着不同的key值, 就像这世上没有两个完全相同的叶子一样——key具备 唯一性 . Git对这种key默认使用 SHA-1 格式表示, 即: 40 位 16进制组成的字符串 。 我们可以重复执行上面的 echo "some contents" | git hash-object --stdin 命令, 可以发现每次输出的key值都是相同的, Git 对象的key值也被叫做 OID (Object ID) 。
简单说来, 如果key值相同, 一定代表两个对象代表同一个含义, 否则就会有大的问题, 这种Git对象之间的 hash碰撞 (collision) 将会引发仓库可靠性和安全性上的严重隐患。 实际上 SHA-1 也确实在 2017 年被 Google 证明可以被攻破 (https://phys.org/news/2017-02-cwi-google-collision-industry-standard.html), 所以在 Git 新版本中, Git支持升级更严格的 SHA-256 哈希算法, 即64位的16进制字符串表示 ,从而增强Git本身的安全性。
Git对象的key我们有了初步了解, 那对象的value是什么呢? 我们可以执行下面的命令:
➜ echo "some contents" | git hash-object --stdin -w
f70d6b139823ab30278db23bb547c61e0d4444fb
➜ tree .git/objects
.git/objects
├── f7
│ └── 0d6b139823ab30278db23bb547c61e0d4444fb
├── info
└── pack
与之前执行 hash-object 子命令所不同的是, 这一次我们添加了 -w 选项, 这表示本次执行将不光计算blob的OID, 还会将OID对应的 value 写入到 Git 仓库当中, 我们可以对 .git/objects 目录执行tree命令来验证这一点, 可以看到随着命令执行成功, 在 .git/objects 下产生了 f7/0d6b139823ab30278db23bb547c61e0d4444fb 文件. 其中目录名 f7 代表的是OID的前 2 位字符, 而OID剩余的 38 个字符则为文件名, 这样做的原因是可以让Git对象更加离散的存放到 00~ff 范围的 256 个目录之中, 提升操作系统查找性能和解决单个目录下可能带来的文件数量的限制, 这种对象的存放方式叫做 松散存储(loose) 或者 未打包存储(unpacked) 。
另外, 值得注意的是, 在执行 tree 命令的输出结果中, 可以发现除了用于存储松散对象的目录之外, 还有 pack 和 info 目录, 他们其实也是用于存储对象的, 只不过不是在当前仓库中采用松散的形式, 而是采用其他的方式, 比如将对象打包为 pack-file 或者从 alternates 资源中 (本地link 或者 http协议) 获取对象.。这些内容如果有机会, 也会在后面的视频和文章中会继续介绍, 本文不会过度引申. 对于之前不了解Git的读者来说, 暂知晓松散存储的方式即可。
我们可以执行 cat 命令查看松散文件的内容:
➜ cat .git/objects/f7/0d6b139823ab30278db23bb547c61e0d4444fb
xblob 14some contents
S[q
很明显, 这里并没有直接存储我们期待的文本内容: "some contents" , 而看似多了一些其他的内容。 这是因为Git使用了 zlib 对文本内容和 对象的头信息(header) 进行了压缩, 为了真实还原我们存储的文本内容, 我们可以使用 git cat-file 子命令来查看:
➜ git cat-file -p f70d6b139823ab30278db23bb547c61e0d4444fb
some contents
➜ git cat-file -t f70d6b139823ab30278db23bb547c61e0d4444fb
blob
git cat-file 子命令是用于查看Object value的绝佳助手, 这里我们通过指定的 OID 作为执行参数 (arg) 并添加 -p 选项 (opt) 来让 cat-file 自动适配对象的类型, 并且以较为美观的输出方式 (pretty-print) 对象中存储的数据 (Git Object Data) 进行输出。 另外, 我们也可以使用 -t 选项来获取对象的类型。
以上, 我们简单介绍了blob对象——这是Git用来仅存放文件文本内容的一种存储的格式。 同时, 我们还介绍了对象的key-value是如何将对象松散存储在Git仓库中, 以及如何根据 OID 来查看对应的 data。
文件的内容得以保存, 那么文件名等信息到底存在哪里呢? 接下来就要给大家介绍另一个对象类型 tree 。
Tree
Git 的一个重要设计是: 在版本控制过程中, Git针对变更是基于创建 完整快照 (Snapshots) 而非增量修改 (Deltas) 。需要其他的VCS工具是使用增量编码(压缩)的形式存储增量, 从而节约存储空间 (通常是这个目的) , 但 Git 并没有沿用这一理念, 而是坚定的使用了快照的设计. 快照的方式不可避免的会对带来存储空间的快速增长, 但是也带来了Git对象在存储上不可变性 (Immutable) 的优势, 这是设计上的一个权衡 (trade-off)。
Snapshots得以让Git可以针对一个提交、目录树或者文件, 在无需保存任何其他的关联对象的信息的情况下, 随时可以还原某一个历史时刻的仓库状态。
tree对象负责存储一个目录的快照(snapshot), 快照包含一组文件的文件类型(type)、文件名、文件模式 (mode)等信息。
tree 对象保存的信息和我们常见的目录结构很类似, 其内部可以递归包含其他的目录和 blobs, 这其实和 Unix 的文件系统 (树映射) 的设计方式很相似 (https://en.wikipedia.org/wiki/Unix_filesystem#Principles)。 tree 对象其内部的存储方式是使用 哈希树 的结构 (https://en.wikipedia.org/wiki/Merkle_tree)。 这样, Git只需要一个 根树 (root-tree) 的 OID 就足够推演整个目录的状态, 随后将根树和 commit对象 关联, 就可以知道在那个提交的时间节点上工作空间的快照。
根树对象—Root Tree
为了更进一步的解根树的存储方式 (root-tree) , 我们可以执行如下命令:
➜ echo 'readme' > README.md
➜ git add README.md
➜ git commit -s
[master (root-commit) 3de31e2] COMMIT A
1 file changed, 1 insertion(+)
create mode 100644 README.md
➜
➜ git cat-file -p master
tree 764409de08fa4fda9ba6c85a54f5f31d00cec93e
author Dyrone Teng 1633679101 +0800
committer Dyrone Teng 1633679101 +0800
COMMIT A
➜
➜ git cat-file -t 764409de08fa4fda9ba6c85a54f5f31d00cec93e
tree
➜ git cat-file -p 764409de08fa4fda9ba6c85a54f5f31d00cec93e
100644 blob 8178c76d627cade75005b40711b92f4177bc6cfc README.md
➜ git cat-file -p 8178c76d627cade75005b40711b92f4177bc6cfc
readme
我们首先创建了一个名为 README.md 的文本文件. 随后执行了 git add 子命令, 在这个过程中Git会将该文本文件转化为 blob 对象进行存储。 随后执行 git commit 子命令, 在这个过程中Git会生成一个 tree 对象来存储当前仓库目录的快照, 之后再生成一个指向该 tree 的 commit 对象, 这个新生成并被 commit 引用的 tree 即 root-tree 。
我们可以执行 git cat-file 子命令来查看某个提交所引用的 root-tree 对象, 并递归执行查看查看该tree对象的类型和内容。
通过上面的输出结果可以看到, 此时 root-tree 中包含了 README.md 中文本对应的blob对象: 8178c76d627cade75005b40711b92f4177bc6cfc , blob 中存储了内容: "readme" 。