用 Go 实现配置中心 Part 1
引言
源码见 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).
之后也有持续关注这方面, 有几个人气较高的项目:
这三个配置中心都是由大厂开发, 质量肯定有保证, 功能也很强大. 不过对于小团队来说还是太重量级了, 用起来会有不少运维压力.
长期观望无果, 所以最终还是决定自己实现一个. 先总结一下最核心的需求点:
- 配置一定要有版本管理, 能追溯历史 (Git)
- 分布式存储, 避免单点故障 (Git)
- 能很方便地复用公共配置 (比如数据库 DataSource)
- 简洁的配置语法, 适当的校验 (JSON 太冗长, YAML 容易出错)
- 多环境支持 (生产/测试环境)
- 轻量级 (最小化运维压力)
1~2 条由于 Git 本身就是一个分布式版本管理工具, 所以就不用自己实现了.
3~5 条自己从头搞有点麻烦, 不过我很幸运地找到一个配置专用语言 Jsonnet, 所以就直接拿来用咯.
好了, 背景介绍完毕, 下面正式开始吧.
接口定义
首先我们来定义 HTTP API, 这里采用和 spring-cloud-config-server
差不多的格式.
GET /:namespace/:filepath
参数 | 说明 |
---|---|
namespace | 命名空间, Git 中就是分支 |
filepath | 配置文件路径, 相对于 Git 仓库根目录. 后缀代表返回内容格式, 比如 (.json 和 .yaml ). |
接着我们来梳理下最小工作流程:
- 从底层存储读取读取配置文件模板
- 将配置文件模板渲染为目标格式
从上面的流程中我们可以抽象出两个接口:
// 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 镜像
- 环境变量支持
- …
这些就等后面的文章里实现咯, 毕竟篇幅已经很长了.
最后, 谢谢阅读! 觉得有收获欢迎点赞!
源码仓库地址写在开头了, 觉得有疑问可以在文章下面留言.