日志与配置必须解耦且初始化顺序为“配置先于日志”:用 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。
正确结构应是:

internal/config,导出 Load() 函数返回结构体--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,要么静默失败。
info),用于打印配置加载过程init())函数里禁止调用任何日志或配置访问逻辑;它们只能做纯声明或注册uber/fx),把配置和日志作为构造依赖显式注入,由框架控制顺序log.level 但没触发日志器重建,或者重载了配置却忘了重置日志的输出目标。这些细节不会报错,但会让问题排查变成盲猜。