GIT 傳輸協定實作
在 GIT 的三種主流傳輸協定 HTTP SSH GIT 中,GIT 協定是最少被使用的協定(也就是 URL 以 git:// 開始的協定)。 這是由於 git 協定的權限控制幾乎沒有,要麼全部可讀,要麼全部可寫,要麼全部可讀寫。所以對於程式碼託管平台來說, git 協定的目的只是為了支援 公開專案的唯讀存取。
在 git 的各種傳輸協定中,git 協定無疑是最高效的,HTTP 受限於 HTTP 的特性,傳輸過程需要建構 HTTP 請求和回應。 如果是 HTTPS 還牽涉到加密解密。另外 HTTP 的超時設置,以及包體大小限制都會影響使用者體驗。
而 SSH 協定的效能問題主要集中在加密解密上。當然相對於使用者的資訊安全來說,這些代價都是可以接受。
git 協定實際上相當於 SSH 無加密無驗證,也就無從談起權限控制,但實際上程式碼託管平台內部的一些同步服務,如果使用 git 協定實現,將會得到很大的效能提升。
傳輸協定規範
git 協定的技術文件可以從git 原始碼目錄的 Documentation/technical 找到,即 Packfile transfer protocols 建立TCP 連線後,git 用戶端率先發送請求體,請求格式基於BNF 的描述如下:
一個例子如下:0033git-upload-pack /project.git在 C 语言中,有 popen 函数,可以创建一个进程,并将进程的标准输出或标准输入创建成一个文件指针,即 FILE*其他可以使用 C 函数的语言很多也提供了类似的实现,比如 Ruby,基于 Ruby 的 git HTTP 服务器 grack 正是使用 的 popen,相比与其他语言改造的 popen,C 语言中 popen 存在了一些缺陷,比如无法同时读写,如果要输出标准 错误,需要在命令参数中额外的将标准错误重定向到标准输出。
在 musl libc 的中,popen 的实现如下:
FILE *popen(const char *cmd, const char *mode) { int p[2], op, e; pid_t pid; FILE *f; posix_spawn_file_actions_t fa; if (*mode == 'r') { op = 0; } else if (*mode == 'w') { op = 1; } else { errno = EINVAL; return 0; } if (pipe2(p, O_CLOEXEC)) return NULL; f = fdopen(p[op], mode); if (!f) { __syscall(SYS_close, p[0]); __syscall(SYS_close, p[1]); return NULL; } FLOCK(f); /* If the child's end of the pipe happens to already be on the final * fd number to which it will be assigned (either 0 or 1), it must * be moved to a different fd. Otherwise, there is no safe way to * remove the close-on-exec flag in the child without also creating * a file descriptor leak race condition in the parent. */ if (p[1-op] == 1-op) { int tmp = fcntl(1-op, F_DUPFD_CLOEXEC, 0); if (tmp < 0) { e = errno; goto fail; } __syscall(SYS_close, p[1-op]); p[1-op] = tmp; } e = ENOMEM; if (!posix_spawn_file_actions_init(&fa)) { if (!posix_spawn_file_actions_adddup2(&fa, p[1-op], 1-op)) { if (!(e = posix_spawn(&pid, "/bin/sh", &fa, 0, (char *[]){ "sh", "-c", (char *)cmd, 0 }, __environ))) { posix_spawn_file_actions_destroy(&fa); f->pipe_pid = pid; if (!strchr(mode, 'e')) fcntl(p[op], F_SETFD, 0); __syscall(SYS_close, p[1-op]); FUNLOCK(f); return f; } } posix_spawn_file_actions_destroy(&fa); } fail: fclose(f); __syscall(SYS_close, p[1-op]); errno = e; return 0; }
在 Windows Visual C++ 中,popen 源码在 C:\Program Files (x86)\Windows Kits\10\Source\${SDKVersion}\ucrt\conio\popen.cpp , 按照 MSDN 文档说明,Windows 32 GUI 程序,即 subsystem 是 Windows 的程序,使用 popen 可能导致程序无限失去响应。
所以在笔者实现 git-daemon 及其他 git 服务器时,都不会使用 popen 这个函数。
为了支持跨平台和简化编程,笔者在实现 svn 代理服务器时就使用了 Boost Asio 库,后来也用 Asio 实现过一个 git 远程命令服务, 每一个客户端与服务器连接后,服务器启动程序,需要创建 3 条管道,分别是 子进程的标准输入 输出 错误,即 stdout stdin stderr, 然后注册读写异步事件,将子进程的输出与错误写入到 socket 发送出去,读取 socket 写入到子进程的标准输入中。
在 POSIX 系统中,boost 有一个文件描述符类 boost::asio::posix::stream_descriptor 这个类不能是常规文件,以前用 go 做 HTTP 前端 没注意就 coredump 掉。
在 Windows 系统中,boost 有文件句柄类 boost::asio::windows::stream_handle 此处的文件应当支持随机读取,比如命名管道(当然 在 Windows 系统的,匿名管道实际上也是命名管道的一种特例实现)。
以上两种类都支持 async_read async_write ,所以可以很方便的实现异步的读取。
上面的做法,唯一的缺陷是性能并不是非常高,代码逻辑也比较复杂,当然好处是,错误异常可控一些。
在 Linux 网络通信中,类似与 git 协议这样读取子进程输入输出的服务程序的传统做法是,将 子进程的 IO 重定向到 socket, 值得注意的是 boost 中 socket 是异步非阻塞的,然而,git 命令的标准输入标准错误标准输出都是同步的,所以在 fork 子进程之 前,需要将 socket 设置为同步阻塞,当 fork 失败时,要设置回来。
socket_.native_non_blocking(false);
另外,为了记录子进程是否异常退出,需要注册信号 SIGCHLD 并且使用 waitpid 函数去等待,boost 就有 boost::asio::signal_set::async_wait 当然,如果你开发这样一个服务,会发现,频繁的启动子进程,响应信号,管理连接,这些操作才是性能的短板。
一般而言,Windows 平台的 IO 并不能重定向到 socket,实际上,你如果使用 IOCP 也可以达到相应的效率。还有,Windows 的 socket API WSASocket WSADuplicateSocket 复制句柄 DuplicateHandle ,这些可以好好利用。
其他
对于非代码托管平台的从业者来说,上面的相关内容可能显得无足轻重,不过,网络编程都是殊途同归,最后核心理念都是类似的。关于 git-daemon 如果笔者有时间会实现一个跨平台的简易版并开源。