Go工程化实践之配置管理

配置管理

Configuration 种类

  • 环境变量(配置)

    Region(区域)、Zone(华南、华北)、Cluster(集群)、Environment(预发布、测试、研发)、Color(染色、组合)、Discovery、AppID、Host等之类的环境信息,都是通过在线运行时平台打入到容器或者物理机,供 kit 库读取使用。

  • 静态配置

    资源需要初始化的配置信息,比如 http/gRPC serverRedisMySQL等,这类资源在线变更配置的风险非常大,通常不鼓励 on-the-fly(运行时) 变更,很可能会导致业务出现不可预期的事故,变更静态配置和发布 bianry app 没有区别,应该走一次迭代发布的流程。(例如 IM 这类长连接的服务,应该将连接层和逻辑层分开,逻辑层更新,但是连接层不断开)

  • 动态配置

    应用程序可能需要一些在线的开关,来控制业务的一些简单策略,会频繁的调整和使用,这类是基础类型(intbool)等配置,用于可以动态变更业务流的收归一起,同时可以考虑结合类似 https://pkg.go.dev/expvar 来结合使用

  • 全局配置

    通常,依赖的各类组件、中间件都有大量的默认配置或者指定配置,在各个项目里大量拷贝复制,容易出现意外,所以使用全局配置模板来定制化常用的组件,然后再特化的应用里进行局部替换。

用途

例如一个 Redis client 例子

1
2
3
// DialTimeout acts like Dial for establishing the
// connection to the server, writing a command and reading a reply.
func Dial(network, address string) (Conn, error)

但是在创建中需要增加需求,例如

  1. 要自定义超时时间
  2. 要设定 Database
  3. 要控制连接池的策略
  4. 要安全使用 Redis,需要使用 Password
  5. 要提供慢查询请求记录,并且可以设置 slowlog 的时间

如果要满足这些需求,则可以修改代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// DialTimeout acts like Dial for establishing the
// connection to the server, writing a command and reading a reply.
func Dial(network, address string) (Conn, error)

// DialTimeout acts like Dial but takes timeouts for establishing the
// connection to the server, writing a command and reading a reply.
func DialTimeout(network, address string, connectTimeout, readTimeout, writeTimeout time.Duration) (Conn, error)

// DialDatabase acts like Dial but takes database for establishing the
// connection to the server, writing a command and reading a reply.
func DialDatabase(network, address string, database int) (Conn, error)

// DialPool
func DialPool...

可以看到,这种实现方式是非常繁杂的,增加一个功能要增加一个函数,不灵活。

仿照一下标准库里面的

net/http/server.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// A Server defines parameters for running an HTTP server.
// The zero value for Server is a valid configuration.
type Server struct {
// Addr optionally specifies the TCP address for the server to listen on,
// in the form "host:port". If empty, ":http" (port 80) is used.
// The service names are defined in RFC 6335 and assigned by IANA.
// See net.Dial for details of the address format.
Addr string

Handler Handler // handler to invoke, http.DefaultServeMux if nil

// TLSConfig optionally provides a TLS configuration for use
// by ServeTLS and ListenAndServeTLS. Note that this value is
// cloned by ServeTLS and ListenAndServeTLS, so it's not
// possible to modify the configuration with methods like
// tls.Config.SetSessionTicketKeys. To use
// SetSessionTicketKeys, use Server.Serve with a TLS Listener
// instead.
TLSConfig *tls.Config

使用时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import (
"log"
"net/http"
"time"
)

func main() {
s := &http.Server{
Addr: ":8080",
Handler: nil,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}
log.Fatal(s.ListenAndServe())
}

这种方式的弊端:

  1. 大写暴露出来,也就是外部可以拿到,也可以修改,修改之后可能引起的问题无法预料
  2. 没有配置的默认值需要通过文档获取

如果说,需要从配置文件进行解析,初始化 Redis 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Config redis settings.
type Config struct {
*pool.Config
Addr string
Auth string
DialTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
}

// NewConn new a redis conn.
func NewConn(c *Config) (cn Conn, err error)

func main() {
c := &redis.Config{
Addr: "tcp://127.0.0.1:3389",
}
r, _ := redis.NewConn(c)
c.Addr = "tcp://127.0.0.1:3390" // 副作用是什么?
}

这种方式与 HTTP 初始化方式一样,无法预知初始化之后再修改的效果。

1
2
3
4
5
6
7
8
// NewConn new a redis conn. 使用配置结构体,与上面一样
func NewConn(c Config) (cn Conn, err error)

// NewConn new a redis conn. 使用只读配置,无法确定零值
func NewConn(c *Config) (cn Conn, err error)

// NewConn new a redis conn. 使用可变长的配置,一样无法确定零值
func NewConn(c ...*Config) (cn Conn, err error)

当什么都不传(或者传 nil ),就使用默认配置。

1
2
3
4
5
6
7
8
import (
"github.com/go-kratos/kratos/pkg/log"
)

func main() {
log.Init(nil) // 这样使用默认配置
// config.fix() // 修正默认配置
}

nil 不能作为一个参数传递到函数中

“I believe that we, as Go programmers, should work hard to ensure that nil is never a parameter that needs to be passed to any public function.” – Dave Cheney

推荐方法:

Self-referential functions and the design of options - rob pike

Functional options for friendly APIs - Dave Cheney

函数级别的可选项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// DialOption specifies an option for dialing a Redis server.
type DialOption struct {
f func(*dialOptions) // 内部使用函数
}

// Dial connects to the Redis server at the given network and
// address using the specified options.
func Dial(network, address string, options ...DialOption) (Conn, error) { // 函数签名中显示必选和可选参数
do := dialOptions{
dial: net.Dial, // 使用默认值
}
for _, option := range options {
option.f(&do) // 将设定值替换掉默认值
} // ...
}

使用时,可以通过函数进行配置

1
2
3
4
5
6
7
8
9
10
11
12
package main
import (
"time"
"github.com/go-kratos/kratos/pkg/cache/redis"
)

func main() {
c, _ := redis.Dial("tcp", "127.0.0.1:3389",
redis.DialDatabase(0),
redis.DialPassword("hello"),
redis.DialReadTimeout(10*time.Second))
}

这样的思想简化下来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// DialOption specifies an option for dialing a Redis server.
type DialOption func(*dialOptions)


// Dial connects to the Redis server at the given network and
// address using the specified options.
func Dial(network, address string, options ...DialOption) (Conn, error) {
do := dialOptions{
dial: net.Dial,
}
for _, option := range options {
option(&do)
}
// ...
}

或者使用这样这种方式,暂时使用指定配置,实用完后,恢复成原来的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type option func(f *Foo) option

// Verbosity sets Foo's verbosity level to v.
func Verbosity(v int) option {
return func(f *Foo) option {
prev := f.verbosity
f.verbosity = v
return Verbosity(prev)
}
}

func DoSomethingVerbosely(foo *Foo, verbosity int) {
// Could combine the next two lines,
// with some loss of readability.
prev := foo.Option(pkg.Verbosity(verbosity))
defer foo.Option(prev)
// ... do some stuff with foo under high verbosity.
}

又或者使用这种方式,在 gRPC 中的 CallOption

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type GreeterClient interface {
SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
}

type CallOption interface {
before(*callInfo) error
after(*callInfo)
}

// 内部实现对象实现上面的 CallOption

// EmptyCallOption does not alter the Call configuration.
type EmptyCallOption struct{}

// TimeoutCallOption timeout option. 包含 EmptyCallOption 则直接实现 interface,再增加字段实现扩展
type TimeoutCallOption struct {
grpc.EmptyCallOption
Timeout time.Duration
}

那么,回到最开始的问题,如何使用配置

1
2
3
4
5
6
// Dial connects to the Redis server at the given network and
// address using the specified options.
func Dial(network, address string, options ...DialOption) (Conn, error)

// NewConn new a redis conn.
func NewConn(c *Config) (cn Conn, err error)

JSONYAML 的配置无法映射 DialOption,也只能使用第二种 *Config 的方式。

For example, both your infrastructure and interface might use plain JSON. However, avoid tight coupling between the data format you use as the interface and the data format you use internally. For example, you may use a data structure internally that contains the data structure consumed from configuration. The internal data structure might also contain completely implementation-specific data that never needs to be surfaced outside of the system.

例如,您的基础架构和接口都可能使用纯JSON。但是,请避免在用作接口的数据格式与内部使用的数据格式之间紧密耦合。例如,您可以在内部使用一个数据结构,其中包含从配置中使用的数据结构。内部数据结构可能还包含完全特定于实现的数据,这些数据永远不需要在系统外部显示。

The Site Reliability Workbook 站点可靠性工作手册 中文版

将配置数据,和系统进行解耦

1
2
3
// Dial connects to the Redis server at the given network and
// address using the specified options.
func Dial(network, address string, options ...DialOption) (Conn, error)
  • 仅保留 options API
  • config fileoptions struct 解耦

image-20231017151856686

使用 YAML(JSON) + Protobuf 结合,实现配置文件与系统分开

  • 通过 Protobuf + validation 实现语义验证
  • 通过 Protobuf 实现高亮
  • 使用 Protobuf 做 Lint 校验
  • 使用 YAML 做格式化

具体解耦方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Options apply config to options.
func (c *Config) Options() []redis.Options {
return []redis.Options{ // 返回数组
redis.DialDatabase(c.Database),
redis.DialPassword(c.Password),
redis.DialReadTimeout(c.ReadTimeout),
}
}

func main() {
// instead use load yaml file.
// 使用 protobuf 定义 Config 结构体,而不是 yaml
c := &Config{
Network: "tcp",
Addr: "127.0.0.1:3389",
Database: 1,
Password: "Hello",
ReadTimeout: 1 * time.Second,
}
r, _ := redis.Dial(c.Network, c.Addr, c.Options()...)
}

自定义方法,将 Option 转换成 Redis 可以使用的方法

1
2
3
4
5
6
package redis

// Option configures how we set up the connection.
type Option interface {
apply(*options)
}

结合 YAML

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func ApplyYAML(s *redis.Config, yml string) error {
js, err := yaml.YAMLToJSON([]byte(yml))
if err != nil {
return err
}
// YAML 转换成 JSON,JSON 做胶水格式,映射到 Config 中
return ApplyJSON(s, string(js))
}

// Options apply config to options.
func Options(c *redis.Config) []redis.Options {
return []redis.Options{
redis.DialDatabase(c.Database),
redis.DialPassword(c.Password),
redis.DialReadTimeout(c.ReadTimeout),
}
}

实际上的配置格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
syntax = "proto3";

import "google/protobuf/duration.proto";

package config.redis.v1;

// redis config.
message redis {
string network = 1;
string address = 2;
int32 database = 3;
string password = 4;
google.protobuf.Duration read_timeout = 5; // 表示可选
}

最终使用起来

1
2
3
4
5
6
func main() {
// load config file from yaml.
c := new(redis.Config)
_ = ApplyYAML(c, loadConfig())
r, _ := redis.Dial(c.Network, c.Address, Options(c)...)
}

Configuration Best Pratice

代码更改系统功能是一个冗长且复杂的过程,往往还涉及 Review、测试等流程,但更改单个配置选项可能会对功能产生重大影响,通常配置还未经测试。

配置的目标:

  • 避免复杂:使用通用中间件,全局配置模板
  • 多样的配置:模板 + 自定义 做多样化
  • 简单化努力:默认配置使用最佳配置
  • 以基础设施 -> 面向用户进行转变:基础配置多,用户配置少,可以针对场景进行演化
  • 配置的必选项和可选项:默认值应该也是一个科学的值
  • 配置的防御编程:在基础库中如果有不合理的配置,应该直接 panic,避免影响全局
  • 权限和变更跟踪:例如使用 git 做权限,配置中心做回滚
  • 配置的版本和应用对齐:应用变更之后,配置也要一并变更
  • 安全的配置变更:逐步部署、回滚更改、自动回滚;逐步部署,可应用突发状况

Config VS Options

  1. 配置都是简单的类型,比如说字符串、数字等,优先考虑 Configs,可以直接从文件加载
  2. 配置含有复杂结构体,比如说 Repo 的实现,如果允许用户传入一个 cache,以缓存数据,cache 实例的构建本身非常复杂,那么只能使用 Options
  3. 如果希望能够灵活监听配置变更,基本上只能使用 Configs
  4. 如果提供绝大部分配置的默认值 – 这些配置都是必须的,优先考虑 Options
  5. 如果觉得没必要引入一个新的 Configs 结构体,使用 Options。例如 DB 可以直接包含所有的配置,也可以 DB 包含一个 Config 的字段,这个取决于个人
  6. 配置的是行为,使用 Options,比如说,允许用户传入 log 方法

有个比较好用的实践

通过运行时加后缀,pflag 接受参数,或者编译时变更配置 go build -ldflags "-X" 'main.Env=aaa'

image-20231019163701009 image-20231019163713536

使用编译植入的劣势是需要重新编译,使用参数的劣势是需要有默认配置。