お米 is ライス

C#やらUnityやらを勉強していて、これはメモっといたほうがええやろ、ということを書くつもりです

【C#】SpracheでJsonのパーサを実装する方法を1行ずつ解説

下記のサンプルをパースすることを目的とする
サンプルはJSON入門 - とほほのWWW入門様から引用させていただいた(いい感じにいろいろなパターンが詰め込まれているので非常に便利でした)

{
  "color_list": [ "red", "green", "blue" ],
  "num_list": [ 123, 456, 789 ],
  "mix_list": [ "red", 456, null, true ],
  "array_list": [ [ 12, 23 ], [ 34, 45 ], [ 56, 67 ] ],
  "object_list": [
    { "name": "Tanaka", "age": 26 },
    { "name": "Suzuki", "age": 32 }
  ]
}

パーサの書き始め方

Spracheの使い方はあいわかった、じゃあ書いてみるかと思ってもパーサを書いたことが無いから何から始めていいのかわからんかった。
とりあえずパースしようとしている構文がどんな要素から出来ているかを把握する。
そして最小の要素のパーサから書き始めて、一階層ずつ大きくしていくといい感じにするすると実装できた。多分これが正解。
Jsonの最小要素は「数値・文字列・真偽値の値」なので、まずはこれをパースするコードを書いていく。
配列の中身が配列だったりと、一階層ずつ大きくできない(配列のパーサの定義で配列のパーサが必要になる)こともあるが、そういう場合は飛ばしておいて後で追加する感じにすればよい。

数値・文字列・真偽値をパース

数値

  • 数字(Digit)が
  • 少なくとも一文字以上連続している部分(AtLeastOnce())の
  • 文字列(Text())をintに変換した値を返す

小数とか指数表記の実装はさぼる

    public static Parser<object>? numParser = from num in Parse.Digit.AtLeastOnce().Text()
                                              select int.Parse(num) as object;

文字列

  • ダブルクォーテーションで始まり(from open in Parse.Char('"'))、
  • ダブルクォーテーションで終わる(from close in Parse.Char('"'))部分の
  • 文字列(Parse.CharExcept('"').AtLeastOnce().Text())を返す

文字列の中にダブルクォーテーションが入ってる場合は死ぬ

    public static Parser<object>? strParser = from open in Parse.Char('"')
                                              from str in Parse.CharExcept('"').AtLeastOnce().Text()
                                              from close in Parse.Char('"')
                                              select str;

真偽値

  • ダブルクォーテーション無しでtrueかfalsetという文字列であれば(Parse.String("true").Or(Parse.String("false")))
  • 文字列を真偽値に変換した値を返す
    public static Parser<object>? boolParser = from b in Parse.String("true").Or(Parse.String("false")).Text()
                                               select bool.Parse(b) as object;

null

  • ダブルクォーテーション無しでnullという文字列であれば(Parse.String("null"))
  • nullを返す

(nullだけだと型を推論できないので、オブジェクトとして用いる型(Dictionary)を使っておく)

    public static Parser<object>? nullParser = from _ in Parse.String("null").Token()
                                               select (Dictionary<string, object>?)null;

配列をパース

次に大きな要素は配列の値。

  • 角括弧で始まり(from open in Parse.Char('['))
  • 角括弧で終わる(from close in Parse.Char(']'))部分の中に
  • 任意の型の要素が(numParser.Or(strParser).Or.....)
  • カンマ区切りで並べられている(parser.Token().DelimitedBy(Parse.Char(','))ものを
  • 配列として返す

配列の要素が配列だったりオブジェクトだったりするのでarrayParserの定義の中にarrayParserを使ってたり、まだ実装していないオブジェクトのパーサ(objParser)を使ってたりとちょっとややこしい

    public static Parser<IEnumerable<object>>? arrayContent(this Parser<object>? parser) => parser.Token().DelimitedBy(Parse.Char(',').Token());
    public static Parser<IEnumerable<object>>? arrayParser = from open in Parse.Char('[').Token()
                                                             from array in
                                                                 numParser
                                                                 .Or(strParser)
                                                                 .Or(boolParser)
                                                                 .Or(nullParser)
                                                                 .Or(arrayParser)
                                                                 .Or(objParser)
                                                                 .arrayContent()
                                                             from close in Parse.Char(']').Token()
                                                             select array;

KeyとValueの組をパース

値が全て(オブジェクトがまだだけど)パースできたので、次はキーと値の組をパースする

  • セミコロンで区切られた部分の(Parse.Char(':'))
  • 左側の文字列をKeyとし(from key in strParser)
  • 右側の任意の型の値をValueとした(from value in numParser.Or(strParser).Or.....)
  • KeyValuePairを返す
    public static Parser<KeyValuePair<string, object>>? keyValueParser = from key in strParser.Token()
                                                                         from separator in Parse.Char(':').Token()
                                                                         from value in
                                                                             numParser
                                                                             .Or(strParser)
                                                                             .Or(boolParser)
                                                                             .Or(nullParser)
                                                                             .Or(arrayParser)
                                                                             .Or(objParser).Token()
                                                                         select new KeyValuePair<string, object>((string)key, value);

オブジェクトをパース

ここまでやればJson全体をパースすることができる。

  • 中括弧で始まり(from open in Parse.Char('{'))
  • 中括弧で終わる(from close in Parse.Char('}'))部分に
  • カンマ区切りで列挙されているKeyとValueの組を要素とする(from keyValues in keyValueParser.Token().DelimitedBy(Parse.Char(','))
  • Dictionaryを返す
    public static Parser<Dictionary<string, object>>? objParser = from open in Parse.Char('{').Token()
                                                                  from keyValues in keyValueParser.Token().DelimitedBy(Parse.Char(',').Token())
                                                                  from close in Parse.Char('}').Token()
                                                                  select new Dictionary<string, object>(keyValues);

Jsonパーサの出来上がり

出来上がったオブジェクトのパーサ(objParser)がそのままJsonのパーサとなる。
あとはobjParserにパースしたいJson形式のテキストを食わせてやれば辞書型にパースしてくれる。

var obj = objParser.Parse(jsontext);

かなりしっかりとしたJsonのパーサがわずか50行程度(しかも体裁整えるための改行こみこみ)で出来上がってしまった。Sprache(というか構文解析ライブラリ?)めちゃくちゃ便利ですね。

補記

改行とか空白があるので要所要所でToken()するのを忘れないように。

自分がコレクションをいい感じに処理する目的でしかLinq構文を使ったことが無かったので混乱した(from a in arrayA from b in arrayB select (a,b)みたいな感じになると期待した)のだけど、

  • Parse関数を実行したときに文字列を1文字ずつ処理するオブジェクト(Inputクラス)が生成される
  • from * in *されるごとにその塊でパースした結果を変数に保持
  • パースした結果と文字列の処理状況を次のfrom * in *に渡す

という流れを実現するためのSelectManyの仕組みを使っている(ので字義通りにSelect"Many"しているわけではない)と理解したらしっくりときた。
Linqまじ強力だ。