Go 进阶训练营 – Go 工程化实践三:配置管理

2022-10-07 14:35:16 浏览数 (1)

配置分类

环境变量(配置)

在部署、运行时能获取到的信息,没必要放到配置文件里。可通过K8S注入到容器或者物理机,供kit 库读取使用。

  • Region:区域
  • Zone:可用区
  • Cluster:集群
  • Environment:环境,由集群本身决定,而不是取决于应用
  • Color:染色信息,可用于分发流量,实现多租户
  • Discovery:注册中心?课程没说清楚
  • AppID:应用ID中的部分信息
  • Host

静态配置

需要初始化的配置信息,比如 http/gRPC server、redis、mysql 等。

这类资源在线变更配置的风险非常大,不鼓励 on-the-fly 变更,很可能会导致业务出现不可预期的事故。

变更静态配置和发布 bianry app 没有区别,应该走一次迭代发布的流程。

动态配置

  • 应用程序可能需要一些在线的开关,来控制业务的一些简单策略,会频繁的调整和使用,我们把这类是基础类型(int, bool)等配置,用于可以动态变更业务流的收归一起,同时可以考虑结合类似 https://pkg.go.dev/expvar 来结合使用。 expvar 包提供了一种标准化接口用于公共变量,例如针对 server 中的操作计数器; expvar 以 JSON 格式通过 HTTP 的 /debug/vars 来暴露这些变量; 针对这些公共变量的 set 或 modify 操作具有原子性; 内部通过并发安全的map存放各种键值对,可用于暴露应用运行指标,例如默认提供的内存状态。
  • 业务配置最好做到管理后台,运营同事才好使用

全局配置

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

可选函数模式

Go 中没有Java里的方法重载,导致配置可选参数时,需要些提供很多不同命、不同参数的方法来进行初始化(例如Redis)。

如何解决:使用可选函数模式,区分必填参数和非必填参数。

方式一

参数仅用于一个对象。

代码语言:javascript复制
type Option func(log *logrus.Logger)

func Level(level logrus.Level) Option {
    return func(log *logrus.Logger) {
        log.Level = level
    }
}

func Output(w io.Writer) Option {
    return func(log *logrus.Logger) {
        log.Out = w
    }
}
...
func NewLogrusLogger(options ...Option) log.Logger {
    logger := logrus.New()

    // 默认值
    logger.Level = logrus.DebugLevel
    logger.Out = os.Stdout
    logger.Formatter = &logrus.JSONFormatter{}

    for _, option := range options {
        option(logger)
    }

    return &LogrusLogger{
        log: logger,
    }
}

方式二

可选参数用于多个对象,或者还需要在其他地方使用可选参数,可将可选参数单独封装为一个对象。

代码语言:javascript复制
todo gorm db

自定义扩展配置

如果想要用户可以自定义一些配置,可以看看 grpc 的配置定义,主要的思路就是把 option 从函数修改为接口,并提供一些实现类(也可以空实现,交给子类自己重写,例如下面的EmptyCallOption),自定义配置可以继承实现类,并增加额外的字段。在使用这些可选参数时,通过类型转化得到扩展参数。

代码语言:javascript复制
type CallOption interface {
    before(*callInfo) error
    after(*callInfo, *csAttempt)
}

type EmptyCallOption struct{}

func (EmptyCallOption) before(*callInfo) error      { return nil }
func (EmptyCallOption) after(*callInfo, *csAttempt) {}

func Header(md *metadata.MD) CallOption {
    return HeaderCallOption{HeaderAddr: md}
}

type HeaderCallOption struct {
    HeaderAddr *metadata.MD
}

func (o HeaderCallOption) before(c *callInfo) error { return nil }
func (o HeaderCallOption) after(c *callInfo, attempt *csAttempt) {
    *o.HeaderAddr, _ = attempt.s.Header()
}

配置文件

Protobuf 作为强 schema 的描述文件,也可以方便扩展,是不是用于配置文件定义也可?

当然可以,kratos就是这么干的,理由如下:

  1. 强类型。
  2. 可添加约束,提前暴露问题。例如在配置中心页面录入配置时,就可以进行校验。
  3. pb可做lint
  4. pb的反序列化支持解析多种格式的配置文件,例如yaml、json。
如何区分零值?

可使用pb里的包装类:https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/wrappers.proto

配置文件的反序列化对象如何赋值给上诉的可选函数配置?

方式一:直接拷贝

反序列化对象和配置对象定义成一样的结构,赋值时直接拷贝。

不可取,这样强耦合了。并且需要暴露配置对象,这是不能保证的,有的三方包仅提供可选函数配置。

方式二:统一使用可选函数配置

不管是根据配置文件进行配置,还是其他地方需要配置,都使用可选函数模式,不要提供多种初始化方式,这样基础库才精简。

使用时,额外写个反序列化对象转option数组的函数,但不能封装到kit库,因为这个反序列对象是使用者通过pb定义的,大家都不同。

代码语言:javascript复制
// 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.
  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数组的函数意义不大,还是直接给可选函数赋值简单。

另外毛老师说kratos讲配置定义的pb文件生成go代码时,直接额外生成转option的函数,这是不可行的,因为option是基础库里定义的,kratos现在也并没有这么做。

最佳实践

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

修改配置其实是一件比较危险的事情,很多时候我们缺乏足够的敬畏,因为现在在线的配置中心越来方便,所以修改的成本越来越低,大家就越来越随意,所以我们需要对配置的修改慎重一些。

配置的目标:

  • 避免复杂
  • 多样的配置简
  • 单化努力
  • 以基础设施 -> 面向用户进行转变
  • 配置的必选项和可选项
  • 配置的防御编程
  • 权限和变更跟踪
  • 配置的版本和应用对齐
  • 安全的配置变更:逐步部署、回滚更改、自动回滚

参考

GO 编程模式:FUNCTIONAL OPTIONS——陈皓

Go工程化(六) 配置管理

Post Views: 5

0 人点赞