Git | 随着代码存储二进制数据

2022 08 20, Sat

团队一般除了代码以外其实还需要管理很多二进制的数据,但是 Git 本身并不擅长存储大量的二进制数据,这时候我们最好用一些额外的工具来协助 Git 完成二进制数据的存放。

比如说 Git LFS。

TL;DR:

# 全局初始化
git lfs install
# 添加二进制文件
git lfs track xxx.png # 在 .gitattributes 中记录
git add xxx.png
git commit -m 'add xxx.png'
git push # 实际将文件推送到 lfs 服务中
# 拉取,一般是自动的,出问题时可以手动
git lfs pull
# 改写历史,把需要的大文件自动转换成 lfs 存储
git lfs migrate
# 查看当前LFS中的文件
git lfs ls-files

Git 为什么不擅长存储二进制数据。

因为 Git 要压缩文件

之前的博客《Git | 如何迁出非常大的仓库》中我们提到 Git 存储系统中核心的几个类型,主要是 commit -> tree -> {tree, 多个blob} 这样的一种树形结构,每一次都存储全量的文件内容和属性,并且依赖压缩算法生成 packfile 并以此降低 git 存储中的空间消耗。所以 Git 对象的生命周期中有一个非常重要的步骤就是压缩。

二进制引入到压缩这个过程就会有两个问题。

一个是二进制文件通常情况下不好压缩,会消耗更多内存。压缩可以看成是把问容做成字典、然后用字典做翻译的过程,二进制产生的字典远超普通的文本文件,尤其是代码这种关键字、符号反复出现的文本文件。压缩一个带二进制文件的仓库,消耗的大部分内存都会是这个二进制文件。

另一方面是二进制文件压缩率不理想,不适合放在高频访问的存储中。毕竟二进制文件通常情况下存在的可能的数据组合更多,重复段出现的概率不像文本文件那样高。

结果就是压缩费时、费力、不讨好。

因为 Git 一类工具经常涉及到文本处理

除此之外 Git 客户端或者托管服务的很多功能都依赖文本行,比如说 git diff、git blame、git merge、patch commit 等等都依赖行。但是二进制文件是没有行的,所以很多时候消耗多少内存空间来凑一行就比较玄学。当然现在很多服务都会预先判断你的这些文件要不要做处理,但是并不能排除他去傻傻的凑一整行。

那么就有一种可能性,二进制文件因为一定的特征,被 git 当成是文本文件,结果一个文本行搜索花了 1M,并且在你的终端里面拉了一坨屎输出很多代码、干扰正常的终端功能。也不利于服务或者客户端后续进一步做索引或者别的处理。

典型的问题

  • Git 拉取、推送非常慢,一个 object 很久都拉不下来,导致整个仓库无法快速迁出文件
  • 从 Git 托管服务拉取或者托管服务后台压缩时,因为 OOM 直接爆掉

Git 提供的思路之一:gitattributes

Git 中提供了 gitattributes 这样一种能力,可以随着仓库一起储存一部分文件的属性,定制一部分文件的操作。其中包含三种选项可以部分的缓解上面的问题:diff、merge、delta 和 filter。 gitattributes 中 diff / merge 解决比较的问题,delta 解决压缩的问题,filter 可以把内容在不同的阶段对内容进行转换。

那么我们就可以利用 git attributes 来修改对二进制文件的行为:

  • 在 diff 时不直接 diff,而是要么通过其他工具获取到二进制文件特征的文本描述,要么 diff 文件属性的差异,比如说文件摘要、修改时间。
  • push 时把文件转换成其他形式,或者存到其他地方、留一份游标,pull 时恢复文件。
  • merge 时也是通过修改行为,避免 git 或者人在合并时浪费时间,毕竟二进制文件通常修改时不需要处理差异,不一样就是不一样。

有这些可以配置的选项,我们就可以通过 git attributes 来把二进制文件存到另一个地方,比如说 git annex 就是曾经流行过的一个方案。

我们也可以自己写一些工具,但是情况会复杂一些,我们需要处理比如说鉴权啊、统计啊这些事情,还需要让托管服务集成进来、可以正常展示这些内容、做各种统计和处理。

Github 提供的终极思路:git lfs

当然 gitattributes 很好用很强大,但是需要自己配置很多东西,git 的配置也需要每个人维护,就可能会有问题。所以 Github 主导,其他很多 Git 有关的厂商以及开源社区,一起开发一个拓展叫 git lfs。

公有托管服务,比如说 Github 、 Gitlab 、 Atlassian Bitbucket 、 Gitee(企业) 等服务都已经内置了 Git LFS 支持,而私有托管服务,包括上面厂商提供企业服务和类似于 Gitea 这样的私有部署方案,也大都支持了 Git LFS。

TL;DR:怎么使用

回到开头:

  • 全局:
    • 在配置文件中配置 lfs filter
  • 本地仓库
    • 告诉 git 哪些文件需要由 lfs 处理、跟踪:git lfs track xxx.xxx
    • 正常走 git 流程:commit / push / pull
    • 如果 git lfs 之前没有配置好,项目需要重新单独拉取 git lfs 的文件:git lfs pull
    • 如果 git lfs 之前没有用过,项目需要把老文件也放进来:git lfs migrate

架构

Git LFS 分了几个部分:

  • git lfs 客户端
    • 我们操作 LFS 用的客户端,它在 commit 时替换文件为一个pointer,push 时上传 pointer,pull 时拉取并展开文件,diff / merge 时只比较 pointer 。pointer 文件类似于这样
      version https://git-lfs.github.com/spec/v1
      oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393
      size 12345
      
    除此之外,git lfs 存储的配置发现也是由客户端完成的,它会在特定的 HTTP 端点和 ssh 命令上尝试获取存储后端的各种信息。
  • 托管服务的 API
    • Lock API:阻止两个人一起上传或者修改文件
    • Batch API:返回一系列上传链接的接口
  • ssh 提供的 git lfs 认证后端
    • git-lfs-authenticate:返回存储后端身份认证所需要的 HTTP Header
  • 存储后端
    • 一般是 S3 或者某种支持通过 HTTP Put 上传的存储服务

解决了的问题

  • 二进制文件存储
  • 正确处理
  • 存储服务的发现和认证
  • 反复写工具
  • 托管服务集成
  • 和 git + ssh 协议集成

托管服务不支持的情况下怎么办

之前提到 Git LFS 的存储后端发现是客户端完成的,那么这个方案还有个好处是,万一真的你用的托管服务它不支持 Git LFS,我们也可以通过在客户端配置 Git LFS 来让它单独使用一套 Git LFS 存储服务。

简单来说就是可以配置 lfs.url 这个配置来单独配置一个 Git LFS 端点:

git config -f .lfsconfig lfs.url https://lfs.example.com/foo/bar/info/lfs

Git LFS 端点架设具体的可以参考 https://github.com/git-lfs/git-lfs/wiki/Implementations