Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

akka-httpで得られるJSONレスポンスをcase classに自動マップする方法

調べてみたけどぜんぜん分からなくて3日くらい悩んだのでメモ.

akka-http

まずakka-httpについて.akka-httpは見てのとおりakkaで作られたHTTPライブラリで,client業もserver業もこなせる何でも屋です.じゃあakkaは何かというと,アクターモデルによる非同期・分散処理を実現するライブラリです.akkaは,scalaとjavaで使うことができます.

ところでClient-sideは3階建て構造になっていて,Request-Level Client-Side APIとHost-Level Client-Side APIとConnection-Level Client-Side APIから構成されており,Connection-Levelが一番下で,その上にHost-Levelが,そして最も上にRequest-Levelが載っています.大抵の目的はRequest-Levelで事足り,今回もRequest-Level Client-Side APIを使うことにします.

// build.sbt
val AkkaVersion = "2.6.8"
val AkkaHttpVersion = "10.2.0"

libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % AkkaVersion

libraryDependencies += "com.typesafe.akka" %% "akka-protobuf" % AkkaVersion

libraryDependencies += "com.typesafe.akka" %% "akka-stream" % AkkaVersion

libraryDependencies +=  "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion

JSONライブラリ(Jackson + json4s)

さて,HTTPリクエストをしてJSONのレスポンスが来ることが分かりきっている場合は自動的にJSONをパースしたいですよね. PerlやRubyといったLLに近い言語では,平易なハッシュとしてパースすることが多いのですが,Scalaは強い静的型付けなので適当にやるとMap[String, Any]みたいな悲惨な状態になってしまいます. scalaとしては,あらかじめこういうオブジェクトが返ってくるというのを期待していますから,その型に治めてほしいものです.例えば以下の例みたいに.

// Example case class
case class QueueResult(
    name: String,
    polling_interval: Long,
    max_workers: Long
)

// List[QueueResult] が返ってきてほしい!!!!!

幸運なことに,そのへんをうまくやってくれるJSONライブラリがscalaには数多くあります.今回はJacksonを使うことにします.

// build.sbt
libraryDependencies += "com.fasterxml.jackson.core" % "jackson-core" % "2.10.5"

とはいえJSONライブラリが乱立するとインターフェイスが滅茶苦茶になってしまいます.これでは使いにくいですね.世の中うまくできているもので,JSONライブラリを統一したインターフェイスで扱うためのjson4sというライブラリがあります(こうしてどんどんライブラリが増えていくんですね).今回もこれを使います.

// build.sbt
libraryDependencies += "org.json4s" %% "json4s-jackson" % "3.6.9"

ちなみに,上掲のcase classはFireworqというジョブキューのとあるエンドポイントのレスポンスから持ってきています.なぜかというと,Fireworqを使いたかったからです.

akka-http-json

さて,Jacksonとjson4sを導入すればJSONをパースできるようになるのですが,このままではHTTPリクエストをcase classに変換するまでに以下の手順を踏む必要があります.

  1. akka-httpでリクエストを送信する
  2. 返ってきたレスポンスのbodyをStringに変換する
  3. json4sを使ってStringを目標のcase classに変換する

これを毎回やるのは大変です.

そこで,akka-httpとjsonライブラリとをスムーズに繋ぐライブラリが登場しました.それがakka-http-jsonです.このライブラリはjson4sにも対応しており,akka-httpが持ち帰ったレスポンスを自動的にcase classに変換してくれます.ちなみに,このパースと型変換のことをakkaまわりではunmarshallと呼んでいるようです.

// build.sbt
libraryDependencies += "de.heikoseeberger" %% "akka-http-json4s" % "1.31.0"

実践

とまあここまで言うのは簡単なのですが,巷にまったくサンプルが不足しているので,実際にこれらを合体させるとさっぱりうまくいかず困ってしまいます.こういうライブラリはimplicit conversionで全部なんとかしているようなもんなので,いい具合にimportしてやらなければ動作しませんし,implicit解決パスにうまくオブジェクトが乗らなければunmarshallするための型が通らず失敗します. 実際,うまくいくまで何度もライブラリの実装を見たりcase classの位置を動かしたりしました.

で,たどりついたサンプルがこちらです.

AkkaHttpClient.scala

こちらはakka-httpでgetするためのクライアントです.参考文献のサンプルコードを多分に使っています.

ここではレスポンスコードのパースをしていますが,bodyには手を加えずそのままResponseEntityという型で返しています.

なんでActorSystemが??と思われるかと思います.これは内部的にActorでHTTPまわりを扱っているようなのですが,難解で自分でも良くわかっていないのです.akka-httpはフレームワークを目指さず汎用性を重視するという哲学で設計されており,ストリーミングといった機能も提供しているので,どうしても難解になってしまうんですね.

package akkahttptest

import akka.actor.typed.ActorSystem
import akka.actor.typed.scaladsl.Behaviors
import akka.http.scaladsl.client.RequestBuilding.{Get, Post, Put, Delete}
import akka.http.scaladsl.model.{HttpResponse, StatusCodes, ResponseEntity}

import akka.stream.ActorMaterializer

case class CustomResponse[T](statusCode: Int, status: String, body: T)

object AkkaHTTPClient {
  implicit val system = ActorSystem(Behaviors.empty, "SingleRequest")
  implicit val materializer = ActorMaterializer()(system.classicSystem)

  def get(uri: String) = {
    import akka.http.scaladsl.Http

    implicit val executionContext = system.executionContext

    Http().singleRequest(Get(uri)).map(extractResponse)
  }
  def extractResponse(
      res: HttpResponse
  ): CustomResponse[ResponseEntity] = {
    import scala.language.postfixOps

    val statusCode = res.status.intValue()
    val httpStatus = statusCode match {
      case StatusCodes.OK.intValue                  => "OK"
      case StatusCodes.Created.intValue             => "CREATED"
      case StatusCodes.Unauthorized.intValue        => "UNAUTHORIZED"
      case StatusCodes.BadRequest.intValue          => "BAD_REQUEST"
      case StatusCodes.NotFound.intValue            => "NOT_FOUND"
      case StatusCodes.NoContent.intValue           => "NO_CONTENT"
      case StatusCodes.InternalServerError.intValue => "INTERNAL_SERVER_ERROR"
      case _                                        => "OTHER_STATUS_CODE"
    }

    return CustomResponse(
      statusCode,
      httpStatus,
      res.entity
    )
  }
}

App.scala (クライアント)

こちらが上掲のAkkaHttpClientを呼び出すコードで,エントリポイントです.

package akkahttptest

trait JsonSupport extends de.heikoseeberger.akkahttpjson4s.Json4sSupport {
  // Trait Json4sSupport provides implicit marshaller / unmarshaller from Serialization and Formats.
  implicit val serialization = org.json4s.jackson.Serialization
  implicit val formats =
    org.json4s.jackson.Serialization.formats(org.json4s.NoTypeHints)
  // or: implicit val json4sFormats = org.json4s.DefaultFormats

  // Provides ExecutionContext for unmarshalling
  implicit val materializer = AkkaHTTPClient.materializer
}

// Example case class
case class QueueResult(
    name: String,
    polling_interval: Long,
    max_workers: Long
)

object Main extends App with JsonSupport {
  import scala.concurrent.duration._
  import scala.concurrent.Await
  import scala.language.postfixOps

  implicit val executionContext = scala.concurrent.ExecutionContext.global

  import akka.http.scaladsl.unmarshalling.Unmarshal

  val mappedResult = for {
    result <- AkkaHTTPClient.get("http://localhost:8100/queues")
    parsedBody <- Unmarshal(result.body).to[List[QueueResult]]
  } yield result.copy(body = parsedBody)

  println(Await.result(mappedResult, 10 seconds))
}

一番のキモはUnmarshal(result.body).to[List[QueueResult]]としている箇所ですね.Unmarshal.toはレスポンスを要求された型へと変換しようとしますが,そのためにいくつかのimplicit valueを要求します.そのうち最も重要なのがUnmarshallerです.JSONからcase classへの変換の実態は一手にこのインスタンスに担わされていて,良い感じに型が適合するUnmarshallerがあれば万事型変換ができる,という感じです.つまり,インスタンスさえあればJSONでなくてもよいということです.MessagePackを扱えるUnmarshallerのインスタンスが提供されて型が適合すれば,akka-httpはMessagePackをパースできるというわけです.

で,そのインスタンスは誰が提供するのかというとakka-http-jsonです.akka-http-jsonはそれ自体ではJSONをパースする能力を持たないため,インスタンスをimplicit conversionとして提供します. どういうことかというと,json4sが持っているパース用の値やメソッドを,いい感じにUnmarshallerに変換してしまうことによって,akka-httpへとインスタンスを提供するのです.(着いてこられますか?)

trait JsonSupportでいくつかのimplicit valueを置いているのはまさにこのための処理です!trait JsonSupportde.heikoseeberger.akkahttpjson4s.Json4sSupportを継承しているので,先程述べたakka-http用のUnmarshallerを提供する準備ができています.そこにorg.json4s.jackson.Serializationorg.json4s.jackson.Serialization.formats(org.json4s.NoTypeHints)とをimplicit valueとして置くことで,万事全ての点が繋がり,Unmarshallerのインスタンスが使えるようになるというわけ!

後はJsonSupportを継承したMainオブジェクトでAkkaHTTPClient.get()を呼び出し,そのbodyをUnmarshalに放り込むだけで見事型変換ができるようになります.面倒な変換定義必要無し!

めでたし!このコードを動作させるとhttp://localhost:8100/queuesにアクセスした結果がList[QueueResult]型に変換されて画面に表示されることでしょう!

case classの場所問題

ちょっと待って!変換先のcase classの事も思い出しましょう.実験の結果,どうやらUnmarshallerからすぐ見える位置に変換先のcase class(ここではQueueResult型ですね)が配置されていないと,Unmarshallerはunmarshalのためのインスタンスを生み出せずにコンパイルに失敗してしまうようです.本当は,こうしたunmarshal処理もAkkaHTTPClientオブジェクトに格納してしまったほうが呼び出す側としては綺麗なのですが,うまくいかないようです.綺麗な方法があったら誰か教えて!

まとめ

こうして人類はakka-httpを使ってJSONをcase classに封じ込めて安全に外部APIを呼び出せるようになりましたとさ……

参考文献

qiita.com

github.com

doc.akka.io

github.com

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?