开源软件架构-Git

Git 能够让不同的协作者通过一个点对点的仓库网络对数据内容(通常是代码,当然不仅限于代码)进行维护。它支持分布式的工作流程,能够让数据内容临时分离,并最终合并到一起。

原文地址:http://aosabook.org/en/git.html;作者:Susan Potter

本节将阐述 Git的内部实现是如何提供以上功能的,以及它和其他版本控制系统(VCS)的 区别。

Git 的起源

为了更好地理解 Git 的设计思想,我们有必要先了解一下 Git 项目的发源地-Linux 内核开发社区所面临的问题。

Linux 内核开发与其他商业软件项目有很大不同,因为它的开发者众多,且每个开发者的参与程度和对 Linux 内核代码的理解有很大差异。多年以来,内核代码一直都是以 Tar 压缩文件以及补丁的形式维护的,而当时的核心开发团队一直在寻找一个能够满足他们各方面需求的版本控制系统。

Git 就是在这样的背景下于2005年作为一款开源软件诞生的。当时,Linux 内核代码通过两种版本控制系统进行维护,BitKeeper 和CVS,分别由两组核心开发团队使用。BitKeeper 相较于当时颇为流行的 VCS,提供了一种不同的历史展示方式。

当 BitKeeper 的所有者 BitMover 决定收回 Linux 内核开发人员的使用许可时,Linux Torvalds 紧急开启了一个项目,也就是后来的 Git。一开始,他通过编写一组 Shell 脚本来帮助他将邮件中的补丁按顺序应用到代码中。这组原始脚本能够在代码合并过程中迅速中断,让维护者能够进行人工干预,修改代码,然后继续合并。

Linus Torvalds在Ted的Talk:Linus Torvalds: The mind behind Linux

从项目开始之初,Torvalds就为 Git 制定了一个目标-要和 CVS 的做法完全相反,同时还包含了以下三条设计目标:

  • 支持分布式的协作流程,类似 BitKeeper
  • 预防代码错乱
  • 高性能

这些设计目标都被实现了,我会在下文中通过解析 Git 的各种做法来阐述,包括在内容管理中使用有向无环图(DAG),头指针引用,对象模型,远程协议,以及 Git 如何追踪合并树。

虽然 Git 设计之初受到了很多 BitKeeper 的影响,但是两者还是有根本上的区别的,如 Git 提供了更多分布式和本地开发流程,这点是 BitKeeper 做不到的。Monotone,2003年启动的一个开源分布式版本控制系统,也对 Git 的早期开发产生了影响。

分布式版本控制系统在提供更灵活的工作流程的同时,往往会增加它的复杂程度。分布式模型的独特优点有:

  • 能够线下进行增量提交
  • 开发者可以决定自己的代码何时能够开放出来
  • 能够线下浏览历史
  • 可以将工作成果发布到不同的仓库,以不同的分支、不同的提交粒度展现出来

在 Git 项目的开发期间,诞生了其他三个开源分布式版本控制系统。这些分布式版本控制系统(dVCS)都提供了非常灵活的工作流程,这是先前的集中式版本控制系统做不到的。注意:Subversion 有一款插件名为 SVK,由不同的开发者维护,提供了服务器之间的同步功能。

目前流行的dVCS包括Bazaar,Darcs,Fossil,Git,Mercurial 以及Veracity。

版本控制系统的设计

现在让我们回过头来看看 Git 之外的其他版本控制系统是如何设计的。通过比较他们和 Git 之间的区别,可以帮助我们去理解 Git 在架构设计中的选择。

版本控制系统通常有三项核心功能(需求):

  • 保存内容
  • 记录变更历史(包括具体的合并信息)
  • 向协作者分发内容和变更历史

注意:第三项并不是所有版本控制系统的核心功能。

保存内容

在 VCS 中保存内容,最普遍的做法是保存增量的修改,或使用有向无环图(DAG)。

增量修改可以反映出两个版本之间的内容差异,以及一些额外的信息。使用有向无环图保存内容则是将特定对象构造成一种树状结构,作为某一次提交的快照保存下来(树状结构中未发生变化的对象是可以重用的 )。Git 使用有向无环图来保存内容,它所使用的不同对象类型会在本文的“对象数据库”一节中有所描述。

提交和合并的历史

在保存历史、记录变化方面,大部分 VCS 使用以下方式之一:

  • 线性历史
  • 有向无环图

Git 使用的还是有向无环图,这次则是用来保存历史。每次提交包含了它父节点的元信息 -Git 中的一次提交可以拥有 0 个或多个父节点(理论上没有个数限制 )。例如,Git 仓库的第一次提交就没有父节点,而一次三头合并则有三个父节点。

Git 和 SVN 线性提交的另一个重要区别是 Git 可以直接进行分支的创建,并记录下大部分合并历史。

开源软件架构-Git

通过采用有向无环图保存内容,Git 能够提供完整的分支功能。一个文件的历史会通过它所处的目录结构位置和根节点关联起来,并最终和一个提交节点关联。这个提交节点又会有一个或多个父节点。这种组织方式提供了以下两个特性,让我们能够更好地在 Git 浏览文件历史和内容:

  • 当内容节点(文件或目录)在有向无环图中有相同的标识(Git 中以 SHA 码表示), 即使它们处于不同的提交节点,也能保证它们的内容是一致的,从而使得 Git 在差异比对时更为高效。
  • 在对两个分支进行合并时,实质上是在对两个有向无环图节点进行合并。有向无环图能够让 Git 更为高效地判断出他们共同的父节点。

内容分发

版本控制系统在向协作者分发内容时通常有以下三种做法:

  • 仅限本地:某些版本控制系统没有上文提到的第三项需求。
  • 中央服务器:版本库的所有改动都必须在一个中央版本库中进行,也只有这个版本库会记录历史
  • 分布式模型:虽然分布式模型中也会有一个中央仓库供协作者 “推送”自己的改动,但协作者可以在本地进行提交,并稍后再推送到远程。

为了展示以上设计模式的优点和不足,我们设想这样一个应用场景:一个 SVN 仓库和一个 Git 仓库,有着相同的内容(即 Git 默认分支的头指针指向的内容和 SVN 仓库最新的 trunk 分支内容一致 )。一个名叫 Alex 的开发者在本地检出了一份 SVN 代码,以及克隆了一个 Git 版本库。

假设 Alex 在本地对一个1M 大小的文件进行了修改,并进行了提交。提交后本地更新了元信息,远程服务器则是将文件的差异记录了下来。

Git 下则有所不同。Alex 对文件的变动首先会在本地进行记录,然后再“推送”到远程的公共仓库,这样文件的改动就能被其他开发者看到了。文件内容的变动记录在不同的版本库之中的表示方式是完全一致的。除了本地提交之外,Git 会为变动后的文件创建一个对象来保存它(包括其完整的内容),然后逐层为该文件的父目录创建对象,直至仓库根目录。接下来Git会创建一个有向无环图,从刚才新创建的根目录节点开始,指向各个二进制单元(期间会重用那些内容没有改变的二进制单元),并使用新创建的二进制单元去替代那些变动的部分(一个二进制单元通常用来表示一个文件)。

到此为止,本次提交还是只保存在 Alex 克隆下来的本地仓库中。当 Alex 将这个提交推送 到远程仓库后,远程仓库会验证这次提交是否能应用到当前分支中,然后这些对象将会按照原样保存下来,如同在本地仓库中创建的一样。

在 Git 中会有很多可变动的部分,有些对用户是透明的,有些则需要用户显示地指定这些内容是否需要分享出来,或是只在本地保存。虽然增加了复杂性,但也提供给团队开发者更大的自由度,得以更好地控制工作流程和发布内容,这在“Git 起源”一节中已经有所阐述。

在 SVN 中,开发者不会忘记将变动内容提交至远程仓库。从效率上讲,SVN 仅保存变动内容的方式会比 Git 保存文件每个版本的完整内容要来得高效,但是之后我们会讲述 Git 其实已经通过某种方式对此进行了优化。

工具包

如今,Git 已然形成一个生态系统,在各种操作系统上(包括 Windows)都开发出了大量命令行和图界面工具,而他们大部分都是构建在 Git 核心工具包之上的。

由于 Git 是 Linus 发起和开发的,它又立足于 Linux 社区,因此 Git 工具包的设计理念和传统的 Unix 命令行工具相仿。

Git 工具包分为两个部分:底层命令和上层命令。底层命令提供了基本的内容追踪手段,以及直接操纵有向无环图。上层命令则是用户主要接触的命令,用以维护仓库,以及在多个仓库间进行协作。

虽然 Git 工具包提供了足够多的命令来操纵仓库,但是开发者们还是抱怨 Git 没有提供类库以供调用。Git 命令最终会执行 die()方法,使得 GUI 和 Web 界面在使用它时必须启动一个新的进程,效率较低。

不过这一问题已经得到处理,我会在本文的“当前进展和未来规划”一节加以阐述。

版本库、暂存区、工作区

让我们开始深入研究一下 Git 吧,了解其中几个关键概念。

首先让我们在本地创建一个 Git 版本库。在类 Unix 系统下,我们可以执行以下命令:

这样我们就在 testgit 目录中初始化了一个新的版本库。我们可以建立分支、提交、创建里程碑(Tag)、和远程 Git 仓库进行交互。我们甚至可以和其他类型的版本控制系统进行交互,只需要借助若干 git 命令即可。

git init 命令会在 testgit 目录下创建一个名为.git 的子目录。我们来看一下这个目录的结构:

.git 目录默认创建在工作区的根目录下,也就是 testgit。它包含了以下几种类型的文件和目录:

  • 配置文件:.git/config.git/description 、.git/info/exclude,这些文件会用来配置本地仓库。
  • 钩子:.git/hooks 目录下的脚本可以在 Git 运作的各个环节中得到执行。
  • 暂存区:.git/index 文件(它并没有在上述目录结构中显示出来)会用来保存工作区准备提交的内容。
  • 对象数据库:.git/objects 是默认的 Git 对象数据库存放目录,囊括了本地仓库的所有文件内容和指针。对象一经创建则不能修改。
  • 引用:.git/refs 目录用来存放本地和远程仓库的分支、里程碑(Tag)、头指针等信息。 “引 用”表示指向某个对象指针,通常是 tag 和 commit 类型。引用之所以放置在对象数据库 之外,是为了让他们能够随版本库的演进而变化。特殊的引用可以指向其他引用,如HEAD。

.git 目录是真正意义上的版本库。工作区指的是包含所有工作文件的目录,它通常是 .git 目录的父目录。如果你需要创建一个没有工作区的远程仓库,可以使用 git init –bare 命令。它会直接在根目录下生成 Git 仓库的各类文件,而不是放置在一个子目录中。

另一个较为重要的文件是 Git 暂存区:.git/index。它在工作区和本地版本库之间增加了一个缓冲区,可以将需要提交的内容暂存在这里,最后一起提交。即使你对很多文件进行了修改,通过暂存区可以将它们作为一次完整的提交,并加注合理的注释。如果想将工作区某些文件的部分修改保存至暂存区,可以使用 git add -p 命令。

Git 暂存区里的内容默认保存在单个文件中。版本库、暂存区、工作区的存放位置都是可以通过环境变量来进行配置的。

我们有必要了解一下以上三个区域的文件是如何进行交互的,以几个核心的 Git 命令举例:

这条命令会将 HEAD 引用指向指定分支的引用(如 refs/heads/master),并用该引用指向的内容替换掉暂存区和工作区中的内容。

这条命令会检验工作区中指定的文件和暂存区是否一致,若不一致则更新暂存区。版本库不会发生变化。

为了深入挖掘其中的原理,让我们看看.git 目录下的文件都发生了哪些变化:

这里会返回一个错误信息,因为我们还没有在 Git 仓库中进行过任何提交,因此不会存在任何分支,包括默认分支 master。

让我们进行一次提交,这时 master 分支会自动创建:

输出的内容就是 Git 对象数据库中保存的信息了。

对象数据库

开源软件架构-Git

Git 有四种基本对象类型,版本库中的所有内容都是由这些基本对象类型构成的。每种对象类型包含以下属性:类型、大小、内容。这四种基本对象类型是:

  • 树(Tree):用来表示目录结构,树中的元素可以是另一棵树或是一个二进制单元(blob)。
  • 二进制单元(blob):表示一个文件。
  • 提交(Commit):提交会指向一个根节点树对象,并保存父提交的信息和其他基本信息。
  • 里程碑(Tag):里程碑有一个名称,并指向版本库中的一个提交对象。

所有的基本对象类型使用 SHA 码来标识,它是一种40位的十六进制字符串,含有以下特性:

  • 如果两个对象是一致的,则 SHA 码一致。
  • 如果两个对象不一致,则 SHA 码也不一致。
  • 如果只是拷贝了对象的一部分,或者对象的数据发生了其他更改,只需重新计算其 SHA 码就能区别开来。

前两种属性使得 Git 能够实施它的分布式模型(Git 的第二个目标),第三种属性则是杜绝了数据混乱(第三个目标)。

存储和压缩技术

Git 是通过压缩数据内容来解决存储大小的问题的,它使用一个索引文件来标识对象内容在压缩文件中的实际位置。

开源软件架构-Git

我们可以使用 git count-objects 来查看版本库中未经压缩的对象数量,然后让 Git 对未压缩的对象进行压缩,删除冗余的对象等。

Git 对象的压缩方式进行过升级。过去,压缩文件和索引文件的 CRC 校验码会全部保存在索引文件中,这就无法检测出压缩对象中存在的数据混乱,因为后续压缩过程中不会再进行校验。新版本(Version 2)的压缩格式中将每个压缩对象的 CRC 校验码都保存了下来,从而解决了这一问题。同时,新版格式允许压缩文件大于4GB,这在以前是不支持的。为了更快地检测压缩文件是否损坏,文件末尾会保存一个20个字节的SHA1码,对压缩文件中所有对象的 SHA 码进行排序和校验。新版压缩格式的主要目的是为了满足 Git 设计目标中的杜绝数据混乱。

对于远程传输,Git 会计算同步版本库(或分支)需要传输的提交和文件内容,生成相应的 压缩文件,通过指定协议进行传输。

记录合并历史

上文我有提到过,Git 和其它类 RCS 的版本控制系统的最大区别在于对合并历史的记录。如 SVN 将文件和目录结构的改动用线性提交来表示,版本号高的内容一定会覆盖版本号低的内容。因此,SVN不能直接提供分支功能,而是使用一种人为规定的目录结构来实现:

开源软件架构-Git

首先让我们用一个示例来说明要维护多个分支会多么麻烦,而且在某些场景下是有局限性的。

SVN 中的“分支”通常放置在 branches/branch-name,它和主干分支 trunk(相当于 master ) 目录同级。我们假设这个分支和主干分支是并行开发的。

举例来说,我们可能需要修改某个软件的数据库连接类型。在此过程中,我们想要将其他分支(非 trunk)的内容合并到当前分支中。合并完成后(可能需要手工合并 ),我们继续修改。全部完成后,我们需要将 branches/branch-name 分支合并到 trunk 中。在类似 SVN 的线性历史版本控制系统中,我们无法得知其他分支的内容是否已经包含在 trunk 中了。

而对于以有向无环图为基础的版本控制系统(如 Git)来说,就能很好地处理这种应用场景。如果某个分支不含没有合并至当前分支(如 db-migration)的“提交”,我们就可以通过“提交” 对象的继承关系来确定 db-migration 分支包含了那个分支的 HEAD 引用。由于“提交”对象可以包含零个或多个父提交,因此就能通过 db-migration 中的那次合并提交的信息来确定当前 HEAD 包含了两个分支中的内容。同理,当将 db-migration 合并至 master 分支时也能确认这些关系。

然而有一个问题无论是使用有向无环图还是线性提交都无法解决的,就是判断某个提交是否存在于每个分支中。例如上述例子中,我们假设已经将每个分支的提交都合并到各个分支去了。并不是所有情况下都是如此。

对于较为简单的情况,Git 可以将其它分支的 “提交”拣选(cherry-pick)到当前分支中,当然前提是这次提交必须是能够直接应用进来的。

下一步做什么?

上文提到,Git 采用来自 Unix 世界的工具包设计理念,因此非常适合用来编写脚本。但是,当需要在长时间运行的应用程序或服务中内嵌 Git 工具库的话就不太容易了。虽然目前流行的 IDE 都提供了 Git 图形化界面,但开发这些工具所需花费的精力还是比其他版本控制系统要多,因为它们提供了便于使用的链接库。

为了解决这个问题,Shawn Pearce(来自谷歌开源程序办公室)率先实现了一个可供链接的 Git 类库,且发布协议较为宽松,因此没有阻碍该类库的推广。这个类库的名字是 libgit2。一开始它并不流行,直到一个名叫 Vincent Marti 的学生在谷歌编程夏令营中使用了它。从那以后,Vincent 和 Github 持续对 libgit2 类库贡献代码,并为其他语言编写了相应类库, 包括 Ruby、Python、PHP、.NET、Lua、Object-C 等。

Shawn Pearce 还开启了一个名为 JGit 的 BSD 项目,使用纯 Java 语言实现,能够对 Git 版本库进行基本的操作。该类库现在由 Eclipse 基金会维护,用于 Eclipse IDE 的 Git 插件中。

还有其他一些有趣的周边项目,带有实验性质,使用各类数据源来保存 Git 对象,如:

  • jgit_cassandra 使用 Apache Cassandra 作为 Git 对象数据库。它是一种混合型的数据源,提供了动态的 BigTable 式的数据模型。
  • jgit_hbase 能够将 Git 对象保存在 HBase 中,一种 KV 型分布式数据库。
  • libgit2-backends 由 libgit2项目衍生而来,致力于提供其他种类的数据源, 如 Memcached, Redis, SQLite,MySQL 。

以上这些都是独立于 Git 核心工具包之外的项目。

如你所见,我们可以用各种方式来使用 Git,它的表现形式不再只有命令行这一种了,而是成为一种版本控制系统的协议。

在本文撰写之时,这些项目都还没有发布稳定版本,所以还是有很多工作要做,但整体看来未来是光明的。

经验教训

在软件设计中,任何一个决定都有正反两面。作为一个在日常工作中大量使用 Git,并且还为 Git 对象数据库开发了周边软件的程序员,我觉得 Git 目前的组织方式非常棒。因此,下文提到的 “经验教训 ”更多的是来自其他开发者对于 Git 目前设计方式的不满,主要归咎于 Git 核心开发者当初做出的决定。

最常见的问题在于 Git 相较于其他 CVS 不能很好地和 IDE 进行整合,因为 Git 是基于工具包设计的,整合起来会比较具有挑战性。

早期 Git 的实现是采用 shell 脚本的方式,不能很好地跨平台,特别是对于 Windows 操作 系统。虽然我相信 Git 开发者不会因为这个问题而寝食难安,但这的确阻碍了 Git 在大型公司内的推广。现在,有一个名为 Git for Windows 的项目由志愿者发起,及时地将最新 的 Git 开发成果移植到 Windows 平台上。

Git 工具包的设计方式所带来的另一个间接影响是,它的底层命令繁多,会让初学者陷入困境,难以理解 Git 出错时抛出的异常信息,最后无可适从。这就使得 Git 在某些开发团队中的推广受到阻碍。

即便如此,我仍然对 Git 核心项目以及其周边项目的开发充满信心。

相关文章

《开源软件架构-Git》有1个想法

发表评论

电子邮件地址不会被公开。 必填项已用*标注