Magnolia Tech

いつもコードのことばかり考えている人のために。

Json4sについて、その機能と内部構造

Json4sの使い方と、内部構造についてのいくつかのこと

年明けからJson4sのメンテナーをやっている。

Json4sは、2012年に作られた歴史あるScala用のJSONライブラリで、Apache Sparkで使われていることもあり、割と広く使われている。しかし、ランタイムリフレクションをフルに使った(使いすぎた?)その非常に複雑な構造が足かせとなって、エッジケースの改修が全然追いついていない。そのため、今ではこんな評価となっている(このツイート自体ですら、4年も前…)。

とはいえ、まだまだ使われているし、古いissueが解決されないままになっているのも良くない。

元々、ScalatraのSwaggerライブラリのコードがJson4sベースだったということもあり、それなりにコードを読み込んでいたので、年末年始のまとまった時間が取れたタイミングで放置されてたissueにコメントしたり、PR作ったりしていたら、メンテナに入れてもらった。

あらためて、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

上記のことから、JSONPerlのデータ構造に移し替えるのは比較的容易であることが分かってもらえたと思う。

ScalaにおけるJSON

JavaScriptと同様に、組み込み型としてobjectやarrayに相当するデータ型を持ち、かつ動的にネストした構造が作れるPerlの場合、割と素直にJSONを言語組み込みのデータ形式に変換できた(真偽値だけは、固有のオブジェクトを利用したが)。

一方でScalaは静的型付言語であるため、事前に型は一つに決まっている必要がある。JSONを読み取りながら、そのデータの内容に応じて動的にネストしたobjectやarrayに相当するデータ構造を汎用的に作り出すことができない。

例えば、arrayを標準コレクションクラスに用意されているList[Int]マッピングすると、Int型のデータしか保持できない。おなじようにobjectをMap[String]マッピングすると、String型のデータしか保持できない。

Scalaに予め用意されているデータ型だけを使って混在させることは、できない。

もちろんあらかじめデータ構造を決め打ちにすればできるが、それでは汎用性が失われる。また、全部Any型にしてしまえばできなくはないが、それでは型情報が失われ、Scalaのメリットが失われてしまう。

つまり、Scalaの標準のコレクション型ではJSONが前提とするデータ構造を素直にマッピングすることはできない。

では、どうするか?

上記のListMapはあくまで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
}