关于 Go 配置文件的最优设计方案?

看到 G02 的 3.9 小节,里面的代码非常长,花了蛮长时间看,然后有些问题想和大家一起探讨一下。

原文中的配置文件相关代码如下:
config/app.go
config/config.go
pkg/helpers/helpers.go
pkg/config/config.go
main.go
.env

总体分析一下,config 包的目的是为了封装 viper,让其他包的项目代码感知不到 viper 的存在,并且在 config 包里定义了 Get、GetString、GetInt 等方法,这样可以方便地在其他包中使用 config.Get 获取到配置项。

pkg/config 包内定义了一个 ConfigFuncs

问题 1:config 包中 Initialize 方法存在的意义?

在 main.go 中的 init 方法中调用了 btsConfig.Initialize(),这个方法里是空的,里面什么都没有,目的只是单纯地想触发包内所有的 init 函数而已。但是包内的 init 总是会比 Initialize 先执行,也就是说,把 init 方法里的内容剪贴到 Initialize 函数里执行结果是没有任何差异的对吧?

那按我的理解,Initialize 存在的意义是省去了新增配置文件的时候(例如:新增了一个 config/cors.go 文件,用于添加跨域配置项),需要动脑筋想函数名,还要在 Initialize 中调用一次该函数的过程,是不是可以这么理解?

问题 2:为什么 pkg/config 包中定义的 ConfigFunc 不是 map[string]interface{} 类型,而是返回该类型的方法?

我觉得 config/app.go 中添加配置的方法看起来比较麻烦,是这样的:

func init() {
    config.Add("app", func() map[string]interface{} {
        return map[string]interface{} {
            // 应用名称
            "name": config.Env("APP_NAME", "Gohub"),
        }
    })
}

于是我就把 ConfigFunc 改成了 map[string]interface{} 类型,pkg/config 包内相关的函数参数类型也改了,config/app.go 也改成了这样:

var app = map[string]interface{} {
    // 应用名称
    "name": config.Env("APP_NAME", "Gohub"),
}

func init() {
    config.Add("app", app)
}

然后一跑,发现 config.Get 获取不到配置项的值了!

排查了很久,发现函数执行的顺序和想象中是一样的:

2022-01-10 09:40:08.038 debug   pkg/config/config.go:24     init pkg/config/config
2022-01-10 09:40:08.039 debug   config/config.go:14         init config/config
2022-01-10 09:40:08.039 debug   pkg/config/config.go:93     Add pkg/config/config
2022-01-10 09:40:08.048 debug   config/config.go:10         Initialize config/config
2022-01-10 09:40:08.048 debug   pkg/config/config.go:42     InitConfig pkg/config/config
2022-01-10 09:40:08.050 debug   pkg/config/config.go:50     loadConfig pkg/config/config
// 但是接下来 config.Get("app.name") 就获取不到配置项的值了

关键在于 pkg/config/config.go 中 Add 方法和 loadConfig 方法的执行顺序:

// Add 新增配置项
func Add(name string, configFn ConfigFunc) { // 这里的 ConfigFunc 被我改成 map[string]interface{} 类型了
    ConfigFuncs[name] = configFn
}

// loadConfig 载入配置项
func loadConfig() {
    for name, fn := range ConfigFuncs {
        viper.Set(name, fn()) // 这里的 fn() 也改成了对应的 fn,即 map 类型
    }
}
// main.go

package main

func init() {
    // 加载 config 目录下的配置信息
    btsConig.Initialize()
}

func main() {

    // 配置初始化,依赖命令行 --env 参数
    var env string
    flag.StringVar(&env, "env", "", "加载 .env 文件,如 --env=testing 加载的是 .env.testing 文件")
    flag.Parse()
    config.InitConfig(env)

    // ...
}

Add 方法是由 config/config.go 的 init 调用的,config 包的初始化是 config/config.go 的 Initialize 触发的。Add 方法结束后,回溯到 Initialize 结束。接下来才是 InitConfig 和 loadConfig。那么问题来了,如果经过 loadConfig 中的 viper.Set 将配置项载入到 viper 中,那为什么在 loadConfig 之后的 config.Get 也获取不到值呢?但是从 map 改回 func 就可以?

啊,想了好久,没搞明白。传递一个闭包和传递一个值的关系有什么区别呢?唔……

问题三:有什么简化的方案?

经过分析发现这个方案几乎就是 laravel 的同款配置方案,以多维数组的形式分割为多个模块的配置,统一载入,随处获取,具有可覆盖性。但是获取配置项的时候是需要自己控制类型的,例如端口:

    "port": config.Env("HTTP_PORT", 8000), // 默认值这么定义也可以
    "port": config.Env("HTTP_PORT", "8000"), // 这样也可以

不管配置文件类型如何,不管给的默认值类型如何,关键在于取出来是什么类型,如果需要 Int 那就 GetInt,需要 String 那就 Get 或者 GetString,写着写着就感觉丢失了 Go 的强类型优势,而有一种在写 PHP 的错觉?

我看我的小伙伴的配置管理是这么写的,非常之简单:

boot_boot.go

config_config.go

这样虽然异常简单,但对于中小项目来说使用上基本足够了。

虽然我也很想用 viper,但是教程的做法有种杀鸡用牛刀的感觉。

所以想知道,如果折中一下,用 viper 解析配置文件绑定到结构体上对于系统的健壮性会不会更好一点,毕竟强类型是 Go 的优势,总担心写 Go 写成 PHP,嗐~


P.S. 这里把教程里的代码以图片的形式贴出来了,目的只是单纯地希望没有购买课程的同学也能一起看一看探讨一下我提出的问题啊!不是想盗版啊什么的啊! 🙏🏻

本帖已被设为精华帖!
本帖由系统于 1年前 自动加精
讨论数量: 13

问题1:

Initialize 方法只是为了导入 /config 包,因为 golang 不允许未使用的导入,使用匿名导入就可以不需要这个函数。

问题2:

使用该类型的方法是为了避免包初始化中即时求值。 init 函数中使用 Add 函数增加配置时还未导入配置文件中的配置信息,留下一个匿名的求值函数,这样等到main函数调用加载配置文件时就可以取到正确的配置文件数据。下面用伪流程标识一下主要区别。 以下/config包称为config,/pkg/config称为pconfig

// 使用 map [string] interface {}
pconfig.init(初始化ConfigFuncs) 
-> config.init(调用Add函数即时求值,此时viper对象未导入配置数据,因此ConfigFuncs中全为默认值) 
-> main() 
-> pconfig.InitConfig.loadEnv(此时加载env配置文件) 
-> pconfig.initConfig.loadConfig(此时默认值覆盖配置文件)
// 使用 func() map [string] interface {}
pconfig.init(初始化ConfigFuncs) 
-> config.init(调用Add函数存储匿名的求值函数) 
-> main() 
-> pconfig.InitConfig.loadEnv(此时加载env配置文件) 
-> pconfig.initConfig.loadConfig(此时调用上述匿名求值函数,从viper对象中求值)

如果希望使用更直观的map [string] interface {} 类型,可以考虑在main函数中增加配置信息。

2年前 评论
苏亦坤 (楼主) 2年前

比如说获取配置的时候,是 config.Get("app.name"),但是这个 app.name 实际上对系统的重构非常不方便。如果是结构体的话,可能就类似于 config.App.Name,这样一来用 IDE 可以方便地看到类型,重构的话也会非常简单。唔……不知道大家怎么看?

2年前 评论

我看这章有很深的挫败感,这才是刚开始配置 ,完全看不懂。

楼主这个方案我大致看得明白。 我也一直以为配置方案就是读取个json、ini之类的文本之类的东西呢,最多看看文件io就行了。

上来整的viper把我干懵了。

2年前 评论
苏亦坤 (楼主) 2年前

gocn.vip/topics/11898

看了这篇文章我才意识到,不是Viper复杂,是站长把他描述的很复杂。直接劝退难度了。

2年前 评论

看了你这篇文章 我研究了下 config/app.go的init方法是先于pkg/config/config.go的loadEnv方法执行的,所以你直接赋值的话这时候是取不到.env里面的配置的。 按你的方法其实是可以取到默认值,但是需要改一个地方 viper.Set(name, map[string]interface{}(fn)) // 这里的fn需要从自定义类型转回map。 viper对自定义类型是整体赋值的,具体可以搜索下viper的toCaseInsensitiveValue这个方法。

2年前 评论

对于大项目就是对配置的封装后期要方便得多,如果是小项目,只需要封装下自己常用的一些方法就行

2年前 评论

问题1:

Initialize 方法只是为了导入 /config 包,因为 golang 不允许未使用的导入,使用匿名导入就可以不需要这个函数。

问题2:

使用该类型的方法是为了避免包初始化中即时求值。 init 函数中使用 Add 函数增加配置时还未导入配置文件中的配置信息,留下一个匿名的求值函数,这样等到main函数调用加载配置文件时就可以取到正确的配置文件数据。下面用伪流程标识一下主要区别。 以下/config包称为config,/pkg/config称为pconfig

// 使用 map [string] interface {}
pconfig.init(初始化ConfigFuncs) 
-> config.init(调用Add函数即时求值,此时viper对象未导入配置数据,因此ConfigFuncs中全为默认值) 
-> main() 
-> pconfig.InitConfig.loadEnv(此时加载env配置文件) 
-> pconfig.initConfig.loadConfig(此时默认值覆盖配置文件)
// 使用 func() map [string] interface {}
pconfig.init(初始化ConfigFuncs) 
-> config.init(调用Add函数存储匿名的求值函数) 
-> main() 
-> pconfig.InitConfig.loadEnv(此时加载env配置文件) 
-> pconfig.initConfig.loadConfig(此时调用上述匿名求值函数,从viper对象中求值)

如果希望使用更直观的map [string] interface {} 类型,可以考虑在main函数中增加配置信息。

2年前 评论
苏亦坤 (楼主) 2年前

兄弟,这是怎么测试的。用哪个插件

2022-01-10 09:40:08.038 debug   pkg/config/config.go:24     init pkg/config/config
2022-01-10 09:40:08.039 debug   config/config.go:14         init config/config
2022-01-10 09:40:08.039 debug   pkg/config/config.go:93     Add pkg/config/config
2022-01-10 09:40:08.048 debug   config/config.go:10         Initialize config/config
1年前 评论
苏亦坤 (楼主) 1年前

反正这章给我的观感给常不好,不好的点在于

  • 硬是在viper和用户代码之间封装了一个config,定义了自己的set方法和取方法,且写的很复杂,个人觉得没那个必要
  • init方法的使用,uber的go规范都说了,应该避免使用init方法,极少情况除外(这种极少情况,我认为数据库驱动算一个),这种情况并不适合,它容易导致代码读者区分不出加载顺序
  • env文件的使用,配置文件中以APP_NAME的形式提供,使用时以app.name的形式获取,APP_NAME转app.name还有一个config/app.go,个人觉得完全没必要。yaml、json、properties。。。带层级的配置文件直接使用就好了呀。
  • 是否将配置unmarshal为结构体,我倾向于是,作为一个静态类型语言,就应该充分利用其优点。一个配置项,多个地方调用,是看调用关系简单,还是全局搜索字符串简单呢,肯定是前者呀。
1年前 评论
wonderfate 1年前

过度封装了,没必要搞这么复杂的配置库进来。一个配置文件搞定多好。

8个月前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!