日志与配置必须解耦且初始化顺序为“配置先于日志”:用 zap/zerolog 封装可注入日志实例,避免 log.SetOutput 污染全局;配置统一放 internal/config,支持环境变量覆盖与安全重载,引导日志器用于加载过程。

日志配置要和运行环境解耦,别硬编码 log.SetOutput
Go 标准库的 log 包默认输出到 os.Stderr,直接调用 log.SetOutput 会污染全局状态,尤其在测试或引入第三方库时容易被覆盖。更严重的是,它无法按环境(dev/staging/prod)动态切换输出目标(如文件、syslog、JSON 格式)。
推荐做法是封装一个可注入的日志实例:
- 用
zap.Logger或zerolog.Logger替代标准log,它们支持结构化、多输出、级别控制 - 日志初始化逻辑放在
pkg/logger或internal/logger,接收配置参数(如logLevel、outputPath) - 避免在
init()中初始化日志;应在main()开头或依赖注入容器中完成 - 开发环境默认输出到
os.Stdout并启用颜色;生产环境写入轮转文件(用lumberjack.Logger),且禁用颜色和 caller 信息以减少开销
配置文件目录不能放在 cmd/ 下,优先用 internal/config
把 config.yaml 放进 cmd/myapp/ 会导致多个命令(如 cmd/api 和 cmd/cli)重复读取逻辑,也违背 Go 的包可见性原则——cmd/ 下的包不应被其他模块 import。
正确结构应是:
立即学习“go语言免费学习笔记(深入)”;
- 配置定义与解析统一放在
internal/config,导出Load()函数返回结构体 - 配置文件路径由外部传入(如 flag
--config),或按约定查找:./config.yaml→$HOME/.myapp/config.yaml→/etc/myapp/config.yaml - 不要用
os.Getwd()拼路径;改用filepath.Join(filepath.Dir(os.Args[0]), "config.yaml")获取二进制同级路径 - 敏感字段(如
db.password)不写死在 YAML 中,而是通过环境变量覆盖:env: DB_PASSWORD(需用github.com/knadh/koanf或spf13/viper支持)
viper 自动重载配置有陷阱,别在热更新时忽略结构体零值
viper.WatchConfig() 确实能监听文件变化并触发回调,但常见错误是:在回调里直接用 viper.Unmarshal(&cfg) 覆盖原结构体,导致未出现在新配置中的字段保留旧值(非零值),而不是恢复为零值。
例如原配置有 timeout: 30,新配置删了这一项,但 cfg.Timeout 仍为 30 而非 0 —— 这违反“缺失即默认”的语义。
-
解决方法:每次重载都新建一个空结构体实例,再
Unmarshal,而非复用旧变量 - 或者改用
viper.UnmarshalExact(),它会在字段未定义时报错,强制你处理缺失情况 - 注意
WatchConfig()不保证线程安全;回调中修改全局配置需加锁,或用原子指针(*atomic.Value)交换 - 仅对真正需要热更新的配置项启用监听(如限流阈值、开关),数据库连接串这类关键配置建议重启生效
日志与配置的初始化顺序必须是「配置先于日志」
如果日志初始化依赖配置(比如从配置读取 log.level 或 log.file),而配置又依赖日志(比如加载失败时想打一条 error 日志),就会形成循环依赖,最终要么 panic,要么静默失败。
- 启动时先用极简配置初始化一个“引导日志器”(只输出到 stderr,level 固定为
info),用于打印配置加载过程 - 再加载完整配置,最后用配置参数重建正式日志器,并替换引导实例
- 所有包初始化(
init())函数里禁止调用任何日志或配置访问逻辑;它们只能做纯声明或注册 - 如果用了 DI 框架(如
uber/fx),把配置和日志作为构造依赖显式注入,由框架控制顺序
log.level 但没触发日志器重建,或者重载了配置却忘了重置日志的输出目标。这些细节不会报错,但会让问题排查变成盲猜。










