1. Qiita
  2. 投稿
  3. Jsonnet

Jsonnetの薦め

  • 64
    いいね
  • 2
    コメント
この記事は最終更新日から1年以上が経過しています。

JsonnetというJSONテンプレート言語を紹介する。
後で見るように、これはJSONを生成するための汎用テンプレートというよりはむしろ、計算や依存関係を含む設定を静的に書き下すために便利なのではないかと考えられる。

JSONテンプレート言語

ある意味でJsonnetは毎度おなじみのやつだ。JavaScriptの文法の不便さに対してalt JSが多数出てきた。CSSにおけるネストの分かりづらさやの記述の重複に対してCSS preprocessorが多数出てきた。それと同じようにして、Webにおける機械可読データのLingua FrancaたるJSONを記述するのが不便なのでJSONテンプレートが出てきた。
Jsonnetはその中の1つであると捉えられる。

例えば、次のコードを評価したとしよう。

Jsonnet
local base_id = 1;
[
  {
    id: base_id,
    name: "Alice",
  },
  {
    id: base_id+1,
    name: "Bob",
  },
]

これは次のようなJSONを生成する。

JSON
[
  {
    "id": 1,
    "name": "Alice"
  },
  {
    "id": 2,
    "name": "Bob"
  }
]

もっと複雑な例として次のようなJsonnetコードを考えよう。
これは各ノードが次の通信まで待つべきバックオフをサーバーサイドで計算して通知することを意図している。関数定義とリスト内包表記を利用していて、配列変数nodesからデータを生成する。

なお、ここでは配列変数nodesはハードコードしているが、実際にはstd.extVarという関数を用いて外部
から与えられた変数を参照することができる。

Jsonnet
local fibonacci(n) = if n <= 1 then 1
                     else fibonacci(n - 1) + fibonacci(n - 2);
local nodes = ["Node A", "Node B", "Node C"];
[
    {
        id: i,
        node: nodes[i],
        backoff: fibonacci(i+1)
    } for i in std.range(0, std.length(nodes)-1)
]

評価結果は次のようになる。

JSON
[
  {
     "backoff": 1,
     "id": 0,
     "node": "Node A"
  },
  {
     "backoff": 2,
     "id": 1,
     "node": "Node B"
  },
  {
      "backoff": 3,
      "id": 2,
      "node": "Node C"
  }
]

ここまで書いておいて何だが、実のところ上記の例はJsonnetの使い方として必ずしも適切ではないと思われる。次節でその辺をもう少し検討しよう。

Jsonnetのつかいどころ

Jsonnetはwebアプリケーションのサーバーサイドにおけるview templateではないと思う。どちらかと言えば設定を記述する言語だ。

まず、現在のwebシステムにおけるJSONの使いどころを確認しよう。

  1. 設定 -- プログラムの設定を記述する。例としてはSensuやChef, Kubernetesなどが挙げられる。
  2. データ転送 -- サーバーサイドで機械生成されたデータを直列化してクライアントサイドに転送するために、あるいはその逆に用いる。例としては良くあるREST APIが挙げられる。

Jsonnetはこの両方に使えるが、基本的には1に向けたものだと考えている。両者を順に見ていこう。

設定言語

人間が手で書いてプログラムに与える設定ファイルのフォーマットとしては、とりわけprovisioningやdeploymentの設定言語としてはJsonnetは優秀である。

例えば次のようなJSONで書かれた2種類の設定を考えよう。

ステージング環境の設定:

staging.json
{
  "service_config": {
    "backend_a_endpoint": "http://a.staging.example.com:8080",
    "backend_b_endpoint": "http://b.staging.example.com:8080",
    "backend_c_endpoint": "http://c.staging.example.com:8080/experiment",
    "timeout": 10
  }
}

本番環境の設定:

prod.json
{
  "service_config": {
    "backend_a_endpoint": "http://a.production.example.com:8080",
    "backend_b_endpoint": "http://b.production.example.com:8080",
    "backend_c_endpoint": "http://c.production.example.com:8080",
    "timeout": 10
  },
  "monitoring_config": {
    "interval": 5,
    "url": "http://localhost:8080/health"
  }
}

当たり前だが、staging設定と本番設定の構造は似ている。似ているが異なるので独立して管理せねばならず、修正時に面倒である。
実際、似たような構造だが環境ごとに微妙に異なる100個ほどの設定が並ぶファイルを見たことがあるが、それらを整合性を保ったまま編集するのは苦行であった。

上のJSONはJsonnetを用いて次のように書ける。
共通の構造を変えるにはbase.jsonnetを編集すれば良いし、個別の環境のみの設定を変えるにはその環境用のファイルを編集すれば良い。更に各環境においてどの部分が共通設定と異なるのかが明確である。

base.jsonnet
local endpoint(svc, env) = if env == null
    then error "no env configured"
    else std.format("http://%s:8080",
                    std.join(".", [svc, env, "example.com"]));

{
  svc_config: {
    env:: null,
    backend_a_endpoint: endpoint("a", self.env),
    backend_b_endpoint: endpoint("b", self.env),
    backend_c_endpoint: endpoint("c", self.env),
    timeout: 10,
  },
}
staging.jsonnet
import "base.jsonnet" + {
  svc_config+: {
    env: "staging",
    backend_c_endpoint: super.backend_b_endpoint + "/experiment"
  },
}
prod.jsonnet
import "base.jsonnet" + {
  svc_config+: {
    env: "production"
  },
  monitoring_config: {
    interval: 5,
    url: "http://localhost:8080/health"
  },
}

この例ではこれまでに見た基本演算や関数の他に3つの機能を利用している。

  1. 隠しフィールド: 通常の:の代わりに::で宣言されたフィールドは最終的なJSON生成には使われない。
  2. super: superを用いて継承元での対応する値を参照できる。
  3. 継承: + を用いて他のJsonnetオブジェクトを継承し、差分だけを記述できる。
    • 特にimport式を用いて他のファイルに書かれたオブジェクトを読み込んで継承できる。
    • foo+: barfoo: super.foo + barに展開されるが、:+の利用は継承を記述するときのみにとどめ、文字列結合には用いない方が分かりやすいと思う。

更に、先に触れたstd.extVar関数を用いれば評価時にコマンドラインで指定する変数を定義することも可能だ。

データ転送用テンプレート

最後に、JSONのもう1つの典型な用途であるデータ転送形式について考えてみよう。

機械可読データと言うよりは人間に向けたプレゼンテーションであれば、サーバーサイドで転送データを生成するときJSPやERB, Hamlあるいは何らかの言語内DSLのようなview templateを利用するのが一般的だ。よって、JsonnetをそのJSON版と捉えるのも自然だし、事実としてそのようにも利用できる。
しかし、それは適切ではないように思う。

まず、Jsonnetは単なるview templateには過剰である。四則演算や変数ぐらいならまだしも、継承はtemplateを無駄に複雑にする。
ちょっとした差分を解決するぐらいはホスト言語で処理できるはずだ。

更に言えば、そもそも四則演算も変数も次のようにホスト言語で解決すべきだ。

def fibonacci(n)
  n <= 1 ? 1 : fibonacci(n-1) + fibonacci(n-2)
end

data = ["Node A", "Node B", "Node C"].map.with_index do |node, i|
  {
    "id" => i,
    "backoff" => fibonacci(i+1),
    "node" => node,
  }
end
puts JSON.dump(data)

これはホスト言語のほうが制約の強いテンプレート言語よりも自由だからというだけの理由だけではない。プログラムのための機械可読データを転送しようとしているのだからプログラム内に対応するデータ構造が出現するのが自然だということもある。

つまり、JSONは「直列化形式」であってサーバーサイドのロジックやデータ構造そのものと結びつきが強いため、この点でHTMLやPDFといったプレゼンテーション言語とはやや趣が異なる。

プログラム内部処理用のデータ構造と転送用のデータ構造を分離する層を設けるのが適切な場合も多いだろう。その場合に更にどうしても必要ならばテンプレート言語を利用する選択肢も否定するものではないが、そうだとしてもJsonnetはオーバーキルである。

比較

JSON5じゃ駄目なの?

JSON5は比較的シンプルなJSONの拡張であり、列挙末尾にカンマを入れられないとかコメントを入れられないなどのJSONを書くときの苦痛を軽減してくれる。JSON5の利点としてはJsonnetより簡潔で、しかもJavaScriptのsubsetであることから逸脱していないので覚えやすいということが挙げられる。よって、importや継承を必要としないシンプルなケースではJsonnetに勝るだろう。

しかし、依然として小規模な計算や継承といった複数個の複雑な設定ファイルを書くときにしばしば必要とされる機能を欠いている。

YAMLじゃ駄目なの?

YAMLはJSONよりも柔軟な記述を許容するし、anchorを使えば部分的な「継承」も実現できる。しかし、依然として小規模な計算やenvを上書きするとendpointの値も連動して変わるような大規模な設定で必要とされる機能を欠いている。

汎用言語じゃ駄目なの?

より強力な汎用プログラミング言語であれば、当然Jsonnetが実現しているような機能は理論的には実現可能だ。しかし、問題領域は設定ファイルである。設定したい事柄だけをシンプルに記述するのが望ましく、それを本格的なプログラムにはしたくない。
一方、「環境ごとにちょっとだけ違う設定を使いたい」といった設定ファイル向けの機能をネイティブにサポートしている汎用言語はあまりない。

では内部DSLでは駄目だろうか。駄目と言うことはないし例えばRubyならそうした内部DSLを書くのは容易だが、車輪の再発明を更に重ねなくても良いだろう。JsonnetならRuby処理系に依存していないという利点もある。

まとめ

  • Jsonnetは設定には便利だ。特におおよそ同じだが微妙に異なる設定群を管理するのに威力を発揮する。
    • import式と継承がその要である。これらが必要ない単純なケースではJsonnetでなくてもYAMLやJSON5あたりで足りるだろう。
  • View templateとしては機能過剰である。そもそもJSONに専用のview template言語が必要か疑わしいし、必要だとしてももっと簡潔な言語を使った方が良い。
Comments Loading...