goで設定ファイルを読み込むときに上書きしたい

goで設定ファイルを読み込むときに設定の一部だけを上書きしたいことがある。それ用のコードのメモ。

github.com

mergo?

mergoというのはけっこう古くからあるライブラリみたい。

A helper to merge structs and maps in Golang. Useful for configuration default values, avoiding messy if-statements.

2つのデータをいい感じにマージしてくれる。

振る舞いについては以下を見ると良い

雑にまとめると mergo.Merge(<dst>, <src>)

  • unexported fieldは無視
  • zero valueは無視

という条件で再帰的に上書きしていく。<src>自体は書き換わらずに<dst>が書き換わる。

試しに使ってみる

実際に設定の上書きを試してみる(意外とコード例までたどり着くまでが長かったのでコード例の部分だけが知りたい場合にはgistを直接見たほうが早いかもしれない)。

やりたいことの整理

設定ファイルを上書きする例を無理やりひねり出す。

テキトーにネストした形状になっている設定ファイルを探してくる。docker composeの例などが便利かもしれない。

docker-compose.yml

version: "3.7"

services:
  wordpress:
    image: wordpress
    ports:
      - "8080:80"
    networks:
      - overlay
    deploy:
      mode: replicated
      replicas: 2
      endpoint_mode: vip

  mysql:
    image: mysql
    volumes:
       - db-data:/var/lib/mysql/data
    networks:
       - overlay
    deploy:
      mode: replicated
      replicas: 2
      endpoint_mode: dnsrr

volumes:
  db-data:

networks:
  overlay:

これを以下の様に変更して利用したい。

--- 03config/output.txt  2020-02-19 20:07:00.000000000 +0900
+++ 04overwrite-config/output.txt 2020-02-19 20:07:05.000000000 +0900
@@ -3,7 +3,7 @@
   wordpress:
     image: wordpress
     ports:
-    - '8080:80'
+    - '9090:80'
     networks:
     - overlay
     deploy:
@@ -18,7 +18,7 @@
     - overlay
     deploy:
       mode: replicated
-      replicas: 2
+      replicas: 5
       endpoint_mode: dnsrr
 volumes:
   db-data: null

goで読み込めるようにjsonに変換しておく(別にgo-yamlなどを使っても良い)

$ dictknife cat -o json docker-compose.yml > config.json

ふつうの読み込み

コードの一部分だけを抜粋という形式は好きではないのでまじめにふつうの読み込み部分のコードも書く。

変換しておいたJSONを以下のサービスにテキトーに貼り付けてgoの構造体のコードを得る。

conf/config.go

package conf

// https://docs.docker.com/compose/compose-file/
// https://mholt.github.io/json-to-go/

type Config struct {
    Version  string   `json:"version"`
    Services Services `json:"services"`
    Volumes  Volumes  `json:"volumes"`
    Networks Networks `json:"networks"`
}
type Deploy struct {
    Mode         string `json:"mode"`
    Replicas     int    `json:"replicas"`
    EndpointMode string `json:"endpoint_mode"`
}
type Wordpress struct {
    Image    string   `json:"image"`
    Ports    []string `json:"ports"`
    Networks []string `json:"networks"`
    Deploy   Deploy   `json:"deploy"`
}
type Mysql struct {
    Image    string   `json:"image"`
    Volumes  []string `json:"volumes"`
    Networks []string `json:"networks"`
    Deploy   Deploy   `json:"deploy"`
}
type Services struct {
    Wordpress Wordpress `json:"wordpress"`
    Mysql     Mysql     `json:"mysql"`
}
type Volumes struct {
    DbData interface{} `json:"db-data"`
}
type Networks struct {
    Overlay interface{} `json:"overlay"`
}

読み込む関数は以下のようなもの。

// JSONLoadFile ...
func JSONLoadFile(filename string, c interface{}) error {
    f, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer f.Close()
    decoder := json.NewDecoder(f)
    return decoder.Decode(c)
}

以下の様にして使う。

   var c conf.Config
    if err := JSONLoadFile(config, &c); err != nil {
        return err
    }

    pp.ColoringEnabled = false
    pp.Println(c)

上書きの確認

上書きの確認は以下の様にすることにする

  1. 00config/main.go は上書きを行わないコード
  2. 01config/main.go は上書きを行うコード (overwrite.jsonを利用して上書きする)
  3. 00と01の実行結果のdiffを取りその差分を見る
00config
├── main.go
└── output.txt
01overwrite-config
├── main.go
└── output.txt
config.json
overwrite.json
Makefile
conf
└── config.go

こんな感じで実行する想定

$ go run 00config/main.go --config config.json | tee 00config/output.txt
OVERWRITE_CONFIG=overwrite.json
$ go run 01overwrite-config/main.go --config config.json | tee 01overwrite-config/output.txt
$ diff -u 00config/output.txt 01overwrite-config/output.txt > 02.diff

ちなみにgo.modは以下の様な状態。

go.mod

module m

go 1.13

require (
    github.com/imdario/mergo v0.3.8 // indirect
    github.com/k0kubun/pp v3.0.1+incompatible // indirect
    github.com/mattn/go-colorable v0.1.4 // indirect
    github.com/spf13/pflag v1.0.5 // indirect
)

実際の上書きのコード

ようやく本題。

OVERWRITE_CONFIGという環境変数に値が入っていたらそれを使って上書きすることにする。

以下の様なファイルを渡す。

overwrite.json

{
  "services": {
    "wordpress": {
      "ports": [
        "9090:80"
      ]
    },
    "mysql": {
      "deploy": {
        "replicas": 5
      }
    }
  }
}

設定の読み込みは以下の様なコードに変わった。

// JSONLoadFile ... (再掲)
func JSONLoadFile(filename string, c interface{}) error {
    f, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer f.Close()
    decoder := json.NewDecoder(f)
    return decoder.Decode(c)
}

// LoadConfig ...
func LoadConfig(filename string) (*conf.Config, error) {
    var c conf.Config
    if err := JSONLoadFile(filename, &c); err != nil {
        return nil, err
    }

    overwritefile := os.Getenv("OVERWRITE_CONFIG")
    if overwritefile == "" {
        return &c, nil
    }

    fmt.Fprintf(os.Stderr, "***** OVERWRITE CONFIG by %q *****\n", overwritefile)

    var c2 conf.Config
    if err := JSONLoadFile(overwritefile, &c2); err != nil {
        return &c, err
    }
    if err := mergo.Merge(&c2, &c); err != nil {
        return &c, err
    }
    return &c2, nil
}

以下の様な形で実行する。

$ go run 00config/main.go --config config.json | tee 00config/output.txt
OVERWRITE_CONFIG=overwrite.json
$ go run 01overwrite-config/main.go --config config.json | tee 01overwrite-config/output.txt
$ diff -u 00config/output.txt 01overwrite-config/output.txt > 02.diff || exit 0

diff。良さそう。

02.diff

--- 00config/output.txt  2020-02-19 20:01:01.000000000 +0900
+++ 01overwrite-config/output.txt 2020-02-19 20:01:01.000000000 +0900
@@ -1,10 +1,10 @@
-conf.Config{
+&conf.Config{
   Version:  "3.7",
   Services: conf.Services{
     Wordpress: conf.Wordpress{
       Image: "wordpress",
       Ports: []string{
-        "8080:80",
+        "9090:80",
       },
       Networks: []string{
         "overlay",
@@ -25,7 +25,7 @@
       },
       Deploy: conf.Deploy{
         Mode:         "replicated",
-        Replicas:     2,
+        Replicas:     5,
         EndpointMode: "dnsrr",
       },
     },

gist

全体のコードは以下。ただしgistにあげる過程でディレクトリ構造が壊れてしまっているのでそのままでは実行できない。

(最近はpflagの方を使い始めている(cobraやviperはどうなんだろう?))

github.com

ちなみに

ちなみにpythonで似たようなことをやる場合には以下の様な感じ(schemaを定義していないので同じ状態ではない。zero valueが存在していないので同様のマージは難しい)。

import os
from handofcats import as_command
from dictknife import loading
from dictknife import deepmerge

# pip install dictknife[yaml] handofcats
# $ OVERWRITE_CONFIG=<overwrite file> python <file>.py --config <config file>

@as_command
def run(*, config: str) -> None:
    c = loading.loadfile(config)

    overwrite_file = os.environ.get("OVERWRITE_CONFIG")
    if overwrite_file is not None:
        c2 = loading.loadfile(overwrite_file)
        c = deepmerge(c, c2, method="replace")
    loading.dumpfile(c)

そして実はnestしたdictのマージはこれが良いというデフォルトが意外と決まらないという話。

ちなみに2

ちなみにpythonでhydraなどを使うと以下の様な形でコマンドライン引数として上書きする値を渡せたりする。

github.com

こういうかんじに。

$ python 06hydra/main.py
db:
  driver: mysql
  pass: secret
  user: omry

$ python 06hydra/main.py db.pass=oyoyo
db:
  driver: mysql
  pass: oyoyo
  user: omry

このときのコードは以下。

この辺の説明を読めば雰囲気は分かる。

(追記: viperも似たような機能を提供していたような記憶)

ちなみにのまとめ

  • (mergoを使うとそれなりに手軽に設定の上書きができる)
  • schemaを定義するか否かどちらが良いかは考える必要がありそう
  • コマンドライン引数での上書きをサポートするCLIも考えられる
  • (viperなどを使って環境変数経由での設定への以降(12FactorApp的な文脈))
  • (AWSのparameter storeなどを使う場合の対応)

(追記: validationの話がすっぽり抜け落ちているかも)