文章写于2014年11月,当时的自己本科四年级,导师让我翻译一个叫The Architecture of Open Source Applications的书,然后就有了这两篇文章。至于为什么就翻译了两篇,因为翻译到第二篇的时候导师告诉我是叫我看,不是让我翻译。是不是我真的听错了已经不得而知,也许是看到我翻译的这么烂也不想让我继续下去了😂。两篇文章已经丢在github上有些年头了,本来是想翻译完所有的文章,搞个大新闻,结果就这两片孤零零的在仓库里。现在回想起来本科即将毕业的自己,雄心勃勃的要干一番大事业,如今读研到了找工作的时节却又多些心酸和迷茫。往前走终会有路,热血不会冷却。
6.1 Git简介
Git能够让合作者们使用一个p2p的网络仓库来维护工作的电子文档部分(不仅仅局限于代码)。Git支持分布式的工作流程,允许部分工作暂时的分散,最后再聚集一起。
这一章节将会阐明Git用来实现这些功能的许多不为人知的方面,并且会提及Git与其它版本控制系统(VCSs)的不同之处。
6.2 Git起源
为了更好地理解Git的设计哲学,首先你要知道在Linux内核社区中Git项目是在什么样的环境下启动的。
Linux内核相对于那时的其它商业软件项目而言是特立独行的,因为Linux内核的贡献者数量庞大,并且贡献者的参与程度和对现有代码库的了解程度参差不齐。几年来,内核一直是利用打包工具和打补丁来维护,所以核心开发社区一直在竭力寻求一个能够满足他们大部分需求的VCS。
为了解决这些需求和在2005年遇到的困境,Git作为一个开源项目诞生了。在当时,核心开发成员们使用了两种VCS:BitKeeper和CVS来管理Linux内核代码库。其中BitKeeper提出了一个与众不同的设计理念,深受Linus喜欢。
在BitKeeper的开发商BitMover宣布要撤销一些Linux内核核心开发者的许可证之后不久,Linus Torvalds急忙地着手开发一个自己的VCS,也就是后来的Git。他开始时写了一组脚本来帮助他管理邮件补丁,然后逐渐应用于管理其它软件补丁。原始脚本的目的是能够快速地终止合并,所以维护人员可以把中期补丁流修改成手动合并,然后继续合并后续的补丁。
从表面上来看,对于Git,Torvals有一个哲学目标–反CVS–加上三个实用的设计目标:
- 和BitKeeper类似,Git要能够支持
分布式工作流(distributed workflows)
- 提供避免内容错误的
安全保障
- 具有
高性能
在某种程度上,这些设计目标已经被实现并将保持下去,就像下面我要展现的一样。接下来会我说明Git如何利用有向无环图(DAG)来处理内容存储,头部指针的引用,对象模型的表现和远程协议;最后会说到Git如何追踪树的合并。
尽管BitKeeper对Git的早期设计产生了影响,但是Git的实现使用了根本不同的方法,并且Git能允许用户分布式地增加更多的本地工作流,这一点是BitKeeper无法做到的。Monotone,一个在2003年启动的开源分布式VCS,可能是早期Git开发的另一个灵感来源。
分布式的版本控制系统通常能花费很小的代价提供很好的工作流的灵活性。分布式模型有如下优点:
- 让合作者能够线下工作并且递增的提交
- 让某个合作者自己决定他何时分享他的工作
- 让合作者能够在线下访问仓库历史
- 允许被管理的工作发布到多样的仓库中去,发布的工作中潜在的含有分支和细小的变化
在Git项目开始的时期,三个其它的开源分布式VCS(dVCS)也被发起。(其中有一个在The Architecture of Open Source Applications 的第一卷中被讨论过。)所有的这些分布式VCS都用了稍微不同的方法来提供具有很大的灵活性工作流,这一点将它们从其它VCS中分离出来。注意:SubVersion有一个被不同开发者维护着的叫SVK的扩展,它用来支持服务器到服务器的同步。
如今,比较流行的并且被积极维护着的dVCS有:Bazaar,Darcs,Fossil,Git,Mercurial和Veracity。
6.3 版本控制系统设计
现在让我们回顾一下在开发Git时可选择的VCS解决方案。了解它们的不同之处,有利于我们探究在Git开发时面临的架构选择。
一个版本控制系统通常有三个核心需求,分别是:
- 存储内容
- 追踪内容变化(含有合并信息的历史记录)
- 在合作者之间分布式的管理内容和历史记录
注意:不是所有的VCS都要求第三点。
内容的存储
在版本控制系统界里,为了实现存储内容功能,最一般的选择是利用基于delta的变化集合,或者利用有向无环图(DAG)的内容表示。
基于Delta的变化集合会将两个版本的扁平化内容的相异点封装进来,同时附加一些元数据,这种方式只关心具体的差异。
Git用有DAG来表示文件,关心的是文件的整体变化,用层次结构的对象关系来表示树状结构的文件系统,不同的文件类型用不同的对象来表示。为了提高性能,只有变化的文件才新建立快照并保存在仓库里,没有修改的文件则会利用原来的快照做一个引用。
提交(commit)与合并(merge)历史记录
在历史记录和向前变更追踪方面,大部分VCS软件使用了下列方法之一:
- 线性历史记录
- 用有向无环图表示的历史记录
Git再一次使用了DAG,不过这次是使用在存储历史记录方面。每一个commit都包含了它的祖先的元数据;Git中一次commit可能有零个或者多个(理论上可以有无限个)父节点。例如,在Git仓库里的第一次commit就没有父节点,然而通过三路合并而来的commit就有三个父节点。
Git和Subversion(使用的是线性历史祖先)另一个重要的区别是,Git能够直接地支持分支,并且分支能够记录大部分的历史事件。
Git通过DAG存储内容可以充分的发挥分支的能力。一个文件的历史记录是连续不断的。从该文件到根目录都是连着的,根目录又连着一个提交节点。这个提交节点,依次又有一个或多个父节点。这样,Git就有两个很好的特性,利用这两个特性我们就能够确切地推断出历史记录和内容,比从RCS发展而来的一些VCS更为准确,具体是:
- 如果图中的一个内容节点与其它提交中的某个节点有相同的引用ID(在Git使用SHA),则这两个节点一定含有相同的内容,这样Git就有效地缩短了内容的差异对比过程。
- 当合并两个分支的时候,我们合并的是DAG中两个节点的内容。DAG使得Git可以“高效地”(相对于RCS家族的那些VCS)判定出这些节点的共同祖先。
分布
VCS用如下三种方法中的一种来处理一项工作在合作者之间的分配:
- 仅本地:作为一个VCS解决方法,没有满足上面提到的第三个需求。
- 中心服务器:所有对仓库的改变必须由一个特定的仓库来记录。
- 分布式模型:在该模型中,一般合作者会有一个可访问的公共仓库来进行“推送”,但是可以在本地提交并且在稍后推送,允许离线工作。
为了阐明每个关键的设计选择的优点与局限,我们将含有同样内容的SubVersion仓库和Git仓库进行对比(例如,Git仓库默认分支的HEAD上的内容与Subversion仓库主干上最新版本的内容相同)。一个叫Alex的开发者,签出(checkout)一个Subversion的本地仓库,同时克隆(clone)了一个Git的本地仓库。
我们假设,在本地的Subversion仓库中,Alex改变了一个1MB大小的文件,并且提交了这个改变。在本地,签出的这个文件会做出相应的改变并且本地的元数据也会更新。在Alex提交到中心Subversion代码库中的时候,会将旧文件的快照和新的改变进行对比,生成一个对比文件,该对比文件会存在仓库中。
下面和Git的工作过程对比一下。当Alex在本地的Git仓库中对同一个文件做了相同的修改后,修改首先会存储在本地,然后Alex“推送”本地待定的提交到公共仓库里,以便该项目的其他合作者能够分享。最后该内容变化会一致的存储在每一个含有这次提交的Git仓库里。在本地提交时(最简单的情况下),本地的Git仓库会创建一个新的对象表示一个新的文件,而新文件表示这次修改后的文件(新文件包含了所有的内容)。对被修改文件上面的每一层目录(包括根目录),都会创建一个具有新ID的新对象。从新的根节点到新的叶节点,一个新的DAG被创建出来了(对于在本次提交中没有修改的文件,它们的叶节点引用会被再利用),新DAG会引用新建的叶节点,而不是被修改文件在原来那棵树中的旧叶节点。(叶节点表示存在仓库里的文件。)
在这时,这次提交还在Alex的本地设备中的Git仓库里。当Alex“推送”这次提交后,这次提交才算进入公共Git仓库里。在公共仓库核定这次提交适用于该分支后,在本地仓库中创建的那些对象才被存储在公共仓库中。
Git的解决方案更加灵活,它们既是底层的,又是用户层的。这些功能让用户能够明确地表达出是想在远程仓库里分享修改还是仅仅在本地提交。但是,增加这两个层次的复杂性可以为团队在工作流和发布能力上提供极大的灵活性,就像在“Git起源”中提到的一样。
在Subversion的解决方案中,合作者准好分享修改时,他没必要记得把修改推送到远程仓库中。当对大文件进行小的修改时,基于delta的存储比存储各个版本的整个文件更为有效。但是,就如我们将要看到的,Git在某些方面利用了变通的方案。
6.4 工具包(ToolKit)
如今,Git生态系统中,有很多命令行和UI工具适用于各种操作系统(包括原来几乎不支持的Windows系统)。其中大部分工具都是建立在Git工具包之上的。
因为Git一开始是Linus写的,并且在Linux社区里启动,所以Git遵循的是Unix传统的命令行模式的工具设计哲学。
Git工具包分为两个部分:“铅(plumbing底层命令)”和“瓷(porcelain高层命令)”。plumbing是指一些底层的命令,这些命令可以提供基本的内容追踪和DAG操作功能。porcelain是较小的git
命令集合,用户可能用这些命令来维护仓库和在各个仓库间进行交流。
尽管工具包已经为许多编码器提供了足够多的细小粒度的功能,但是应用开发者仍旧抱怨Git缺少一个可以链接的函数库。因为Git的可执行函数为die()
,它不可重入并且没有界面,网络接口和长时间运行的服务器必须用fork/exec命令来调用Git的二进制文件,这样做十分低效。
为应用开发者改变这种状况的工作正在进行中;详情请看“现在和未来的动作”部分。
6.5 仓库,缓存区和工作区(The Repository, Index and Working Areas)
git init
会创建一个.git子文件,如下图所示。
tree .git/ .git/ |-- HEAD |-- index |-- config |-- description |-- hooks | |-- applypatch-msg.sample | |-- commit-msg.sample | |-- post-commit.sample | |-- post-receive.sample | |-- post-update.sample | |-- pre-applypatch.sample | |-- pre-commit.sample | |-- pre-rebase.sample | |-- prepare-commit-msg.sample | |-- update.sample |-- info | |-- exclude |-- objects | |-- info | |-- pack |-- refs |-- heads |-- tags
Configuration:
初步配置本地仓库。
.git/config
- repositoryformatversion:如果需要改变仓库在磁盘上的形式,就可以修改这个参数。
- filemode: ture or false。是否注意文件的执行权限。false指当文件权限变化时,没有修改提示。
- bare:ture是指该仓库没有工作目录,只有git的相关文件。
- logallrefupdates: 记录所有的更新日志。
- ignorecase: 是否对大小写敏感。
.git/info/exclude
如果你不想提交一些在本地的文件,.gitignore
文件是一个选择。但是exclude文件不会被分享给其他人。
Hooks.git/hooks
一些常用的示例hooks被放在仓库里,它们只在特定的时刻触发,可以进行检查和提示。默认不生效,将“.sample”去掉可使其生效。
Staging Area.git/index
缓冲区。在工作目录下的修改,使用git add
命令后,可以将修改添加进缓冲区,这是并没有提交到仓库,使用git commit
后才真正的提交到仓库中。这样方便多个修改一次性提交。
1. 运行 “git add README”. 这会导致 git:
1. 拷贝内容( README )到git对象存储区,并将其内容包含为一个blob.
2. 在git index中记录: 文件名 README 和存储于对象存储区的 README blob的哈希值.
3. 标记 (记住) 这个 README 现在在用户的工作目录中状态为 “追踪”.
2. 运行 “git commit” ,以 “git index” 中指代的文件为参数,创建commit对象,Tree对象,最后建立新的树(树中的对象也放在对象存储区),并将它们保存到git仓库里.
详情参考:git index
Object Database.git/objects
一个对象数据库,包含了所有的内容和指向内容的指针,对象一旦创建就不可变了。.git文件比较大,并且用.git文件可以恢复出所有的工作目录。
References.git/refs
存储了本地和远程的branch、tag和commit。是一个指向一个对象的指针,一般是tag或者commit。
git checkout [branch]
将本地仓库的HEAD引用变成branch的引用,用该branch的数据填充缓冲区,更新工作目录下的文件为该branch的内容。
git add [files]
会将工作目录下的文件和index中的文件的校验和进行比较,看是否需要更新index中的文件,git仓库并没有改变。
6.6.对象数据库(The Object Database)
所有的对象都如下参数:类型,大小和内容。
Tree: 一般是表示一个目录。
Blob: 用来表示一个存储在仓库里的文件。
Commit: 指向一棵树,指向父commit,记录该次提交的一些信息。
Tag: 标记一个commit,可以给某个时间点记录别名和附加信息。
对象使用SHA来引用,这是一个由40个十六进制的数字组成的ID。ID具有如下属性:
- 如果两个对象完全相同,则它们拥有相同的SHA。
- 不同对象有不同的SHA。
- 对象的缺失和错误会通过重新计算SHA发现。
6.7. 存储和压缩
技术
将对象打包,用压缩到的形式存储,用一个目录文件来索引某个对象。git add命令会将工作目录下的修改文件创建对象,放入对象数据库,是没有被打包的,松散的对象。git会周期的进行压缩,或者通过命令(git gc)直接压缩。.git/objects/pack/
有两个文件,一个.idx文件和一个.pack文件,如图所示。
打包文件有两个版本,如图所示,详细解释。
版本2包含了每个对象的CRC校验值, 因此在重打包的过程中, 压缩过的对象可以直接进行包间拷贝(from pack to pack)而不用担心数据损坏. 版本2的打包文件索引同时亦支持大于4G的打包文件。
在第1版中, offset(偏移)和SHA值存在在同一位置. 但是在第2版中, SHA值, CRC值和offset被放在不同的表中. 两个版本的文件最后都是索引文件以及指向的打包文件的CRC校验值。
很重要的一点是, 要从打包文件中提取(extract)出一个对象, 索引文件不是必不可少的. 索引文件的作用是帮助用户快速地从打包文件中提取对象。 那些”上传打包”(upload-pack)和”取回打包”(receive-pack)程序(译注: 实现push和fetch协议的程序)使用打包文件格式(packfile format)去传输对象, 但是没有使用索引–索引可以在上传或者取回打包文件之后通过扫描打包文件重新建立。
6.8. 历史记录合并(Merge Histories)
Subversion这一类用线状历史的VCS,高版本的会取代它之前所有旧的版本,所以不直接支持分支。分支合并到主线后,无法知晓哪些变化来自哪些分支。
Git使用基于DAG的历史合并,某一分支的某次合并可以通过它父节点的SHA知道它由哪些分支合并而来。如图。
6.9. 接下来要做的事
不仅要方便脚本使用,还需要进一步设计来方便地嵌入其它应用,方便的集成进开发环境。现在已经开发出了一些可以被其他语言调用的库,同时还有一些支持Git存储的后端被开发出来。
6.10. 经验和教训
In software, every design decision is ultimately a trade-off.
每一个设计策略的选择都最终是一个权衡的过程。
- 原来的Git工具包的设计为其它IDE的集成带来了麻烦。早期,命令行的形式导致Git的可移植性很差,特别是对Windows而言,导致受众范围很小。
- Git命令也难记,错误信息也难以理解。