Go工程化实践之配置管理
配置管理
Configuration 种类
环境变量(配置)
Region(区域)、Zone(华南、华北)、Cluster(集群)、Environment(预发布、测试、研发)、Color(染色、组合)、Discovery、AppID、Host等之类的环境信息,都是通过在线运行时平台打入到容器或者物理机,供 kit 库读取使用。
静态配置
资源需要初始化的配置信息,比如
http/gRPC server
、Redis
、MySQL
等,这类资源在线变更配置的风险非常大,通常不鼓励on-the-fly
(运行时) 变更,很可能会导致业务出现不可预期的事故,变更静态配置和发布bianry app
没有区别,应该走一次迭代发布的流程。(例如 IM 这类长连接的服务,应该将连接层和逻辑层分开,逻辑层更新,但是连接层不断开)动态配置
应用程序可能需要一些在线的开关,来控制业务的一些简单策略,会频繁的调整和使用,这类是基础类型(
int
,bool
)等配置,用于可以动态变更业务流的收归一起,同时可以考虑结合类似https://pkg.go.dev/expvar
来结合使用全局配置
通常,依赖的各类组件、中间件都有大量的默认配置或者指定配置,在各个项目里大量拷贝复制,容易出现意外,所以使用全局配置模板来定制化常用的组件,然后再特化的应用里进行局部替换。
用途
例如一个 Redis client
例子
1 | // DialTimeout acts like Dial for establishing the |
但是在创建中需要增加需求,例如
- 要自定义超时时间
- 要设定
Database
- 要控制连接池的策略
- 要安全使用
Redis
,需要使用Password
- 要提供慢查询请求记录,并且可以设置
slowlog
的时间
如果要满足这些需求,则可以修改代码
1 | // DialTimeout acts like Dial for establishing the |
可以看到,这种实现方式是非常繁杂的,增加一个功能要增加一个函数,不灵活。
仿照一下标准库里面的
net/http/server.go
1 | // A Server defines parameters for running an HTTP server. |
使用时
1 | package main |
这种方式的弊端:
- 大写暴露出来,也就是外部可以拿到,也可以修改,修改之后可能引起的问题无法预料
- 没有配置的默认值需要通过文档获取
如果说,需要从配置文件进行解析,初始化 Redis
对象
1 | // Config redis settings. |
这种方式与 HTTP
初始化方式一样,无法预知初始化之后再修改的效果。
1 | // NewConn new a redis conn. 使用配置结构体,与上面一样 |
当什么都不传(或者传 nil
),就使用默认配置。
1 | import ( |
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 | // DialOption specifies an option for dialing a Redis server. |
使用时,可以通过函数进行配置
1 | package main |
这样的思想简化下来
1 | // DialOption specifies an option for dialing a Redis server. |
或者使用这样这种方式,暂时使用指定配置,实用完后,恢复成原来的配置
1 | type option func(f *Foo) option |
又或者使用这种方式,在 gRPC
中的 CallOption
1 | type GreeterClient interface { |
那么,回到最开始的问题,如何使用配置
1 | // Dial connects to the Redis server at the given network and |
JSON
,YAML
的配置无法映射 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 | // Dial connects to the Redis server at the given network and |
- 仅保留
options API
config file
和options struct
解耦
使用 YAML(JSON) + Protobuf 结合,实现配置文件与系统分开
- 通过 Protobuf + validation 实现语义验证
- 通过 Protobuf 实现高亮
- 使用 Protobuf 做 Lint 校验
- 使用 YAML 做格式化
具体解耦方式
1 | // Options apply config to options. |
自定义方法,将 Option
转换成 Redis
可以使用的方法
1 | package redis |
结合 YAML
1 | func ApplyYAML(s *redis.Config, yml string) error { |
实际上的配置格式
1 | syntax = "proto3"; |
最终使用起来
1 | func main() { |
Configuration Best Pratice
代码更改系统功能是一个冗长且复杂的过程,往往还涉及 Review
、测试等流程,但更改单个配置选项可能会对功能产生重大影响,通常配置还未经测试。
配置的目标:
- 避免复杂:使用通用中间件,全局配置模板
- 多样的配置:模板 + 自定义 做多样化
- 简单化努力:默认配置使用最佳配置
- 以基础设施 -> 面向用户进行转变:基础配置多,用户配置少,可以针对场景进行演化
- 配置的必选项和可选项:默认值应该也是一个科学的值
- 配置的防御编程:在基础库中如果有不合理的配置,应该直接
panic
,避免影响全局 - 权限和变更跟踪:例如使用 git 做权限,配置中心做回滚
- 配置的版本和应用对齐:应用变更之后,配置也要一并变更
- 安全的配置变更:逐步部署、回滚更改、自动回滚;逐步部署,可应用突发状况
Config VS Options
- 配置都是简单的类型,比如说字符串、数字等,优先考虑
Configs
,可以直接从文件加载 - 配置含有复杂结构体,比如说
Repo
的实现,如果允许用户传入一个cache
,以缓存数据,cache
实例的构建本身非常复杂,那么只能使用Options
- 如果希望能够灵活监听配置变更,基本上只能使用
Configs
- 如果提供绝大部分配置的默认值 – 这些配置都是必须的,优先考虑
Options
- 如果觉得没必要引入一个新的
Configs
结构体,使用Options
。例如DB
可以直接包含所有的配置,也可以DB
包含一个Config
的字段,这个取决于个人 - 配置的是行为,使用
Options
,比如说,允许用户传入log
方法
有个比较好用的实践
通过运行时加后缀,pflag
接受参数,或者编译时变更配置 go build -ldflags "-X" 'main.Env=aaa'
使用编译植入的劣势是需要重新编译,使用参数的劣势是需要有默认配置。