GoでWebアプリケーションを作るときに使いやすい設定ファイルの作り方
GoでWebアプリケーションなどを作る場合に、使いやすい設定ファイルを作成する方法についてかきます。
基本的には、以下ができる設定ファイルを扱えるようにします。
- 設定ファイルはYAMLで記述できる
- 設定ファイルから環境変数が参照できる
- 環境別に設定ファイルを分けられる
- 共通の設定ファイルと環境別の設定ファイルを再起的にマージできる
- 設定ファイルをバイナリに埋め込める
- 設定ファイルの値はグローバルにアクセスできる
設定ファイルはYAMLで記述できるようにする
設定ファイルはYAMLで記述します。YAMLはどの言語でもだいたいパーサーライブラリが存在する、コメントが記述できる、JSONの上位互換なのでJSONを埋め込んでもYAMLとしてパースできるなどの理由があります。
まずは、YAMLで記述された設定ファイルをパースできるようにします。これには gopkg.in/yaml.v3
というパッケージを使用します。基本的な使用方法は標準パッケージのjson
パッケージと同じです。
hoge: value
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Hoge string `yaml:"hoge"`
}
func main() {
var c Config
b, _ := os.ReadFile("config.yaml")
yaml.Unmarshal(b, &c)
fmt.Printf("%+v\n", c)
}
$ go run .
{Hoge:value}
設定ファイルから環境変数が参照できるようにする
環境変数で設定を変えれるようにするためにYAMLで環境変数を参照できるようにします。これにはGo標準のテンプレートエンジンのtext/template
と環境変数を取得するカスタム関数を用いることで可能になります。
hoge: {{ env "HOGE" "default-value" }}
package main
import (
"html/template"
"io"
"os"
)
type YAMLTemplate struct{}
func NewYAMLTemplate() *YAMLTemplate {
return &YAMLTemplate{}
}
func (t *YAMLTemplate) Compile(name string, r io.Reader, w io.Writer) error {
funcMap := template.FuncMap{
"env": t.Env, // カスタム関数の登録
}
value, err := io.ReadAll(r)
if err != nil {
return err
}
tmpl, err := template.New("name").Funcs(funcMap).Parse(string(value))
if err != nil {
return err
}
err = tmpl.Execute(w, nil)
return err
}
// キーで指定された環境変数を返す。このとき値が空なら、デフォルト引数リストの中から最初のゼロでない値を返す。
//
// os.Setenv("A", "1")
//
// a: {{ env "A" }} #=> a: 1
// b: {{ env "B" "val" }} #=> b: val
// c: {{ env "C" (env "D") "val" }} #=> c: val
func (t *YAMLTemplate) Env(key string, defaultValues ...string) string {
val := os.Getenv(key)
if val == "" {
values := compact(defaultValues)
if len(values) == 0 {
return ""
}
return values[0]
}
return val
}
func compact[T comparable](list []T) []T {
result := make([]T, 0)
var zero T
for _, v := range list {
var vv interface{} = v
if vv != nil && v != zero {
result = append(result, v)
}
}
return result
}
package main
// ...
func main() {
fs_ := os.DirFS(".")
f, _ := fs_.Open("config.yaml")
w := bytes.NewBuffer([]byte{})
name := "main"
t := NewYAMLTemplate()
t.Compile(name, f, w)
var c Config
yaml.Unmarshal(w.Bytes(), &c)
fmt.Printf("%+v\n", c)
}
$ go run .
{Hoge:default-value}
$ HOGE=hoge go run .
{Hoge:hoge}
環境別にファイル分けられるようにする
Webアプリケーションでは環境ごとに設定ファイルを分けるのが一般的なので、ENV
環境変数を参照して config.${ENV}.yaml
というファイルを読み込むようにします。
ここで、テスト環境については、flag.Lookup("test.v")
というフラグを参照するようにします。これは go test
で TestMain()
が実行されるタイミングで設定されるものです。したがって、このタイミングより前、たとえばグローバル変数の初期化などのタイミングではテスト環境かどうかを判定できないので注意が必要です。
hoge: {{ env "HOGE" "dev-value" }}
hoge: {{ env "HOGE" "prod-value" }}
hoge: test-value
package main
//...
type Env string
const (
EnvTest Env = "test"
EnvDev Env = "dev"
EnvStg Env = "stg"
EnvProd Env = "prod"
)
func CurrentEnv() Env {
if IsTestEnv() {
return EnvTest
}
env := os.Getenv("ENV")
if env == "" {
return EnvDev
}
return Env(env)
}
func IsTestEnv() bool {
return flag.Lookup("test.v") != nil
}
func main() {
env := CurrentEnv()
fs_ := os.DirFS(".")
r, _ := fs_.Open(fmt.Sprintf("config.%s.yaml", env))
w := bytes.NewBuffer([]byte{})
name := "main"
Compile(name, r, w)
var c Config
yaml.Unmarshal(w.Bytes(), &c)
fmt.Printf("%+v\n", c)
}
package main
import (
"bytes"
"fmt"
"os"
"testing"
"gopkg.in/yaml.v3"
)
func TestConfig(t *testing.T) {
env := CurrentEnv()
fs_ := os.DirFS(".")
r, _ := fs_.Open(fmt.Sprintf("config.%s.yaml", env))
w := bytes.NewBuffer([]byte{})
name := "main"
Compile(name, r, w)
var c Config
yaml.Unmarshal(w.Bytes(), &c)
fmt.Printf("%+v\n", c)
}
$ go run .
{Hoge:dev-value}
$ ENV=prod go run .
{Hoge:prod-value}
$ go test
{Hoge:test-value}
PASS
ok example.com/go-config 0.200s
共通の設定ファイルと環境別の設定ファイルを再起的にマージできるようにする
共通の設定、環境特有の設定、ローカル用の設定を差分で管理できると便利なので、これらの設定ファイルを再起的にマージできるようにします。
dario.cat/mergo というライブラリを使うと、map
などを再起的にマージすることが可能となるので、これを導入して使用します。
package main
import (
"fmt"
"dario.cat/mergo"
)
func main() {
result := map[string]interface{}{}
v1 := map[string]interface{}{
"hoge": "hoge",
"fuga": map[string]interface{}{
"bar": map[string]interface{}{
"value1": 1,
"value2": 2,
},
},
}
v2 := map[string]interface{}{
"fuga": map[string]interface{}{
"bar": map[string]interface{}{
"value2": 22,
},
},
}
mergo.Merge(&result, &v1, mergo.WithOverride)
mergo.Merge(&result, &v2, mergo.WithOverride)
fmt.Printf("%+v\n", result) // map[fuga:map[bar:map[value1:1 value2:22]] hoge:hoge]
}
複数の設定ファイルを再起的にマージするには、config.yaml
をベースにして config.${ENV}.yaml
で各環境ごとの設定を再起的にマージするようにします。また、リポジトリで管理しないローカル用のファイル(config.local.yaml
など)を追加して上書きできるようにします。
そのため、以下順序で再起的にマージするようにします。ファイルがない場合は無視します。
config.yaml
config.local.yaml
config.${ENV}.yaml
config.${ENV}.local.yaml
hoge: {{ env "HOGE" "default-value" }}
fuga:
bar:
value1: 1
value2: 2
fuga:
bar:
value2: 22
package main
import (
"bytes"
"fmt"
"io/fs"
"os"
"dario.cat/mergo"
"gopkg.in/yaml.v3"
)
type Loader struct {
fs fs.FS
}
func NewLoader(fs_ fs.FS) *Loader {
return &Loader{fs: fs_}
}
func (l *Loader) Load(env Env) (*Config, error) {
conf := map[string]interface{}{}
c, err := l.loadYAML("config.yaml")
if err != nil {
return nil, err
}
mergo.Merge(&conf, &c, mergo.WithOverride)
paths := []string{
"config.local.yaml",
fmt.Sprintf("config.%s.yaml", env),
fmt.Sprintf("config.%s.local.yaml", env),
}
for _, p := range paths {
c, err := l.loadYAML(p)
if os.IsNotExist(err) {
continue
}
if err != nil {
return nil, err
}
mergo.Merge(&conf, &c, mergo.WithOverride)
}
result := &Config{}
tmp, err := yaml.Marshal(conf)
if err != nil {
return nil, err
}
err = yaml.Unmarshal(tmp, result)
if err != nil {
return nil, err
}
return result, nil
}
func (l *Loader) loadYAML(path string) (map[string]interface{}, error) {
f, err := l.fs.Open(path)
if err != nil {
return nil, err
}
w := bytes.NewBuffer([]byte{})
name := "main"
t := NewYAMLTemplate()
err = t.Compile(name, f, w)
if err != nil {
return nil, err
}
c := map[string]interface{}{}
err = yaml.Unmarshal(w.Bytes(), &c)
if err != nil {
return nil, err
}
return c, nil
}
package main
//...
type Config struct {
Hoge string `yaml:"hoge"`
Fuga ConfigFuga `yaml:"fuga"`
}
type ConfigFuga struct {
Bar ConfigFugaBar `yaml:"bar"`
}
type ConfigFugaBar struct {
Value1 int `yaml:"value1"`
Value2 int `yaml:"value2"`
}
//...
func main() {
env := CurrentEnv()
loader := NewLoader(os.DirFS("."))
c, _ := loader.Load(env)
fmt.Printf("%+v\n", *c)
}
$ go run .
{Hoge:default-value Fuga:{Bar:{Value1:1 Value2:22}}}
設定ファイルをバイナリに埋め込めるようにする
GoでWebアプリケーションを作る場合は、productionなどはシングルバイナリにしておくと運用が楽ですが、いまのままだと設定ファイルも配置する必要があり煩雑です。
ここで、Go には io/fs
というファイルシステムを抽象化したパッケージがあります。そして、このパッケージにある fs.FS
のインターフェースに準じた embed.FS
というのが embed
パッケージにあるので、これと go:embed
というコメントディレクティブを使うと、ディレクティブで指定されたファイル群をビルド時にバイナリに埋め込むことができます。
package main
import (
"embed"
"fmt"
"io"
)
// 一番下の記述がコメントディレクティブで、go:embed は指定されたファイル群をビルド時に対象の変数に埋め込める。
// このとき、このファイル群は fs.FS として扱われ、ファイルシステムにアクセスするのと同様のインターフェースでアクセスできる。
//
// Files の構造:
//
// .
// ├── a.txt
// └── b.txt
//
//go:embed *.txt
var Files embed.FS
func main() {
f, err := Files.Open("a.txt")
if err != nil {
panic(err)
}
defer f.Close()
contents, _ := io.ReadAll(f)
fmt.Println(string(contents))
}
この機能を使って、os.DirFS(".")
でファイルシステムにアクセスしていた部分をファイルを埋め込んだConfFS
に置き換えます。このようにすることで go build
でビルドして生成したバイナリに設定ファイルが埋め込まれるので、バイナリ単体を配置するだけでよくなります。
package main
import (
"embed"
"flag"
"fmt"
"os"
)
//go:embed *.yaml
var ConfFS embed.FS
//...
func main() {
env := CurrentEnv()
loader := NewLoader(ConfFS)
c, _ := loader.Load(env)
fmt.Printf("%+v\n", *c)
}
$ go build
$ cp go-config /tmp
$ cd /tmp
$ ./go-config
{Hoge:default-value Fuga:{Bar:{Value1:1 Value2:22}}}
設定をグローバルにアクセスできるようにする
設定を都度ロードしたり、ロードした設定を各モジュールや関数で引き回したりするのは煩雑なので、グローバルにアクセスできるようにします。最初に取得しようとするときにロードするようにします。ここで、この関数は複数のgoroutineから呼ばれる可能性があるので、sync.Mutex
を使ってスレッドセーフにしておきます。
package main
import (
"io/fs"
"sync"
)
type GlobalLoader struct {
loader *Loader
conf *Config
mu sync.Mutex
}
func NewGlobalLoader(fs_ fs.FS) *GlobalLoader {
return &GlobalLoader{
loader: NewLoader(fs_),
}
}
func (gl *GlobalLoader) Load() error {
defer gl.mu.Unlock()
gl.mu.Lock()
env := CurrentEnv()
conf, err := gl.loader.Load(env)
if err != nil {
return err
}
gl.conf = conf
return nil
}
func (gl *GlobalLoader) Get() *Config {
if gl.conf == nil {
err := gl.Load()
if err != nil {
panic(err)
}
}
return gl.conf
}
package main
//...
var globalLoader = NewGlobalLoader(ConfFS)
func Load() error { return globalLoader.Load() }
func Get() *Config { return globalLoader.Get() }
func main() {
fmt.Printf("%+v\n", *Get())
}
パッケージに切り出して汎用性を持たせる
Configまわりのコードをconfutil
パッケージに切り出して、GlobalLoader
/Loader
をジェネリクスを使ってConfig
型を型パラメータとして受け取るようにします。
このようにすると、設定ファイルの型に依存しなくなるので、monorepo構成などで使いまわせたりします。また、config
以下の見通しがよくなります。
.
├── config
│ ├── config.yaml
│ ├── config.dev.yaml
│ ├── config.prod.yaml
│ ├── config.test.yaml
│ └── config.go
├── confutil
│ ├── env.go
│ ├── global_loader.go
│ ├── loader.go
│ └── yaml_template.go
└── main.go
package confutil
type GlobalLoader[C any] struct {
loader *Loader[C]
conf *C
mu sync.Mutex
}
func NewGlobalLoader[C any](fs_ fs.FS) *GlobalLoader[C] {...}
func (gl *GlobalLoader[C]) Load() error {...}
func (gl *GlobalLoader[C]) Get() *C {...}
package confutil
type Loader[C any] struct {
fs fs.FS
}
func NewLoader[C any](fs_ fs.FS) *Loader[C] {...}
func (l *Loader[C]) Load(env Env) (*C, error) {
//...
result := new(C)
tmp, err := yaml.Marshal(conf)
if err != nil {
return nil, err
}
err = yaml.Unmarshal(tmp, result)
if err != nil {
return nil, err
}
return result, nil
}
//...
package config
import (
"embed"
"example.com/go-config/confutil"
)
//go:embed *.yaml
var ConfFS embed.FS
type Config struct {
Hoge string `yaml:"hoge"`
Fuga ConfigFuga `yaml:"fuga"`
}
type ConfigFuga struct {
Bar ConfigFugaBar `yaml:"bar"`
}
type ConfigFugaBar struct {
Value1 int `yaml:"value1"`
Value2 int `yaml:"value2"`
}
var globalLoader = confutil.NewGlobalLoader[Config](ConfFS)
func Load() error { return globalLoader.Load() }
func Get() *Config { return globalLoader.Get() }
package main
import (
"fmt"
"example.com/go-config/config"
)
func main() {
fmt.Printf("%+v\n", *config.Get())
}
まとめ
以上で、GoでWebアプリケーションなどを作る場合に、使いやすい設定ファイルを作成する方法についてかきました。このように実装しておくと、だいたいのWebアプリケーションで使いやすい設定ファイルを作成できると思います。
なお、上記で実装したものは mrk21/go-web-config-sample にあります。
Discussion