在使用任何一种代码版本管理软件之前,我曾经这样做:找了一台 windows 电脑,建立了一个网络共享文件夹,然后把我的源码工程目录,每天都复制一份到这个共享文件夹中,用日期作为文件夹名字。这样不管是老板的需求“退回”到之前的任何一天的说法,还是有一个小伙伴不小心覆盖了我写的某个源码文件,我都能找回来——最多损失一天的工作量。
很多年以前,即便是开发操作系统这种大型软件的公司,也缺乏代码版本管理的工具,程序员们会把每个能运行的源码,用打印机打印出来存档。那个时候的软件公司,可真的有“堆积如山”的代码。然而,代码版本管理的需求,其实远远不止是软件开发领域需要,任何需要人来对电脑进行输入的工作,其实都需要。因为这种需求包括了:
后悔药:退回之前某个工作状态的需求
合作:方便两个以上的人,合作编写一份文件或者一组文件的需求
分支:工作的内容,有可能需要从某一个版本开始,变成两个以上的不同内容的产品,而各自延续开发下去的需求
从最初的共享网络文件夹的方案来看,其实上面三个需求,基本都是可以手工处理的:
每次自己觉得满意的时候,就复制一份自己的工作目录到网络共享文件夹中,然后取个新的目录名
和其他同事共同使用一个网络共享文件夹
如果需要的话,可以从以上的任何一个目录,复制一份到自己的工作电脑里面继续工作
但是,上面这三个操作,其实是需要大脑去记住这些规矩,然后才能生效的。另外,这里还有一个很大的问题,就是:如果两个人同时修改了一个文件,那么就可能出现一个人覆盖另外一个人的工作的情况——这种情况下的办公室里面,常常会突然有个人大喊:“我现在开始改 XXX 文件了,大家不要覆盖了我的啊!”。这显然是一个很容易出问题的工作方法,所以这种情况最好也能通过某些软件工具来处理。
因此就有人发明了所谓的“版本管理软件”,来代替完全手工的进行上述的操作——但实际上还是需要人去操作,这些工具也是需要人理解后按规矩操作的。由于最早流行的 CVS(Concurrent Versions System) 软件已经很少用了,所以我们先来看看著名的 SVN(Subversion) 是怎么解决上面的问题的。
对于 SVN 来说,那个“网络共享文件夹”还是存在的,但就不仅仅那么简单,而是会使用一个专门的服务器进程,一般叫 Subversion Server,这个服务器可以通过 HTTP 协议来访问,也可以通过自己的 SVN 协议。每个 SVN 库都会有一个 URL 来代表,任何一个 SVN 客户端都可以通过这个 URL 对此库进行访问,HTTP 协议的 URL 就会是 http:// 或者 https:// 开头,SVN 协议的 URL 会是 svn:// 开头。通过 HTTP 协议,我们能通过 Web 浏览器就能访问存放的目录和文件,而且可以通过各种防火墙来使用;如果使用 SVN 协议,性能和安全性都会更好一点。
当我们建立一个 SVN 库(Repository),在 SVN 服务器上表现为就是一个目录。当你使用 svn checkout url 命令,在你的工作电脑上建立一个“同步盘”目录时,称为建立了一个“工作目录”(Working Copy),然后你就可以开始在这个目录里面工作了。
添加文件/目录:由于你可以在这个目录里面放入任何文件和子目录,而真正需要进行“版本管理”的内容可能只是其中一部分,譬如有些目录和文件夹是编译器生成的临时文件,或者是一些测试用的数据文件等待,所以你需要用一些命令来让 SVN 明确需要“跟踪”的文件,这个命令叫 svn add <file.name> ;
删除文件/目录:既然有增加文件到 SVN 里,就有删除: svn delete <file.name> 。
上传文件:一旦一个文件被 SVN 管理起来了,最基本的就是“上传”和“下载”了,在 SVN 里面,使用 svn update <Dir> 命令,可以把服务器上对应目录 Dir 的最新内容,都下载到本地的工作目录来;
下载文件:使用 svn commit <Dir> 命令,会把目录中做的修改,包括新增或者删除文件,以及文件的修改,都上传到服务器去。
从基本操作来说,SVN 的功能其实和使用网盘或者共享文件夹,是非常类似的,所以很多人都很容易上手。——值得注意的是,如果你没有用 svn delete 命令告诉 SVN 要删除某个文件,那么就算你在本地目录删除了一个文件,只要一运行 svn update ,那个文件就重新生成,所以有时候我们在一顿修改把工作目录搞的很乱,想重新来过的时候,会直接删掉一堆目录,然后再运行一次 svn update
如果你认为 SVN 只是一种网盘的话,那就太小看它了。它最核心的功能之一,是能发现“冲突”,并且提示使用者去解决冲突。假如你和另外一个开发者,对同一个文件进行了 svn update 下载,然后你们分别对这个文件都进行了修改,然后你们之中的其中一位用 svn commit 上传了这个文件,这个时候 SVN 是不会有问题的,但是你们之中第二个人,在第一个人 svn commit 之后,再运行 svn commit 命令试图上传自己修改的版本时,SVN 就会返回一个提示:文件出现冲突,并且给你提供了几个选项:
如果你选择手工操作,你可以在手工改好内容后,对改好的文件使用 svn resolve 命令,通知 SVN 你已经搞定这个冲突,然后就可以继续 svn commit 去上传没问题的文件了。之所以 SVN 能发现“冲突”,是因为每次不管谁进行了 svn commit ,服务器上的对应文件就会生成一个新的“版本”,它的版本号就会 +1。一旦发现你有人提交的文件的版本号,是比服务器上更“旧”(数字更小)的,那么服务器就会认为由冲突发生了。(这在软件开发里面我们称为“乐观锁”)。由于 SVN 服务器会自动生成很多版本,所以你也可以通过 svn revert 命令来让任何一个文件“回退”到任何一个过去的版本上,这个能力在有些系统中称为“快照”(snapshot)功能。
如果是一般的工作,用 svn update 和 svn commit 就能完成,但是有一种情况还是有问题:如果你的工作内容,需要同时保留多个副本,每个副本需要提供给不同用处。这些副本也可能各自进行修改以满足不同的需求,最后可能其中一些副本的修改,还需要同样应用到其他副本上。这种需求我们一般称为“分支”功能,我们的目录和文件,就好像一棵树一样,需要分开两条分叉,分别修改演化。对于这种需求,最原始的做法,就是把工作的目录文件,拷贝出多个目录来,然后每个“副本”目录用于特定的一个需求,如果需要修改,就直接改那个目录好了。但是如果这样的话,我们这些不同的“分支”,互相之间的修改,可能很难重复应用过来,譬如在分支一上修改了一个文件,在分支二上也要修改这个文件,很容易出现重复修改改错的情况。
SVN 对于分支的操作,非常的在直观。我们可以通过 svn copy 的方式对任何一个目录建立一个分支,建完之后你看到的就是多拷贝了一个目录。然后你就可以对这个目录,进行任何的 SVN 操作。最重要的是,你可以通过 svn merge 把另外一个目录,以分支的方式,把它的修改全部“应用”到当前这个目录上来。这样你就不用担心重复操作会有问题了。你也不需要担心 svn copy 造成太多的服务器磁盘空间占用,因为并不会真正的完整复制一个目录里的全部文件,而仅仅是复制了那些文件的修改记录的一个 ID。
在真正的项目开发中,一个工程可能是持续演进的:一个版本是已经发布给客户使用的,一个版本正在进行发布前的测试或部署,另外可能还有多个不同的版本正在进行后续的开发。虽然以上情况,我们可以通过建立分支来应对,但是这些分支之间,也需要一定的规矩,才能避免混乱(标志就是分支合并时出现大量的冲突)。现在比较公认的分支管理风格,是把分支分成三类:
trunk 主干分支,一般集成或者测试使用这个分支。所有的其他分支,都从这个分支 copy 出去;所有其他分支的有用代码,都需要 merge 回到这个分支。所以称之为“主干”。通常不会直接对这个分支进行 commit 操作。
branches 各种分支,每个新的特性、迭代、对外版本,都会建立一个 branches/xxx 的分支。然后在这些分支上开发功能和测试验证,每个对外的大的“软件版本”都对应一个这种分支。如果指定的功能完成了,就需要把所有的修改都 merge 到 trunk 分支去。
tags 如果一个软件的大版本通过测试可以发布了,就会从 trunk 分支 copy 一个 tags/x.xx 的分支,分支的目录名 x.xx 一般是对外的软件版本号。然后所有的软件打包、部署、发布,都是用 tags/x.xx 这个目录里的文件。如果软件发布后,发现有 bug 需要修正,就从 tags/x.xx 上 copy 一个专门修 BUG 的分支(brances/hotfix_x.xx 分支),在新分支上 commit 修改代码,重新测试完成后,需要把这些修改 merge 回 trunk 上面,视紧急情况是立刻从 trunk 建立新的 tags/x.xx.x 进行发布,还是等待下一次功能版本完成后一起发布。总体来说,tags 目录下的分支,也都不进行 commit 以及 merge 操作,只从 trunk 进行 copy 出来,所以看起来好像是 trunk 的一个个不同版本阶段的“标记”一样。
这种分支管理的规矩,能让项目始终都有一个核心的代码集成的地方 trunk;对外发布的代码由 tags 目录下的分支代表,每个用户版本的代码,都有一份完整的存档,对于找到用户反馈的故障非常重要;而每个短期开发目标建立一个 branches 分支,可以让开发过程中避免其他认或者其他任务的干扰,集中精力先完成预定的目标,当然,最后在向 trunk 进行 merge 的时候,可能会收到很多冲突报告,需要专门集中力量进行解决;如此用 trunk 代码进行完整测试,如果有问题应该都可以发现。大家在很多开源软件的源码库当中,可能还能发现有 trunk/branches/tags 这种目录,就是因为使用了这种分支管理风格进行开发。
以上关于分支的用法,比起只用一个分支把 SVN 当网盘使用的用法,要复杂的多。但是 trunk/branch/tag 的概念,直接启发了后续最常见的版本管理软件 Git 的设计。
从某种意义上来说,SVN 已经足够好了。但是对于 Linux 的发明者 Linus Torvalds 来说还不够好,他为了更好的管理开源的 Linux 内核,又创造了一套 Git 进行版本管理。Git 和 SVN 的主要区别,就是所谓的“分布式”和“集中式”的差异:
SVN 需要有一个 SVN 服务器来使用。如果你暂时连不上网,譬如在飞机上,那么你写的代码就是“没有后悔药”状态;又如果你和服务器之间的网络比较慢,那么每次修改的 commit 和 update 都会非常慢,严重影响开发的速度;最糟的情况是你的 SVN 服务器整个挂了,甚至是硬盘完蛋了,那么你的项目可以说基本灰飞烟灭了。
GIT 不需要一个服务器就可以使用。你通过 git init 命令,可以让你电脑上的任何目录,都成为一个 GIT 的“版本库”,然后你就可以直接在这个目录里面,进行修改和 git commit 操作建立“版本”(快照),随后你也可以用 git revert 吃下“后悔药”找回以前版本的文件。同时你还可以进行分支建立 git branch 和打标签 git tag 操作(这两个操作的概念明显来源于 trunk/branch/tag 的想法)。
由于 git 库是在本地的,所以你不需要用类似 svn update 的方法来获得版本库里的内容,只要你建立了 git 库,直接修改完文件,就可以直接 git commit 向库里提交修改了;同样的,一个文件想要加入 git 库,也是需要通过 git add 开始跟踪,同样要删除文件也需要 git rm 。为了让不同的文件修改可以被放到不同的 commit 提交批次里, git add 命令还承担了添加其他修改、删除操作到 commit 列表里的功能。
显然 GIT 不可能只用在本地电脑上进行版本管理,它同样可以把版本库放到网络上的其他机器上,你可以设置 git remote 来指定多个远程机器作为你的 git 库的备份。git 支持 http 协议和 ssh 协议,在一般的 Linux 上很容易架起一个“服务器”让你的 git 目录成为一个远程分支。和 SVN 不同的是,git 是把版本库里的某个分支里所有内容,包括全部的文件目录,放到另外的一个机器上,而不仅仅是某个特定的目录,因此如果你想向远程 git 分支 git push 的时候,就算远程分支和你现在的分支的差异不在同一个文件上,你也需要先通过 git pull 的方法把远程分支的完整内容合并过来,才能成功的把你做的修改合并到远程分支上。
git 的远程操作,完全是由“分支”的概念进行操作的,所以不存在一个核心的主版本库,任何人都可以把其中一个分支放到网上,然后互相合并,只要你能解决分支合并时碰到的冲突就可以——如果你要推送你的分支到服务器上,你就要先获得其他人已经推送了的分支内容,处理这些内容你现在的分支内容的差异,然后合并成一个最终的版本,再让服务器的分支进行合并。
由于 git 的分支,并不是以目录的形式直观的展示的,所以很多使用者一开始都觉得很费解。而早期的很多 git GUI 客户端程序,也没能很明白的表达清楚这个概念,所以刚开始很多人会觉得 git 很难用——特别是期待 git 可以用类似网盘的想法来使用的人。实际上,git 不存在类似网盘的操作,它是把两个版本库(等同于 SVN 的服务器)进行合并,来实现网络代码共享的。git 库在建立的第一个时刻,就已经建立了一个分支,一般叫 main 或者 master,无论何时,你总是工作在某个分支上。而对于 SVN 使用者来说,很多时候可以对“分支”完全没有概念,也可以有效的工作。
当然 git 的基于分支的版本管理理念,并不总是“先进”的。如果的 git 库里面有大量的文件,譬如整个公司所有的微服务接口代码都存在一个库里(为了方便大家随意调用任何一个微服务),那么在每个新员工或者新电脑上,进行第一次的 git clone 的时间,将是无比漫长的,因为这是在把整个公司的全部微服务接口的完整历史都下载一次到这个电脑上。其中有些服务接口可能这位员工永远都不会用上。这个特征对于游戏项目来说也是很要命的,因为游戏项目中可能有大量的美术资源图片文件,这些文件不但数量多,而且不是文本的,这意味着这些文件的历史修改记录,是以文件拷贝的形式存在,每一个版本都需要复制一份拷贝到本地目录(文本文件由于可以通过 diff 算法对比出两次修改的差异,所以只会存储差异的部分)。对于大型游戏来说,光是这些历史文件就可以撑爆当前电脑的硬盘。所以 git 很适合主要是文本文件构成的项目,而且项目的整体文件数不会太大的情况,对于一般的开源软件、库,确实是非常好的选择。
对于 git 来说,同样有分支管理风格,现在比较公认的是所谓 git flow 风格:
master 分支:长期分支,代表了项目已经发布的最新版本状态。所有的正式发布都使用此分支,禁止直接进行 commit 修改代码,只接受从其他分支进行代码合并。
develop 分支:长期分支,代表了项目的最新状态。所有的新特性,都应该从这个分支拉出新分支开发,开发完成后需要合并回到这个分支。一般软件的测试和集成都使用这个分支。
feature/xxx 分支:短期分支,项目的具体特性开发,每个功能建立一个分支,从 develop 建立,最后合并到 develop 去。如果最后功能完成发布,这个分支可以删除。
tag 功能,git 支持对分支的某个版本做上标记(tag),然后可以指定一个 tag 的名字下载那个版本的全部文件。软件测试完成后,在 master 分支上打上 tag,然后用这个 tag 对应的文件进行发布。
hotfix/xxx 分支,短期分支,发布后发现 bug 之后的修复分支,从 master 的 tag 版本新建,修复完成后合并代码回 master 分支。此 hotfix 分支验证无误后,可以删除。
从这个风格上看,其实和 SVN 的 trunk/branch/tag 如出一辙,trunk 就是 master+develop,branch 就是 feature,只是因为 git 有了给版本打标记 tag 的功能后,就不需要搞一堆 tags/x.x 的分支用来进行发布代码存档了。
但是,由于 git 的分支功能非常“常用”,有一些人会整出一些“邪门”的玩法:譬如一个项目的 master 分支是没有什么内容的,而把项目中的每个模块建立一个分支,最后集成的时候,分别拉取不同的分支下来进行合并部署、发布;又或者每个参与项目的程序员自己建立一个 dev_Tom/dev_Jerry 这样的分支,每个人都在自己的分支上开发,最后再向 master 进行合并。这些做法普遍是把分支当成的网盘的目录来用,虽然不能说有什么现实的“危害”,但并不是分支设计之初希望的用法。由于 git flow 风格不但是一个使用的规矩,还有一些开源的软件对 git 命令进行的包装,所以相对来说是更方便的,所以我们还是应该尽量了解 git flow 这一类风格。另外,像 Perforce(P4) 这种版本管理软件——在各大型游戏公司都很流行,它的设计思想,就是和 git flow 风格非常类似的——Perforce 的 Stream 基本上就是按 git flow 风格自动管理的分支。
从 SVN 到 GIT,再到 Perforce,我们可以看到版本管理的思想,是一步步的变化的,这些工具也是顺应着大家的想法来制作的。对于版本管理来说,每次提交生成的“版本”观念,可以并行存在的“分支”观念,就是版本管理这方面知识背后的“方法论”,而 gif flow 这种分支管理的规矩,就是所谓的“过程”,因此版本管理的这些知识,完全符合软件工程的知识特征。它是为了提高开发效率而诞生的知识,有自己的方法论、工具和过程。
对于其他的软件工程知识来说,版本管理的知识也非常重要。譬如“持续集成”的思想中,就是依赖于版本管理的分支概念,来实现自动化的,可靠的集成操作。很多的软件工具,为了利用版本管理工具,也把自己的数据文件设计成文本文件格式,譬如 Unity 引擎的资源文件,Office 文件中的 XML 格式;Unreal 引擎则是自带了 Perforce 的支持。
虽然 SVN/GIT 最初是设计用来作为软件代码开发使用的工具,但除了软件源代码以外,我们工作中的大部分文档和其他格式的工作成果,都可以借鉴甚至直接使用 SVN/GIT 来大大提高我们的工作效率。从这个角度来看,软件工程的知识用处还真的是很广泛呢。
评论区
共 4 条评论热门最新