引言

源码见: https://github.com/gota33/go-config-server/tree/v0.2.3

Part 1 中我们用少量代码实现了配置中心的最小可用版本. 其中只包含能够使流程跑通的基础功能:

  • Git 后端存储
  • Jsonnet 渲染引擎
  • 优雅退出
  • HTTP 接口
  • 命令行界面

有了这些, 我们已经可以做到将 Jsonnet 文件推送至 Git 后, 通过 HTTP 接口读取渲染好的 JSON 格式的配置.

不过, 若想用于生产环境, 还需要不少改进来适应实际情况, 在这篇 Part 2 中我们就来处理其中一个问题: 如何使 Git 存储变得并发安全?

问题分析

我们先看下之前定义的存储接口.

// pkg/storage/storage.go
// 底层存储接口
type Storage interface {
    // 切换命名空间, 类似于 MySQL 中的 use
	Use(ctx context.Context, namespace string) (err error)
    // 读取文件内容
	Read(path string) (content string, err error)
}

这个接口符合直觉, 但是却包含隐患. 如果两个用户同时在使用这个接口, A 在调用 Read() 读取文件内容时, B 却调用 Use() 切换了命名空间, 这时 A 读取到的数据很可能来自于两个不同的命名空间, 这显然是不可接受的.

一般来说我们有两大法宝来处理并发问题:

  1. 加锁 — 时间隔离
  2. 拷贝 — 空间隔离

那么我们该如何做选择呢?

早期软件开发中, 由于硬件条件受限, 所以有时会用时间换空间.

不过现在这已不是问题, 所以我们的目标是: 总体响应时间最快, 空间占用只要在合理范围内都可以接受.

我们先来分析一下储存在 Git 仓库中的文件在本地可读前需要经历几个步骤:

  1. 从远程仓库将本地缺失的文件拉取下来 — git fetch — 网络 IO
  2. 检出目标文件所在的分支 — git checkout — 本地 IO, CPU
  3. 将检出的文件交给用户读取 — 本地 IO

上面这三步中, 耗时从大到小依次为 1 > 2 > 3.

首先, 网络 IO 是最耗时的, 一般说来我们不应该在非常耗时的操作上加锁. 但是, 如果我们每次都拉取全量数据, 就无法享受到 Git 增量更新的优势了, 总体说来会消耗更长的时间. 所以, 我们这里还是选择加锁, 但是会通过 TTL 将 fetch 的频率降低. 做一个实时性和响应时间之间的权衡.

其次, checkout 实际上只有在远端发生变化时才需要执行, 所以这是一个比较低频的操作 (比 fetch 还低), 所以我们在检出时加锁, 然后把检出内容缓存在内存中.

最后, 用户读取的就是内存中的只读拷贝了, 由于我们刷新缓存时只是将 key 指向新的拷贝, 所以对于已经在读取的用户是没有影响的, 也就不存在并发问题了.

下面就让我们来逐个实现上面的这些步骤.

接口设计

根据上面的分析, Storage 的主要功能是: 提供对应命名空间下的只读文件系统. 所以接口这样设计.

// pkg/storage/storage.go
// Provider 底层存储接口
type Provider interface {
	// Provide 提供 namespace 对应的只读文件系统
	Provide(ctx context.Context, namespace string) (fs ReadonlyFs, err error)
}

// ReadonlyFs 只读文件系统
type ReadonlyFs interface {
	io.Closer

	// Open 返回一个以 O_RDONLY 模式打开的文件.
	Open(name string) (ReadonlyFile, error)
}

// ReadonlyFile 只读文件
type ReadonlyFile interface {
	io.ReadCloser
}

Git 简介

在正式开工前, 我们先简单看下 Git 常用命令和文件系统, 这对接下来的工作会很有帮助.

git clone

我们日常使用 git 一般都是从 git clonegit init 这两个命令开始的. 但这次我们却直接使用了 git fetch 而不是 git clone, 这是为什么呢?

git clone 实际上是好几个命令的封装, 这里截取一段 Git Pro 中的描述:

git clone 实际上是一个封装了其他几个命令的命令。 它创建了一个新目录,切换到新的目录,然后 git init 来初始化一个空的 Git 仓库, 然后为你指定的 URL 添加一个(默认名称为 origin 的)远程仓库(git remote add),再针对远程仓库执行 git fetch,最后通过 git checkout 将远程仓库的最新提交检出到本地的工作目录。

可以看到 git clone 确实帮我们做了不少事情, 这在日常使用中是一个非常方便的命令. 不过这次我们需要的就只有 git fetch 这一个操作.

git fetch

看下 Git Pro 中的描述:

git fetch 命令与一个远程的仓库交互,并且将远程仓库中有但是在当前仓库的没有的所有信息拉取下来然后存储在你本地数据库中。

除了本地数据库这个需要后面解释一下外, 其他部分还是很直白的, 就不多讲了. 主要还是为了和更常用的 git pull 命令做个对比.

git pull

这可能是我们日常使用中第二常用的命令了, 那么为什么不用它而用 git fetch 呢?

git pull 命令基本上就是 git fetchgit merge 命令的组合体,Git 从你指定的远程仓库中抓取内容,然后马上尝试将其合并进你所在的分支中。

可以看出这也是一个复合命令, 它还多做了一步 git merge 的操作. 但由于我们检出的文件系统都是只读拷贝, 所以一般不需要 merge, 并且, 如果 merge 的话, 可能出现和远程仓库冲突的情况 (远程分支被 reset), 这就很难处理了.

所以更新是我们一律使用 git fetch --force 彻底避免冲突的可能.

git checkout

git checkout 命令用来切换分支,或者检出内容到工作目录。

很简单的一句介绍, 不过注意其中的 工作目录 , 这和上面的 本地数据库 是 Git 文件系统中两个不同的概念, 理解它们对我们接下来的工作会很有帮助.

Git 文件系统

Git 最初是作为一个文件系统来设计的, 所以这部分内容展开的话会特别多, 有兴趣的话可以看 Git Pro 做更深入的了解. 这里我们只要了解上面提到的 本地数据库工作目录 这两个概念就足够了.

平时我们使用 Git 时的目录结构是这样的:

.             # 工作目录
├── .git      # 本地数据库
└── README.md # 工作目录中的文件

一般 .git 就在我们的工作目录下, 不过这只是为了方便日常使用, 而实际上它们是可以独立存在的.

下面来简单演示一下.

# 首先, 克隆远程仓库但是不检出任何分支.
git clone --no-checkout https://github.com/github/gitignore.git && cd gitignore

# 在 master 目录下检出 master 分支
mkdir master && git --work-tree=master checkout -f master

# 在 ghfw 目录下检出 ghfw 分支
mkdir ghfw && git --work-tree=ghfw checkout -f ghfw

# 目录结构
ls -a
# .  ..  .git  ghfw  master

这里的 .git 目录就是本地数据库, master 和 ghfw 目录就是两个工作目录.

不过请注意, 分支切换的操作是在 .git 目录中的, 所以当前所在的分支是 ghfw, 并不是说在不同的目录下有两个不同的分支 (虽然文件确实是对应分支下的文件).

如果要建立两个彻底隔离的工作空间, 需要用到 Git v2.15 中新加入的 git worktree 命令, 由于 go-git 库中并不支持这个命令, 这里就不展开了.

实现阶段

主流程

首先我们把 Fetch 的主流程写出来, 其中包含一些待实现的函数, 后面会逐个实现.

// pkg/storage/git.go
// ...
func (g *Git) Provide(ctx context.Context, namespace string) (fs ReadonlyFs, err error) {
	// 检查 fetch 缓存, 在必要时执行 fetch
    if !g.skipFetch() {
		if err = g.fetch(ctx); err != nil {
			return
		}
	}

    // 查找 namespace 对应的 branch, 若不存在则返回 error
	var branch plumbing.ReferenceName
	if branch, err = g.findBranch(namespace); err != nil {
		return
	}

    // 检查 checkout 缓存, 在必要时执行 checkout
	if !g.skipCheckout(branch) {
		if err = g.checkout(branch); err != nil {
			return
		}
	}
	return g.infos[branch].fs, nil
}
// ...

Fetch

第一步, 我们先定义好数据库对象, 我们需要通过它建立起和远程仓库的联系.

// pkg/storage/git.go
// ...
// Git 是一个基于 Git 的多命名空间文件系统
type Git struct {
	URL      string                              // 远程仓库地址
	Auth     transport.AuthMethod                // 身份验证信息
	FetchTTL time.Duration                       // Fetch TTL

	store    storage.Storer                      // .git 对象
	remote   *Remote                             // 远程仓库对象
	lock     sync.Locker                         // 锁
	syncTime time.Time                           // 最后一次 fetch 的时间
	infos    map[plumbing.ReferenceName]info     // 分支信息
}

func NewGit(URL string) *Git {
	store := memory.NewStorage() // 我们将 .git 保存在内存中
	remote := NewRemote(store, &config.RemoteConfig{
		Name:  "origin",
		URLs:  []string{URL},
		Fetch: []config.RefSpec{"refs/heads/*:refs/heads/*"}, // 只拉取 branch, 忽略 tag
	})

	return &Git{
		URL:      URL,
		FetchTTL: DefaultFetchTTL,
		lock:     &sync.Mutex{},
		store:    store,
		remote:   remote,
		infos:    make(map[plumbing.ReferenceName]info),
	}
}
// ...

然后, 来实现具体的 fetch 流程, 将远端变化拉取到本地.

// pkg/storage/git.go
// ...
func (g *Git) fetch(ctx context.Context) (err error) {
	g.lock.Lock()
	defer g.lock.Unlock()

	if err = g.remote.FetchContext(ctx, &FetchOptions{
		RefSpecs: g.remote.Config().Fetch,
		Auth:     g.Auth,
		Force:    true,      // 强制覆盖本地文件, 避免冲突
		Progress: os.Stderr, // 输出调试信息
	}); errors.Is(err, NoErrAlreadyUpToDate) ||
		errors.Is(err, transport.ErrEmptyUploadPackRequest) {
		// 如果远程仓库没有变化, 或者本地仓库没有变化 go-git 会返回 error,
		// 这里我们忽略它
		err = nil
	} else if err != nil {
		return
	}

	// 检出后没有自动设置 HEAD, 需要手工设置一下
	ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.Master)
	if err = g.store.SetReference(ref); err != nil {
		return
	}
	// 更新本地缓存信息
	if err = g.updateRefs(); err != nil {
		return
	}

	log.Println("Fetch: OK")
	return
}
// ...

Checkout

将对应分支中的文件从 .git 检出到内存文件系统中.

// pkg/storage/git.go
// ...
func (g *Git) checkout(branch plumbing.ReferenceName) (err error) {
	g.lock.Lock()
	defer g.lock.Unlock()

	var (
		repo *Repository               // Git 仓库
		wt   *Worktree                 // 工作目录
		head *plumbing.Reference       // 当前分支的 head
		fs   internalFs                // 只读文件系统 (适配 billy.FileSystem)
	)
	fs.Filesystem = memfs.New()        // 工作目录也在内存中

	// 打开 Git 仓库
    if repo, err = Open(g.store, fs.Filesystem); err != nil {
		return
	}
    // 获取工作目录, 实际上就是 fs.Filesystem
	if wt, err = repo.Worktree(); err != nil {
		return
	}
	// 强制检出目标分支到内存文件系统
	if err = wt.Checkout(&CheckoutOptions{
		Branch: branch,
		Force:  true,
	}); err != nil {
		return
	}
	// 获取当前分支 HEAD, 用于缓存
	if head, err = repo.Head(); err != nil {
		return
	}
	// 更新缓存信息
	old := g.infos[branch]
	g.infos[branch] = info{
		remote: old.remote,
		local:  head.Hash(),
		fs:     fs,
	}

	log.Println("Checkout: OK")
	return
}
// ...

缓存相关

首先来看下和缓存相关的结构体和变量

// pkg/storage/git.go
// ...
// info 记录了分支信息
type info struct {
	remote plumbing.Hash  // 远程分支的 HEAD, git fetch 时更新
	local  plumbing.Hash  // 本地分支的 HEAD, git checkout 时更新
	fs     internalFs     // 工作目录, 只读文件系统
}

// SameHash 当本地和远程 HEAD 相同时返回 true
func (i info) SameHash() bool {
	return i.local == i.remote
}

type Git struct {
    // ...
	syncTime time.Time                       // 最后一次成功 fetch 的时间
	infos    map[plumbing.ReferenceName]info // 分支信息缓存
}
// ...

两个检查缓存的工具函数

// pkg/storage/git.go
// ...
// skipFetch 当上次 fetch 时间距今小于 TTL 时返回 true
func (g *Git) skipFetch() bool { return time.Since(g.syncTime) < g.FetchTTL }

// skipCheckout 当 branch 本地与远程 HEAD 相同时返回 true
func (g *Git) skipCheckout(branch plumbing.ReferenceName) bool { return g.infos[branch].SameHash() }
// ...

总结

至此, 我们就实现了一个并发安全的 Git 后端存储. 虽然响应时间上依然有提升空间, 不过一般场景已经够用了.

考虑到篇幅, 这次主要分析了 pkg/storage/git.go 中的代码, 其他文件中的变化可以到 GitHub 上查看.

接下来

我们可以将重心放到优化使用体验上, 不过这就涉及到 Jsonnet 的相关知识, 我考虑先写一篇介绍文章, 然后再为我们的配置服务器添加新的 Feature.

最后

感谢阅读! 欢迎点赞! 有疑问可以在下面留言, 源码地址见文章开头.