从零开始写一个web服务到底有多难?(四)——配置管理

2024-01-06 23:33:53 浏览数 (1)

配置

从配置的种类来说,我们的配置可以分成四大类。

环境变量

Cluster,Environment,AppID,Host之类的环境信息,都是通过在运行时平台注入容器或者物理机。

静态配置

资源初始化时需要的配置信息,如redis,db等,这类资源在线变更配置的风险非常大,通常不鼓励在线变更,很可能会导致业务出现不可预期的事故,变更静态配置应该走一次迭代发布的流程。

动态配置

应用程序可能需要一些在线的开关,来控制业务流程的一些简单策略,会频繁的调整和使用。

全局配置

通常,我们依赖的各类组件,中间件都有大量的默认配置或指定配置,在各个项目里大量拷贝复制,非常容易出现意外,所以我们会使用全局配置来标准化配置常用的组件,然后在应用中有特殊配置需求时进行局部替换。

Redis client example

假设我们业务现在有一个需求,连接Redis。

那么我们在创建Redis实例时,自然会有许多允许用户自定义的配置。

我要自己输入Redis的地址端口,连接方式。我要自定义超时时间。我要设定Database。我要控制连接池的策略。我要安全使用Redis,要允许我输入Password。

我们要做什么

我们可以试想一下,在一个真正的业务系统中,我们不会在每个类中自己去实例一个redis对象,而是通过依赖注入的方式,由一个对象负责管理redis的生命周期,配置维护,然后将这个对象注入到各个业务中。

我们需要在创建实例时支持用户自定义用户需要的配置。

看看标准库是怎么做的

我们很容易拿出一个启动服务的例子。代码很简单,我们new了一个httpServer,把一些配置信息传参传入,最终调用标准库的启动服务方法。

这样做的好处在于可以通过文档的形式将入参的定义告知用户。并且每个参数如果有默认值,也可以通过文档的形式说明。比如Addr如果为空,会默认使用80端口。

但是这样做也会存在一些问题。比如我们这个服务启动以后,server对象被暴露给用户。意味着当我们的服务启动后,可以在外部修改ReadTimeout,WriteTimeout,甚至可以修改Handler,Addr。但是在服务运行时,我们直接修改这些配置会产生生么影响?我们不知道也无法预期。并且通过文档描述缺省值并不直观。必须通过阅读注释才能知道。

代码语言:go复制
func main() {
	server := &http.Server{
		Addr:         ":8080",
		Handler:      nil,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
	}
	log.Fatal(server.ListenAndServe())
}

优化一下

我们将配置单独生成一个config对象,将config对象作为入参新建连接。在新建连接之后修改配置会产生什么结果?和原来一样还是无法预期。

代码语言:go复制
func main() {
	config := &server.Config{
		Addr:         "localhost:6379",
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
	}
	conn, _ = server.NewConn(config)
	config.Addr = "localhost:6380"
}
代码语言:go复制
type Config struct {
	Addr         string
	ReadTimeout  time.Duration
	WriteTimeout time.Duration
}

type Conn struct {
	Config *Config
}

func NewConn(c *Config) (cn *Conn, err error) {
	return &Conn{Config: c}, nil
}

换一下入参的方式

代码语言:go复制
func NewConn(c ...*Config) (cn *Conn, err error) {
	config := defaultConfig()
	if c != nil {
		config = c[0]
	}
	return &Conn{Config: config}, nil
}

func defaultConfig() *Config {
	return &Config{
		Addr:         "localhost:6379",
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
	}
}

我们可以尝试使用可选参数的方式,如果参数不传,则使用默认配置。如果传入则使用传入的参数。但是这样做会有一个副作用,可选参数也可以传多个,如果传多个我们就不知道应该让哪一个config生效了。

代码语言:javascript复制
func NewConn(c Config) (cn *Conn, err error) {
	return &Conn{Config: &c}, nil
}

我们也可以尝试使用值的入参方式,将config变成只读的。这样做config是做了deepclone传进去的,当然我们在外面就无法再修改config了。这样做解决了我们前面2种方式的问题。但是这样做仍然会有问题,当我们Config中某些字段不想设置,而想用缺省值时,比如Addr。如果我们不填写,Addr会传入空字符串。如果我们将空字符串时替换为缺省值。当用户真的想自己设定某个string变量成空字符串呢?也就是说在这种情况下,用户未设定和用户设定为零值/空值,我们是无法区分的。

Functional options——函数式选项

先定义一个DialOption的结构体,包含了一个f的回调函数。我们在创建实例的时候会执行里面的回调来修改config。

代码语言:go复制
type DialOption struct {
	f func(*Config)
}

同样我们的New的方法也对应进行修改。Addr是一个必填项,其他选项是可选项。同样我们这样写也非常容易为我们的config设置默认值。如果想使用默认值,只要不传options,那么我们就不会替换任何东西。会直接使用生成的defaultConfig中配置的缺省值。

代码语言:go复制
func NewConn(Addr string, options ...DialOption) (cn *Conn, err error) {
	config := defaultConfig(Addr)
	for _, option := range options {
		option.f(config)
	}
	return &Conn{
		Config: config,
	}, nil
}

func defaultConfig(Addr string) *Config {
	return &Config{
		Addr:         Addr,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
	}
}

func DialReadTimeout(r time.Duration) DialOption {
	return func(c *Config) {
		c.ReadTimeout = r
	}
}

func DialWriteTimeout(w time.Duration) DialOption {
	return func(c *Config) {
		c.WriteTimeout = w
	}
}

那么我们的业务代码就会变成这样,第一个必选的参数addr,即是必填的配置项,后面多个可选的参数,是多个可选的配置项。那么将来我们要扩展配置项时,只需要同样扩展一个DialXXX的回调函数生成函数,就可以扩展可选项了。

代码语言:go复制
func main() {
	conn, _ := server.NewConn("localhost:6379",
		server.DialReadTimeout(10*time.Second),
		server.DialWriteTimeout(10*time.Second))
}

当然我们也可以写的更简单一点,不需要定义结构体,直接定义一个函数指针。同样我们可以把Conn中的config改成private的,这样在运行时外部是无法轻易修改我们的配置的,只能通过对外暴露的NewConn方法或Dial方法来进行修改。

代码语言:go复制
type DialOption func(*Config)

type Config struct {
	Addr         string
	ReadTimeout  time.Duration
	WriteTimeout time.Duration
}

type Conn struct {
	config *Config
}

func NewConn(Addr string, options ...DialOption) (cn *Conn, err error) {
	config := defaultConfig(Addr)
	for _, option := range options {
		option(config)
	}
	return &Conn{
		config: config,
	}, nil
}

func (c *Conn) Dial(options ...DialOption) {
	for _, option := range options {
		option(c.config)
	}
}

其实写到这里,会感觉到这个和我们常写的通过get,set暴露属性的写法非常像。那么同样我们也可以类似在Set时添加监听的写法。在生成DialOption时,加入一些我们想要的逻辑。

在有一些情况下,我们可能会希望配置支持回滚。

日志级别

我们加入一个日志级别的配置,在有时候,可能我们希望临时打印一下info级别的日志,但是打印完之后,需要把配置恢复到设置成info之前的配置。

代码语言:go复制
type config struct {
	Addr         string
	ReadTimeout  time.Duration
	WriteTimeout time.Duration
	Log          string
}

func defaultConfig(Addr string) *Config{
	return &config{
		Addr:         Addr,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
		Log:          "ERROR",
	}
}

修改一下DialOption的定义,他会返回一个回滚配置的DialOption。

代码语言:go复制
type DialOption func(*Config) DialOption

func DialLog(l string) DialOption {
	return func(c *Config) DialOption {
		prev := c.Log
		c.Log = l
		return DialLog(prev)
	}
}

同样我们也需要为此另外定义一个Dial的方法Option

代码语言:go复制
func (c *Conn) Option(option DialOption) DialOption {
	return option(c.config)
}

这样我们通过prev与defer两行,就实现了修改配置,并在做完业务之后回滚的操作。

代码语言:go复制
func main() {
	conn, _ := server.NewConn("localhost:6379",
		server.DialReadTimeout(10*time.Second),
		server.DialWriteTimeout(10*time.Second))


	prev := conn.Option(server.DialLog("INFO"))
	defer conn.Dial(prev)
	// ...do something
}

JSON/YAML

现在我们又有了一个问题。写在配置文件里的配置该怎么加载呢?他们无法直接映射DialOption啊。

映射Option的方法可由我们自己来提供

代码语言:go复制
func (c *Config) Options() []DialOption {
	return []DialOption{
		DialReadTimeout(c.ReadTimeout),
		DialWriteTimeout(c.WriteTimeout),
		DialLog(c.Log),
	}
}

这里Config的值我们模拟从yaml或者json读取。然后通过一个Options的方法,将属性还原成DialOption。然后做对象的初始化。

这样我们的业务代码就通过Config的结构作为桥梁将对象的初始化和配置文件本身依赖的结构彻底解耦了。相当于做了个适配器。

代码语言:go复制
func main() {
	//instead use load yaml/json file
	c := &server.Config{
		Addr:         "localhost:6379",
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
		Log:          "ERROR",
	}
	conn, _ := server.NewConn(c.Addr, c.Options()...)

	prev := conn.Option(server.DialLog("INFO"))
	defer conn.Dial(prev)
	// ...do something
}

总结

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

因此配置的目标:

避免复杂,提供多样的配置,有基础的模板配置,支持在模板的基础上自定义并合并,但配置流程尽量简单,缺省值尽量提供最佳实践的配置值。

配置要区分必选项和可选项。

配置的防御编程,对于过于不合理的配置应该有校验,如果用户不小心输入了明显不合理的配置,比如超时时间10秒写成了1000秒,那么我们应该能够识别并抛出错误。

权限和变更跟踪。

安全的配置变更:逐步部署,回滚更改,自动回滚。

go

0 人点赞