Akka-HTTPでREST APIを作る

Akka-HTTP

Akka-HTTPはAkka-ActorとAkka-StreamsをベースとしたAkkaのHTTP moduleです。
異なるレベルでのAPIを提供してくれているので高レベルから低レベルなところまでカスタムすることができて良いです。
あとはSprayの開発チームがLightbend(旧Typesafe)にそのまま移行して開発している(?)ので割とSprayのAPI、DSLに似ている部分が多かったりしてSpray使ったことがある人は移行しやすいと思います。パフォーマンスは最近はSprayに迫るところまで来ていて実用に足るところまで来ているのでは無いでしょうか。
Akka-HTPPは幾つかのmoduleから成り立っていてそれぞれを軽く説明すると以下の感じになります。

  • akka-http-core: ほとんどが低レベルで構成されていてhttp server, clientのためのmodule(WebSocketsも含む)
  • akka-http: 高レベルな関数やhttp serverのAPI定義などに使用出来るDSLを含んでいるmodule
  • akka-http-testkit: http serverのためのテストツールmodule
  • akka-http-spray-json: JSONのシリアライズ、デシリアライズのためのmodule
  • akka-http-xml: XMLのシリアライズ、デシリアライズのためのmodule

実装

UserのCRUDができるREST APIを作ってみます。
ソースコードはGithubにあげておきました。

sbt

まずはplugin.sbtを書きます。
sbt-assemblyはStand-aloneなjarを作るのに使用します。

logLevel := Level.Warn
addSbtPlugin("io.spray" % "sbt-revolver" % "0.8.0")
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.2")

次にbuild.sbtに必要なライブラリを追記しておきます。

name := "akka-http-standalone"
version := "1.0"
scalaVersion := "2.11.8"
scalacOptions := Seq("-unchecked", "-deprecation", "-encoding", "utf8", "-Xlint")
assemblyOutputPath in assembly := file("./akka-http-standalone.jar")
libraryDependencies ++= {
val akkaV = "2.4.2"
Seq(
"com.typesafe.akka" %% "akka-actor" % akkaV,
"com.typesafe.akka" %% "akka-stream" % akkaV,
"com.typesafe.akka" %% "akka-http-experimental" % akkaV,
"com.typesafe.akka" %% "akka-http-spray-json-experimental" % akkaV,
"com.typesafe.akka" %% "akka-http-xml-experimental" % akkaV,
"com.typesafe.akka" %% "akka-http-testkit" % akkaV
)
}
Revolver.settings

Scala

Routeを書く前にデータやリクエストを定義します、JSONを使いたいのでさらにJsonProtocolも定義します。
jsonFormatの後ろについている1や2はClassのパラメータ数を表しています。

import spray.json.DefaultJsonProtocol
object MyData {
case class User(id: Int, name: String)
case class ErrorResponse(message: String)
case class CreateUserRequest(name: String)
case class UpdateUserRequest(name: String)
}
object JsonProtocol extends DefaultJsonProtocol {
import MyData._
implicit lazy val userFormat = jsonFormat2(User)
implicit lazy val errorResponse = jsonFormat1(ErrorResponse)
implicit lazy val createUserRequestFormat = jsonFormat1(CreateUserRequest)
implicit lazy val updateUserRequest = jsonFormat1(UpdateUserRequest)
}

データの定義が完了したので実際にRouteを書いていきます、ほぼSprayの書き方と同じです。
本来であればRouteとServiceは分離してあった方が良い気がしますが今回は気にせず一体型にします。
JSONを使いたいのでSprayJsonSupportを継承しています、この辺まだSprayのままみたいですね。
先に定義したJsonProtocolをimportしないとcompileが通らないので注意してください。

import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
import akka.http.scaladsl.model._
import akka.http.scaladsl.server.Directives._
trait Route extends SprayJsonSupport {
import MyData._
import JsonProtocol._
implicit val db: DB
val routes =
pathSingleSlash {
get {
// GET localhost:8080
index()
}
} ~
path("ping") {
get {
// GET localhost:8080/ping
complete("pong")
}
} ~
pathPrefix("users") {
pathEndOrSingleSlash {
get {
// GET localhost:8080/users
getUsers()
} ~
post {
// POST localhost:8080/users
entity(as[CreateUserRequest]) { request =>
createUser(request.name)
}
}
} ~
path(IntNumber) { id =>
get {
// GET localhost:8080/users.:id
getUser(id)
} ~
patch {
// PATCH localhost:8080/users.:id
entity(as[UpdateUserRequest]) { request =>
updateUser(id, request.name)
}
} ~
delete {
// DELETE localhost:8080/users.:id
deleteUser(id)
}
}
}
private def index() = complete(
HttpResponse(
entity = HttpEntity(
ContentTypes.`text/html(UTF-8)`,
<html>
<body>
<h1>Welcome to <i>akka-http</i>!</h1>
</body>
</html>.toString
)
)
)
private val userNotFound = "user_not_found"
private def createUser(name: String)(implicit db: DB) =
db.createUser(name) match {
case Left(err) =>
failWith(err)
case Right(user) =>
complete(user)
}
private def getUsers()(implicit db: DB) =
db.getUsers match {
case Left(err) =>
failWith(err)
case Right(users) =>
complete(users)
}
private def getUser(id: Int)(implicit db: DB) =
db.getUser(id) match {
case Left(err) =>
failWith(err)
case Right(None) =>
complete(StatusCodes.NotFound -> ErrorResponse(userNotFound))
case Right(Some(user)) =>
complete(user)
}
private def updateUser(id: Int, name: String)(implicit db: DB) =
db.updateUser(id, name) match {
case Left(err) =>
failWith(err)
case Right(None) =>
complete(StatusCodes.NotFound -> ErrorResponse(userNotFound))
case Right(Some(user)) =>
complete(user)
}
private def deleteUser(id: Int)(implicit db: DB) =
db.deleteUser(id) match {
case Left(err) =>
failWith(err)
case Right(()) =>
complete(StatusCodes.OK)
}
}

indexとpingは関係ないですがサンプルとして追加しました。
リクエストの処理が完了して手っ取り早くOKだけ返したい時はStatusCodes.OKを使ってあげると簡単です。
全体的に特に難しいところもなく割と直感的に書けるところは良いですね。
他に便利なrouting DSLもたくさんあるので公式ドキュメントで適宜探してみると良いと思います。
次にDBのモックをこんな感じに作っておきます。

import MyData.User
trait DB {
def createUser(name: String): Either[Throwable, User]
def getUsers: Either[Throwable, Seq[User]]
def getUser(id: Int): Either[Throwable, Option[User]]
def updateUser(id: Int, name: String): Either[Throwable, Option[User]]
def deleteUser(id: Int): Either[Throwable, Unit]
}
object MockDB extends DB {
private var user_table = Seq[User]()
private var next_user_id = 0
private def nextId() = {
next_user_id += 1
next_user_id
}
def createUser(name: String) = {
val user = User(nextId(), name)
user_table = user_table :+ user
Right(user)
}
def getUsers = Right(user_table)
def getUser(id: Int) = Right(user_table.find(_.id == id))
def updateUser(id: Int, name: String) = {
user_table = user_table.map(u =>
if (u.id == id) u.copy(name = name)
else u
)
getUser(id)
}
def deleteUser(id: Int) = {
user_table = user_table.filterNot(_.id == id)
Right(())
}
}

Boot(Main)を書いていきます。この辺はSprayとちょっと異なる部分なので注意しながらやってみてください。
bindingが失敗したときのためにloggerを仕込んでいます。

import akka.actor.ActorSystem
import akka.event.Logging
import akka.http.scaladsl.Http
import akka.stream.ActorMaterializer
object Boot extends App with Route {
implicit lazy val system = ActorSystem("my-system")
implicit lazy val materializer = ActorMaterializer()
override implicit val db = MockDB
implicit val ec = system.dispatcher
val interface = "localhost"
val port = 8080
val logger = Logging(system, getClass)
val binding = Http().bindAndHandle(routes, interface, port)
binding.onFailure {
case err: Exception =>
logger.error(err, s"Failed to bind to $interface $port")
}
}

ここまできたらsbtでreStartしてみてください、API Serverが立ち上がると思います。
jarが欲しい場合はsbt assemblyでjarが生成されます、Stand-aloneなjarなので扱いやすいのが嬉しいです。

Akka-HTTPは本当にSprayとほぼ同じ感じでかけるので使ったことある人はとっつきやすいと思いました。
現在はパフォーマンスチューニングを中心に開発しているようなのでどこまでSprayに近づけるのか楽しみですね。