从字面上来看,版本控制系统要控制的是版本,所以它本质上就是一个管理多个版本内容的工具。举个具体的例子,我在写这篇文章的时候,前前后后会需要修改很多遍,在这个过程中就会产生多个版本,但在这个过程中我没有使用版本控制,所以当我保存的时候,我就只有最新的这个版本,我没有办法回到中间产生的任意一个版本。假如我使用了版本控制来管理的话,当我觉得这个版本已经写得比较不错了,我可以保存后生成一个版本,后面即使我又改了几版,我依然可以回退到我保存过的任意版本。这种场景下,本地版本控制就可以解决。
另外一个产生多版本的原因,就是多人协作。团队开发会存在多人共同编辑同一个文件,大家改动的内容需要同步给其他人,这个时候就需要集中式或者分布式的版本控制系统。
下面我们先从本地版本控制入手,分析一个版本控制工具需要解决哪些基本问题:
接下来我们先抛开专业软件的实现原理,按照常规的思路推演,尝试针对上述的几个问题提出解决方案。
最常见的方式是在项目的根目录里创建一个隐藏目录,在该目录里保存版本信息。
project_directory .versions
复制代码
首先肯定是内容有变化的时候才需要生成一个版本,但是又不能一变化就自动生成版本,因为这样的话版本数量会爆炸,不利于跟踪内容变化过程。因此生成版本的时机,应该是由用户通过指令明确告诉系统。
一个项目里可能一次性改动了很多内容,但是并不是所有变化了的内容都需要生成版本,因为某些文件的内容并不需要记录版本信息(比如 IDE 自动生成的一些隐藏文件),或者用户不希望某些内容在这个版本体现,所以提交一个版本最关键的信息至少应该指明包含了哪些变化的文件。
最朴素的想法就是每新增一个版本,就把内容备份一遍。按照这个思路,一个版本实际上就是指向备份文件的根目录,这里面既包含了变化的文件,也包含了没有改动的文件。
图 1 版本的存储示意图
Path sourcePath = Paths.get("project_directory");Path destPath = Paths.get("project_directory/.versions");FileUtils.copyToDirectory(sourcePath.toFile(), destPath.toFile())
复制代码
把文件恢复到历史版本,有两种场景:一种是仅恢复单个文件,另一种是把该版本的文件都恢复。最直接的方式就是拿到这个版本里的文件,替换掉当前工作目录里对应的文件。如果是恢复单个文件,就只恢复指定的文件即可。为了以示区分,这里我们可以把当前工作目录称为“工作区”,把存放历史版本文件的地方称为“本地仓库”。
图 2 工作区和仓库
上述简单方案存在一个很明显的缺陷,那就是每个版本都把整个项目空间的文件复制了一遍,这样会导致版本控制的内容快速膨胀。因此,我们很快可以想到一个改进的地方:只保存变化的文件。假设现在每个版本只记录变化的文件,那在这个版本没有发生变化的文件,应该去哪里获取呢?有人可能会想到,去上一个版本,但上个版本还不一定能找到,因此可能还要再往前面的版本找。
图 3 文件查找示意流程
但这样效率太低了,效率低的原因在于我们不知道没有变化的文件到底记录在哪个版本下。因此需要再次改进:除了保存变化的文件,同时还要记录未变化的文件存放在哪个版本(可以保存一个指向未变化文件的软链)。
以上是一个比较简单且可落地的一个方案,下面我们看下当下最流行的 Git 是如何实现的。
Git 是一个内容寻址文件系统,核心部分是一个简单的键值对数据库。我们向 Git 仓库中插入任意类型的内容,它会返回一个唯一的键,通过该键可以在任意时刻再次取回该内容。
当在一个新目录或已有目录执行 git init
时,Git 会创建一个 .git
目录。 这个目录包含了几乎所有 Git 存储和操作的东西。新初始化的 .git
目录下最重要的 4 个条目:
1、HEAD
文件:指向目前被检出的分支;
2、objects
目录:存储所有数据内容;
3、refs
目录:存储指向数据(分支、远程仓库和标签等)的提交对象的指针;
4、index
文件:保存暂存区信息。
与我们的简单方案不同,Git 不是直接把项目空间的内容按照目录结构复制一份,而是通过引入三个 Git 对象来实现记录版本快照:数据对象(blob object)、树对象(tree object)、提交对象(commit object)。
数据对象用于记录文件。每个文件通过git hash-object
生成文件校验和,并把该对象写入数据库中,也就是前面提到的 objects 目录下。一个文件对应一条内容,以校验和为文件命名。 校验和的前两个字符用于命名子目录,余下的 38 个字符则用作文件名。
可以通过 cat-file
命令从 Git 那里取回数据。
git cat-file -p <checksum>
复制代码
数据对象代表文件,那项目中的目录层次信息在哪里维护?答案就是树对象。
Git 以一种类似于 UNIX 文件系统的方式存储内容,但作了些许简化。 所有内容均以树对象和数据对象的形式存储,其中树对象对应了 UNIX 中的目录项,数据对象则大致上对应了 inodes 或文件内容。 一个树对象包含了一条或多条树对象记录(tree entry),每条记录含有一个指向数据对象或者子树对象的 SHA-1 指针,以及相应的模式、类型、文件名信息。
从概念上讲,Git 内部存储的数据有点像这样:
图 5 Git 存储逻辑视图(引用自《Pro Git》)
数据对象和树对象解决了项目文件和目录结构的存储问题,Git 返回的都是校验和,如果要读取只能通过校验和,不能直观看出改动了哪些内容。此外,版本的其他信息,例如是谁保存了这些快照,在什么时刻保存的,以及为什么保存这些快照也没有地方记录。因此,引入了提交对象解决这个问题。
通过调用
commit-tree
命令创建一个提交对象,为此需要指定一个树对象的 SHA-1 值,以及该提交的父提交对象(如果有的话)。
git commit-tree <checksum> -p <parent checksum>
复制代码
图 6 Git 对象关系图(引用自《Pro Git》)
[1]. 《Pro Git》, https://git-scm.com/book/zh/v2/Git-内部原理-Git-对象
领取专属 10元无门槛券
私享最新 技术干货