始めに
ここ数ヶ月Scalaを触ってみたので、その成果としてTODOアプリ用のAPIをAkka+AkkaHttp+Slickで組んでみる。
ついでと言うと失礼だけどScalazもかじってみる。
*それぞれのライブラリについての詳細な解説はしんどいので極力省略します。
構成
- IntelliJ 2018
- Scala 2.12.5
- sbt 1.1.4
- Akka 2.5.11
- AkkaHttp 10.1.0
- Slick 3.2.3
- Scalaz 7.2.19
成果物
設計
大まかに設計してみます。
まずは構成図です。それぞれのライブラリを用いて三層チックに構成します。クライアントは当然作りません。
次に大まかなクラス図です。アクターには一応Supervisorをかませます。レポジトリについてはインターフェースと実装を分離する予定です。
本来であればわざわざアクター使う必要がない気もしますが、それを言ったらお終いになってしまうので気にしません。
環境構築
IntelliJにScalaPlugin導入済みとして進めます。
とりあえずテンプレートを使ってもいいけど、今回は空のプロジェクトから作ります。「create new project」から「Scala」「sbt」で新規プロジェクトを作成。
早速、sbt周りを整備します。
まずはsbtプラグイン関係。唐突ですが最終的にDockerImage化したいのでsbt-native-packagerを入れます。
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.4")
次にbuild.sbtを編集。使用するライブラリを追加し、DockerImageでビルドできるように定義します。scalacOptionsはお好みで。
lazy val akkaVersion = "2.5.9"
lazy val akkaHttpVersion = "10.1.0"
lazy val slickVersion = "3.2.3"
lazy val scalazVersion = "7.2.19"
scalaVersion := "2.12.5"
organization := "todo.api"
name := "todo-api"
version := "0.1-SNAPSHOT"
libraryDependencies ++= Seq(
"com.typesafe.akka" %% "akka-actor" % akkaVersion,
"com.typesafe.akka" %% "akka-slf4j" % akkaVersion,
"ch.qos.logback" % "logback-classic" % "1.2.3",
"com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test,
"com.typesafe.akka" %% "akka-stream" % akkaVersion,
"com.typesafe.akka" %% "akka-http" % akkaHttpVersion,
"com.typesafe.akka" %% "akka-http-testkit" % akkaHttpVersion % Test,
"com.typesafe.akka" %% "akka-http-spray-json" % akkaHttpVersion,
"io.spray" %% "spray-json" % "1.3.4",
"com.typesafe.slick" %% "slick" % slickVersion,
"com.typesafe.slick" %% "slick-hikaricp" % slickVersion,
"org.scalaz" %% "scalaz-core" % scalazVersion,
"org.scalaz" %% "scalaz-concurrent" % scalazVersion,
"org.scalaz" %% "scalaz-effect" % scalazVersion,
"org.scalaz" %% "scalaz-iteratee" % scalazVersion,
"org.scalatest" %% "scalatest" % "3.0.5" % Test,
"com.h2database" % "h2" % "1.4.196"
)
mainClass in Compile := Some("todo.api.Main")
dockerBaseImage := "java:8-jdk-alpine"
dockerExposedPorts := Seq(8000)
enablePlugins(JavaAppPackaging, AshScriptPlugin, DockerPlugin)
scalacOptions ++= Seq(
"-deprecation",
"-feature",
"-unchecked",
"-language:_",
"-Xlint",
"-Xfatal-warnings",
"-Ywarn-dead-code",
"-Ywarn-numeric-widen",
"-Ywarn-unused",
"-Ywarn-unused-import",
"-Ywarn-value-discard"
)
この段階でIntelliJのsbtシェルにエラーが出てなければ大丈夫なはず・・・。あと、reloadコマンドを打っておいた方が無難かも。
とりあえずAkka・AkkaHttp・Slickがそれぞれ動くか確かめます。
Hello Akka
まずはAkkaのアクターを動作させてみます。
AkkaはHOCONベースの設定が必須です(Slickも使うけど)。今回はテスト側しか使わないので「src/test/resources」配下に「application.conf」を作成します。詳細な説明はドキュメントを参照してください。
akka {
loggers = ["akka.testkit.TestEventListener"]
loglevel = "DEBUG"
actor {
provider = "akka.actor.LocalActorRefProvider"
}
}
mainのルートパッケージに以下を作成します。
package todo.api
import akka.actor.{Actor, ActorLogging}
object HelloActor {
// 受信用メッセージ
final case class HelloCommand(name: String)
// 返信用メッセージ
final case class HelloReply(message: String)
}
class HelloActor() extends Actor with ActorLogging {
import HelloActor._
override def preStart(): Unit = log.info("starting hello actor.")
override def postStop(): Unit = log.info("stopping hello actor.")
// HelloCommandを受け取ったらHelloReplyを返す
override def receive: Receive = {
case HelloCommand(name) =>
sender() ! HelloReply(s"Hello $name!!")
}
}
次にtestのルートパッケージに以下を作成します。
package todo.api
import akka.actor.{ActorSystem, Props}
import akka.testkit.{TestKit, TestProbe}
import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpecLike}
class HelloActorSpec
extends TestKit(ActorSystem("hello-actor-spec"))
with WordSpecLike
with Matchers
with BeforeAndAfterAll {
import HelloActor._
override def afterAll(): Unit = TestKit.shutdownActorSystem(system)
"hello actor" should {
"hello!!" in {
val probe = TestProbe()
val helloActor = system.actorOf(Props[HelloActor])
helloActor.tell(HelloCommand("ME"), probe.ref)
probe.expectMsg(HelloReply("Hello ME!!"))
}
}
}
sbtシェルからtestコマンドを呼んで、完了すればOKです。
Hello AkkaHttp
続いてAkkaHttpでルーティングを動作させてみます。
まずmainのルートパッケージに以下を作成します。
package todo.api
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
object HelloRoute {
// nameパラメータを受け付けテキストを返信する
val helloRoute: Route = path("hello") {
parameter('name) { name =>
complete(StatusCodes.OK -> s"Hello $name!!")
}
}
}
次にtestのルートパッケージに以下を作成します。
package todo.api
import akka.http.scaladsl.testkit.ScalatestRouteTest
import org.scalatest.{Matchers, WordSpecLike}
class HelloRouteSpec extends WordSpecLike with Matchers with ScalatestRouteTest {
import HelloRoute._
"hello route" should {
"hello!!" in {
Get("/hello?name=ME") ~> helloRoute ~> check {
responseAs[String] shouldBe "Hello ME!!"
}
}
}
}
sbtシェルからtestコマンドを呼んで、完了すればOKです。
Hello Slick
最後にSlickで簡単なCRUDの動作を確認します。
Slickのデータベースインスタンスの生成方法はいくつかありますが、今回はHOCON(application.conf)から生成します。
Hello Akka時に作成した「application.conf」に以下を追記します。
urlにある環境変数についてはドキュメントを参照してください。
hello-slick-db {
dataSourceClass = "slick.jdbc.DriverDataSource"
connectionPool = disabled
properties = {
driver = "org.h2.Driver"
url = "jdbc:h2:mem:todo-api-hello-slick;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false;MODE=MySQL;INIT=runscript from 'src/test/resources/create-hello.sql'"
}
}
加えて、上記のINIT句で指定したデータベースの初期化スクリプトを作成します。
CREATE TABLE IF NOT EXISTS hello(
id INT NOT NULL AUTO_INCREMENT
, name VARCHAR(50)
, PRIMARY KEY (id));
次にmainのルートパッケージに以下を作成します。
package todo.api
import scala.concurrent.{ExecutionContext, Future}
import slick.jdbc.H2Profile.api._
object HelloSlick {
def apply(db: Database)(implicit ec: ExecutionContext): HelloSlick = new HelloSlick(db)
// モデル定義
final case class Hello(id: Int, name: String)
// テーブル定義
class HelloTable(tag: Tag) extends Table[Hello](tag, "hello") {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def name = column[String]("name")
override def * = (id, name) <> (Hello.tupled, Hello.unapply)
}
val helloTable = TableQuery[HelloTable]
}
class HelloSlick(db: Database)(implicit ec: ExecutionContext) {
import HelloSlick._
// 全件取得
def findAll(): Future[Seq[Hello]] = db.run(helloTable.result)
// Id指定取得
def findById(id: Int): Future[Option[Hello]] =
db.run(helloTable.filter(_.id === id).result.headOption)
// 追加
def create(hello: Hello): Future[Hello] =
db.run(helloTable returning helloTable.map(_.id) += hello).map(id => hello.copy(id = id))
// 更新
def update(hello: Hello): Future[Int] =
db.run(helloTable.filter(_.id === hello.id).map(_.name).update(hello.name))
// 削除
def delete(id: Int): Future[Int] = db.run(helloTable.filter(_.id === id).delete)
}
次にtestのルートパッケージに以下を作成します。
package todo.api
import scala.concurrent.ExecutionContext
import org.scalatest.concurrent.ScalaFutures
import org.scalatest.time.{Millis, Seconds, Span}
import org.scalatest.{Matchers, WordSpecLike}
import slick.jdbc.H2Profile.api._
class HelloSlickSpec extends WordSpecLike with Matchers with ScalaFutures {
import HelloSlick._
implicit val executor: ExecutionContext = ExecutionContext.global
override implicit val patienceConfig: PatienceConfig =
PatienceConfig(timeout = Span(5, Seconds), interval = Span(200, Millis))
val db = Database.forConfig("hello-slick-db")
val helloSlick = HelloSlick(db)
"hello slick" should {
"crud!!" in {
// c: create
val created = helloSlick.create(Hello(0, "ME")).futureValue
created.id shouldBe 1
// r: read
val findAll = helloSlick.findAll().futureValue
findAll.size shouldBe 1
val findById = helloSlick.findById(created.id).futureValue
findById.nonEmpty shouldBe true
findById.foreach(a => a.id shouldBe created.id)
// u: update
val updated = helloSlick.update(Hello(created.id, "ME2")).futureValue
updated shouldBe 1
// d: delete
val deleted = helloSlick.delete(created.id).futureValue
deleted shouldBe 1
}
}
}
sbtシェルからtestコマンドを呼んで、完了すればOKです。
終わりに
次回から実際に作成していきます。次回はレポジトリを作成します。