用 Go 实现配置中心 Part 1 - Go语言中文社区

用 Go 实现配置中心 Part 1


引言

源码见 https://github.com/GotaX/go-c...

只要接触过微服务的同学都知道, 配置管理不好, 运维真的压力山大. 过去呢, 我一直都首选 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.

https://jsonnet.org/img/venn.svg

下面摘录一段官网介绍.

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 相关的外围工作了.

用户接口适配

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

版权声明:本文来源Segmentfault,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://segmentfault.com/a/1190000039947891?sort=newest
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。
  • 发表于 2021-06-13 21:52:12
  • 阅读 ( 854 )
  • 分类:Go

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢