基于 Go 语言构建在线论坛增补篇:通过 Viper 读取配置文件并实现热加载

viper

简介

之前我们在论坛项目中使用了单例模式全局加载配置文件,这样做有一个弊端,就是不支持热加载,每次修改配置文件,需要重启应用,不太灵活,所以这篇教程我们引入 Viper 重构配置读取逻辑,并支持配置文件的热加载(所谓热加载指的是配置文件修改后无需重启应用即可生效)。

Viper 是 Go 语言的完整配置解决方案,支持多个数据源和丰富的功能:

  • 支持设置默认配置值
  • 从 JSON、YAML、TOML、HCL 等格式配置文件读取配置值
  • 支持从 OS 中读取环境变量
  • 支持读取命令行参数
  • 支持从远程 KV 存储系统读取配置值,包括 Etcd、Consul 等
  • 可以监听配置值变化,支持热加载

配置好数据源,初始化并启动 Viper 后,就可以通过 viper.Get 获取任意数据源的配置值,非常方便,还可以调用 viper.Unmarshal 方法将配置值映射到指定结构体指针。

目前已经有很多知名 Go 项目在使用 Viper 作为配置解决方案,包括 HugoDocker NotaryNanoboxEMC REX-RayBloomApidoctlMercure 等。

话不多说,下面我们在论坛项目中引入 Viper 来加载配置文件。

使用 Viper 加载配置

开始之前,先安装 Viper 扩展包:

go get github.com/spf13/viper

然后,我们在 config 目录下创建 viper.go 来定义基于 Viper 的配置初始化逻辑:

package config

import (
    "encoding/json"
    "fmt"
    "github.com/nicksnyder/go-i18n/v2/i18n"
    "github.com/spf13/viper"
    "golang.org/x/text/language"
    "time"
)

var ViperConfig Configuration

func init()  {
    runtimeViper := viper.New()
    runtimeViper.AddConfigPath(".")
    runtimeViper.SetConfigName("config")
    runtimeViper.SetConfigType("json")
    err := runtimeViper.ReadInConfig()
    if err != nil {
        panic(fmt.Errorf("Fatal error config file: %s \n", err))
    }
    runtimeViper.Unmarshal(&ViperConfig)

    // 本地化初始设置
    bundle := i18n.NewBundle(language.English)
    bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
    bundle.MustLoadMessageFile(ViperConfig.App.Locale + "/active.en.json")
    bundle.MustLoadMessageFile(ViperConfig.App.Locale + "/active." + ViperConfig.App.Language + ".json")
    ViperConfig.LocaleBundle = bundle
}

这里,我们基于项目根目录下的 config.json 作为配置文件,在 init 方法中对应的配置文件设置代码是如下这三行:

runtimeViper.AddConfigPath(".")
runtimeViper.SetConfigName("config")
runtimeViper.SetConfigType("json")

. 表示项目根目录,config 表示配置文件名(不含格式后缀),json 表示配置文件格式,然后通过 runtimeViper.ReadInConfig() 从配置文件读取所有配置,再通过 runtimeViper.Unmarshal(&ViperConfig) 将其映射到之前定义的位于 config.go 中的 Configuration 结构体变量 ViperConfig。接下来,就可以通过 ViperConfig 变量访问系统配置了,不过,由于本地化 LocaleBundle 属性需要额外初始化,所以我们参照单例模式中的实现对其进行初始化。ViperConfig 变量对外可见,所以只要引入 config 包的地方,都可以直接访问 ViperConfig 属性来读取配置值(init 方法会自动调用并且全局执行一次,所以无需考虑性能问题)。

重构配置读取代码

接下来,我们可以重构之前业务代码中的配置读取代码,首先是 main.go

func startWebServer()  {
    r := NewRouter() // 通过 router.go 中定义的路由器来分发请求

    // 处理静态资源文件
    assets := http.FileServer(http.Dir(ViperConfig.App.Static))
    r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", assets))

    http.Handle("/", r)

    log.Println("Starting HTTP service at " + ViperConfig.App.Address)
    err := http.ListenAndServe(ViperConfig.App.Address, nil)

    if err != nil {
        log.Println("An error occured starting HTTP listener at " + ViperConfig.App.Address)
        log.Println("Error: " + err.Error())
    }
}

然后是 handlers/helper.go

func init()  {
    // 获取本地化实例
    localizer = i18n.NewLocalizer(ViperConfig.LocaleBundle, ViperConfig.App.Language)
    file, err := os.OpenFile(ViperConfig.App.Log + "/chitchat.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
    ...
}

...

// 生成 HTML 模板
func generateHTML(writer http.ResponseWriter, data interface{}, filenames ...string) {
    var files []string
    for _, file := range filenames {
        files = append(files, fmt.Sprintf("views/%s/%s.html", ViperConfig.App.Language, file))
    }
    ...
}

最后是 models/db.go

func init() {
    var err error
    driver := ViperConfig.Db.Driver
    source := fmt.Sprintf("%s:%s@(%s)/%s?charset=utf8&parseTime=true", ViperConfig.Db.User, ViperConfig.Db.Password,
        ViperConfig.Db.Address, ViperConfig.Db.Database)
    ...
}

业务逻辑非常简单:取消了之前通过 LoadConfig 方法以单例模式初始化全局配置,改为直接调用 ViperConfig 对象属性获取配置值。

通过 Viper 实现热加载

但是现在配置文件依然不支持热加载,不过 Viper 提供了对应的 API 方法实现该功能,我们打开 config/viper.go,在 init 方法最后加上如下这段代码:

func init()  {
    ...

    // 监听配置文件变更
    runtimeViper.WatchConfig()
    runtimeViper.OnConfigChange(func(e fsnotify.Event) {
        runtimeViper.Unmarshal(&ViperConfig)
        ViperConfig.LocaleBundle.MustLoadMessageFile(ViperConfig.App.Locale + "/active." + ViperConfig.App.Language + ".json")
    })
}

我们通过 runtimeViper.WatchConfig() 方法监听配置文件变更(该监听会开启新的协程执行,不影响和阻塞当前协程),一旦配置文件有变更,即可通过定义在 runtimeViper.OnConfigChange 中的匿名回调函数重新加载配置文件并将配置值映射到 ViperConfig 指针,同时再次加载新的语言文件。

这样,我们就实现了在 Go 项目中通过 Viper 实现配置文件的热加载,当然,这里只是一个简单的示例,Viper 还支持更丰富的数据源和配置操作,如果你想要了解更多可以参考 Viper 官方文档,这里就不一一介绍了。

测试配置文件读取和热加载

接下来,我们启动应用:

启动应用

启动成功,则表示通过 Viper 可以正确读取 App 配置,然后访问应用首页:

在线论坛首页

一切都正常,表示数据库配置读取也是 OK 的,本地化设置也正常,为了测试配置文件的热加载,我们将 App.Language 配置值设置为 en

{
  "App": {
    "Address": "0.0.0.0:8080",
    "Static": "public",
    "Log": "logs",
    "Locale": "locales",
    "Language": "en"
  },
  ...
}

在不重启应用的情况下,刷新论坛首页:

在线论坛首页

页面变成通过英文模板渲染的了,表明配置文件热加载生效。

上一篇: 基于 Go 语言构建在线论坛(九):部署 Go Web 应用

下一篇: Go 语言 HTTP 请求处理的底层机制