⚙️

GoでWebアプリケーションを作るときに使いやすい設定ファイルの作り方

2023/12/01に公開

GoでWebアプリケーションなどを作る場合に、使いやすい設定ファイルを作成する方法についてかきます。

基本的には、以下ができる設定ファイルを扱えるようにします。

  • 設定ファイルはYAMLで記述できる
  • 設定ファイルから環境変数が参照できる
  • 環境別に設定ファイルを分けられる
  • 共通の設定ファイルと環境別の設定ファイルを再起的にマージできる
  • 設定ファイルをバイナリに埋め込める
  • 設定ファイルの値はグローバルにアクセスできる

設定ファイルはYAMLで記述できるようにする

設定ファイルはYAMLで記述します。YAMLはどの言語でもだいたいパーサーライブラリが存在する、コメントが記述できる、JSONの上位互換なのでJSONを埋め込んでもYAMLとしてパースできるなどの理由があります。

まずは、YAMLで記述された設定ファイルをパースできるようにします。これには gopkg.in/yaml.v3 というパッケージを使用します。基本的な使用方法は標準パッケージのjsonパッケージと同じです。

config.yaml
hoge: value
main.go
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)
}
terminal
$ go run .
{Hoge:value}

設定ファイルから環境変数が参照できるようにする

環境変数で設定を変えれるようにするためにYAMLで環境変数を参照できるようにします。これにはGo標準のテンプレートエンジンのtext/templateと環境変数を取得するカスタム関数を用いることで可能になります。

config.yaml
hoge: {{ env "HOGE" "default-value" }}
yaml_template.go
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
}
main.go
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)
}
terminal
$ go run .
{Hoge:default-value}

$ HOGE=hoge go run .
{Hoge:hoge}

環境別にファイル分けられるようにする

Webアプリケーションでは環境ごとに設定ファイルを分けるのが一般的なので、ENV環境変数を参照して config.${ENV}.yaml というファイルを読み込むようにします。

ここで、テスト環境については、flag.Lookup("test.v") というフラグを参照するようにします。これは go testTestMain() が実行されるタイミングで設定されるものです。したがって、このタイミングより前、たとえばグローバル変数の初期化などのタイミングではテスト環境かどうかを判定できないので注意が必要です。

config.dev.yaml
hoge: {{ env "HOGE" "dev-value" }}
config.prod.yaml
hoge: {{ env "HOGE" "prod-value" }}
config.test.yaml
hoge: test-value
main.go
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)
}
main_test.go
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)
}
terminal
$ 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 など)を追加して上書きできるようにします。

そのため、以下順序で再起的にマージするようにします。ファイルがない場合は無視します。

  1. config.yaml
  2. config.local.yaml
  3. config.${ENV}.yaml
  4. config.${ENV}.local.yaml
config.yaml
hoge: {{ env "HOGE" "default-value" }}
fuga:
  bar:
    value1: 1
    value2: 2
config.dev.yaml
fuga:
  bar:
    value2: 22
loader.go
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
}
main.go
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)
}
terminal
$ 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 でビルドして生成したバイナリに設定ファイルが埋め込まれるので、バイナリ単体を配置するだけでよくなります。

main.go
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)
}
terminal
$ go build
$ cp go-config /tmp
$ cd /tmp
$ ./go-config
{Hoge:default-value Fuga:{Bar:{Value1:1 Value2:22}}}

設定をグローバルにアクセスできるようにする

設定を都度ロードしたり、ロードした設定を各モジュールや関数で引き回したりするのは煩雑なので、グローバルにアクセスできるようにします。最初に取得しようとするときにロードするようにします。ここで、この関数は複数のgoroutineから呼ばれる可能性があるので、sync.Mutexを使ってスレッドセーフにしておきます。

global_loader.go
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
}
main.go
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
confutil/global_loader.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 {...}
confutil/loader.go
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
}

//...
config/config.go
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() }
main.go
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