摘要:众所周知,Git 是当前最流行的分布式版本控制系统,近两年由于 DevOps 的迅速发展,一切皆代码正在成为标准实践。而这一切,都需要有一个配置管理中心统一管控,Git 毫无疑问的成为了这个领域的宠儿。日常开发工作中,我们经常用不同方式去下载和上传代码到 Gitee,那么这背后是如何实现的呢,让我们一起来聊聊 Git 不同的传输协议以及具体的实现。
前段时间在 InfoQ 公开课分享了 《Gitee 架构演进之路》主题(回放地址:https://live.infoq.cn/room/602 ),中间有简单介绍了集中 Git 传输协议,并展开讲解了一下 HTTP 协议的传输机制,分享后不少同学通过公众号反馈有没有更详细的实战介绍,除了推荐一些博客、官方文档,好像也没有更好的资料,于是想着写写文章来聊聊自己的理解。
Git 在官方文档介绍了四种传输协议,并且对比了它们之间的优劣,这里就不再赘述了,感兴趣的可以翻阅上面公开课的PPT或者直接查看官方文档。另外在源码设计文档非常详细的介绍了HTTP、SSH 及 Git 三种传输协议的定义和规范,后续所有的介绍均围绕官方文档进行,与官方文档不同的是本系列文章会通过具体的实战,使用 Go 语言来实现相关协议 Git 服务端,以此来加深对相关传输协议的理解。
Git 协议介绍:https://git-scm.com/book/zh/v2/%E6%9C%8D%E5%8A%A1%E5%99%A8%E4%B8%8A%E7%9A%84-Git-%E5%8D%8F%E8%AE%AE
源码设计文档:https://github.com/git/git/blob/master/Documentation/technical
HTTP 传输协议
Git HTTP 协议主要分为两种,一种是哑协议(Dump),另外一种是智能协议(Smart),也是目前各个提供 Git 托管服务普遍所采用的协议。
官方文档:https://github.com/git/git/blob/master/Documentation/technical/http-protocol.txt
HTTP 哑协议(Dump Protocol)
在 Git 1.6.6 版本之前是只提供哑协议的,哑协议只需要一个标准的 HTTP 静态文件服务,这个服务只需要能够提供文件的下载即可,Git 客户端会自动进行文件的遍历和拉取。
无论是哑协议还是智能协议,Git 在使用 HTTP 协议进行 Fetch 操作的时候,总是要先获取info/refs文件,这个文件是在裸仓库的目录下的,如果你已经有一个通过 Git 拉取的仓库,这个文件就在仓库根目录的.git/info/refs。不过这个文件一般情况下是没有的,它需要你在相应的目录执行git update-server-info进行生成:
.git git:(master) cat info/refs21f45f60fa582d085497fb2d3bb50163e59891eerefs/heads/historyef8021acf4c29eb35e3084b7dc543c173d67ad2arefs/heads/master
文件内容主要是服务端上每个引用的版本,客户端拿到这些引用之后,就可以跟本地的引用进行对比,对于缺失的对象文件,则通过 HTTP 的方式进行下载。
Tips1: 关于 Git 存储格式请参见:https://github.com/git/git/blob/master/Documentation/gitrepository-layout.txt,后续文章会展开介绍Tips2: 如果有更新都要手动执行update-server-info?答案是 No,可以配置 Git 服务端的post-receive钩子自动执行更新
所以,一次通过哑协议 Clone 的过程如下:(U:用户 C:客户端 S:服务端)
上面的那些地址是为了演示用,实际 Gitee 仅支持智能协议而不支持哑协议,毕竟对于一个公有云服务是不安全的。关于对象如何遍历这里也不再展开,后续文章会介绍
哑协议的实现非常简单,通过nginx即可简单实现,只需要配置一个静态文件服务器,然后将 Git 仓库以单目录的形式放上去即可;也可以使用 Go 快速实现一个简单的 Git HTTP Dump Server:
HTTP 智能协议(Smart Protocol)
HTTP 智能协议与哑协议最大的区别在于:哑协议在获取想要的数据的时候要自行指定文件资源的网络地址,并且通过多次的下载操作来达到目的;而智能协议的主动权则掌握在服务端,服务端提供的info/refs可以动态更新,并且可以通过客户端传来的参数,决定本次交互客户端所需要的最小对象集,并打包压缩发给客户端,客户端会进行解压来拿到自己想要的数据,整个交互过程如下:
通过监听对应端口,我们可以看到整个过程客户端发送了两次请求:
引用发现 GET https://gitee.com/kesin/taskover/info/refs?service=git-{upload|receive}-pack数据传输 POST https://gitee.com/kesin/taskover/git-{upload|receive}-packGit HTTP 协议要求无论是下载操作还是上传操作,都必须先执行引用发现,也就是需要知道服务端的各个引用的版本信息,这样的话才能让服务端或者客户端知道两方之间的差异以及需要什么样的数据。
1. 引用发现
与哑协议不同的是,智能协议的的服务端是动态服务器,能够根据期望来提供相关的引用信息,你可以根据自己的业务需求来决定想让客户端知道什么样的信息,通过抓包我们可以看到客户端请求的数据以及 Gitee 服务端返回的引用信息格式
我们需要关注的信息在 Header 和 Body,这里简单介绍一下,更详细的介绍请参见上面提到的http-protocol.txt文档 Header 包含了一些约定:
Cache-Control 必须禁止缓存,不然可能看不到最新的提交信息Content-Type 必须是application/x-$servicename-advertisement,不然客户端会以哑协议的方式去处理客户端需要验证返回的状态码,如果是401那么就提示输入用户名密码另外我们能看到返回的 Body 格式跟哑协议所用的info/refs内容是不一样的,这里是智能协议所约定的格式,客户端根据这个来识别支持的属性和验证信息,这是一个pkt-line格式的数据:
客户端需要验证第一首行的四个字符符合正则^[0-9a-f]{4}#,这里的四个字符是代表后面内容的长度客户端需要验证第一行是# service=$servicename服务端得保证每一行结尾需要包含一个LF换行符服务端需要以0000标识结束本次请求响应在HEAD引用后还有一系列的服务端能力的参数,这些参数会告诉客户端服务端具有什么样的能力,比如可以通过multi_ack模式进行数据交互等,这里不在赘述。再往后就是具体的每一个引用的信息,每行的开头四个字符均是本行的长度。
在介绍哑协议的时候,我们使用通过git update-server-info命令生成的info/refs文件,但是很明显我们在智能协议这里无法直接使用,因为它不符合pkt-line的格式,所以 Git 提供另外一种方式:通过 git upload-pack命令来直接获取最新的pkt-line格式的引用信息,来看看它的参数支持:
upload-pack是用来发送对象给客户端的一个远程调用模块,但是它提供了--stateless-rpc和--advertise-refs参数,能够让我们快速拿到当前的引用状态并退出,我们在服务端的裸仓库目录执行就可以直接拿到最新的引用信息:
.gitgit:(master)gitupload-pack--stateless-rpc--advertise-refs.010aef8021acf4c29eb35e3084b7dc543c173d67ad2aHEADmulti_ackthin-packside-bandside-band-64kofs-deltashallowdeepen-sincedeepen-notdeepen-relativeno-progress
include-tagmulti_ack_detailedno-donesymref=HEAD:refs/heads/masteragent=git/2.24.3.(Apple.Git-128)003fef8021acf4c29eb35e3084b7dc543c173d67ad2arefs/heads/master0000%
这里的内容是不是似曾相识,跟上面我们抓包获取到的 Gitee 返回的引用数据格式一样,只是少了首行的# service=git-upload-pack,所以我们现在思路非常清晰,可以先来实现第一步引用发现的服务端的处理,通过对参数的解析,我们可以拿到仓库名称以及相应的操作名称,就可以进一步整理出客户端要的响应格式:
上面我们也提到了,无论是拉取还是推送,都需要先进行引用发现,实际上upload-pack和receive-pack所处理的差别仅仅是调用的命令不同而已,这一点我们也在handleRefs函数里面做了相应的兼容处理,这里不再赘述。
2. 数据传输
数据传输分为两部分:客户端向服务端传输(Push)、服务端向客户端传输(Fetch)。两者的区别在于:
Fetch 操作在获取引用发现之后,由 服务端 计算出客户端想要的数据,并把数据以pkt-line的格式POST给服务端,由服务端进行Pack的计算和打包,将包作为 POST 的响应发送给客户端,客户端进行解压和引用更新Push 操作获取到服务端的引用列表后,由 客户端 本地计算出客户端所缺失的数据,将这些数据打包,并POST给服务端,服务端接收到后进行解压和引用更新Fetch 操作其实用到了上面我们提到的upload-pack,它是一个发送对象给客户端的远程调用模块,为了实现拉取功能,我们只需要在服务端启动 git upload-pack --stateless-rpc ,这个命令阻塞的接收一串参数,而这串参数是客户端的第二次请求发送过来的,把它传递给这个命令,Git 就会自动的计算客户端所需要的最小对象集并打包,以流的形式返回这个包数据,我们只需要把这个包作为 POST 请求的响应发给客户端就好了。
那么,在 Fetch 操作中,客户端第二次 POST 请求发过来的数据是什么呢,我们也来抓个包分析一下:
客户端在拿到第一次引用发现服务端返回的数据后,会根据服务端所提供的能力(capabilities)列表以及引用(refs)列表来计算出第二次需要发送的数据,比如会根据服务端的能力列表来决定客户端和服务端通信所需要的能力参数,这些能力参数服务端必须全部支持。另外,客户端发送的数据必须包含一个want指令,我们在 Clone 一个仓库的时候所发送的数据全部都是want指令,而不包含have指令,因为本地什么都没有;而在进行有数据的更新的 Fetch 操作的时候,就会有have指令。客户端会根据返回的引用信息计算出所需要的 Commit、Common Commit 以及 自己有服务端没有的 Commit,并将这些数据一次性的通过第二次请求发给服务端,具体客户端的协商过程可以参见http-protocol.txt,这里不再赘述。
服务端在收到这些数据之后,会先确认want指令所指定的对象是否都能够在引用中找到,如果没有want指令或者指令指定的对象中有不包含在服务端的,则会返回给客户端错误信息,服务端根据这些信息计算出客户端所需要的对象的集合,并把这些对象打包返回给客户端,客户端接收后解压包并更新引用。
Push 操作大同小异,只不过在第二步的时候,客户端会根据服务端的引用信息计算出服务端所需要的对象,直接通过 Post 请求发送给服务端,并同时附带一些指令信息,比如新增、删除、更新哪些引用,以及更新前后的版本,具体格式如下:
这里的包数据格式为"PACK" ,会以PACK开头。服务端接收到这些数据后,启动一个远程调用命令receive-pack,然后将数据以管道的形式传给这个命令即可。
所以,整个数据传输的过程无非就是客户端与服务端的upload-pack和receive-pack对规定格式的数据交换而已,根据这个思路,我们可以继续完善我们的 Smart Git HTTP Server,来增加对第二步的处理能力:
完整的实现见:https://gitee.com/kesin/go-git-protocols/tree/master/http-smart
Git && SSH 传输协议
Git 协议以及 SSH 协议都是四层的传输协议,而 HTTP 则是七层的传输协议,受限于 HTTP 协议的特点,HTTP 在 Git 相关的操作上存在传输限制、超时等问题,这个问题在大仓库的传输中尤为明显,相比与 HTTP 而言,Git 以及 SSH 协议在传输上更简单而且更稳定。
官方文档:https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt
Git 协议
Git 协议最大的优势就是速度快,因为它没有 HTTP 的传输协议的条条框框,也没有 SSH 加解密的成本,但受限于协议的缺点,Git 协议常用于开源项目的下载,不作为私有项目的传输协议。
在上面我们研究 HTTP 智能协议的实现的时候,我们知道 Git 客户端跟服务端的交互主要包含两个步骤:
获取服务端的引用客户端根据服务端的引用数据与服务端进行数据交换Git 协议也是如此,只不过相比于 HTTP 协议,Git 协议直接在四层与服务端建立连接,通过这个长链接直接完成两个步骤:
MMwkPXVJQrK2g/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1)在使用Git协议操作的时候,首先客户端会把相关的信息发给服务端,这个信息的格式同样的采用pkt-line的格式:
003egit-upload-pack /project.git\0host=myserver.com\0\0version=1\0
其中包含了命令、仓库名称、Host 等相关信息,服务端建立连接之后,接收到这串信息,需要对其中的信息进行加工,找到对应的仓库所在的位置也就是目录,当所有的信息都符合要求之后,只需要在服务端启动upload-pack命令即可,这里需要注意的是我们不需要添加--stateless-rpc参数,直接git upload-pack {repo_path},这个命令启动后会马上返回相关的引用信息并且阻塞等待下一次信息的输入:
hello git:(master) git upload-pack .010234d8ed9a9f73d2cac9f50a8c8c03e4643990a2bf HEADmulti_ack thin-pack side-band side-band-64k ofs-delta shallow deepen-since deepen-not deepen-relative no-progress include-tag multi_ack_detailed symref=HEAD:refs/heads/master agent=git/2.24.3.(Apple.Git-128)003f34d8ed9a9f73d2cac9f50a8c8c03e4643990a2bf refs/heads/master0000
这个时候我们所做的其实就是数据的转发,命令的标准输出信息我们原封不动的发送给客户端,客户端则会进行跟 HTTP 协议类似的处理产生数据,接着会把数据发给服务端,我们再原封不动的发给git upload-pack {repo_path}命令的标准输入,然后服务端处理完成后会把相应的包通过标准输出返回,我们原封不动的发给客户端,就完成了一次 Fetch 操作,而 Push 的 receive-pack 操作原理相同,这里不再赘述。
需要注意的是,如果客户端发送的信息不符合要求,或者处理过程中出现了问题,我们返回错误告知客户端,这个错误的格式也是pkt-line格式的,以ERR开头:
客户端接收到这个信息之后,就会打印信息并关闭连接,整个过程的数据均可以通过转包获取到,感兴趣的同学可以通过抓包来进一步加深了解 Git 协议的传输过程。
了解了 Git 协议的过程之后,我们就可以通过代码来实现一个简单的 Git 协议服务器:
完整的实现见:https://gitee.com/kesin/go-git-protocols/tree/master/git-server
SSH 协议
SSH 协议也是应用的比较广泛的一种 Git 传输协议,相比于 Git 协议,SSH 协议从数据传输和权限认证上都相对安全,但是受限于加解密的成本,速度会稍慢,但是这个时间成本在安全面前绝对是可以接受的。与 Git 协议比较,不同点是 SSH 协议传输的数据经过加密,相同点是 SSH 协议的传输过程与 Git 协议一致,都是跟服务端的进程做数据交换:
SSH 的下载地址一般都是 [email protected]:kesin/go-git-protocols.git 这种形式的,在执行 Clone 或者 Push 的时候,会拆解成:
[email protected]"git-upload-pack '/project.git'"
所以 SSH 协议在首次传参的时候与 Git 协议的格式不同,其他情况基本一致,比如引用发现、Packfile 机制、错误处理等等,这里都不再做延伸,可以参加官方文档。
对 Packfile 的协商生成策略感兴趣的可以参见:https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt#L229
明白 SSH 协议是怎么回事后,我们想要实现一个 Git SSH 服务器就比较明确了,只需要实现一个 SSH Server 并且在对应的 Session 做对应的数据传输即可,我们来实现一个简单的 Git SSH 服务,代码如下:
完整的实现见:https://gitee.com/kesin/go-git-protocols/tree/master/ssh-server
总结
Git 传输协议还有很多进阶的用法由于篇幅的限制未介绍到,比如 Packfile 协商机制、Shallow mode、Protocol V2等等,基于这些机制我们可以发挥想象力,实现具有特定功能的 Git 服务来为某一场景的用户服务,更多的请研读官方文档,没有什么比官方文档更全面了。Git 是一个非常好的版本控制系统,相信未来它会有更广泛的应用,也会有更多的功能推出来适应飞速发展的研发效能体系。
举报/反馈