blog.petitviolet.net

Akka-HTTP - spray-jsonの使い方とAkka-HTTP

2016-06-02

QiitaScalaAkka-HTTP

Akka-HTTP で公式にサポートされている JSON ライブラリのspray-jsonの使い方。 JSON Support — Akka Documentation Akka-HTTP と一緒に使いたいので、Marshaller/Unmarshaller として使用法も載せる。

tl;dr

  • クラスのフィールドに独自の型を使わない場合 or 独自の型に対応させて json を入れ子にする場合

    • jsonFormatNを使えば手軽で良い
  • クラスのフィールドと json のフォーマットを変えたい場合

    • RootJsonProtocolを一生懸命実装する
  • Akka-HTTP ではSprayJsonSupportを extends すれば implicit にやってくれる - akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport

基本的な使い方

case class を json に変換、再変換を行う。

case class User(id: Long, name: String)

この case class に対応するRootJsonFormatを用意すればよい。 DefaultJsonProtocolを extends した object 内で実装するとこうなる。

object UserJsonProtocol extends DefaultJsonProtocol {
  implicit val format = jsonFormat2(User.apply)
}

これを使って json <-> case class をするとこんな感じ。

import UserJsonProtocol._

// case class -> json
val user = User(100, "alice")
user.toJson  // {"id":100,"name":"alice"}

// json -> case class
val json = "{\"id\":10,\"name\":\"bob\"}"
json.parseJson.convertTo[User]  // User(10,bob)

ここで使ったjsonFormat2Userクラスのコンストラクタ引数が 2 つであることに対応している jsonFormatNを使用するとフィールド名(コンストラクタの仮引数名)が json の key となる。 フィールド名と json の key を変えたい場合はjsonFormatを使う。

implicit val format: RootJsonFormat[User] = jsonFormat(User.apply, "user_id", "user_name")

こう書くとコンストラクタに対して順番にuser_id, user_nameを割り当てることとなる。 つまり、この様に json <-> case class の関係が変化する。

// case class -> json
val user = User(100, "alice")
user.toJson  // {"user_id":100,"user_name":"alice"}

// json -> case class
val json = "{\"user_id\":10,\"user_name\":\"bob\"}"
json.parseJson.convertTo[User]  // User(10,bob)

akka-http で使う

akka-http と spray-json を組み合わせる。 具体的にはToResponseMarshallerとして spray-json を使うこととなる。 completeにクラスTのオブジェクトを渡して json として返却したい場合、 implicit なRootJsonProtocol[T]をスコープ内に用意しておけば良い。 変換が case class から json への一方向のみの場合、RootJsonWriterだけでも可。 Marshalling/Unmarshalling と json<->case class の対応関係は以下。

  • json -> case class が Unmarshalling

    • RootJsonReaderが必要
  • case class -> json が Marshalling

    • RootJsonWriterが必要

controller 的なところにSprayJsonSupportを extends しておけば implicit にやってくれる。

import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport

// jsonで返したい型
case class User(id: Long, name: String)

// implicitなRootJsonProtocol[User]を用意
implicit val format: RootJsonProtocol[User] = jsonFormat2(User.apply)

val route =
  // GET /user/<name>
  path("user" / ".+".r) { name =>
    val id: Long = new Random().nextInt(100).toLong
    val user: User = User(id, name)

    complete(user)
  }

リクエストとレスポンスはこのようになる。 Content-Type: application/jsonとなっており、body も json になっている。

$ curl localhost:8080/user/alice -i
HTTP/1.1 200 OK
Server: akka-http/2.4.2
Date: Tue, 03 May 2016 10:30:40 GMT
Content-Type: application/json
Content-Length: 33

{
  "id": 90,
  "name": "alice"
}

やや複雑な例

前述の例ではクラスのフィールドの型がいわゆるプリミティブ型だけのものであった。 次は複雑なオブジェクト、クラスのフィールドの型が独自クラスになっているような場合を考える。

クラス定義

例として使うクラス定義を以下に示す。

// IDにまつわる型
trait Identifier[+A] extends Any {
  def value: A
}
object Identifier {
  type IdType = String
}
case class ID[A <: Entity[_]](value: IdType) extends AnyVal with Identifier[IdType]

// Userの親となるEntity
trait Entity[Id <: Identifier[_]] {
  val id: Id
}

// Userのフィールド型
case class Name(value: String) extends AnyVal
case class Email(value: String) extends AnyVal

case class User(id: ID[User], name: Name, email: Email) extends Entity[ID[User]]

単純な RootJsonProtocol を実装

定義したUserクラスを json に変換するためのRootJsonProtocolを実装する。 RootJsonProtocol[User]を実装するためには、各フィールドの型に応じたRootJsonProtocolが必要となる。

object UserJsonProtocol extends DefaultJsonProtocol {
  implicit val userIdFormat = jsonFormat1(ID.apply[User])
  implicit val nameFormat = jsonFormat1(Name.apply)
  implicit val emailFormat = jsonFormat1(Email.apply)
  implicit val userFormat = jsonFormat3(User.apply)
}

このRootJsonProtocol群を使ってUserクラスのオブジェクトを json に変換してみる。

import UserJsonProtocol._
val user = User(ID("alice-id"), Name("alice"), Email("alice@example.com"))
user.toJson

結果として以下の様な json が得られる。

{
  "id": {
    "value": "alice-id"
  },
  "name": {
    "value": "alice"
  },
  "email": {
    "value": "alice@example.com"
  }
}

入れ子になってしまっているが、オブジェクトをクラス定義に沿って正しく serialize していると言える。

akka-http での使用

上記のjsonFormatNを使ったRootJsonFormatの実装だと、なぜか akka-http ではうまくいかず以下の様なエラーメッセージが出力されてレスポンスは返ってこない。

Caused by: java.lang.RuntimeException: Cannot automatically determine case class field names and order for ‘net.petitviolet.domain.user.Name’, please use the ‘jsonFormat’ overload with explicit field name specification

メッセージにある通り、明示的にjsonFormatを使う。

object UserJsonProtocol extends DefaultJsonProtocol {
  implicit val userIdFormat = jsonFormat(ID.apply[User] _, "value")
  implicit val nameFormat = jsonFormat(Name.apply _, "value")
  implicit val emailFormat = jsonFormat(Email.apply _, "value")
  implicit val userFormat = jsonFormat(User.apply, "id", "name", "email")
}

これで上と同様な json が返ってくるようになる。

フラットな json を作る

入れ子になったクラスを serialize すると入れ子になった json が返ってきた。 入れ子になったvalueが必要でない場合、jsonFormatNを使った実装では実現できない。 そのため、RootJsonProtocol[User]を自前で実装することとなる。

object UserJsonProtocol extends DefaultJsonProtocol {
  implicit val userFormat: RootJsonFormat[User] = new RootJsonFormat[User] {
    override def read(json: JsValue): User =
      json.asJsObject.getFields("id", "name", "email") match {
        case Seq(JsString(id), JsString(name), JsString(email)) =>
          User(ID(id), Name(name), Email(email))
        case _ => throw new DeserializationException("User")
      }

    override def write(user: User): JsValue = JsObject(
      "id" -> JsString(user.id.value),
      "name" -> JsString(user.name.value),
      "email" -> JsString(user.email.value)
    )
  }
}

これを使ってUserを json に変換するとフラットな json が得られる。

{
  "id": "alice-id",
  "name": "alice",
  "email": "alice@example.com"
}

from: https://qiita.com/petitviolet/items/79f2bd3b4f1d54d38db1