引言

源码见 https://github.com/GotaX/go-config-server/tree/v0.1.0

只要接触过微服务的同学都知道, 配置管理不好, 运维真的压力山大. 过去呢, 我一直都首选 spring-cloud-config-server 做配置服务器, 主要是因为:

  • 遗留了不少 Java 服务, 改造起来很费事
  • 它提供了 HTTP API, 不需要 SDK 也能接入其他服务
  • 自带多 Profile 组合的功能
  • 可以使用 Git 做存储, 方便版本管理

不过随着配置文件越写越多, 还是逐渐暴露出不少问题:

  • 对 YAML 文件的解析有 BUG, 写 Kubernetes 配置的时候很头疼
  • 包含不少 spring 约定俗成的用法, 比如 application.yml 下的配置会被全部导入, 无法只导入部分
  • 写 YAML 时, 字符串和数字有时无法很好区分. 写 JSON 时又太冗长.
  • Java 服务的启动时间和内存占用都偏高 (相较于 Go).

之后也有持续关注这方面, 有几个人气较高的项目:

这三个配置中心都是由大厂开发, 质量肯定有保证, 功能也很强大. 不过对于小团队来说还是太重量级了, 用起来会有不少运维压力.

长期观望无果, 所以最终还是决定自己实现一个. 先总结一下最核心的需求点:

  1. 配置一定要有版本管理, 能追溯历史 (Git)
  2. 分布式存储, 避免单点故障 (Git)
  3. 能很方便地复用公共配置 (比如数据库 DataSource)
  4. 简洁的配置语法, 适当的校验 (JSON 太冗长, YAML 容易出错)
  5. 多环境支持 (生产/测试环境)
  6. 轻量级 (最小化运维压力)

1~2 条由于 Git 本身就是一个分布式版本管理工具, 所以就不用自己实现了.

3~5 条自己从头搞有点麻烦, 不过我很幸运地找到一个配置专用语言 Jsonnet, 所以就直接拿来用咯.

好了, 背景介绍完毕, 下面正式开始吧.

接口定义

首先我们来定义 HTTP API, 这里采用和 spring-cloud-config-server差不多的格式.

GET /:namespace/:filepath

参数 说明
namespace 命名空间, Git 中就是分支
filepath 配置文件路径, 相对于 Git 仓库根目录. 后缀代表返回内容格式, 比如 (.json.yaml).

接着我们来梳理下最小工作流程:

  1. 从底层存储读取读取配置文件模板
  2. 将配置文件模板渲染为目标格式

从上面的流程中我们可以抽象出两个接口:

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

// pkg/storage/render.go
// 模板渲染接口
type Renderer interface {
    // 执行渲染, entry 为入口文件, outputType 为目标格式
	Render(entry string, outputType ContentType) (doc string, err error)
}

// ContentType 的定义
type ContentType int
const (
	Unknown ContentType = iota
	JSON
	YAML
)

不过第一版是最小可用版本, 我们先把多格式输出放一边, 一律输出为 JSON. 下面来逐个实现这两个接口吧.

接口实现

Jsonnet 渲染器

先简单介绍下 Jsonnet. 上面之所以称其为配置语言, 是因为它并不是一个简单的静态配置, 而是一个实打实的函数式语言. 并且它是 JSON 的超集, 所以任何合法的 JSON 都是合法的 Jsonnet.

下面摘录一段官网介绍.

Jsonnet 被设计用于处理复杂系统的配置. 标准用例是用于集成多个相互独立的服务. 独立的编写每个配置将会导致大量的重复, 并且极有可能变得难以维护. Jsonnet 使您可以根据自己的条件指定配置, 并以编程的方式配置所有独立的服务.

再看一个示例:

// Edit me!
{
  person1: {
    name: "Alice",
    welcome: "Hello " + self.name + "!",
  },
  person2: self.person1 { name: "Bob" },
}

上面配置可以渲染为如下 JSON:

{
  "person1": {
    "name": "Alice",
    "welcome": "Hello Alice!"
  },
  "person2": {
    "name": "Bob",
    "welcome": "Hello Bob!"
  }
}

现在我们已经对 Jsonnet 有了基本的认知, 就不继续深入了. 如果想详细了解, 可以看官网教程, 或者等我之后再写一些介绍文章.

下面开始动手写实现, 这里我们使用 go-jsonnet 这个库来运行 Jsonnet 代码.

// pkg/render/jsonnet.go
import (
	. "github.com/google/go-jsonnet"
)

type Jsonnet struct {
	Importer Importer
}

func (r Jsonnet) Render(entry string, outputType ContentType) (doc string, err error) {
	vm := MakeVM()          // 新建虚拟机
	vm.Importer(r.Importer) // 替换默认的文件存储, 之后会把 Git 挂载进来

	doc, err = vm.EvaluateFile(entry) // 指定入口文件, 执行渲染
    
    // TODO: 之后在这里实现多格式输出
	return
}

大部分的事情都由 go-jsonnet 替我们做了, 所以代码很简洁. 后面支持多格式输出的时候会再增加一些代码.

接着写个测试来验证下. 先定义测试用例.

// pkg/render/render_test.go
// ...
type TestCase struct {
	Entry      string
	Files      map[string]string
	OutputType ContentType
	OutputDoc  string
}

var cases = []TestCase{
	{
		OutputType: JSON,
		Entry:      "example1.jsonnet",
		OutputDoc:  `{"person1":{"name":"Alice","welcome":"Hello Alice!"},"person2":{"name":"Bob","welcome":"Hello Bob!"}}`,
		Files:      map[string]string{"example1.jsonnet": `/* Edit me! */ {person1:{name:"Alice",welcome:"Hello "+self.name+"!",},person2:self.person1{name:"Bob"},}`},
	},
}
// ...

定义两个工具函数

// pkg/render/render_test.go
// ...

// 用 map[string]string 模拟文件存储, key 为文件名, value 为文件内容
func makeImporter(files map[string]string) jsonnet.Importer {
	data := make(map[string]jsonnet.Contents, len(files))
	for name, content := range files {
		data[name] = jsonnet.MakeContents(content)
	}
	return &jsonnet.MemoryImporter{Data: data}
}

// 将默认输出的多行 JSON 格式化为单行 JSON
func compactJson(input string) string {
	var buf bytes.Buffer
	if err := json.Compact(&buf, []byte(input)); err != nil {
		panic(err)
	}
	return buf.String()
}

定义测试

// pkg/render/render_test.go
package render

import (
	"bytes"
	"encoding/json"
	"testing"

	"github.com/google/go-jsonnet"
	"github.com/stretchr/testify/assert"
)

func TestJsonnet(t *testing.T) {
	for _, c := range cases {
		r := Jsonnet{Importer: makeImporter(c.Files)}
		doc, err := r.Render(c.Entry, c.OutputType)
		if assert.NoError(t, err) {
			assert.Equal(t, c.OutputDoc, compactJson(doc))
		}
	}
}
// ...

运行下

$ go test github.com/GotaX/go-config-server/pkg/render
# ok      github.com/GotaX/go-config-server/pkg/render    0.205s

看起来没问题, 渲染器已经 OK 了.

Git 存储

因为要操作 Git 仓库, 这里我们使用 go-git 作为 Git 客户端. 首先定义结构体.

// pkg/storage/git.go
type Git struct {
	URL  string                // 存配置的源码仓库地址, 先支持 HTTPS 端点
	Auth transport.AuthMethod  // 身份认证信息, 公开仓库该字段为 nil

	namespace string           // 记录当前的命名空间, 第一次访问前该字段为空
	repo      *Repository      // 记录当前的仓库对象, 第一次访问前该字段为空
}

接着实现 Use 函数, 第一版先不支持分支切换, 在检出时只检出当前命名空间的分支.

// pkg/storage/git.go
func (g *Git) Use(ctx context.Context, namespace string) (err error) {
    // 第一次访问时 clone 当前命名空间对应的分支
    if g.namespace == "" {
		if g.repo, err = g.clone(ctx, namespace); err == nil {
			g.namespace = namespace
		}
		return
	}
    
    // TODO: 之后在这里实现分支切换, 从而支持多命名空间
    return
}

还有 Read 函数.

// pkg/storage/git.go
func (g *Git) Read(path string) (content string, err error) {
	var (
		wt   *Worktree
		fd   billy.File
		data []byte
	)
	if wt, err = g.repo.Worktree(); err != nil {
		return
	}
	if fd, err = wt.Filesystem.Open(path); err != nil {
		return
	}
	if data, err = ioutil.ReadAll(fd); err != nil {
		return
	}
	return string(data), nil
}

上面用到的两个工具函数:

// pkg/storage/git.go
// git clone 函数, 使用内存文件系统, 所以不会在硬盘上留下文件.
func (g *Git) clone(ctx context.Context, namespace string) (repo *Repository, err error) {
	opts := &CloneOptions{
		URL:          g.URL,
		Auth:         g.Auth,
		SingleBranch: true,
		Depth:        1,
		Progress:     os.Stdout,
	}
	if opts.ReferenceName, err = g.branchRef(namespace); err != nil {
		return
	}

	return CloneContext(ctx, memory.NewStorage(), memfs.New(), opts)
}

// 根据传入的命名空间返回对应的分支引用名称, 并且命名空间不能为空
func (g *Git) branchRef(namespace string) (ref plumbing.ReferenceName, err error) {
	if namespace == "" {
		err = fmt.Errorf("namespace is required")
		return
	}

	ref = plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", namespace))
	return
}

最后写个测试验证一下:

// pkg/storage/storage_test.go
package storage

import (
	"context"
	"path/filepath"
	"runtime"
	"testing"

	"github.com/stretchr/testify/assert"
)

// 如果可以正常读取 go.mod 内容则实现有效
func TestGit(t *testing.T) {
	g := &Git{URL: localRepo()}

	if err := g.Use(context.TODO(), "master"); !assert.NoError(t, err) {
		return
	}

	if content, err := g.Read("go.mod"); assert.NoError(t, err) {
		assert.NotEmpty(t, content)
	}
}

// 用当前的项目作为测试库
func localRepo() string {
	_, filename, _, _ := runtime.Caller(0)
	return filepath.Join(filename, "../../..")
}

运行…

$ go test github.com/GotaX/go-config-server/pkg/storage
# ok      github.com/GotaX/go-config-server/pkg/storage   0.390s

OK 完全没有问题.

组装 App

接下来, 我们在 app 中将上面两个组件组装起来.

// pkg/handler/app.go
package handler

import (
	"context"

	"github.com/GotaX/go-config-server/pkg/render"
	"github.com/GotaX/go-config-server/pkg/storage"
)

type App struct {
	Storage  storage.Storage
	Renderer render.Renderer
}

func (a App) Handle(ctx context.Context, namespace, name string) (doc string, err error) {
    // 切换到指定命名空间
	if err = a.Storage.Use(ctx, namespace); err != nil {
		return
	}
    // 执行渲染
	return a.Renderer.Render(name, render.JSON)
}

测试可不能少.

// pkg/handler/handler_test.go
package handler

import (
	"context"
	"testing"

	"github.com/GotaX/go-config-server/pkg/render"
	"github.com/stretchr/testify/assert"
)

func TestApp(t *testing.T) {
	s := &MockStorage{}
	r := &MockRender{}
	app := App{Storage: s, Renderer: r}

	_, _ = app.Handle(context.TODO(), "", "")
    
    // 确保 Storage.Use 和 Renderer.Render 有被调用到
	assert.True(t, s.useInvoked)
	assert.True(t, r.renderInvoked)
}

type MockStorage struct { useInvoked bool }
func (m *MockStorage) Use(ctx context.Context, namespace string) (err error) { m.useInvoked = true;	return }
func (m *MockStorage) Read(path string) (content string, err error) { return }

type MockRender struct { renderInvoked bool }
func (m *MockRender) Render(entry string, outputType render.ContentType) (doc string, err error) { m.renderInvoked = true; return }

运行一下:

$ go test github.com/GotaX/go-config-server/pkg/handler
# ok      github.com/GotaX/go-config-server/pkg/handler   0.191s

好, 到目前为止所有的核心组件就都实现完了. 是不是很轻松呀? 下面就是一些与 API 相关的外围工作了.

用户接口适配

稍微偷下懒, 外围接口就不写测试了 😝.

服务器

这里我们使用 Fiber 做应用服务器, 如果要追求极致性能也可以选用 Fasthttp, 不过区别其实很小很小, 为了轻松实现功能, 我就用 Fiber 了.

先定义一下启动参数.

// pkg/service/service.go
type Options struct {
    HttpAddr string  // HTTP 监听地址, 默认为 :8080
	URL      string  // Git 仓库地址, 目前仅支持 HTTPS
	Username string  // Git 用户名, 访问公共仓库留空, 使用 AccessToken 访问随便填一个
	Password string  // Git 密码, 访问公共仓库留空, 使用 AccessToken 访问填 AccessToken
}

初始化 Service 对象

// pkg/service/service.go
type Service struct {
	httpAddr string
	app      handler.App
}

func New(opts Options) *Service {
	store := &storage.Git{
		URL: opts.URL,
	}
	if opts.Username != "" {
		store.Auth = &http.BasicAuth{
			Username: opts.Username,
			Password: opts.Password,
		}
	}
	renderer := render.Jsonnet{
         // 见下面说明
		Importer: render.StorageImporter{
			Storage: store,
		},
	}
	return &Service{
		httpAddr: opts.HttpAddr,
		app: handler.App{
			Storage:  store,
			Renderer: renderer,
		},
	}
}

这里解释下 render.StorageImporter , Importer 是 Jsonnet VM 使用的接口, 和我们的 Storage 接口有一点差异, 所以定义了这个适配器.

// pkg/render/jsonnet.go
type StorageImporter struct {
	Storage storage.Storage
}

// importFrom 为使用 import 的源文件, 绝对路径
// importedPath 为 import 的目标文件, 相对路径, 相对于源文件
// contents 为文件内容
// foundAt 为 import 目标文件的绝对路径
func (s StorageImporter) Import(importedFrom, importedPath string) (contents Contents, foundAt string, err error) {
	dir, _ := filepath.Split(importedFrom)
	path := filepath.Join(dir, importedPath)
	data, err := s.Storage.Read(path)

	if err == nil {
		contents = MakeContents(data)
		foundAt = path
	}
	return
}

接着继续定义路由的 Handler:

// pkg/service/service.go
func (srv Service) Handle(c *fiber.Ctx) (err error) {
	var (
		ctx       = c.Context()
		name      = c.Params("+")          // fiber 的 wildcard 语法, 等下解释
		namespace = c.Params("namespace")
	)
	doc, err := srv.app.Handle(ctx, namespace, name)
	if err != nil {
		return
	}

	c.Set("Content-Type", "application/json")
	return c.SendString(doc)
}

继续定义路由:

// pkg/service/service.go
const (
	readTimeout    = 10 * time.Second  // 读请求超时时间
	writeTimeout   = 10 * time.Second  // 写响应超时时间
	endpointHealth = "/healthz"        // 健康检查端点
)

func (srv Service) Run(ctx context.Context) (err error) {
	server := fiber.New(fiber.Config{
		ReadTimeout:  readTimeout,
		WriteTimeout: writeTimeout,
	})

	registerAccessLogger(server)    // 访问日志中间件
	registerHealthHandler(server)   // 健康检查中间件
	srv.registerAppHandler(server)  // 注册路由

	chErr := make(chan error, 1)

	go func() {
        // 开始监听端口
		if err := server.Listen(srv.httpAddr); err != nil {
			chErr <- err
		}
	}()

    // 实现 Graceful shutdown, 进程结束前会先取消掉 Context
	select {
	case err = <-chErr:
	case <-ctx.Done():
		err = server.Shutdown()
	}
	return
}

上面用到的几个工具函数:

// pkg/service/service.go
func (srv Service) registerAppHandler(server *fiber.App) {
    // 定义 path params, namespace 为第一个参数, 后面所有字符包括 '/' 为第二个参数
    // 例如: /master/example/arith.jsonnet
    // 参数一: namespace = master
    // 参数二: + = example/arith.jsonnet
	server.Get("/:namespace/+", srv.Handle)
}

// 访问日志
func registerAccessLogger(server *fiber.App) {
	server.Use(logger.New(logger.Config{
		Next: func(c *fiber.Ctx) bool { return c.Path() == endpointHealth },
	}))
}

// 健康检查
func registerHealthHandler(server *fiber.App) {
	server.All(endpointHealth, func(ctx *fiber.Ctx) error {
		return ctx.SendStatus(fiber.StatusOK)
	})
}

命令行

最后我们来做启动程序的命令行界面, 顺便解释一下优雅退出怎么实现.

这里我们使用 urfave/cli 这个库. 首先, 定义一些命令行参数, 环境变量等之后做 Docker 镜像的时候再支持.

// internal/web.go
package internal

import (
	"github.com/GotaX/go-config-server/pkg/service"
	. "github.com/urfave/cli/v2"
)

const (
	flagHttp       = "http"        // HTTP 监听地址, 默认为 :8080
	flagRepository = "repository"  // Git 仓库地址, 目前仅支持 HTTPS
	flagUsername   = "username"    // Git 用户名, 访问公共仓库留空, 使用 AccessToken 访问随便填一个
	flagPassword   = "password"    // Git 密码, 访问公共仓库留空, 使用 AccessToken 访问填 AccessToken
)

var CmdWeb = &Command{
	Name:   "web",
	Usage:  "Start config server",
	Action: runWeb,
	Flags: []Flag{
		&StringFlag{
			Name:  flagHttp,
			Value: ":8080",
		},
		&StringFlag{
			Name:     flagRepository,
			Required: true,
			Aliases:  []string{"repo"},
		},
		&StringFlag{
			Name:    flagUsername,
			Aliases: []string{"user"},
		},
		&StringFlag{
			Name:    flagPassword,
			Aliases: []string{"pass"},
		},
	},
}

func runWeb(ctx *Context) (err error) {
	srv := service.New(service.Options{
		HttpAddr: ctx.String(flagHttp),
		URL:      ctx.String(flagRepository),
		Username: ctx.String(flagUsername),
		Password: ctx.String(flagPassword),
	})
	return srv.Run(ctx.Context)
}

定义用于优雅退出的 Context:

// internal/context.go
package internal

import (
	"context"
	"fmt"
	"os"
	"os/signal"
	"syscall"
)

// 该函数返回的 context 会在第一次收到 SIGINT 或者 SIGTERM 信号时取消,
// 如果在关闭过程中再次收到信号, 则会强制结束进程
func NewAppContext() (ctx context.Context, cancel func()) {
	ctx, cancel = context.WithCancel(context.Background())

	c := make(chan os.Signal, 2)
	signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
	go func() {
		<-c
		fmt.Println("Try exit...")
		cancel()

		<-c
		fmt.Println("Force exit")
		os.Exit(0)
	}()
	return
}

Main

终于到了最后, 写 main 函数的时候尽量保持简洁就行了:

// main.go
package main

import (
	"log"
	"os"

	"github.com/GotaX/go-config-server/internal"
	"github.com/urfave/cli/v2"
)

func main() {
	ctx, cancel := internal.NewAppContext()
	defer cancel()

	app := cli.App{
		Name:     "config-server",
		Commands: []*cli.Command{internal.CmdWeb},
	}

	if err := app.RunContext(ctx, os.Args); err != nil {
		log.Fatal("Error: ", err.Error())
	}
}

实际运行

最小可用的实现完成啦! 来实际运行下吧. 就用 Jsonnet 库中的例子来看看效果.

# 在第一个命令行中启动服务器
$ go build main.go && main web -repo=https://github.com/google/jsonnet.git 

# 在第二个命令行中查配置, 由于要访问 GitHub 第一次可能很慢
$ curl http://localhost:8080/master/examples/arith.jsonnet

# 查询到的结果
{
   "concat_array": [
      1,
      2,
      3,
      4
   ],
   "concat_string": "1234",
   "equality1": false,
   "equality2": true,
   "ex1": 1.6666666666666665,
   "ex2": 3,
   "ex3": 1.6666666666666665,
   "ex4": true,
   "obj": {
      "a": 1,
      "b": 3,
      "c": 4
   },
   "obj_member": true,
   "str1": "The value of self.ex2 is 3.",
   "str2": "The value of self.ex2 is 3.",
   "str3": "ex1=1.67, ex2=3.00",
   "str4": "ex1=1.67, ex2=3.00",
   "str5": "ex1=1.67\nex2=3.00\n"
}

接下来

以上就是第一部分的内容了.

我们已经实现了一个最小可用的配置中心, 没有任何外部依赖, 只要一个命令就能启动, 看起来还是不错的.

不过这个实现其实比较粗糙, 还要不少改善才能用于生产环境. 简单列举一下:

  • 支持多分支切换
  • 支持多格式输出
  • 并发访问安全
  • 打包 Docker 镜像
  • 环境变量支持

这些就等后面的文章里实现咯, 毕竟篇幅已经很长了.

最后, 谢谢阅读! 觉得有收获欢迎点赞!

源码仓库地址写在开头了, 觉得有疑问可以在文章下面留言.