Future Tech Blog
フューチャー開発者ブログ

一周回って、人間が読み書きする設定ファイルはJSONが良いと思った


最近GoでCLIツールを作っていますが、JSONが良いとなんとなく思っています。

続編も公開しました(追記:2019年10月2日)。

設定ファイルフォーマット

近年、設定ファイルを書くプレーンテキストのフォーマットとしては次のようなものが多いかと思われます。

  • XML
    • 多くのプログラミング言語において標準ライブラリで扱える(ただしNode.jsにはない)
    • XMLスキーマ、XSLTなどの周辺ツールも揃っているが、記述が冗長になりがちで、敬遠されがち。
  • ini
    • QtやPythonの標準ライブラリで扱える
    • 深い階層や配列を扱うのが苦手
  • JSON
    • ほとんどのプログラミング言語で標準ライブラリに入っている
      • 特にフロントエンドのJavaScriptでは追加のライブラリを利用する必要がなく、速度も早く、gzipすればファイルサイズもかなり小さくなる。T
    • 閉じかっこが必要、コメントがつけられない、末尾のカンマをきちんとルール通りに表記しなければならない
      • そのためコンフリクト時のマージにコツが必要
      • JSON5といった別のバリエーションもある
  • YAML
    • インデントで階層を表現できるし、配列、辞書など表現力はJSONに負けない
    • Ruby以外は外部ライブラリ必須
    • 配列のときにインデントの字下げをする?など書き方に好き嫌いがある
  • TOML
    • 書きやすいiniみたいな
    • 標準ライブラリで扱える言語はない

JavaScript系のものはJSONが多いですし、docker-composeやKubernetesはYAMLですし、Pythonは古来からのフォーマットは.iniが多かったりはしますが、最近登場したPoetryやPipenvはTOMLを採用しています。まあJSだと、package.jsonをのぞいてはJSON対応ではあるけど、JavaScriptでも書ける(module.exports =で書く)とかあったりもします。

JSONは周辺ツールが充実している

で、いろいろある中で、最近作っているツールではJSONを使いました。

汎用フォーマットは実装側からすると便利だけど、ユーザー視点だと自由すぎるので、制約が欲しくなってきます。JSONにはJSONスキーマがあります。

生のJSONを作って、次のようなツールに入れると、それを満たすJSONスキーマをざざっと作ってくれます。構造化とかはされていないので共通で使える部品をdefinitionsに移動する、のリファクタリングをしていく感じです。

Visual Studio CodeとIntelliJでしか試してませんが最近はエディタ側もかなり便利に進化しています。IntelliJでは、要素のコピペでは、末尾に貼り付けた時はカンマを削除してくれたりといった入力支援があったりします。もちろん、文法チェックもしてくれるため、エディタ上で入力したものをプログラムに持って行ってエラーになって悩まされる、ということは今はもう過去の話でしょう。また、JSONスキーマがあると、JSONファイルをエディタで入力するときにも入力支援で補完してくれたり、スキーマ違反を指摘してくれたりします。

VSCodeだと、このあたりを読むと、設定の仕方が書かれており、ファイル名のパターンごとにスキーマを設定できます。

settings.json
1
2
3
4
5
6
7
8
"json.schemas": [
{
"fileMatch": [
"/.babelrc"
],
"url": "http://json.schemastore.org/babelrc"
}
]

わざわざ設定しなくても、編集したいJSONファイルの中からスキーマを指定することもできます。この記法はVSCodeでもIntelliJでも有効でした。スキーマは相対パスでローカルのスキーマファイルを指定することもできますし、URLを書くこともできそうです(試した時にはファイルがプライベートリポジトリだったので失敗しましたが)。

編集しているJSONファイル
1
2
3
4
{
"$comment": "これで補完とかチェックが効くようになります",
"$schema": "../schema.json"
}

設定ファイルという文脈ではあまり登場はしませんが、大規模なデータになると、jqみたいなツールを使って情報を取り出す、という活用方法もあります。

Goから扱うのも簡単です。JSON to Goというウェブサイトを僕はよく使いますが、これを使うと、生のJSONから、それを解釈するための構造体を作って来れます。これで、encoding/jsonを使った読み書きがだいぶ簡単になります。これ以外にもいろいろあります。JSON Schema や JSON から Go の struct を生成するというエントリーにまとまっています。

JSONスキーマをGo上で利用してバリデーションもできます。

Goの構造体からJSONスキーマを生成することもできます。

現在の時点でどのぐらい使われているかはわかりませんが、JSONスキーマから編集画面生成というのもありましたね。

マージ難しい問題も、マージ用のツールがあったりもします。行志向のdiffロジックだとそりゃーJSONとは相性が悪いわけで、木構造をきちんと理解してdiffを出せばマージの難易度は下がります。JSONは末尾のカンマとかのせいでコンフリクトはしやすいですが、ちょっと込み入った変更で辛いのはJSONもYAMLも変わらないですし。

マージ以外のエコシステムを図示したのが次のフローになります。

JSONとコメント

JSONの不満というとコメントがあります。JSON5などを使うという方法もありますが、JSONの派生のフォーマットを使うと、標準のパーサーではダメだったり、JSONのメリットがいくつかスポイルされてしまいます。品質とか機能性が純正に比べると・・・みたいなこともありますし。

JSONスキーマのところのサンプルで少し紹介していましたが、JSON Schemaでは、Draft 7にコメント記法が入りました。好き嫌いはあるとは思いますが、とりあえずこれだけあれば十分使えるので、僕はこれを利用しています。あ、エディタで余計な要素としてエラーにならないようにするためには、スキーマ定義で “$comment” も許容するようにスキーマを書かないとダメですよ。

編集しているJSONファイル
1
2
3
4
{
"$comment": "これで補完とかチェックが効くようになります",
"$schema": "../schema.json"
}

ちなみに、TypeScriptの設定はコメントが書けますが、あれはJSON5ではなくて、JavaScriptパーサーに特殊なモードがあってそいつを利用しています。処理系内部にハードコードされていますが、あれはライブラリ化して出して欲しいですね。

最近のワークフロー

さて、上記の絵だと、どこから作成しても、他のものを生成できて、自由に作り始められそうな感じはありますが、やはり作りやすい、作りにくいという順序はあります。よくあるちょっとした自動化でもありがちですが、ワークフローを考える上で考慮すべきことはいくつかあります。

  • 生成したあとに手直しが必要で、なんどもイテレーションを回すと、手直しの工数とかミスが無視できない
  • 宣言的な定義と、成果物で使う脳みそが違っていて、かなり頭を使う必要がある

上記のツールだと、JSONからJSONスキーマ生成あたりでかなり冗長なスキーマが出てくるので、手直しの手間暇がそこそこかかりました(ので、点線にしています)。

最初はJSONファーストでやっていましたが、最近はGoの構造体ファーストでやっています。ソースコードジェネレートは初回だけです。ソースコードジェネレートを活用しようとすると、その後手を加えたものとのマージをどうするか問題がよく発生します。ジェネレーションギャップパターンみたいなデザインパターンもありますが、数回分の手間暇のためにわざわざそのためにコードに複雑さを導入するのもなぁ、と思いますし、設計意図が一番白黒はっきりするのがソースコード側なので、他のものをここに追従させるのが一番スムーズに感じます。

  1. 設定ファイルのモックを実際にJSONで作って見る
  2. それをJSON-to-GoでGoの構造体にする
  3. omitemptyとかいろいろGoの構造体のタグを編集する
  4. JSONスキーマ生成
  5. 雑に作ったJSONに$schemaを指定してスキーマを設定し、JSON側をいじって見て感触を確かめる
  6. 足りないところがあればまたGoの構造体を修正してJSONスキーマを生成

JSONスキーマを生成して、設定ファイルにスキーマを設定した段階で、依存関係が完成します。

  • Goの構造体 -> JSONスキーマ -> JSON

ですが、最初からこの依存関係を守ろうとして、設定ファイルの完成イメージと遠いところで試行錯誤するのも脳のメモリを大量に使って効率が悪いので、最初だけは次のフローを入れています。

  • JSON -> Goの構造体

JSONスキーマ生成は次のようなコードを使ってやっています。go generateとかで実行してもいいかも。

/cmd/jsonschema/main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/alecthomas/jsonschema"
"github.com/shibukawa/自分のパッケージ"
)

// https://gist.github.com/abrookins/2732551#file-gistfile1-go
func __FILE__() string {
_, filepath, _, _ := runtime.Caller(1)
return filepath
}

func __DIR__() string {
return filepath.Dir(__FILE__())
}

func gen(fileName string, target interface{}) {
prjPath := filepath.Join(__DIR__(), "../../", fileName)
fmt.Printf("writing: %s\n", prjPath)
prj, err := os.Create(prjPath)
if err != nil {
panic(err)
}
defer prj.Close()
schema := jsonschema.Reflect(target)
e := json.NewEncoder(prj)
e.SetIndent("", " ")
e.Encode(schema)
}

func main() {
gen("config-schema.json", &自分のパッケージ.Config{})
gen("addin-schema.json", &自分のパッケージ.Addin{})
}

次回は、話題の設定ファイル記述言語、CUEを取り上げます。


関連記事: