热重载错误处理与旧配置保留
通过独立加载、严格解码和校验应用新配置,更新失败时保留旧配置。
本系列使用 Viper v1.21.0。每章都是独立实验,示例命令在项目目录中执行;开始新一章时,使用该章的完整程序和配置,不累积上一章的临时修改。
尚未准备练习项目时,先完成第 1 章的项目准备。
本章的目标是读取、解码和校验都成功后才应用新配置;失败时继续使用上次有效值。
初始示例
初始配置 config/config.yaml:
app: name: viper-demo port: 8080 debug: true timeout: 5s allowed_hosts: - localhost - example.com labels: environment: local owner: learner
servers: - name: primary address: "127.0.0.1:9001" - name: backup address: "127.0.0.1:9002"初始完整 main.go:
package main
import ( "fmt" "github.com/fsnotify/fsnotify" "github.com/spf13/viper" "log" "os" "os/signal" "time")
type AppConfig struct { Name string `mapstructure:"name"` Port int `mapstructure:"port"` Debug bool `mapstructure:"debug"` Timeout time.Duration `mapstructure:"timeout"` AllowedHosts []string `mapstructure:"allowed_hosts"` Labels map[string]string `mapstructure:"labels"`}
type Server struct { Name string `mapstructure:"name"` Address string `mapstructure:"address"`}
type Config struct { App AppConfig `mapstructure:"app"` Servers []Server `mapstructure:"servers"`}
func validateConfig(cfg Config) error { if cfg.App.Name == "" { return fmt.Errorf("app.name 不能为空") } if cfg.App.Port < 1 || cfg.App.Port > 65535 { return fmt.Errorf("app.port 必须在 1 到 65535 之间") } if cfg.App.Timeout <= 0 { return fmt.Errorf("app.timeout 必须大于 0") } return nil}
func loadConfig(path string) (Config, error) { v := viper.New() v.SetConfigFile(path) v.SetDefault("app.port", 3000) v.SetDefault("app.timeout", "5s")
if err := v.ReadInConfig(); err != nil { return Config{}, fmt.Errorf("读取配置失败: %w", err) } var cfg Config if err := v.UnmarshalExact(&cfg); err != nil { return Config{}, fmt.Errorf("解码配置失败: %w", err) } if err := validateConfig(cfg); err != nil { return Config{}, err } return cfg, nil}
func main() { const path = "config/config.yaml" cfg, err := loadConfig(path) if err != nil { log.Fatal(err) }
changed := make(chan struct{}, 1) watcher := viper.New() watcher.SetConfigFile(path) watcher.OnConfigChange(func(_ fsnotify.Event) { select { case changed <- struct{}{}: default: } }) watcher.WatchConfig()
stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt) defer signal.Stop(stop) ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() fmt.Println("正在监听,按 Ctrl+C 退出")
for { select { case <-changed: next, err := loadConfig(path) if err != nil { log.Println("拒绝更新,继续使用旧配置:", err) continue } cfg = next fmt.Println("配置已更新") case <-ticker.C: fmt.Printf("当前配置: name=%s port=%d debug=%t\n", cfg.App.Name, cfg.App.Port, cfg.App.Debug) case <-stop: return } }}执行:
go run -race main.go程序每两秒打印当前配置。启动时读取失败就退出,因为还没有有效配置;运行期间更新失败只记录错误并继续运行。
loadConfig 每次创建独立的 Viper 和结构体,失败不会污染当前配置。监听实例只负责发通知,主循环重新读取文件得到明确的错误结果。
容量为 1 的通道保存一个待处理通知,default 跳过重复通知,避免阻塞回调。主循环读取最新文件,不必记录每个保存事件。
只有主循环读写 cfg。其他 goroutine 不访问它,所以这里不需要加锁;若接入并发 HTTP handler,则需要锁或不可变快照来发布配置。
第一次修改
在程序运行期间,将端口改为:
port: 9090保存后应接受更新并打印 9090。
第二次修改
将端口改为:
port: 70000保存后应拒绝更新,继续打印 9090。再将端口字段分别替换为下面两种内容,每次保存观察结果:
port: abc prot: 8080分别应出现类型转换或未知字段错误,并继续用旧值。
第三次修改
临时将文件完整内容改成:
app: [保存后应出现 YAML 读取错误,仍使用旧配置。监听实例自身也可能打印错误,与“拒绝更新”同时出现。
第四次修改
将文件完整恢复成下面的合法配置:
app: name: viper-demo port: 6060 debug: true timeout: 5s allowed_hosts: - localhost - example.com labels: environment: local owner: learner
servers: - name: primary address: "127.0.0.1:9001" - name: backup address: "127.0.0.1:9002"保存后应恢复更新,开始打印 6060。
第五次修改
删除恢复配置中的 port 这一行:
port: 6060保存后应打印默认值 3000。每次创建新结构体可以避免缺失字段无意间保留上次解码结果。
第六次修改
停止程序,创建 config/config.toml,完整内容为:
[app]name = "viper-demo"port = 8080debug = truetimeout = "5s"allowed_hosts = ["localhost", "example.com"]
[app.labels]environment = "local"owner = "learner"
[[servers]]name = "primary"address = "127.0.0.1:9001"
[[servers]]name = "backup"address = "127.0.0.1:9002"将 main 中的路径常量替换为:
const path = "config/config.toml"重新运行 go run -race main.go,将 TOML 的端口行替换为:
port = 9090保存后预期接受更新。将超时行临时替换为:
timeout = "invalid"保存后预期拒绝。再恢复为:
timeout = "5s"保存后应恢复更新。
第七次修改
停止程序,创建 config/config.json,完整内容为:
{ "app": { "name": "viper-demo", "port": 8080, "debug": true, "timeout": "5s", "allowed_hosts": ["localhost", "example.com"], "labels": { "environment": "local", "owner": "learner" } }, "servers": [ {"name": "primary", "address": "127.0.0.1:9001"}, {"name": "backup", "address": "127.0.0.1:9002"} ]}将路径常量替换为:
const path = "config/config.json"重新运行,将 JSON 的端口字段替换为:
"port": 9090,保存后应更新。将文件最后一个服务器对象临时替换为:
{"name": "backup", "address": "127.0.0.1:9002",}这里故意留下末尾逗号,保存后应读取失败。再将该对象恢复为:
{"name": "backup", "address": "127.0.0.1:9002"}保存后应恢复更新。
本章更新的是配置数据,没有启动真实网络服务。端口或数据库地址变化后,业务代码仍需负责切换监听器或连接。
-race 检测本次执行中触发的数据竞争,不等于证明所有并发场景都安全。本例针对普通本地文件保存;删除文件再创建、网络文件系统和容器挂载需要单独验证。v1.21.0 收到目标文件删除事件时可能停止监听,不应把修复语法错误后的恢复等同于支持所有文件生命周期变化。
参考资料
Viper 学习记录
已完结分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
换条路线
从其他文章中稳定抽取
最后更新于 ,距今已过 9 天
部分内容可能已过时