Json4sの使い方と、内部構造についてのいくつかのこと
年明けからJson4sのメンテナーをやっている。
Json4sは、2012年に作られた歴史あるScala用のJSONライブラリで、Apache Sparkで使われていることもあり、割と広く使われている。しかし、ランタイムリフレクションをフルに使った(使いすぎた?)その非常に複雑な構造が足かせとなって、エッジケースの改修が全然追いついていない。そのため、今ではこんな評価となっている(このツイート自体ですら、4年も前…)。
黒魔術なリフレクションAPI使ってるので、値class、type alias、default引数、traitなどJavaにはないScala独自の機能がどうclassファイルにエンコードされてか?の大体を把握しないと完璧にならないし、そんなのを全部メンテできる(したい)コミッターは
— Kenji Yoshida (@xuwei_k) October 10, 2016
いないので、issueはどんどん溜まっていくばかりだし、他の方法で同じ目的を大体達成している他のjsonライブラリ最近は大量にあるのに、今どきjson4s使う積極的な理由ないので、みなさんjson4s使うのやめよう
— Kenji Yoshida (@xuwei_k) October 10, 2016
(blogじゃなくtweetで済ませた)
とはいえ、まだまだ使われているし、古いissueが解決されないままになっているのも良くない。
元々、ScalatraのSwaggerライブラリのコードがJson4sベースだったということもあり、それなりにコードを読み込んでいたので、年末年始のまとまった時間が取れたタイミングで放置されてたissueにコメントしたり、PR作ったりしていたら、メンテナに入れてもらった。
Json4sのメンテナに入れてもらったhttps://t.co/IHFmPCQjqm
— magnoliak🍧 (@magnolia_k_) January 4, 2020
あらためて、JSONについて
一度JSONについてあらためて復習する。
JSON(JavaScript Object Notation)の仕様は、RFC8259で定義されている。
JavaScript Object Notation (JSON) is a text format for the serialization of structured data. It is derived from the object literals of JavaScript, as defined in the ECMAScript Programming Language Standard, Third Edition [ECMA-262].
冒頭にこのように書かれている通り、JavaScriptに由来するデータ構造をシリアライズし、テキストで表現するための規格である。
また、同じ仕様がECMA-404としても規格化されている。
改めて最新のJSONの仕様を見てみると、エンコーディングは必ずUTF-8と決められ、トップレベルのデータ型がobjectやarrayに限定されない(現在では、null
や、"foo"
だけでも有効なJSON)など、初期の頃から意外と変わっていることが分かる。
JSONのデータ構造
簡単におさらいすると、JSONには、object(辞書/連想配列), array(配列), string(文字列), number(数値), boolean(true or false), nullの6種類のデータ型が用意されている。
また、objectやarrayは複数のデータ型を混在して保持でき、かつobejctやarrayは任意の深さでネストできる。
例えば下記のようなデータ構造が作れる(objectの中に、arrayと真偽値の"true"と”null”が混在している)。
{ "foo" : [ "fizz", "buzz", 42, { "wizard" : "Gandalf" } ], "bar" : true, "baz" : null }
この”複数のデータ型が混在できる”、”任意の深さでobjectとarrayをネストできる"という特徴がScalaJSONを扱うときのポイントとなってくる。
各言語の組み込みのデータ構造や、コレクションクラスがサポートするデータ構造は当然JavaScriptと完全に互換性が有るとは限らない。つまり、JavaScriptの言語仕様をベースに作られたJSONを読み込むためにはJSONが表現するデータ構造を再現できる機能を作り込む必要がある。
PerlにおけるJSON
Json4sの解説に入る前に、JavaScriptと同様の動的型付言語であるPerlにおけるJSONの扱いを見てみる。
Perlのコアライブラリに含まれているJSONライブラリJSON::PPのコードを紹介する。このコードはJSON文字列をパースするコードの一部(余計なところは削除している)だが、文字列が'{'
だったらobjectのはじまり、'['
だったら配列の始まりと認識して、それぞれobjectをパースする関数、arrayをパースする関数がコールされていることが分かる。
sub value { return if(!defined $ch); return object() if($ch eq '{'); return array() if($ch eq '['); return string() if($ch eq '"'); return number() if($ch =~ /[0-9]/ or $ch eq '-'); }
実際に配列をパースするコードは以下のようになっていて、最初のmy $a = $_[0] || [];
がポイント。引数無しで呼び出された時は、空の配列へのリファレンスを生成していることが分かる。
sub array { my $a = $_[0] || []; # you can use this code to use another array ref object. if(defined $ch and $ch eq ']'){ --$depth; next_chr(); return $a; } else { while(defined($ch)){ push @$a, value(); if($ch eq ']'){ --$depth; next_chr(); return $a; }
その後、すぐに配列が']'
で閉じられていれば、そのリファレンスを返却しているし、実際のデータが有ればそれを配列に格納(push)して、その配列(リファレンス)を返却している(next_chr
は処理対象の文字を1文字ずつ進める関数、value
は続くデータをパースする関数)。
Perlは動的型付言語であり、組み込みのデータ構造としてハッシュ(JavaScriptのobjectに相当)と、配列(JavaScriptのarrayに相当)を持ちハッシュや、配列には複数のデータ型の混在が許される。また、リファレンスというデータ構造により、任意の深さでハッシュと配列とネストできる。
JSON::PPは、JSONのデータを以下のようにPerlのデータ型へ変換する。
JSONのデータ型 | 対応するPerlのデータ型 |
---|---|
object | ハッシュのリファレンス |
array | 配列のリファレンス |
string | 文字列 |
number | 数値 |
boolean | JSON::PP::Boolean |
null | undef |
Perlには組み込みの真偽値型が存在しないので、if文の中で真偽として使えるJSON::PP::Boolean
というオブジェクトで代替しているが、その他は比較的素直な対応となっている。
特にハッシュリファレンスと、配列リファレンスによりJSONのobjectとarrayがそのまま表現できているところがポイントとなる。
冒頭のJSONデータをJSON::PPで変換すると、以下のようなPerlのデータに変換される。JSONと同じように、複数のデータ型が混在していること、配列の中にハッシュが含まれていることが分かる。
$ref = { 'baz' => undef, 'foo' => [ 'fizz', 'buzz', 42, { 'wizard' => 'Gandalf' } ], 'bar' => bless( do{\(my $o = 1)}, 'JSON::PP::Boolean' ) };
通常のPerlのリファレンスなので、そのままPerlのリファレンスアクセスの構文で値を取り出すことができる。
print $ref->{'foo'}[2]; # prints 42
上記のことから、JSONをPerlのデータ構造に移し替えるのは比較的容易であることが分かってもらえたと思う。
ScalaにおけるJSON
JavaScriptと同様に、組み込み型としてobjectやarrayに相当するデータ型を持ち、かつ動的にネストした構造が作れるPerlの場合、割と素直にJSONを言語組み込みのデータ形式に変換できた(真偽値だけは、固有のオブジェクトを利用したが)。
一方でScalaは静的型付言語であるため、事前に型は一つに決まっている必要がある。JSONを読み取りながら、そのデータの内容に応じて動的にネストしたobjectやarrayに相当するデータ構造を汎用的に作り出すことができない。
例えば、arrayを標準コレクションクラスに用意されているList[Int]
にマッピングすると、Int型のデータしか保持できない。おなじようにobjectをMap[String]
にマッピングすると、String型のデータしか保持できない。
Scalaに予め用意されているデータ型だけを使って混在させることは、できない。
もちろんあらかじめデータ構造を決め打ちにすればできるが、それでは汎用性が失われる。また、全部Any型にしてしまえばできなくはないが、それでは型情報が失われ、Scalaのメリットが失われてしまう。
つまり、Scalaの標準のコレクション型ではJSONが前提とするデータ構造を素直にマッピングすることはできない。
では、どうするか?
上記のList
やMap
はあくまでScalaの標準コレクションライブラリが提供するクラスであり、そのクラスが表現できないデータ構造が必要であれば、作れば良い。
Json4sは、ScalaのクラスとしてJSONの持つ情報量を保持したままScalaであつかえるようなデータ構造、AST(Abstract Syntax Tree)を提供している。
Json4sが提供するASTと、JSONとの対応は以下の通りとなる。
JSONのデータ型 | 対応するJson4sのデータ型 |
---|---|
null | JNull |
string | JString |
number | JNumber |
boolean | JBool |
array | JArray |
object | JObject |
- | JNothing |
最後に追加されているJNothingは、OptionのNone相当であり、JSON文字列のパースに失敗した時などにJson4sが返却するデータ型。
また、実際の定義を抜粋すると、以下のようになる。特に、JObjectやJArrayがどうやって元のJSON情報を保持しているか、よく分かると思う。
object JsonAST { sealed abstract class JValue extends Diff.Diffable with Product with Serializable case object JNothing extends JValue case object JNull extends JValue case class JString(s: String) extends JValue trait JNumber case class JDouble(num: Double) extends JValue with JNumber case class JDecimal(num: BigDecimal) extends JValue with JNumber case class JLong(num: Long) extends JValue with JNumber case class JInt(num: BigInt) extends JValue with JNumber { case class JBool(value: Boolean) extends JValue { type JField = (String, JValue) case class JObject(obj: List[JField]) extends JValue case class JArray(arr: List[JValue]) extends JValue }