社区微信群开通啦,扫一扫抢先加入社区官方微信群
社区微信群
源码见 https://github.com/GotaX/go-c...
只要接触过微服务的同学都知道, 配置管理不好, 运维真的压力山大. 过去呢, 我一直都首选 spring-cloud-config-server
做配置服务器, 主要是因为:
不过随着配置文件越写越多, 还是逐渐暴露出不少问题:
application.yml
下的配置会被全部导入, 无法只导入部分之后也有持续关注这方面, 有几个人气较高的项目:
这三个配置中心都是由大厂开发, 质量肯定有保证, 功能也很强大. 不过对于小团队来说还是太重量级了, 用起来会有不少运维压力.
长期观望无果, 所以最终还是决定自己实现一个. 先总结一下最核心的需求点:
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. 上面之所以称其为配置语言, 是因为它并不是一个简单的静态配置, 而是一个实打实的函数式语言. 并且它是 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 仓库, 这里我们使用 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 中将上面两个组件组装起来.
// 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 相关的外围工作了.
稍微偷下懒, 外围接口就不写测试了
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!