📐

構文のことは忘れて、JSON, S式, XMLのデータモデルを比較する

2022/08/21に公開

データをシリアライズするには、独自のフォーマットを定めるよりも、基本的な定義済みの構造を組み合わせてフォーマットを作るほうが望ましい場合が多いです。

そのような仕組みとしてJSON, S式, XMLなどが存在しますが、これらは 「基本的な構造」として何を選ぶか、という観点からそれぞれに個性を持っています。

本記事では、具体的な構文のことは基本的に忘れて、各フォーマットが採用するデータモデルの違いに焦点を絞って比較します。

JSON

data JSON = Value
data Value =
  -- Compounds
    Array [Value]
  | Object (Map String Value)
  -- Scalars
  | Null
  | Boolean Boolean
  | String String -- UCS-2
  | Number IntegerOrFloat -- no NaNs or Infinities

JSONは配列のほかにオブジェクト (文字列をキーとするマップ) を基本構造に持つ点が特徴的です。

この2つがあることで、以下のように多くのイディオムが自然に表現されます:

  • 配列
    • 同種要素の配列 [1, 2, 3, 4, 5]
    • タプル [22, "John Smith", "engineer"]
      • 構造体で実現されることのほうが多く、あまり使われない
    • タグつきタプル ["Op", "+", ["Var", "x"], ["Var", "y"]]
      • タグつき構造体で実現されることのほうが多く、あまり使われない
  • オブジェクト
    • 同種要素のマップ { "John": "Room 1", "Mary": "Room 5" }
      • 数値など他種のキーも文字列にマップして使う
    • 構造体 { "age": 22, "name": "John Smith", "occupation": "engineer" }
    • タグつき構造体 { "type": "Op", "op": "+", "lhs": { ... }, "rhs": { ... } }
      • タグとして type というメンバー名を使うのが一般的

構造上の欠点として以下のような点が知られています。

  • 数値など他のスカラー値を直接マップのキーに置くことができない。
  • マップは順不同と考えるのが自然だが、実際の実装を見るとこの部分は徹底されていない
  • タグつき構造体のタグが先頭に来る保証がないため、デシリアライズを複雑化する原因になる。

また、JSONのスカラー値はいくつかの困難を抱えています。

  • NaN, ±Infinity のシリアライゼーションが定義されていない。
  • 整数と浮動小数点数の区別が不明瞭。
    • 特にJavaScript実装では数値を浮動小数点数として受理するのが一般的であるため、大きな整数の相互運用性にとぼしい。
  • バイト列がない。
  • その他、日付時刻など

RFC 4627ではトップレベルに配列・オブジェクトのみが許可されていましたが、現在は全てのValueをトップレベルに置くことができます。

TOML

TOMLのデータモデルはJSONに近いですが、スカラー値の表現が追加されています。

  • NaN, ±Infinity の表現 (NaN payloadの区別はなし)
  • 整数と浮動小数点数の区別が定義されている
  • 日付時刻

一方以下の点でJSONよりも制限が加えられています:

  • トップレベルはオブジェクトのみ
  • 文字列中の単独サロゲート (\uD800\uDFFF) は禁止

MessagePack

MessagePackもJSONと同様のデータモデルですが、以下の違いがあるようです:

  • NaN, ±Infinity の表現 (NaN payloadの区別あり)
  • 整数と浮動小数点数の区別が定義されている
  • 整数は固定長
  • バイト列
  • 文字列中の単独サロゲート (\uD800\uDFFF) は禁止
  • タイムスタンプ
  • データ型の拡張

「データ型の拡張」はタグつきタプルのような仕組みですが、使えるタグが256個しかないのでアプリケーションロジックに固有のデータ型を全てこの方法で表現するのは厳しそうです。

CBOR

CBORもJSONと同様のデータモデルですが、以下の違いがあるようです:

  • NaN, ±Infinity の表現 (NaN payloadの区別あり)
  • 整数と浮動小数点数の区別が定義されている
  • 10進小数
  • バイト列
  • 文字列中の単独サロゲート (\uD800\uDFFF) は禁止
  • null と区別される undefined
  • 日付時刻・タイムスタンプ
  • 特定の意味を持つ文字列 (URL, Base64, MIME など)
  • エンコーディング指定つきバイト列 (Base64, Base64url など)
  • データ型の拡張

S式

S式は特定の規格を指しているわけではないため、バリエーションがあります。

タグつき形式のS式

data Value =
    List String [Value]
  | String String
  | -- ...other scalars...

リストの先頭にシンボルや文字列のみを許す形式です。タグつきタプルがネイティブの表現になっています。

タグのいらない場合、たとえば配列などをあらわしたい場合は固定のタグを入れておきます。

(array 1 2 3)

マップや構造体はリストをネストさせて表現します。このときもタグが自然についてくるので、必要ない場合でも適当なタグを置いておくことになります。

(user
  (name "John")
  (age 42))

一般化S式

data Value =
    List [Value]
  | String String
  | -- ...other scalars...

より一般に、 ((foo))() など、先頭が文字列・シンボルでない形式も許すパターンも考えられます。一般化することでタグなしのパターンも可能にはなりますが、イディオマティックかどうかは不明です。

これはJSONからオブジェクトを除いたものとみなすこともできますが、イディオムの形式は大きく異なります。

コンスベースS式

data Value =
    Cons Value Value
  | Nil
  | String String
  | -- ...other scalars...

非真正リスト (foo bar . baz) を許すようにした亜種です。データ形式として使われることがあるかは謎です。

XML

data Node =
    Text String
  | Element
      NamespacedName
      (Map NamespacedName String)
      [Node]

type NamespacedName = (String, String)

XMLのデータモデルはJSONとS式が変な風に合体したような形をしていて歪です。これはある意味当然で、XMLはあくまでマークアップ言語 (プレーンテキストにメタデータを与えたもの) だから、それに特化した構造になっているのは合理的です。しかし、ここではあくまで構造化データの表現のための基層構造という観点から評価します。

XMLは基本的にはS式の構造 (文字列とタグつき配列からなる) と似ています。しかしこの文字列の扱いには注意が必要で、正規化条件がついています。これは、

  • テキストノードは隣接しない。 (→隣接する場合は結合する)
  • 空のテキストノードは存在しない。 (→存在する場合は削除する)

したがって、文字列リテラルにあたるテキストノードをそのまま並べることはなく、要素に入れて記述することになります。

<user>
  <name>John</name>
  <age>42</age>
</user>
<!--
(user
  (name "John")
  (age 42))

(user "John" 42) に対応する書き方はできない
-->

さらに、これとは別にタグに属性を付与することができます。つまりS式と違い、マップ構造をプリミティブに持っていることになります。しかし、これはJSONと違い文字列値のみを入れることができます (再帰的な構造を持ちません)。

<user
  name="John"
  age="42"
/>

構造化データの表現という観点からは、せっかくマップ構造がプリミティブにあるのに、拡張性を考えるとS式と同様のイディオムを使って組んだほうが好ましいというもどかしさがあります。

Protobuf wire format

Protocol Buffers (Protobuf) はスキーマフルなフォーマットですが、その基層としてWire formatというスキーマレスなフォーマットが使われています。

Wire formatのデータモデルは以下のようになっています。

type Message = Map Tag [Value]
-- この Tag はデータ種別ではなく構造体フィールド名に対応する
newtype Tag = Tag Unsigned

data Value =
    Varint Unsigned
  | Uint32 Uint32
  | Uint64 Uint64
  | Bytes Bytes

Protobuf wire formatの第一の特徴は再帰的でない点です。バイト列をエスケープせずに埋め込めるため、ネストしたデータはWire formatをバイト列に埋め込むことで表現します。

Protobuf wire formatの第二の特徴は構造体と配列がセットである点です。ProtobufはHTTPヘッダーのようにName-Valueの対を並べる形で表現するため、同じNameを複数回記載することで多値を表現できます (HTTPでもSet-Cookieのような例があります) この仕組みを使って配列を表現しているため、構造体を表現しようとすると自動的にフィールドが配列 (repeated) 化できるようになり、逆に配列を表現しようとすると構造体のレベルを導入する必要があります。

これは [[1, 2], [3, 4]] のような二次元配列を自然に表現できないことを意味します。

ASN.1 BER

ASN.1は電気通信でよく使われる(らしい)データ記述のフレームワークで、コンピューターネットワークではTLSの証明書 (X.509) やLDAPなどの利用例が有名です。

ASN.1 自体は汎用のスキーマ言語で、スキーマに沿ってオブジェクトを定義すると様々なフォーマットでシリアライズできるというものですが、そのうち最もポピュラーな形式であるBERについてここでは考えます。

BERのレベルで使われるデータモデルは以下のようになっています:

type Value = (Tag, Content)
type Tag = (TagClass, Unsigned)
data TagClass = Application |

data Content =
    Primitive Bytes
  | Constructed [Value]

プリミティブとしてバイト列だけを用意するなど思い切りのいい側面がある一方、実際のエンコードにはlong-form/short-formの違いなど細かい複雑性が色々あり、どういう方向性を指向して設計されたのかいまいちわからない印象もあります。

YAML

YAMLはJSONの亜種として使われることが多いですが、その下層にはもう1つの、はるかに汎用性の高い (悪く言えば取り扱いに困る) データモデルが存在します。JSON風のデータモデルはこの上の高級モデルとして構築されています。

ここではこれをYAMLの基層データモデルと呼ぶことにして、これがJSONデータモデルに対してどのように一般化されているかを説明します。

type YAML = (Graph, [NodeId])
type Graph = Map NodeId (Tag, NodeContent)

newtype Tag = Tag String
data NodeContent =
    Seq NodeId[]
  | Mapping (Map NodeId NodeId)
  | Scalar String

複合キー

マッピング (JSONでいうところのオブジェクト) のキーが任意の値をとることができます。

# ["foo", "bar"] から [3, 4] へのマッピング
? - "foo"
  - "bar"
: - 3
  - 4

タグ

タグは値の種類を区別するための仕組みです。タグはURI (または特別な文字列) になっていて、ユーザー定義のタグを自由に追加できるようになっています。

たとえば、以下のYAML文書はRubyのオブジェクトを表現したものです:

-- `!ruby/object` というローカルタグを持つマップ
!ruby/object {}

タグを省略した場合は、以下の特別なタグがデフォルトで付与されます。

  • ! ... この値の型は基層データモデル上の型 (列、マップ、文字列) をそのまま反映するべきである
  • ? ... この値の型は内容に応じて決定されるべきである

これらは未解決タグと呼ばれ、「解決」というプロセスによって具体的なタグに置き換えられます。このルールはスキーマ依存です。

たとえば、 truefalse が (JSON SchemaやCore Schemaが適用されている時に) ブール値として解釈されるのは、以下のルールによるものです:

  • true にはタグがついていないので、パーサーによりデフォルトのタグが付与される。
    • true はクオートされていないスカラー値 (plain scalar) であるため、 ? が付与される。
  • ? タグが解決される。タグは内容に基づいて決定される。
    • JSON SchemaやCore Schemaの場合、ブール値をあらわすタグ tag:yaml.org,2002:bool に置き換えられる。

RubyのYAMLパーサーが :foo をSymbolと解釈するのも、このパーサーが専用の拡張スキーマを使っているためと考えられます。

ストリーム

1つのYAMLファイルには複数のオブジェクトを入れることができます。類似の形式としてJSON Linesがあります。

---
1
---
2

複数のオブジェクトのdump/loadは通常、別APIとして提供されます。これらのAPIを基準にすれば、「YAMLはトップレベルに配列のみを許している」と解釈することも可能です。

アンカー

YAMLではノードに名前をつけて、あとで呼び出すことができます。

author: &author
  name: John
committer: *author

これは単なる省略機能ではなく、同一オブジェクトの再利用として解釈されます。オブジェクト同一性の区別がある言語で上記のYAMLを読み込んだ場合、 name の書き換えは両方に反映されることが期待されています。

アンカーは循環参照の表現にも使われます。

&it
itself: *it

アンカーはストリーム全体で共有です。

YAMLの仲間

YAMLはプログラミング言語のオブジェクトグラフをそのまま表現することを想定したつくりになっています。同様の目的を持ったフォーマットとして以下があります:

YAMLは同様のコンセプトを人間可読かつ言語依存性の低い方法で実現した点で異質であるといえます。

上に挙げたような各言語のフォーマットは基本的に各言語のオブジェクトモデルに基づいているので、本稿では詳しい説明は省略します。

まとめ

  • JSON ... 配列とマップ。
    • マップがあることで構造体の自然な表現ができる。
    • タグが必要な場合は構造体に type フィールドを入れるイディオムが使われる。
  • S式 ... タグつき配列。
    • マップをプリミティブに持たないが、配列をネストさせて表現できる。
  • XML ... 制限つきのS式 + 属性マップ。
    • 属性マップは構造をネストさせられない。
    • そのため、属性を使わずにS式と同様の方法でマップや構造体を作るパターンが見られる。
  • Protobuf wire format ... 多値構造体、ネストなし。
    • Bytesを使うことで上位フォーマットのレイヤでネストする。
    • 配列と構造体がセットになっている。
  • ASN.1 BER ... TLV + 配列
    • TLVが構造体の一部ではない形で出てくることがあり、implicit tagなどの特殊ケースのハンドルが必要になる。
  • YAML ... オブジェクトグラフ
    • オブジェクトをそのままシリアライズするための道具が揃えられている。
    • 実際には単なるJSONのシンタックスシュガーとして使われることが多い。

全てのフォーマットについて綿密に調べられたわけではなく、またイディオムについては自分の偏見が含まれている可能性があります。もし誤りやイディオムに関する誤解があればコメントで指摘していただけると嬉しいです。

Discussion