FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

JSON Codec を楽しもう、現場で役立つ circe

おつかれさまです。
中途2年目の堀越です。

Webアプリケーションなんかを開発していると、
例として Http Request / Response を処理するのに大抵は JSON を扱いますよね。

わたしは Scala を触り始めてから長らく play-json と歩みを共にしてきたのですが、
最近(今更)、circe を触ってみて大変便利でしたので解説を交えながら紹介していこうかと思います。

circe.github.io

前書き

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.scalatype 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

Request JSON だけだと下記のような感じ。*1

{
  "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 処理の初回実装は技術負債になりがちな実装をしてしまいました。
チームのメンバにコードレビューなどでアドバイスを受けてリファクタリングをした背景があります。
まだまだ、力が足らんなという感じです。

引き続き頑張ります。

*1:必須項目のみ

*2:通知先のチャンネルが存在しなかった場合