おつかれさまです。
中途2年目の堀越です。
Webアプリケーションなんかを開発していると、
例として Http Request / Response を処理するのに大抵は JSON を扱いますよね。
わたしは Scala を触り始めてから長らく play-json と歩みを共にしてきたのですが、
最近(今更)、circe を触ってみて大変便利でしたので解説を交えながら紹介していこうかと思います。
前書き
Scala関西Summitに登壇することになりましたー。
2018.scala-kansai.org
今回の記事は、同イベントでの発表ネタです。
よろしくおねがいします。
会社のお金で大阪に行けるのうれしい。
準備
本題に戻りましょう。
circe を使えるようにするために build.sbt を更新します。
val circeVersion = "0.9.3" ・・・ libraryDependencies ++= Seq( "io.circe" %% "circe-core", "io.circe" %% "circe-generic", "io.circe" %% "circe-parser" ).map(_ % circeVersion)
使い方
公式ドキュメントから主要な箇所を Pick up して紹介していきます。
なお、REPLで動作ログを追いながらの解説になります。
見づらかったらすみません。
パース
circe-parser に パース用のモジュールが実装されています。
scala> import io.circe.parser._ import io.circe.parser._ scala> val jsonString = """{ | "foo" : "foo value", | "bar" : { | "bar_child" : "bar child value" | }, | "array":[ | { "content" : 1 }, | { "content" : 2 }, | { "content" : 3 } | ] | }""" scala> println(parse(jsonString)) Right({ "foo" : "foo value", "bar" : { "bar_child" : "bar child value" }, "array" : [ { "content" : 1 }, { "content" : 2 }, { "content" : 3 } ] })
階層構造や、配列もちゃんとパースしてくれます。
帰り値が Either
で定義されているところがポイントでしょうか。
今回は正常にパースできたので Right
に内包されています。
では、失敗するとどうでしょう。
scala> val invalidJsonString = "Invalid Json" invalidJsonString: String = Invalid Json scala> println(parse(invalidJsonString)) Left(io.circe.ParsingFailure: expected json value got b (line 1, column 1))
失敗したので Left
に内包されて ParsingFailure
という例外が帰ってきているのが確認できます。
メッセージを読むとなんとなく不備のある箇所を明示してくれてそうな感じがします。
変換・抽出
Cursor という概念によって JSON の変換・抽出を行います。
まずは、HCursor を作成しましょう。
io.circe.Json に実装されている cursor という関数を使うだけです。
scala> import io.circe._, io.circe.parser._ import io.circe._ import io.circe.parser._ scala> val jsonString = """{ | "id": "c730433b-082c-4984-9d66-855c243266f0", | "name": "Foo", | "counts": [ | 1, | 2, | 3 | ], | "values": { | "bar": true, | "baz": 100.001, | "qux": [ | "a", | "b" | ] | } | }""" /* 省略 */ scala> val doc: Json = parse(jsonString).getOrElse(Json.Null) doc: io.circe.Json = { "id" : "c730433b-082c-4984-9d66-855c243266f0", "name" : "Foo", "counts" : [ 1, 2, 3 ], "values" : { "bar" : true, "baz" : 100.001, "qux" : [ "a", "b" ] } } scala> val cursor: HCursor = doc.hcursor cursor: io.circe.HCursor = io.circe.cursor.TopCursor@7293c6d6
HCursor の downField という関数が実装されています。
これによって対象のフィールドにフォーカスを当てて抽出を行います。
scala> :paste // Entering paste mode (ctrl-D to finish) val baz: Decoder.Result[Double] = cursor.downField("values").downField("baz").as[Double] val baz2: Decoder.Result[Double] = cursor.downField("values").get[Double]("baz") val qux: Decoder.Result[Seq[String]] = cursor.downField("values").downField("qux").as[Seq[String]] val secondQux: Decoder.Result[String] = cursor.downField("values").downField("qux") .downArray.right.as[String] // Exiting paste mode, now interpreting. baz: io.circe.Decoder.Result[Double] = Right(100.001) baz2: io.circe.Decoder.Result[Double] = Right(100.001) qux: io.circe.Decoder.Result[Seq[String]] = Right(List(a, b)) secondQux: io.circe.Decoder.Result[String] = Right(b)
Decoder.Result[A] について補足すると単に Either をラップした型です。
※ io.circe.Decoder.scala に type Result[A] = Either[DecodingFailure, A]
という宣言がある。
上では正常に抽出できているので Right で帰ってきてます。
続いてカーソルを当てたフィールドに対して変更を加えてみます。
scala> :paste // Entering paste mode (ctrl-D to finish) // name の値を逆文字列にする val reversedNameCursor: ACursor = cursor .downField("name") .withFocus(_.mapString(_.reverse)) // カーソルを一段上の階層に上げて、配列 qux の先頭データを削除してカーソルを右に移動 val deletedHeadArray = reversedNameCursor .up .downField("values") .downField("qux") .downArray .deleteGoRight val maybeJson = deletedHeadArray.top // Exiting paste mode, now interpreting. reversedNameCursor: io.circe.ACursor = io.circe.cursor.ObjectCursor@578e37f2 deletedHeadArray: io.circe.ACursor = io.circe.cursor.ArrayCursor@2448a0f1 maybeJson: Option[io.circe.Json] = Some({ "id" : "c730433b-082c-4984-9d66-855c243266f0", "name" : "ooF", "counts" : [ 1, 2, 3 ], "values" : { "bar" : true, "baz" : 100.001, "qux" : [ "b" ] } })
.deleteGoRight という関数はカーソルが配列に対してフォーカスしてる際に使える関数で、
パラメータを削除した上で、さらにカーソルを右に移動することができる一石二鳥な関数です。
同様に、 deleteGoLeft, deleteGoFirst, deleteGoLast、
削除とは逆に、 setRights, setLefts といった関数も実装されています。
公式ドキュメントでは紹介しておらず、
実際、あまりユースケースも思い浮かばないのですが、
なかなかおもしろい挙動でしたので紹介しました。
Codec(Encode/Decode)
Encode, Decode するためには、Encoder, Decoder という型クラスの
暗黙インスタンスが必要になります。
Encoder[A] は A から Json への変換、
Decoder[A] は Json から Either[DecodingFailure, A] への変換を行います。
なお、Scala 標準ライブラリの大半は circe でサポートしてくれているので、
ある程度はよしなにやってくれます。
scala> import io.circe.syntax._, io.circe.parser.decode import io.circe.syntax._ import io.circe.parser.decode scala> val json = List(1, 2, 3).asJson json: io.circe.Json = [ 1, 2, 3 ] // Json から List[Int] へ scala> val decodedFromJson = json.as[List[Int]] decodedFromJson: io.circe.Decoder.Result[List[Int]] = Right(List(1, 2, 3)) // String から List[Int] へ scala> val decodedFromString = decode[List[Int]](json.noSpaces) decodedFromString: Either[io.circe.Error,List[Int]] = Right(List(1, 2, 3))
プリミティブ型など、Codec処理が circe でサポートされている型をフィールドに持つ case class であれば、
import io.circe.generic.auto._ を宣言するだけです。
scala> import io.circe.syntax._, io.circe.generic.auto._ import io.circe.syntax._ import io.circe.generic.auto._ scala> case class Person(name: String) defined class Person scala> case class Greeting(salutation: String, person: Person, exclamationMarks: Int) defined class Greeting scala> val encoded = Greeting("Hey", Person("Chris"), 3).asJson encoded: io.circe.Json = { "salutation" : "Hey", "person" : { "name" : "Chris" }, "exclamationMarks" : 3 } scala> val decoded = encoded.as[Greeting] decoded: io.circe.Decoder.Result[Greeting] = Right(Greeting(Hey,Person(Chris),3))
Custom Codec
上で紹介した例だけだとカバーしきれないユースケースもあると思います。
その場合は、Encoder, Decoder を独自に定義します。
JSON のフィールド名を明示的に指定したい
case class のフィールド名をそのまま Encode したくないときや、
規約的にスネークケースにしたいときなどあると思います。
そんなときは Encoder, Decoder を定義するのに forProductN を使うと良さそうです。
scala> import io.circe.{ Decoder, Encoder } import io.circe.{Decoder, Encoder} scala> import io.circe.syntax._ import io.circe.syntax._ scala> case class User(id: Long, firstName: String, lastName: String) defined class User scala> :paste // Entering paste mode (ctrl-D to finish) implicit val decoderUser: Decoder[User] = Decoder.forProduct3("id", "first_name", "last_name")(User) implicit val encoderUser: Encoder[User] = Encoder.forProduct3("id", "first_name", "last_name")(u => (u.id, u.firstName, u.lastName)) // Exiting paste mode, now interpreting. decoderUser: io.circe.Decoder[User] = io.circe.ProductDecoders$$anon$3@470c174 encoderUser: io.circe.Encoder[User] = io.circe.ProductEncoders$$anon$3@21ee9cc2 scala> val userJson = User(123, "first name", "last name").asJson userJson: io.circe.Json = { "id" : 123, "first_name" : "first name", "last_name" : "last name" } scala> val decoded = userJson.as[User] decoded: io.circe.Decoder.Result[User] = Right(User(123,first name,last name))
おなじみのTuple22問題の都合だとは思いますが、forProduct1 〜 22 まで用意されています。
独自にCodecを定義したい
Encode の際にそもそものデータ構造を変えたいときや、
その他にひと手間加えたいときなどあると思います。
そんなときは、Encoder, Decoder の実装を拡張すると良さそうです。
scala> :paste // Entering paste mode (ctrl-D to finish) import io.circe.{Decoder, Encoder, HCursor, Json} class Thing(val foo: String, val bar: Int) implicit val encoder: Encoder[Thing] = new Encoder[Thing] { final def apply(t: Thing): Json = Json.obj( ("foo", Json.fromString(s"It's a ${t.foo}")), ("bar", Json.fromInt(t.bar * 1000)) ) } implicit val decoder: Decoder[Thing] = new Decoder[Thing] { final def apply(c: HCursor): Decoder.Result[Thing] = for { foo <- c.downField("foo").as[String] bar <- c.downField("bar").as[Int] } yield { new Thing(foo, bar) } } // Exiting paste mode, now interpreting. import io.circe.{Decoder, Encoder, HCursor, Json} defined class Thing encoder: io.circe.Encoder[Thing] = $anonfun$1@1e9ff15 decoder: io.circe.Decoder[Thing] = $anonfun$2@6caf50b7 scala> new Thing("test", 123) res3: Thing = Thing@9ba29d5 scala> val thing = new Thing("test", 123) thing: Thing = Thing@68656e05 scala> val encoded = thing.asJson encoded: io.circe.Json = { "foo" : "It's a test", "bar" : 123000 } scala> val decoded = encoded.as[Thing] decoded: io.circe.Decoder.Result[Thing] = Right(Thing@5927363e)
他にも自身のインスタンスを生成する関数が、
Encoder, Decoder それぞれに定義されており、割と柔軟に実装できて便利です。
実際に活用してみる
実際の業務で扱った Slack API の chat.postMessage の Codec 処理の実装を紹介したいと思います。
「実際の業務」について詳しくは、以前投稿したエントリを参照ください。
labs.septeni.co.jp
SlackAPI.postMessage の仕様
curl コマンドで実行しようとすると下記のようになります。
$ curl \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json; charset=UTF-8' \ -d '{"channel":"<#xxx or @xxx>","username":"通知くん","text":"通知くんからの通知"}' \ https://slack.com/api/chat.postMessage
{ "channel": "<#xxx or @xxx>", "username": "通知くん", "text": "通知くんからの通知" }
通知に成功した場合のレスポンス。
{ "ok": true, "channel": "<#xxx or @xxx>", "ts": "1536838057.000100", "message": { "text": "通知くんからの通知", "username": "通知くん", "bot_id": "BotのID", "type": "message", "subtype": "bot_message", "ts": "1536838057.000100" } }
通知に失敗すると下記のような感じ。*2
{ "ok": false, "error": "channel_not_found" }
chat.postMessage method | Slack の Errors に失敗ケースの一覧があります。
Request JSON の Encode 処理
case class は下記のようになりそうです。
case class SlackPostMessageRequest( channel: String, username: String, text: String )
薄々勘付いている方もいらっしゃるかもしれませんが、
Encoder の定義は不要で import io.circe.generic.auto._, io.circe.syntax._
を宣言するだけで動作してくれます。
scala> case class SlackPostMessageRequest( | channel: String, | username: String, | text: String | ) defined class SlackPostMessageRequest scala> SlackPostMessageRequest("<#xxx or @xxx>","通知くん","通知くんからの通知") res0: SlackPostMessageRequest = SlackPostMessageRequest(<#xxx or @xxx>,通知くん,通知くんからの通知) scala> .asJson res1: io.circe.Json = { "channel" : "<#xxx or @xxx>", "username" : "通知くん", "text" : "通知くんからの通知" }
Response JSON の Decode処理
意識しておきたいのが成功 or 失敗のケースをそれぞれ考慮しなければならないという点です。
上記を踏まえ Decode 結果を下記のように定義します。
sealed trait SlackAPIResponse case class PostMessageSuccess( ok: Boolean, channel: String, ts: String, message: Message ) extends SlackAPIResponse case object ChannelNotFoundError extends SlackAPIResponse case class UnSupportError(error: String) extends SlackAPIResponse case class Message( text: String, username: String, botId: String, `type`: String, subType: String, ts: String )
ChannelNotFound だった場合にデフォルトのチャンネルに通知しなおす要件があったため切り出してます。
上記を踏まえ、Decoder は下記のように実装してみました。
import io.circe.Decoder.Result import io.circe.generic.auto._ import io.circe.{Decoder, HCursor} object SlackAPIResponse { implicit val decoder: Decoder[SlackAPIResponse] = new Decoder[SlackAPIResponse] { final def apply(c: HCursor): Result[SlackAPIResponse] = for { ok <- c.downField("ok").as[Boolean] maybeErrorString = c.downField("error").as[String].toOption result <- if (ok) c.as[PostMessageSuccess] else Right(buildError(maybeErrorString)) } yield result private def buildError(error: Option[String]): SlackAPIResponse = { error.map { case "channel_not_found" => ChannelNotFoundError case msg => UnSupportError(msg) }.getOrElse(UnSupportError("error does not exist")) } } implicit val messageDecoder: Decoder[Message] = Decoder.forProduct6("text", "username", "bot_id", "type", "subtype", "ts")( Message) }
Decoder の実装が少々めんどうですが、
標準の Scala の実装とうまく組み合わせて使うことで 、
decode 時に成功 or 失敗といった情報を型として表現することができます。
scala> import io.circe.parser.decode import io.circe.parser.decode // 通知成功の Response JSON scala> val postSuccessJsonString = """{ | "ok": true, | "channel": "<#xxx or @xxx>", | "ts": "1536838057.000100", | "message": { | "text": "通知くんからの通知", | "username": "通知くん", | "bot_id": "BotのID", | "type": "message", | "subtype": "bot_message", | "ts": "1536838057.000100" | } | }""" // PostMessageSuccess に Decode される scala> println(decode[SlackAPIResponse](postSuccessJsonString)) Right(PostMessageSuccess(true,<#xxx or @xxx>,1536838057.000100,Message(通知くんからの通知,通知くん,BotのID,message,bot_message,1536838057.000100))) // 通知失敗(channel_not_found) の Response JSON scala> val channelNotFoundJsonString = """{ | "ok": false, | "error": "channel_not_found" | }""" // ChannelNotFoundError に Decode される scala> println(decode[SlackAPIResponse](channelNotFoundJsonString)) Right(ChannelNotFoundError) // 通知失敗(rate_limited) の Response JSON scala> val rateLimitedJsonString = """{ | "ok": false, | "error": "rate_limited" | }""" // UnSupportError に Decode される scala> println(decode[SlackAPIResponse](rateLimitedJsonString)) Right(UnSupportError(rate_limited))
ちゃんと動いてそうです。
まとめ
- パースモジュールは circe に実装済
- 変換・抽出は Cursor を使う
- Scala標準クラスの Codec は circe が良しなにやってくれる
- Encoder / Decoder を定義して Codec を思いのままに
最後に
Decoder を定義するだけで JSON から Scala オブジェクトの生成を実現してくれるあたり大変感動的でした。
今後 JSON Codec には circe を積極的に使っていきたい思いです。
紹介した Slack API の Codec 処理の初回実装は技術負債になりがちな実装をしてしまいました。
チームのメンバにコードレビューなどでアドバイスを受けてリファクタリングをした背景があります。
まだまだ、力が足らんなという感じです。
引き続き頑張ります。