設定ファイルがだるい問題についてあれこれ考えたりしてみた

github.com

今回の話しはあまりまとまっていない。頭の中でぼんやりと考えたことのメモ。

似たような設定ファイルが複数ある場合

似たような設定が複数あるような設定ファイルがある。例えば、local,dev,productionという3つの環境での設定ファイルがある場合など。同じアプリに対する設定ファイルとして複数のバリエーションが必要になる。

例えば以下の様な感じ。

local.yaml

logLevel: debug
port: 8000
db: http://localhost:27001/app
mail: dummy@example.ne.jp

dev.yaml

logLevel: debug
port: 8000
db: http://dev-app.me.jp:27001/app
mail: dummy@example.ne.jp

production.yaml

logLevel: info
port: 8000
db: http://app.me.jp:27001/app
mail: app-me@example.ne.jp

何か共通の設定もあるかもしれない。

extra:
  apiKey: xxxxxxxxxxxxxxxxxxxx
  secretKey: S_xxxxXxxXxxxxx

設定の継承のように共通部分をまとめることを考えてみる(上手く行かない)

設定が継承できる場合について考えてみる。設定の継承と言っても複雑なものを指しているのではなく共通部分をまとめておいてそれを取り込むみたいな感じのもの。

例えば、先程の例での共通部分についてcommon.yamlなどに書いておき、適宜$loadする。

common.yaml

extra:
  apiKey: xxxxxxxxxxxxxxxxxxxx
  secretKey: S_xxxxXxxXxxxxx

local.yaml

$concat:
  - $load: ./common.yaml
  - logLevel: debug
    port: 8000
    db: http://localhost:27001/app
    mail: dummy@example.ne.jp

これで共通部分をまとめる事ができた。また、ちょっとだけ変更すれば共通化できる部分があると思うかもしれない。例えば、dev.yamlとproduction.yamlのdb部分の表示について以下の様な形でまとめておけるならformat部分については共通化できそうに見える。

dev.yamlについては

$format: "http://{prefix}app.me.jp:27001/app"
prefix: dev-

production.yamlについては

$format: "http://{prefix}app.me.jp:27001/app"
prefix: ""

これらの試みは上手く行かない。継承を知った人がむやみに継承しまくるのと似たように、テンプレートメソッドパターンがたいていの状況で役に立たないというのと似たように上手く行かない。上のdbの例についてlocal.yamlの場合を考えてみると、そもそもドメイン名を指定する場合と単にlocalhostと指定する場合とあり、prefixとフォーマット文字列では表すことが出来ない。せいぜいがdb名とホスト名を分ける位。

$format: "{host}/{db}"
host: http://localhost:27001
db: app

さて、本当にこれで共通部分をまとめられたかというと、そうでもない。例えばproduction環境で、replicationなどが必要になった場合にどうするべきか。以下のような設定になるかもしれない。

db: user0:<password>@app.me.jp:27001/app,user1:<password>@readonly-app.me.jp:27001/app

結局それぞれの欄は自由入力にして各環境(local,dev,production)での設定値を書いてあげるだけの方が分かりやすいし意味がある。そして何らかの共通部分と可変部分(prefixないしはhost)とでは表せない項目の方が多いことに気づく。端的に言えば、よくある差分プログラミング的な共通部分をまとめようとするだけの行いは失敗に終わる。

もう少し上の試みの失敗を汎用性を持たせて書いてみるとすると、各パート毎に以下のようなものが行われるという感じになるのかもしれない。

  • (1) 共通部分の分離と$loadによる取り込み
  • (2) 共通template(フォーマット文字列)の共有と$formatによる値の埋め込み
  • (3) 各設定ファイルで固有な部分を直書きする

例えば以下の様な感じになる。

# こういう形状のものが続く
part0:
  # (1)
  - {$load: ./common.yaml#/part0}
  # (2)
  - {$format: {$load: ./common.formats.yaml#/part0}, <arg0>: <value0>, <arg1>: <value1>, ...}
  # (3)
  - <var0>: <varvalue0>
    <var1>: <varvalue1>
    ...
part1:
  # (1)
  - {$load: ./common.yaml#/part0}
  # (2)
  - {$format: {$load: ./common.formats.yaml#/part0}, <arg0>: <value0>, <arg1>: <value1>, ...}
  # (3)
  - <var0>: <varvalue0>
    <var1>: <varvalue1>
    ...
...

これらのある状態のポイントに対して名前(e.g. 通常のコードにおいてはclass名)をつけて、それを利用しようとする場所で名前で参照する感じのことが設定の継承に対応するものになりそうではある。ただ、あまりうまくいくようには思えない。

結局、各環境毎の設定ファイルが並ぶだけになる。local.yaml,dev.yaml,production.yaml。アプリが分離された時などに、やがてこれらが特定のappに対してapp.local.yaml,app.dev.yaml,app.production.yamlなどの名前に変わるだけ。記述量は減るものの値に対する見た目上の差分に縛られるだけで(例えば、上の(1),(2)のどちらかに遷移するかあるいは新たなパターンが出た場合の書き換えなど)メリットがあまり無い。

動的な参照と静的な参照

形象化を模した共通化の試みの問題

上の継承を模した共通化の試みの何が問題だったかというと、字面上の差分に捕らわれて、共通部分を闇雲に共通化しようということが問題だった。とは言え、全ての設定ファイルを直書きのままで頑張ろうとした場合には、体感的な話として、同じような項目を各環境毎(local,dev,productionなど)に繰り返し記述している感じが否めずつらいみたいな気持ちになることは確か。

設定値の分類

共通部分をまとめるということは、何らかの間接参照が行われるということに違いはないのだけれど、もう少し真面目に考えてみると、各状況(localなど)特有の条件によって変わる部分とアプリケーションの設計上の設定値を保持するための部分の間に別れていそうなことに気づく。

例えば、マイクロサービスの様なものを考えた時に、あるサービスIが別のあるサービスJ,Kに依存する事を考えてみる。この時サービスIはJ,KのAPIにアクセスする必要が出てくる。その場合には常にJ,Kのendpoint的な情報が必要になる(例えば、J,Kがweb apiだった場合にはapiのURL部分の設定)。また、マイクロサービス的なものを考えた時に、必ずport番号などが被らないように設定をしなければいけないかつ自分自身がapiを提供するのであればport番号が設定値として必要になる。

サービスIについてだけ考えて見るとすると、各環境毎それぞれI.production,I.dev,I.localについて以下のような設定ができてほしいということになる。

# production I
port: 8000
J:
  endpoint: {$format: "http://{name}.{host}/api/v1", name: J, host: me-api.com
K:
  endpoint: {$format: "http://{name}.{host}/api/v1", name: K, host: me-api.com


# dev I
port: 8000
J:
  endpoint: {$format: "http://{name}.{host}/api/v1", name: J, host: dev.me-api.com
K:
  endpoint: {$format: "http://{name}.{host}/api/v1", name: K, host: dev.me-api.com


# local I
port: 8000
J:
  endpoint: {$format: "http://localhost/{port}/api/v1", port: 8001}
K:
  endpoint: {$format: "http://localhost/{port}/api/v1", port: 8002}

静的な参照

port番号の指定について結局のところユニーク性を担保するための何かのaliasに過ぎないと考えるなら、サービス上の設計上の関連に必要な部分の設定値ということになりそう(常に正しいとは限らないけれど)。

一方で、endpointについてもどのようなendpointが必要になるかという設定や名前の情報も設計上の関連に必要な部分の設定値ということになりそう。これらは何があっても変わらずのものつまり静的に関係性が定まっているものになるはず。なのでこれらを仮に静的な参照と呼ぶことにする。

動的な参照

一方でAPIのendpointを作るためのhost名に当たる部分であったり、endpointの指定にlocalhost+port番号という形式でいくかそれともドメイン名を指定という形式でいくかの意思決定の部分があてはまる。こちらは動的な参照と呼ぶことにする。動的な参照の部分の設定は静的な参照のそれとは異なり設定を利用するタイミング(例えばアプリを起動する時に)でその時の環境によって指す値が変わりうる。

ここで共通化が出来て嬉しいのは静的な参照の部分だけ。動的な参照の部分に関しては自由に入力出来る方が便利。別の言い方をするなら、静的な参照に関しては既存の情報を組み合わせて作ること(e.g. 利用したいservice名とhost名からendpointのドメイン名を作る)が可能なら、設定ファイルとして必要な設定値を減らす事ができるかもしれないということになるのかもしれない(これはまだ憶測の段階)。

静的な参照を抜き出してtemplateを作る

静的な参照部分を抜き出してまとめたい。例えば先程の例でのサービスIについては以下のような静的な関係がある

  • サービスIはサービスJ,Kに依存する
  • 依存するサービス(J,K)に対するendpointが必要になる
  • apiを提供するなら設定値として自身のport番号が必要

これらをyamlとして表してみると以下の様になる。

# I.yaml
port: 8000
services:
  J: {endpoint: {$resolve_endpoint: J, port: 8001}}
  K: {endpoint: {$resolve_endpoint: K, port: 8002}}

port番号はどのサービスに対する設定なのかが決まれば自動的に決まる。どのサービスに依存するかも決まる。また、endpointを生成する処理がresolve_endpointとして与えられているならendpointを指定する箇所も埋める事ができる。これらの静的な参照部分の部分関係に注力して抜き出した設定ファイルがtemplateになる。

動的な参照の部分はdataとして実行時に受け取る

一方で、動的な参照の部分は実行時の環境(local,dev,production)に応じて変わりえる。この部分を設定ファイル(data)として抜き出すと以下の様になる。

# data.yaml
local:
  host: localhost
dev:
  host: dev-api.me.com
production:
  host: api.me.com

また、今まで放置してきたendpointの生成方針については、例えば、hostがlocalhostであるものなら、endpointの指定にlocalhost+port番号を利用すると言った事で決められるかもしれない。それが出来るとすると、以下の様な形でtemplateは書き換えられそう。

# I.yaml
$let:
  _: {$from: ./resolve.py, import: get_resolver}
  resolver: {$get_resolver: {$get: "data#/host"}, data: {$get: data}}
body:
  port: 8000
  services:
    J: {endpoint: {$resolver.resolve_endpoint: J, port: 8001}}
    K: {endpoint: {$resolver.resolve_endpoint: K, port: 8002}}

これで何ができるようになるかというと、今までは設定ファイルを考える上で、サービスの種類(M)と環境の数(N:local,dev,production)でNM個のファイルに触る必要が在ったのが環境の数に関しては無視できるようになりM個のファイルだけを触れば良くなった。

例えば以下の様な形でMN個の設定ファイルを生成する。

$ zenmai I.yaml --data "data.yaml#/local" > conf/I.local.yaml
$ zenmai I.yaml --data "data.yaml#/dev" > conf/I.dev.yaml
$ zenmai I.yaml --data "data.yaml#/production" > conf/I.production.yaml

補足. 省略したresolve.pyのコードについて

例えば、resolve.pyは以下のようなもの(名前はテキトウ)になるのかもしれない(テキトウ)。

# resolve.py

class LocalhostResolver:
    def __init__(self, data):
      self.data = data

    def resolve_endpoint(self, name, port):
        return "http://localhost:{port}/api/v1".format(port=port)


class Resolver:
    def __init__(self, data):
      self.data = data

    def resolve_endpoint(self, name, port):
        return "{name}.{host}/api/v1".format(name=name,host=self.data["host"])


def get_resolver(host, data):
    if host == "localhost":
        return LocalhostResolver(data)
    else:
        return Resolver(data)

他のサービスの状況も含めたtemplateの記述を考えてみる

静的な参照と動的な参照に切り分けたことで触る必要のあるファイルの数を減らす事ができた。ただサービスIについてだけでなくサービスJ,Kについても考えてみる。それぞれのサービス間の依存関係は以下の様なものだった。

I => J, K
J => K
K =>

IはJ,KにJはKにだけ依存していて、Kは何にも依存していない。この時先程のIの設定例を参考にJ,Kについてもtemplateを作ってみると以下の様になる。

I.yaml(再掲)

$let:
  _: {$from: ./resolve.py, import: get_resolver}
  resolver: {$get_resolver: {$get: "data#/host"}, data: {$get: data}}
body:
  port: 8000
  services:
    J: {endpoint: {$resolver.resolve_endpoint: J, port: 8001}}
    K: {endpoint: {$resolver.resolve_endpoint: K, port: 8002}}

J.yaml

$let:
  _: {$from: ./resolve.py, import: get_resolver}
  resolver: {$get_resolver: {$get: "data#/host"}, data: {$get: data}}
body:
  port: 8001
  services:
    K: {endpoint: {$resolver.resolve_endpoint: K, port: 8002}}

K.yaml

port: 8002
services: {}

ここで自身のport番号やK部分のport番号などport番号の対応を取るのが面倒になる。この部分はすべての設定で共通なのでpalette.yamlないしはresources.yamlみたいな名前で作られたファイルを参照することにする(paletteという名前が気に入ったのでこの名前に合うように全体の名前を整えたりしたいかも?)。

# palette.yaml
services:
  I:
    port: 8000
  J:
    port: 8001
  K:
    port: 8002

このファイルを使うようにするとIの設定ファイルは以下の様に書けるようになる。port番号について気にする必要が無くなった。

I.yaml

$let:
  _: {$from: ./resolve.py, import: get_resolver}
  resolver: {$get_resolver: {$get: "data#/host"}, data: {$get: data}}
body:
  port: {$load: "./palette.yaml#/services/I/port"}
  services:
    J: {endpoint: {$resolver.resolve_endpoint: J, port: {$load: "./palette.yaml#/services/J/port"}}}
    K: {endpoint: {$resolver.resolve_endpoint: K, port: {$load: "./palette.yaml#/services/K/port"}}}

J.yamlはこう。

$let:
  _: {$from: ./resolve.py, import: get_resolver}
  resolver: {$get_resolver: {$get: "data#/host"}, data: {$get: data}}
body:
  port: {$load: "./palette.yaml#/services/J/port"}
  services:
    K: {endpoint: {$resolver.resolve_endpoint: K, port: {$load: "./palette.yaml#/services/K/port"}}}

この時例えばdata.yamlを以下の様にして書いておき、

# data.yaml
app:
  local:
    host: localhost
  dev:
    host: dev-api.me.com
  production:
    host: api.me.com

以下の様な感じで使う。

$ zenmai I.yaml --data "data.yaml#/app/local" > conf/I.local.yaml
$ zenmai I.yaml --data "data.yaml#/app/dev" > conf/I.dev.yaml
$ zenmai I.yaml --data "data.yaml#/app/production" > conf/I.production.yaml
$ zenmai J.yaml --data "data.yaml#/app/local" > conf/J.local.yaml
$ zenmai J.yaml --data "data.yaml#/app/dev" > conf/J.dev.yaml
$ zenmai J.yaml --data "data.yaml#/app/production" > conf/J.production.yaml
$ zenmai K.yaml --data "data.yaml#/app/local" > conf/K.local.yaml
$ zenmai K.yaml --data "data.yaml#/app/dev" > conf/K.dev.yaml
$ zenmai K.yaml --data "data.yaml#/app/production" > conf/K.production.yaml

さらに共通化(出来る場合には。あんまり期待していないけれど)

さらに共通化出来る場合があるかもしれない(あんまりないような気がしているのだけれど)。I,J,Kもほぼほぼ同様の形式(service.yaml)としてまとめられるなら以下の様な感じで書くことも出来るかもしれない(個人的にはやりすぎなような気がしている。そして$eachはまだzenmaiには実装されていない)。

# service.yaml
$let:
  _: {$from: ./resolve.py, import: get_resolver}
  resolver: {$get_resolver: {$get: "data#/host"}, data: {$get: data}}
body:
  port: {$load: {$format: "./palette.yaml#/services/{name}/port", name: {$get: "data#/selfname"}}}
  services:
    $each: {$get: "data/dependencies"}
      "<>": {endpoint: {$resolver.resolve_endpoint: "<>", port: {$load: {$format: "./palette.yaml#/services/{name}/port", name: "<>"}}}

data.yamlは以下の様な感じで。

app:
  local:
    host: localhost
  dev:
    host: dev-api.me.com
  production:
    host: api.me.com

services:
  I:
    selfname: I
    dependencies: [J, K]
  J:
    selfname: J
    dependencies: [K]
  K:
    selfname: K
    dependencies: []

こう使う。--data が複数指定できるのが便利。

$ zenmai service.yaml --data "data.yaml#/app/local" --data "data.yaml#/services/I" > conf/I.local.yaml
$ zenmai service.yaml --data "data.yaml#/app/dev" --data "data.yaml#/services/I" > conf/I.dev.yaml
$ zenmai service.yaml --data "data.yaml#/app/production" --data "data.yaml#/services/I" > conf/I.production.yaml
$ zenmai service.yaml --data "data.yaml#/app/local" --data "data.yaml#/services/J" > conf/J.local.yaml
$ zenmai service.yaml --data "data.yaml#/app/dev" --data "data.yaml#/services/J" > conf/J.dev.yaml
$ zenmai service.yaml --data "data.yaml#/app/production" --data "data.yaml#/services/J" > conf/J.production.yaml
$ zenmai service.yaml --data "data.yaml#/app/local" --data "data.yaml#/services/K" > conf/K.local.yaml
$ zenmai service.yaml --data "data.yaml#/app/dev" --data "data.yaml#/services/K" > conf/K.dev.yaml
$ zenmai service.yaml --data "data.yaml#/app/production" --data "data.yaml#/services/K" > conf/K.production.yaml

ところでdb部分の話

ところでdb部分の話が抜けていた。継承じみた共通化のところでこれを元に良くないといったのだから。db部分の設定の例についても考える必要がある。これは単純に$loadと$formatを使えば良いような気がする。静的な参照に当たる部分はおそらくdb名。それ以外の部分は動的な参照の部分にあたる(そしてこれはフォーマット文字列)。

例えば、Iについてのdb部分に対応する設定は以下の様な感じ。

# local
db: http://localhost:27001/I

# dev
db: http://dev-app.me.jp:27001/I

# production
db: http://app.me.jp:27001/I

# production (replication)
db: user0:<password>@app.me.jp:27001/I,user1:<password>@readonly-app.me.jp:27001/I

今回の範囲ではdb名部分を置換フィールドにしてあげれば良さそう。

app:
  local:
    host: localhost
    formats:
      db: "http://localhost:27001/{dbname}"
  dev:
    host: dev-api.me.com
    formats:
      db: "http://dev-app.me.jp:27001/{dbname}"
  production:
    host: api.me.com
    formats:
      db: "user0:<password>@app.me.jp:27001/{dbname},user1:<password>@readonly-app.me.jp:27001/{dbname}"

template部分に以下を追加する。

# I.yaml
# db: {$format: {$get: "data#/formats/db"}, dbname: I} を追加

$let:
  _: {$from: ./resolve.py, import: get_resolver}
  resolver: {$get_resolver: {$get: "data#/host"}, data: {$get: data}}
body:
  port: {$load: "./palette.yaml#/services/I/port"}
  db: {$format: {$get: "data#/formats/db"}, dbname: I}
  services:
    J: {endpoint: {$resolver.resolve_endpoint: J, port: {$load: "./palette.yaml#/services/J/port"}}}
    K: {endpoint: {$resolver.resolve_endpoint: K, port: {$load: "./palette.yaml#/services/K/port"}}}

サービス名のIをdb名にすれば良いので$formatのdbnameには直接Iを渡す。後は今までと同様に出力させれば良い。 (また、より複雑な変換が必要ならendpointと同様に何か名前を解決するresolve_dburlみたいな関数を書いてあげれば良いのかもしれない)

$ make
zenmai I.yaml --data "data.yaml#/app/local" > I.local.yaml
zenmai I.yaml --data "data.yaml#/app/dev" > I.dev.yaml
zenmai I.yaml --data "data.yaml#/app/production" > I.production.yaml
$ grep db *.yaml
I.dev.yaml:db: http://dev-app.me.jp:27001/I
I.local.yaml:db: http://localhost:27001/I
I.production.yaml:db: user0:<password>@app.me.jp:27001/I,user1:<password>@readonly-app.me.jp:27001/I

memo

今回はわりと考えるだけの作業が多く、リポジトリ内のコードの変更はあまり行わなかった。ただちょっとした設定を生成する上では以下のような文字の変換を行う物があると良いかもしれない。

  • kebab-case(lisp-case)
  • snake_case
  • camelCase(PascalCase)

例えばファイル名がsnake_caseでdb名がkebab-caseというようなことがあったりなかったりするかもしれない。

あと、そもそも設定ファイルの数が数個程度だったら直書きで済ませるので問題ない気がするし。このような事を考える必要があるのは触るファイルが10数個を超えたらとかのような気がする。そして、resolve.pyが複雑になったら負けだな〜みたいなことは思ったりした。