スキーマはもう全部CDDLでいいんじゃないかな

こんにちは、OPTiM TECH BLOG Advent Calendar 2020 12/10の記事をR&Dチームの齋藤(@aznhe21)からお送りします。 アドベントカレンダーはクリスマスまでを数えるカレンダーということですが、個人的にはクリスマスよりもクリスマスイブの方が楽しみです。 なぜならEDF:WBが発売されるのと同時に、我が家にPS5が届く予定の日だからです。

さて、スキーマフルなデータ形式を調べる中で見つけたCDDL1(CBOR2のためのデータ定義言語)がとても表現力が高いのに全然情報が無かったのでまとめてみました。

f:id:optim-tech:20201208223815p:plain

はじめに

CDDLの実例から見てみたい方は色々なサンプルをご覧ください。

CDDLとはRFC 8610で定義されるデータ記述言語、いわゆるスキーマ言語です。主に(RFC 7049で定義される)CBORのための言語ではありますが、 CBORはJSONのスーパーセットと見做せるためJSONのバリデーションにも使えます。JSONに使えるということはMessagePackにも使えるでしょう。

CDDLの表現力は、多少の差異があるものの、概ねTypeScriptの型システムを強化したもの、と言えます。 CDDLでは数値や文字列、オブジェクトはもちろんリテラル型やリテラル値の制限、合併型にジェネリクスまでも表現できますし、 また、(JSONに端を発するTypeScriptの型やJSON Schema等と違って)バイナリ形式に対応しているのも大きな特徴です。

では、CDDLがどのようなものであるか、RFCをもとに見ていきましょう。

基本型

最初から定義されている基本型には以下のようなものがあります。

型名 概要
any 何でも入る型
bool 真偽値
uint 正の整数=符号なし整数
nint 負の整数
int 符号付き整数
float16 で定義される16ビット浮動小数点数(IEEE 754)
float32 で定義される32ビット浮動小数点数(IEEE 754)
float64 で定義される64ビット浮動小数点数(IEEE 754)
float 16/32/64ビットの浮動小数点数(IEEE 754)
number 数値(整数か浮動小数点数)
bstr / bytes バイト列
tstr / text 文字列(UTF-8)

他にもfloat16-32biginturiなども定義されていますが、ここでは省略します。 完全なリストはRFCのAppendix Dを参照してください。

構文解説

まずは雰囲気を掴むため、大まかに構文を見てみましょう。

; コメントはセミコロン

; name = typeで型定義に別名を与える。定義順は関係ないため前方参照ができる
my-type = my-uint
my-uint = uint

; 型定義には値そのものも使える(変数宣言ではなくあくまで型宣言)
zero = 0
hoge = "hoge"

; 整数は16進数を2進数が使える
gray = 0x808080
binary = 0b010101
; 浮動小数点は指数記法が使える
mega = 1e+1000000
hexfloat = 0xABC.defp100 ; 16進数記法+指数記法

; 数値は範囲を制限できる
uint8 = 0..255 ; ".."は始端・終端どちらも含有
uint16 = 0x0000...0x10000 ; "..."だと終端は含まない
zero-one = 0.0..1.0 ; 小数も使える

; 文字列は二重引用符を使う
hello = "こんにちは"
escaped = "AB\"CD\nDE\tFG" ; JSONと同じエスケープシーケンスが使える

; バイト列は単一引用符を使う
gif89a-header = 'GIF89a'
; "h"を接頭辞に使うと16進文字列となる
png-header = h'89504e' ; SJISで"臼NG"
jpeg-header = h'FF D8 FF E0' ; 空白は無視される
elf-header = '\x7FELF' ; エスケープでも記述できる

; 構造体は波括弧で定義
struct-test = {
  ; フィールドはkey: typeの形で定義する
  ; フィールドの区切りはコンマを使うが、省略もできる
  key1: uint, ; コンマあり
  key2: uint  ; コンマなし
  key3: uint
  ; キー名を二重引用符で括るとキー名に特殊な文字も使える
  "key:4": uint
  'key{5}': uint
}

; テーブルの定義
table = {
  ; key-type => val-typeという形にするとキーと値の型を指定した辞書型(テーブル)になる
  ; ただし「発生指示子」がないと単一のエントリとしてのみ扱われる
  * tstr => tstr
  ; key1: uintという定義は文字列値をキーにしているのとほとんど同義である
  "key1" => uint
}

; 構造体とテーブルは合わせてマップと呼ばれ、同時に使うことができる
; これにより、TypeScriptのインデックスシグネチャのようなことができる
; JavaScriptの文字列定義の模倣
js-string = {
  length: uint ; 文字列の長さ
  * uint => tstr ; n番目の文字
}

; タプルや配列は角括弧で定義
tuple = [ uint, nint ] ; 1要素目の型はuint、2要素目の型はnint

; 配列は「発生」指示子を使う
array1 = [ * uint ] ; 要素数は0以上
array2 = [ + uint ] ; 要素数は1以上
array3 = [ ? uint ] ; 要素数は0か1
; n*mという形式だとn以上m以下(n <= x <= m)
array4 = [ 1*9 uint ] ; 要素数は1以上9以下
; nを省略すると0
array5 = [ *9 uint ] ; 要素数は0以上9以下
; mを省略すると無制限
array6 = [ 2* uint ] ; 要素数は2以上

; 発生指示子は配列だけのものではないため、構造体にも指定できる
struct2 = {
  ; 構造体に付ける時はキー名よりも前
  ? key1: uint ; 普通はオプショナルのために使う

  ; 普通は使わないが、仕様上は繰り返しもできる
  * key2: uint
  + key3: uint
  ; テーブルと組み合わせれば要素数を制限できる
  *10 tstr => tstr
}

; 丸括弧は「グループ」と呼ばれる機能で、構造体や配列の中で使用すると展開された形になる
group = (
  x: uint
  y: nint
)
; 以下は{ x: uint, y: uint }と同義
struct-using-group = { group }
; 配列の場合キーは無視されて「正の整数と負の整数を繰り返し」の意味になる
array-using-group = [ group ] ; [ 1, -1, 2, -2 ]のような感じ

; インラインで使用することもできる
double-float-array = [ + (float, float) ] ; 要素数を偶数個に制限

; スラッシュで区切ると選択肢(並べた値のどれかだけを許容)
meta = "foo" / "bar"
; 選択肢を後から追加もできる
meta /= "baz"

; もちろん型も指定できる
int-or-str = int / tstr

; "//"は単体の"/"と同じ意味だが、演算子の優先度が低いため同じようには使えない
; 以下2つは不正
; meta-jp = "hoge" // "fuga"
; meta-jp //= "piyo"

; グループや構造体に使うことで効果を発揮する

; { "type": "array", value: [0, 1, 2] }や、
; { "type": "object", value: { "hoge": "fuga" } }のようなオブジェクトのどちらかを許容
typed-value = (
  type: "array"
  value: [ + uint ] //
  type: "object"
  value: { * tstr => tstr }
)
; "//="の形はグループでのみ使える
; { "type": "int", "value": 0 }の形式を追加
typed-value //= (
  type: "int"
  value: int
)

; グループの前に「&」を付けると、そのグループを使った選択肢になる
; これにより列挙型が作れる
enum = &(
  x: 1,
  y: 2,
  z: 3,
)

構文解説

それぞれの構文について解説します。

コメント

コメントは;(セミコロン)で開始され、行末(LFかCRLF)で終端します。

; 行全体がコメント
my-int = int ; 型定義の後にコメント

型定義

型を定義する際は左辺に型名、右辺に型の内容を書きます。また、型の内容は前方参照ができるため、定義順を気にする必要はありません。

type2 = type1
type1 = uint

右辺には型そのものだけでなく、値を書くこともできます。 選択肢や範囲と組み合わせることで、プロパティの値を制限するなど、非常に強力な表現が可能です。

meta = "hoge"

型名には英数字と_-@.$を使用することができます。ただし、最初の文字は英字ないし@-$のみ使用できます。 また、以下のルールがあります。 - 型名は大文字・小文字を区別する - 型名は小文字始まりを推奨 - アンダースコアよりはハイフンを推奨 - モジュール構造はドットで表現すると良い(「tcp.throughput」、「udp.throughput」など)

整数リテラル

整数には10進数や16進数、2進数が使えます。 16進数は0xで始め、大文字・小文字の区別はありません。 2進数は0bで始めます。

digits = 123456789    ; 10進数
hex-digits = 0xABCDEF ; 16進数
bin-digits = 0b101010 ; 2進数

負数も表現できますが、リテラルでしか記述できず、定義済みの値を使って反転させることはできません。

; negative = -digits ; これはできない
negative = -123456789

小数リテラル

小数は12.34のような普通の表記の他、基数指定や16進数表記が可能です。 16進数表記の場合、基数指定にはeではなくpを使い、基数部分は10進数で指定します。

flt = 12.34
min = -3.40282347e+38 ; 基数指定
hex-float = 0xAB.CDp+12 ; 16進数表記+基数指定

文字列リテラル

文字列(tstr型)は二重引用符(")で括ることで記述できます。 文字列の記述方法はJSONと同じです。

hello = "こんにちは"
escaped = "AB\"CD\nDE\tFG" ; JSONと同じエスケープシーケンスが使える

バイト列リテラル

バイト列(bstr型)は単一引用符(')で括ることで記述できます。 接頭辞としてhを使うことで16進文字列を記述できる他、JSONと同じ形式でエスケープもできます。

gif89a-header = 'GIF89a'
png-header = h'89504e' ; SJISで"臼NG"
jpeg-header = h'FF D8 FF E0' ; 空白は無視される
elf-header = '\x7FELF' ; エスケープでも記述できる

裏話

なお、RFCでは3.1項b64''という記法も定義されていますが、その見た目とは裏腹にh''と同じく16進文字列を記述するとされています。 恐らくBase64で文字列を記述できるようにしたかったんでしょうが、仕様バグになってしまっているようです。 そのためか、node実装もRust実装もb64''を解釈せずエラーとしているようです。

選択肢

型を定義する際、右辺を/で区切って複数の型を記述することで選択肢を宣言できます。 選択肢により、指定された複数の型の中から1つだけ、という指定ができます。 また、型を定義する際に=の代わりに/=とすることで、既存の定義に選択肢の後付けができます。

; episodeはエピソード1〜6のうちどれか
episode =  ep-prequel / ep-original
; ep-originalはエピソード4〜6のうちどれか
ep-original = "A New Hope" / "The Empire Strikes Back" / "Return Of the Jedi"
; ep-prequelはエピソード1〜3のうちどれか
ep-prequel = "The Phantom Menace" / "Attack Of the Clones" / "Revenge Of the Sith"

; 数値型と文字列を組み合わせ、なんてこともできる
my-uint = uint
my-uint /= str-uint
str-digit = "zero" / "one" / "two" / "three" / "four" / "five" / "six" / "seven" / "nine"

グループや構造体、配列の中でも選択肢を使うことはできますが、代わりに//を使うことで、「定義の中で選択肢を作る」ことができます。

; typed-valueは{ "type": "array", value: [0, 1, 2] }か、
; { "type": "object", "value": { "foo": "bar" } }のような値を受け入れる
typed-value = (
  type: "array"
  value: [ + uint ] //
  type: "object"
  value: { * tstr => tstr }
)

また、グループを定義する際に=の代わりに//=を使うことで、既存のグループ定義に選択肢の後付けができます。

typed-value //= (
  type: "int"
  value: int
)

範囲

整数や小数で選択肢を作る際、全ての値を列挙するのは面倒なので、代わりに範囲という記法が用意されています。

範囲は整数、小数のいずれかを用いて表現することができます。 A..B(ドットが2つ)あるいはC...D(ドットが3つ)で定義でき、 ドットが2つの場合は両方の値を含む範囲(A <= X <= B)、3つの場合は後半を含まない範囲(C <= X < D)として定義されます。

なお、前半と後半の型は一致させる必要があるため、整数と小数を混ぜて使うことはできません。

int8 = -128..127 ; 符号つき8ビット整数
range-range = 0.0..1.0 ; 0〜1の小数
zero = 0...1 ; 1は含まない

グループの選択肢化

グループの直前に&を付けることで、そのグループを選択肢化することができます。 グループは丸括弧で記述したものでも、既に定義したグループ名でも構いません。

; colorは黒・白・赤・緑・青のうちいずれのカラーコード
color = &colors
colors = (
  black: 0x000000
  white: 0xFFFFFF
  red:   0xFF0000
  green: 0x00FF00
  blue:  0x0000FF
)
kanto = &(
  chiba: 0, ibaraki: 1, tochigi: 2, gunma: 3,
  saitama: 4, tokyo: 5, kanagawa: 6
)

発生指示子

繰り返しを表現するための指示子で、主に配列やマップ(構造体やテーブル)で使われます。配列では型の前、マップではキーの前に記述します。 以下のルールがあります。

  • *では0個以上の要素
  • +では1個以上の要素
  • ?では0か1個の要素
  • n*mn個以上m個以下(mを含む)の要素
  • *mは0個以上m個以下(mを含む)の要素
  • n*n個以上、上限は無制限の要素
x = * uint ; 構文上はこういうこともできる(グループの省略表現で、`x = ( * uint )`と同じ)
array = [ * uint ] ; 普通の配列
sparse-array = {
  ? length: uint ; lengthプロパティは任意
  *100 uint => uint ; 要素は100個まで
}

グループ

グループとは、簡単に言えばキー・値のペアを並べたものです。 グループは丸括弧(())で括り、その中にキー・値を記述します。

各エントリの間はコンマ(,)で区切ることもできますが、省略することもできます。

group = (
  x: uint, ; コンマで区切ってみる
  y: uint  ; コンマを省略してみる
  z: uint
)

グループは構造体や配列の記法の中に書くことで、そのグループの中身を展開することができます。 なお、配列に書く場合はキー名は省略されて展開されます。

struct = { group } ; structはxとyが含まれる
array = [ group ]  ; arrayは2つのuintが含まれる

細かい話

実のところ、CDDLの型の定義はすべてグループに根差しています。 単純な型に始まり構造体や配列も、全て中にグループが存在しているのと同じで、グループの記述を省略しているに過ぎません。

; type1とtype2、struct1とstruct2、array1とarray2の定義は同義
type1 = ( uint )
type2 = uint
struct1 = {
  (
     x: uint
  )
}
struct2 = {
  x: uint
}
array1 = [ * ( uint ) ]
array2 = [ * uint ]

だからこそ、構造体や配列の定義の中で「グループの中身を展開する」ことができるわけです。

配列

CDDLの配列は発生指示子との併用が前提です。それが無ければタプルの定義しかできません。

配列は角括弧([])で括り、その中に発生指示子と型を記述します。 発生指示子とグループを組み合わせて使うことで、異なる要素の繰り返しもできます。

tstr-array = [ * tstr ]
tu-tuple = [ tstr, uint ] ; 1番目の要素はtstr、2番めの要素はuintであるタプル
tu-array = [ * ( tstr, uint ) ] ; tstr, uint, tstr, uint, ...のように並ぶ配列

マップ

CDDLのマップは構造体と辞書型(テーブル)を同時に定義できる表現能力を持っており、これは(構文こそ別ではあるものの)TypeScriptと酷似しています。

マップは波括弧({})で括り、その中にプロパティを記述します。

一般的な構造体を定義する場合、key: valueという記法を用います。 各プロパティ間は,(コンマ)で区切ることもできますが、省略することもできます。

latlon = {
  latitude: float,  ; 緯度
  longitude: float, ; 経度
}

address = {
  zip: tstr           ; 郵便番号
  country: tstr       ; 国
  city: tstr          ; 都道府県
  state: tstr         ; 市区町村
  lines: [ 1*2 tstr ] ; それより下は1行〜2行
  ? phone: tstr       ; 電話番号(任意)
}

キー名は文字列形式で指定することができ、特殊な文字を含ませることができます。

struct = {
  "x:y": uint
  "a[b]": uint
}

key: valueに似た形式として、key => valueという形式もあります。これは、key部分に型を使用することができる形式です。 基本的には発生指示子と共に使われ、いわゆる「辞書型」を表現できます。

この形式が単体で使われているものを、とりわけ「テーブル」と呼称します。

; キーにも文字列にも任意の文字列が使用できる辞書型
; { "foo": "bar", "hoge": "fuga" }のような値が想定される
string-table = {
  * tstr => tstr
}

逆に言えば、構造体の形式と併せて使うこともできるということです。

; JavaScriptの文字列表現
js-string = {
  length: uint ; 文字列の長さ
  * uint => tstr ; n番目の文字
}

プログラミングの世界において、辞書型というのは一般的に順序を保持しません。 CDDLもそれ同じく順序を意識しないのですが、これが罠になることがあります。

以下の例では梨の個数を必須にし、他の果物の個数も任意で入れられるようにしていると読めます。 しかし、仕様としてはマッチングの順番を保証していないため、pearプロパティがあったとしても、 下の* tstr => uintに先にマッチして上の"pear" => uintにはマッチしない、という動作もありえます。

; 仕様上は{ "pear": 100 }というオブジェクトがこの定義にマッチしない可能性がある
fruits = {
  "pear" => uint
  * tstr => uint
}

また、型が一致しない場合も問題になります。 以下の定義に{ "length": "string" }という値を与える場合、上から順にマッチさせて上のlength => uintにマッチしなくても、 下の* tstr => tstrにマッチしてしまい、辞書の長さだと思ってlengthプロパティにアクセスすると文字列が返ってきてしまいます。

string-dict = {
  ? "length" => uint
  * tstr => tstr   ; `{ "length": "string" }`はこちらにマッチする
}

この挙動を上手く使って前方互換性に役立てることもできますが、一般的にはキーが一致する時点でその定義を見るものだと期待します。 そのため、CDDLには「カット」という機能があります。 カットを使うことで、キー名が一致した時点で後ろのプロパティを見なくなります。 これにより、プロパティ定義の型が正しく一致していることを保証できるのです。

string-dict = {
  ? "length" ^ => uint ; "length"という名前のキーは常にここで評価される
  * tstr => tstr
}

鋭い方は気付いているかもしれませんが、ここではkey: valueではなく"key" => valueの形を用いていました。 実は、key: valueの定義では常にカットの機能が有効になっています。 つまり、この様に定義すれば問題は発生しないわけです。

string-dict {
  ? length: uint   ; "length"という名前のキーは常にここで評価される
  * tstr => tstr
}

アンラッピング

グループと同じ様に、配列や構造体を展開させる記法があります。これにより継承のようなことができます。 これをアンラッピングと呼び、チルダ(~)を型名の前に付けることで記述します。

base = {
  x: uint
  y: uint
}
; derivedは{ x: uint, y: uint, z: uint }と同義
; { "x": 0, "y": 1, "z": 2 }などとマッチする
derived = {
  ~base
  z: uint
}
; derived-arrayは[ x: uint, y: uint, z: uint ]と同義
; 結果、uintを3つ持つ配列となり、[0, 1, 2]などとマッチする
derived-array = [ ~derived ]

ジェネリクス

型を定義する際、型名の直後に山括弧(<>)を記述し、その中に引数となる型名を書くことでジェネリクスが定義できます。 これにより汎用な型を定義できます。

; 型を自由に入れ替えられる座標型
point<t> = {
  x: t
  y: t
}
; 型を使う際、山括弧の中に実際の型を入れることで、`t`が指定した型で置き換えられる
int-point = point<int>

; 引数は複数指定もできる
pair<t1, t2> = [ t1, t2 ]

その他の機能

今回は詳しく紹介しませんが、制御演算子やソケットとプラグという機能もあります。

例えば、制御演算子は値に対する制約を追加するもので、バイト列のサイズ制限や文字列の正規表現(XSD3のもの)による制限などがあります。

; RFCより引用

full-address = [[+ label], ip4, ip6]
ip4 = bstr .size 4
ip6 = bstr .size 16
label = bstr .size (1..63)

; 24ビットの整数、つまり「0...16777216」と同等
audio-sample = uint .size 3

; このルールは「N1@CH57HF.4Znqe0.dYJRN.igjf」という文字列のみ受け入れる
nai = tstr .regexp "[A-Za-z0-9]+@[A-Za-z0-9]+(\\.[A-Za-z0-9]+)+"

より細かい仕様についてはRFCをご参照ください。

ツール

CDDLの周辺ツールをいくつか紹介します。

ランダムなデータの生成

Ruby製のcddlコマンドを使うと、CDDLを元にCBORやJSONを検証する、CDDLを元にダミーデータを生成するといったことができます。 後者に感動したのでここで紹介します。

インストールは他のRuby製ツールと同じ様にgem install cddlで可能です。

まずは以下のようなCDDLファイルをspec.cddlとして用意しましょう。 なお、日本語は使えないみたいなのでコメントなどは削除しておきます。

typed-value = {
  type: "array"
  value: [ + uint ] //
  type: "object"
  value: { * tstr => tstr } //
  type: "int"
  value: int
}

あとはコマンドを実行すると、ダミーデータが生成されます。

$ cddl spec.cddl generate
{"type": "object", "value": {"tic": "tac", "toe": "tic", "tac": "toe"}}

generateのあとに数値を指定すると、その分のデータが生成されます。

$ cddl spec.cddl generate 10
{"type": "object", "value": {"tic": "tac"}}
{"type": "object", "value": {"toe": "tic"}}
{"type": "int", "value": 4426}
{"type": "int", "value": 2840}
{"type": "object", "value": {"tac": "toe", "tic": "tac", "toe": "tic"}}
{"type": "int", "value": -103}
{"type": "object", "value": {"tic": "tac", "toe": "tic", "tac": "toe"}}
{"type": "object", "value": {"toe": "tic", "tac": "toe"}}
{"type": "array", "value": [3774, 310, 595]}
{"type": "array", "value": [971, 2672, 1950, 2563]}

CDDLを検証

CDDLを検証するだけであればWeb上でもできます。 CDDL and WASM demoというサイトを使うと、 ブラウザ上で(サーバーにデータを送信すること無く)CDDLファイルそのものの検証ができます。 ちなみにコードはRustで書かれており、WebAssemblyで動作しています。

こちらはcddlコマンドとは異なり、日本語でも正しく動作します。

色々なサンプル

実際の例がないとさっぱりだと思うので、この章ではいくつかの既存サンプルをもとにCDDLで表現してみます。

ファイルシステムをモデル化

JSON SchemaのModeling a file system with JSON SchemaをCDDLで表現してみます。 今回は紹介していませんが、ファイルパスなどを正規表現で制限するために制御演算子を使用しています。

まずはサンプルのJSONデータです。

{
  "/": {
    "storage": {
      "type": "disk",
      "device": "/dev/sda1"
    },
    "fstype": "btrfs",
    "readonly": true
  },
  "/var": {
    "storage": {
      "type": "disk",
      "label": "8f3ba6f4-5c70-46ec-83af-0d5434953e5f"
    },
    "fstype": "ext4",
    "options": [ "nosuid" ]
  },
  "/tmp": {
    "storage": {
      "type": "tmpfs",
      "sizeInMB": 64
    }
  },
  "/var/www": {
    "storage": {
      "type": "nfs",
      "server": "my.nfs.server",
      "remotePath": "/exports/mypath"
    }
  }
}

fstabスキーマを作る

ルートオブジェクトであるfstabスキーマを作ります。

各マウントポイントのエントリがありますが、/のエントリは必須です。

; entryは後で定義
fstab = {
  ; ルートディレクトリ
  "/": entry,
  ; それ以外
  * tstr .regexp "(/[^/]+)+" => entry,
}

entryスキーマを作る

各マウントポイントを表すentryスキーマを作ります。

entrystorageプロパティが必須で、他にはfstypeoptionsreadonlyプロパティがあります。 JSON Schemaではoptionsプロパティ内のアイテムはユニークである、と宣言していますが、CDDLでは不可能だと思われるので省略します。

entry = {
  storage: {
    ; ディスクデバイス、ディスクのUUID、nfs、tmpfsのいずれかが入る
  }

  ; FSは省略可能で、ext3かext4かbtrfs
  ? fstype: "ext3" / "ext4" / "btrfs"

  ; optionsは省略可能な、要素数1以上の文字列の配列
  ? options: [ + tstr ]

  ; readonlyは省略可能な真偽値
  ? readonly: bool
}

disk-deviceの定義

デバイスは/devにあるので、それを強制するために正規表現を指定します。

disk-device = {
  type: "disk"
  ; デバイスへのパスはこの正規表現に一致するもののみ
  device: tstr .regexp "/dev/[^/]+(/[^/]+)*"
}

disk-uuidの定義

ディスクをUUIDで指定する場合の定義です。

disk-uuid = {
  type: "disk"
  ; labelはUUIDのみ
  label: tstr .regexp "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
}

nfsの定義

JSON Schemaではserverの形式を「ホスト名かIPv4かIPv6」と定義していますが、CDDLではそのような型は定義されていません4。 必要であればhostname = tstr .regexp "..."のように定義し、server: hostname / ipv4 / ipv6のように定義すると良いでしょう。

nfs = {
  type: "nfs"
  remotePath: tstr .regexp "(/[^/]+)+"
  server: tstr
}

tmpfsの定義

tmpfsでは容量を16〜512MBに制限する必要があります。

tmpfs = {
  type: "tmpfs"
  sizeInMB: 16...512
}

完成形

というわけで、これまでの定義の仮部分を埋めつつ1つのCDDLファイルとしてまとめるとこの様になります。

fstab = {
  ; ルートディレクトリ
  "/": entry,
  ; それ以外
  * tstr .regexp "(/[^/]+)+" => entry,
}

entry = {
  storage: storage

  ; FSは省略可能で、ext3かext4かbtrfs
  ? fstype: "ext3" / "ext4" / "btrfs"

  ; optionsは省略可能な、1つ以上の要素数の文字列の配列
  ? options: [ + tstr ]

  ; readonlyは省略可能な真偽値
  ? readonly: bool
}

storage = {
  ; disk-device
  type: "disk"
  ; デバイスへのパスはこの正規表現に一致するもののみ
  device: tstr .regexp "/dev/[^/]+(/[^/]+)*" //

  ; disk-uuid
  type: "disk"
  ; labelはUUIDのみ
  label: tstr .regexp "[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}" //

  ; nfs
  type: "nfs"
  remotePath: tstr .regexp "(/[^/]+)+"
  server: tstr //

  ; tmpfs
  type: "tmpfs"
  sizeInMB: 16...512
}

ダミーデータを生成してみる

作ったCDDLファイルと前述したcddlコマンドを使って、ダミーデータを生成してみます。 ランダムなので見た目としては変ですが、それでも雰囲気は感じられると思います。

$ cddl spec.cddl json-generate
{
  "/": {
    "storage": {
      "type": "disk",
      "device": "/dev/s\u0015\u001a\u0007/XW^\u0017Q/hD18/8\u0000\u0011"
    },
    "fstype": "ext4"
  },
  "/\u00032;u-": {
    "storage": {
      "type": "nfs",
      "remotePath": "/i/;\r#%\u001a/H\u00111/5u\fj_\\/2/\"\u000f",
      "server": "tic"
    }
  },
  "/4ml[/Q=WhNL/uH8.\"/\t)P[+f/\u0007Y/(": {
    "storage": {
      "type": "disk",
      "label": "eEeEAbcb-5de7-fCF6-BD11-BC6Fbdd4064f"
    },
    "fstype": "ext3",
    "options": [
      "tac",
      "toe",
      "tic",
      "tac"
    ]
  },
  "/\u0016/\u001b\u000e?\u001f!": {
    "storage": {
      "type": "tmpfs",
      "sizeInMB": 385
    },
    "options": [
      "toe",
      "tic",
      "tac",
      "toe"
    ],
    "readonly": false
  }
}

JSON Schemaのいくつかのサンプル

JSON SchemaのMiscellaneous ExamplesをCDDLで表現してみます。

基礎

JSONデータ:

{
  "firstName": "John",
  "lastName": "Doe",
  "age": 21
}

CDDL:

person = {
  ; 名
  ? firstName: tstr
  ; 姓
  ? lastName: tstr
  ; 年齢。0以上でなければならない
  ? age: uint
}

地理座標系を記述

JSONデータ:

{
  "latitude": 48.858093,
  "longitude": 2.294694
}

CDDL:

; 地理座標系
geographical-location = {
  latitude: -90.0...90.0
  longitude: -180.0...180.0
}

何かしらの配列

文字列の配列や、オブジェクトの配列を記述する。

JSONデータ:

{
  "fruits": [ "apple", "orange", "pear" ],
  "vegetables": [
    {
      "veggieName": "potato",
      "veggieLike": true
    },
    {
      "veggieName": "broccoli",
      "veggieLike": false
    }
  ]
}

CDDL:

; 人、企業、組織または場所の表現(訳注:概要が定義と違う謎)
arrays = {
  ? fruits: [ * tstr ]
  ? vegetables: [ * veggie ]
}

veggie = {
  ; 野菜の種類
  veggieName: tstr
  ; 野菜が好きか?
  veggieLike: bool
}

ポリモーフィズム

JSON用スキーマだと決定打に欠けるポリモーフィズムの記述ですが、CDDLなら簡単に掛けます。

今回はRustのシリアライズ・デシリアライズライブラリであるserdeのEnum representationsを元に、CDDLで表現してみます。

Rust:

#[derive(Serialize, Deserialize)]
enum Message {
    Request { id: String, method: String, params: Params },
    Response { id: String, result: Value },
}

Externally tagged

タグ名のプロパティに実データが入っています。 正直見たことないタイプですが、シンプルです。

JSONデータ:

{"Request": {"id": "...", "method": "...", "params": {...}}}

CDDL:

message = {
  ; Requestにrequestが入っているものとして解釈するか、
  Request: request //
  ; Responseにresponseが入っているものとして解釈
  Response: response
}

request = {
  id: tstr
  method: tstr
  params: {}
}

response = {
  id: tstr,
  result: {}
}

Internally tagged

実データにタグ用のプロパティが埋め込まれています。 よく使われるタイプだと思います。

Rust:

#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Message {
    Request { id: String, method: String, params: Params },
    Response { id: String, result: Value },
}

JSONデータ:

{"type": "Request", "id": "...", "method": "...", "params": {...}}

CDDL:

message = {
  ; typeが"request"なら、それ以外をrequestとして解釈
  type: "request"
  ~request //
  ; typeが"response"なら、それ以外をresponseとして解釈
  type: "response"
  ~response
}

request = {
  id: tstr
  method: tstr
  params: {}
}

response = {
  id: tstr,
  result: {}
}

Adjacently tagged

タグ用のプロパティとコンテンツ用のプロパティが分かれています。 こちらも時々見ますが、Haskellで一般的だそうです。

Rust:

#[derive(Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
enum Block {
    Para(Vec<Inline>),
    Str(String),
}

JSONデータ:

{"t": "Para", "c": [{...}, {...}]}
{"t": "Str", "c": "the string"}

CDDL:

block = {
  ; tが"Para"ならcをparaとして解釈
  t: "Para"
  c: para //
  ; tが"Str"ならcをstrとして解釈
  t: "Str"
  c: str
}

para = [ * {} ]
str = tstr

Untagged

フィールドの違いを利用して、構造を切り替えるものです。 人間には読みやすいですが、機械には難易度高そうです。

Rust:

#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum Message {
    Request { id: String, method: String, params: Params },
    Response { id: String, result: Value },
}

JSONデータ:

{"id": "...", "method": "...", "params": {...}}

CDDL:

message = {
  ; requestかresponseのどちらかで解釈
  ~request //
  ~response
}

request = {
  id: tstr
  method: tstr
  params: {}
}

response = {
  id: tstr,
  result: {}
}

さいごに

非常に柔軟性が高いCDDLですが、残念なことにサポートされている言語環境は大変少なく、現状はJavaScriptやRuby、Rustに限られています。 正直なところ使える言語がここまで少ないと中々採用しづらいです。

しかしJSON SchemaやSwaggerなどよりも直感的ですし、かつバイナリも表現できるためそのポテンシャルは限りなく高いでしょう。 仕様は小さく、実装するのに難易度は高くないと思われますし、みなさんも一度CDDLを実装してみてはいかがでしょうか?

オプティムでは変幻自在なエンジニアを募集しています。

ライセンス表記


  1. Concise Data Definition Language

  2. Concise Binary Object Representation

  3. XML Schema Definition

  4. 拡張型としてIANAに定義されている場合もあります