Play FrameworkでJSONを簡単に扱う
こんばんは。早速ですが,皆さんはScalaでJSONのシリアライズ,デシリアライズはどのように行っていますか?
僕はたくさん悩んで結局行き着いた方法があるので紹介したいと思います。
僕はこの方法を採用してから大分楽になった気がします。
というか,公式で推奨されている方法がすごくめんどくさくて・・。
PlayでJSONを扱う
1. JSONシリアライズ・デシリアライズのやり方
解説は後回しに,やり方だけ先に書いておこうと思います。
{
"name": "miyatin",
"age": 21,
"lang": ["Scala", "C#", "JavaScript", "PHP"],
"univ": {
"name": "Kobe Univ",
"major": "Engineering",
"grade": 4
}
}
自己紹介も兼ねたJSONファイルを定義してみました。
これをScalaのクラスに落とし込むには次のようにcase classとそのCompanion Objectを定義します。
package util
import play.api.libs.json.Json
case class University(name: String, major: String, grade: Int)
object University {
implicit val jsonWrites = Json.writes[University]
implicit val jsonReads = Json.reads[University]
}
case class Profile(name: String, age: Int, lang: List[String], univ: University)
object Profile {
implicit val jsonWrites = Json.writes[Profile]
implicit val jsonReads = Json.reads[Profile]
}
下準備はこれで終わりです。
あとは実際に JSON⇔Scalaのclass という変換を行う方法です。
package util
import play.api.libs.json.Json
object Main extends App {
val str = """
{
"name": "miyatin",
"age": 21,
"lang": ["Scala", "C#", "JavaScript", "PHP"],
"univ": {
"name": "Kobe Univ",
"major": "Engineering",
"grade": 4
}
}
"""
// 文字列からProfileクラスへ
val profile: Profile = Json.parse(str).validate[Profile]
// ProfileクラスからJSONへ
val profileJson = Json.toJson(profile)
}
Models.scalaのように定義しておけばあとはJson.toJsonなどでJsonに変換できます。
これは入り組んでいても大丈夫です。例えばJSONがProfile型のリストであっても
val profiles: List[Profile] = Json.parse(str).validate[List[Profile]]
このように,validateの型にそれを書いてやれば大丈夫です。
逆もしかりで,List[Profile]であろうが,Json.toJsonにそれを渡してやればJsonになります。
悩んだこと
公式やぐぐって出てくるサイトなどではもちろんJson.wirtes,readsについては言及されています。しかし,case classのメンバーに他class型のメンバーが存在する場合や,Listの場合などについてはやり方が載っておらず,とても苦労しました。
case classのメンバーがすべてString, Int, Boolean, Doubleなどのプリミティブ(ちょっと違うけど)的な型だけであればJson.writesを用いたやり方が載っていたのですが・・。
2. 動作の解説
これはScalaのimplicitについての知識が必要です。
Json.toJson(profile)
これを見るとあたかもtoJsonメソッドは引数を1つ取る関数のように見えます。しかし実際は
def toJson[T](o: T)(implicit tjs: Writes[T]): JsValue
このようにカリー化され2つの引数をとり,2つ目の引数はWritesを受け取ります。
しかし,2つ目の引数はimplicitキーワードが付与されているため,コンパイラは自身の検索範囲内にimplicitな第一引数である値の型のWritesを探します。
私はimplicitの適用範囲を理解しておらず憶測になるのですが,implicitな引数が検索される範囲はその関数が使用されているスコープ内だけでなく,その関数で使用されている型のCompanion Object内も検索範囲内になるようです。
3. case classとは少し構造の違うWrites/Readsが欲しい
classをインスタンス化するときに渡す値は最小限になるべきです。
つまり,インスタンス化のときに渡す値から計算できるような物は関数で定義するべきですよね。
そうなると,Json.writesなどでは初期データ(case classのコンストラクタ引数)のみがシリアライズされる訳ですから,困ったことになります。
さすがにこの場合はJson.writes[T]のような楽をすることはできないです。
しかし,implicitなWritesを定義することで,そのようなクラスもJson.toJsonしたりできます。
case class Number(num: Int) {
def isEven = num%2 == 0
def isOdd = !isEven
def divisors = (1 to num).filter(num%_ == 0)
}
例として整数をうけとり,それに関する値を作るclassというクソみたいなclassを定義します。
これを先ほどの方法でシリアライズすれば,案の定
{"num" : 55}
のようなJSONが出来上がります。メンバーであるisEven, isOdd, divisorsをJSONに含むには自作のWritesが必要です。それは次のように作成します。
package util
import play.api.libs.json.{Json, Writes, Reads}
case class Number(num: Int) {
/* 省略 */
}
object Number {
implicit lazy val jsonWrites = new Writes[Number]{
def writes(n: Number) =
Json.obj(
"number" -> n.num,
"isEven" -> n.isEven,
"isOdd" -> n.isOdd,
"divisors" -> n.divisors
)
}
}
Json.objはScalaにおけるJSON値を作成することのできる関数です。
これにより,自由にwritesやreadsを定義することができます。
readsの場合はJsValueを受け取って,Numberを返す関数を定義するということです。
これは割愛します。やり方はWritesと同様です。
4. 最後に
申し訳ございませんが,1つもテストしてません・・()
いつもやってるやり方なのでミスはないかと思いますが,参考にされた方でうまくいかない方がいらっしゃいましたらコメントください。
私はこの方法により作業効率が飛躍的に向上しました。そして楽しくなりました。
私は普段データベースはMongoDBを使用しており,ドライバーはReactiveMongoを使っているのですが,ReactiveMongoにおいても同じような方法が存在し(BSON⇔Scalaのclass),かなり楽しくデータベースを触ることができます。これも時間があれば執筆したいと思います。