ScalatraのAPIをDockerで動かす

ぷりぷりあぷりけーしょんずインフラ担当による [ぷりぷりあぷりけーしょんず Advent Calendar 2019](https://qiita.com/advent-calendar/2019/ppap) の19日目

# 概要
普段クラウドエンジニアとして、パブリッククラウドを利用してアプリケーションサーバの構築といったインフラ寄りの仕事しています。

ただ個人学習としてミドルウェアをインストールしたり、NWの設定をしたりしてサーバを構築することは楽しいですが、サーバに乗っかっているアプリケーションが出来合いのものだと愛着が湧かないというのが不満でした。

ぷりぷりあぷりけーしょんずは名前の通りアプリケーションエンジニアが多数在籍（9割くらい？）しているサークルなので、サークル活動ではアプリケーションの話を聞くこともあったため、せっかくだし興味のあったScalaで`業務でアプリ開発をしたことないけどなんとなくで`自作してみたというお話です。

# 作ったもの
リクエストで投げるとJsonを返してくるという単純なAPIです。

e.g. ユーザ登録
`curl http://localhost:8080/user -X POST -H "Content-Type: application/json" -d '{"name": "MSHR-Dec", "pswd": "mshr-dec"}'`

e.g サイト登録
`curl http://localhost:8080/site/MSHR-Dec -X POST -H "Content-Type: application/json" -d '{"name": "puri", "pswd": "puripuri"}'`

e.g. 参照
`curl http://localhost:8080/user`
`curl http://localhost:8080/user/MSHR-Dec`
`curl http://localhost:8080/site/MSHR-Dec`

テーマはなんでも良いのですが、わかりやすいようにユーザの名前とパスワード、サイトの名前とパスワードを管理するという設定のAPIを、それぞれ別のプロジェクトで作成して、それらを連携させようというものになっています。
ユーザ側のプロジェクトを作成してしまえば、サイト側の実装はユーザの実装とほとんど同様なので省きます。

## Scalatra
http://scalatra.org/
Sinatraを意識したフレームワークらしいです。

PythonだとDjangoのような規約の多いフレームワークと逆のFlaskのような存在です。

## ディレクトリレイアウト
ディレクトリで終わっているものは基本デフォルトです。
ファイルについては今回編集または新規作成しています。

```
.
├── Dockerfile
├── README.md
├── build.sbt
├── project/
│   ├── assembly.sbt
│   ├── build.properties // デフォルト
│   ├── plugins.sbt // デフォルト
│   ├── project/
│   └── target/
├── src/
│   └── main/
│       ├── resources/
│       │   ├── application.conf
│       │   └── logback.xml // デフォルト
│       ├── scala/
│       │   ├── ScalatraBootstrap.scala
│       │   └── com/
│       │       └── hoge/
│       │           ├── app/
│       │           │   └── Launcher.scala
│       │           ├── controller/
│       │           │   ├── HealthCheck.scala
│       │           │   ├── SiteController.scala
│       │           │   └── UserController.scala
│       │           ├── mysql/
│       │           │   ├── Initialize.scala
│       │           │   ├── Mysql.scala
│       │           │   └── MysqlBase.scala
│       │           ├── model/
│       │           │   └── User.scala
│       │           └── utils/
│       │               └── HttpClient.scala
│       ├── twirl/
│       └── webapp/
└── target/
    ├── scala-2.12/
    │   ├── classes/
    │   ├── resolution-cache/
    │   ├── test-classes/
    │   ├── ppap.jar
    │   └── twirl/
    ├── streams/
    └── test-reports/
```

## インストール
http://scalatra.org/getting-started/first-project.html
SBTを利用できる環境さえ用意してあれば、上記サイトを参考にしてScalatraの環境構築を行うことができます。

```
scala_version [2.12.3]: 2.12.6
sbt_version [1.0.2]: 1.2.1
scalatra_version [2.5.3]: 2.6.5
```
バージョンは上記の設定です。

## ライブラリの追加
build.sbtの`libraryDependencies`を書き換えます。
MySQLへの接続やHTTPクライアントを利用する際に必要となるものを定義します。

```build.sbt
libraryDependencies ++= Seq(
  "org.scalatra" %% "scalatra" % ScalatraVersion,
  "org.scalatra" %% "scalatra-scalatest" % ScalatraVersion % "test",
  "ch.qos.logback" % "logback-classic" % "1.2.3" % "runtime",
  "org.eclipse.jetty" % "jetty-webapp" % "9.4.19.v20190610" % "container",
  "javax.servlet" % "javax.servlet-api" % "3.1.0" % "provided",
  "org.scalatra" %% "scalatra-json" % ScalatraVersion,
  "org.json4s"   %% "json4s-jackson" % "3.5.2",
  "org.scalikejdbc" %% "scalikejdbc" % "3.3.5",
  "org.scalikejdbc" %% "scalikejdbc-config" % "3.3.5",
  "mysql" % "mysql-connector-java" % "8.0.11",
  "org.eclipse.jetty" % "jetty-webapp" % "9.4.20.v20190813",
  "org.scalaj" %% "scalaj-http" % "2.4.2"
)
```

## Scalatraサーバ起動の設定
Scalatraで作成したAPIのDockerイメージ`docker run`で起動した際に、コンテナに8080番ポートでアクセスできるようにします。

```project-root/src/main/scala/com/ppap/app/Launcher.scala
package com.ppap.app

import org.eclipse.jetty.server.Server
import org.eclipse.jetty.servlet.{DefaultServlet, ServletContextHandler}
import org.eclipse.jetty.webapp.WebAppContext
import org.scalatra.servlet.ScalatraListener

object Launcher extends App {
  val port = if(System.getenv("PORT") != null) System.getenv("PORT").toInt else 8080

  val server = new Server(port)
  val context = new WebAppContext()

  context setContextPath "/"
  context.setResourceBase("src/main/webapp")
  context.addEventListener(new ScalatraListener)
  context.addServlet(classOf[DefaultServlet], "/")

  server.setHandler(context)

  server.start
  server.join
}
```

## MySQLとの接続設定
- DBのフィールドの型を定義

```project-root/src/main/scala/com/ppap/User.scala
package com.ppap.model

case class User(id: Int, name: String, pswd: String)
```
- テーブルの初期設定やCRUDの定義

`project-root/src/main/scala/com/ppap/mysql`以下に下記のファイルを追加していきます。

```MysqlBase.scala
package com.ppap.mysql

import scalikejdbc.config.DBs
import com.ppap.model._
import scalikejdbc.WrappedResultSet

trait MysqlBase {

  DBs.setupAll()

  protected val allColumns = (rs: WrappedResultSet) => User(
    id = rs.int("id"),
    name = rs.string("name"),
    pswd = rs.string("pswd")
  )

}
```
```Initialize.scala
package com.ppap.mysql

import scalikejdbc._
import scalikejdbc.config._

class Initialize {

  DBs.setupAll()

  def createTable(): Unit = {

    val value = "admin"

    DB autoCommit { implicit session =>
      SQL("""
        CREATE TABLE IF NOT EXISTS `users` (
          `id` INT NOT NULL AUTO_INCREMENT,
          `name` VARCHAR(32) BINARY UNIQUE,
          `pswd` VARCHAR(32),
          PRIMARY KEY (`id`)
        )
      """).execute.apply()
    }

    DB autoCommit { implicit session =>
      SQL("insert into users (name, pswd) values (?, ?) on duplicate key update name = ?").bind(value, value, value).update.apply()
    }
  }

}
```

```Mysql.scala
package com.ppap.mysql

import scalikejdbc._

import com.ppap.model._

class Mysql extends MysqlBase {

  def insert(name: String, pswd: String): Unit = {
    DB localTx { implicit session =>
      SQL("insert into users (name, pswd) values (?, ?)").bind(name, pswd).update.apply()
    }
  }

  def update(name: String, pswd: String): Unit = {
    DB localTx { implicit session =>
      SQL("update users set pswd = ? where name = ?").bind(pswd, name).update.apply()
    }
  }

  def delete(name: String): Unit = {
    DB localTx { implicit session =>
      SQL("delete from users where name = ?").bind(name).update.apply()
    }
  }

  def select(name: String):Option[User] = {
    DB readOnly { implicit session =>
      SQL("select * from users where name = ?").bind(name).map(allColumns).single.apply()
    }
  }

  def getAll(): List[User] = {
    DB readOnly { implicit session =>
      SQL("select * from users").map(allColumns).list.apply()
    }
  }

}
```
- DBの接続情報を定義

```project-root/src/main/resources/application.conf
# JDBC settings
db.default.driver="com.mysql.cj.jdbc.Driver"

# jdbc:mysql://DBのIPorドメイン:ポート/Database名?characterEncoding=UTF-8
db.default.url="xxx.xxx.xxx.xxx:3306/ppap?characterEncoding=UTF-8"
db.default.user="MSHR-Dec"
db.default.password="mshr-dec"
```
## サイト管理プロジェクトとHTTPで接続
HTTPクライアントを作成します。
`curl http://localhost:8080/site/MSHR-Dec`のユーザ名をDBのidに変更して、URIに指定する形でリクエストを投げています。

```project-root/src/main/scala/com/ppap/utils/HttpClient.scala
package com.ppap.utils

import scalaj.http.Http

class HttpClient {

  def getAllSite(userId: Int) = {
    // サイト管理プロジェクトのIPを指定
    // DockerやK8sであればサービス名を指定
    val getUrl = s"http://fuga:8000/$userId"
    Http(getUrl).
      asString
  }

  def postSite(name: String, pswd: String, userId: Int): Unit = {
    // サイト管理プロジェクトのIPを指定
    // DockerやK8sであればサービス名を指定
    val postUrl = s"http://fuga:8000/$userId"
    val data =s"""{"name": "$name", "pswd": "$pswd"}"""

    Http(postUrl).
      postData(data).
      header("content-type", "application/json").
      asString

  }

}
```
## エンドポイントの定義
`project-root/src/main/scala/com/controller`以下にAPIのエンドポイントの定義をします。
K8sの`ReadinessProbe`などのためにHealthcheck用のエンドポイントも作成すると良いです。

getリクエストのパラメータをJSONに変換して、その値をMySQLの操作をするオブジェクトに引数で渡しています。

```UserController.scala
package com.ppap.controller

import org.scalatra._
import org.json4s._
import org.scalatra.json.JacksonJsonSupport
import org.json4s.jackson.Serialization
import org.json4s.jackson.Serialization.{read, write}

import com.ppap.mysql._

case class User(name: String, pswd: String)

class UserController extends ScalatraServlet with JacksonJsonSupport with MethodOverride {

  protected implicit lazy val jsonFormats: Formats = DefaultFormats

  get("/:name") {
    implicit val formats = Serialization.formats(NoTypeHints)
    val mysql = new Mysql()
    write(mysql.select(params("name")))
  }

}
```
サイト管理プロジェクトへ接続する必要があるため`HttpClient`を呼び出しています。

```SiteController.scala
package com.ppap.controller

import org.scalatra._
import org.json4s._
import org.scalatra.json.JacksonJsonSupport
import org.json4s.jackson.Serialization
import org.json4s.jackson.Serialization.{read, write}

import com.ppap.mysql._
import com.ppap.model._
import com.ppap.utils._

case class Site(name: String, pswd: String)

class SiteController extends ScalatraServlet with JacksonJsonSupport with MethodOverride {

  protected implicit lazy val jsonFormats: Formats = DefaultFormats

  get("/:user") {
    val mysql = new Mysql
    val id = mysql.select(params("user")).get.id

    val httpClient = new HttpClient
    httpClient.getAllSite(id)
  }

}
```
```HealthCheck.scala
package com.ppap.controller

import org.scalatra.ScalatraServlet

class HealthCheck extends ScalatraServlet {

  get("/") {
    views.html.hello()
  }

}
```
Postリクエストなども以下のようにすることで定義できます。

```scala
post("/") {
  val user = parsedBody.extract[User]
  val mysql = new Mysql()
  mysql.insert(user.name, user.pswd)
}
```
```scala
post("/:user") {
  val site = parsedBody.extract[Site]
  val mysql = new Mysql
  val id = mysql.select(params("user")).get.id

  val httpClient = new HttpClient
  httpClient.postSite(site.name, site.pswd, id)
}
```
Scalatraの初期化を`project-root/src/main/scala/ScalatraBootstrap.scala`で行います。

```scala
import org.scalatra._
import javax.servlet.ServletContext

import com.ppap.controller._
import com.ppap.mysql._

class ScalatraBootstrap extends LifeCycle {
  val table = new Initialize
  table.createTable()

  override def init(context: ServletContext) {
    context.mount(new HealthCheck, "/healthcheck")
    context.mount(new UserController, "/user")
    context.mount(new SiteController, "/site")
  }
}
```
# Dockerイメージの作成
## jarファイルの作成
build.sbtに下記を追加します。

```build.sbt
resolvers += Classpaths.typesafeReleases

mainClass in assembly := Some("com.ppap.app.Launcher")

assemblyJarName in assembly := "ppap.jar"
```
また下記ファイルを追加します。

```project-root/project/assembly.sbt
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.9")
```
ここまででjarファイル作成の準備ができたので、下記コマンドを実行してjarファイルを作成します。
`$ sbt assembly`
これで`target/scala-2.12/ppap.jar`というjarファイルが作成されます。
このファイルは`$ java -jar target/scala-2.12/ppap.jar`で実行することが可能です。
## Dockerfileの作成
シンプルにjarファイルを実行するだけです。

```Dockerfile
FROM openjdk:8

ADD /target/scala-2.12/ppap.jar /root/ppap.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "/root/ppap.jar"]
```
あとはDockerfileを元にDockerイメージを作成し、コンテナを実行すればScalatraのAPIを利用することが可能になります。
# まとめ
PythonについてはLambdaやFlaskでのAPI作成をしたことがありますが、Scalaでの開発は初めてでした。
ただScalatraは制約が少なく、割とそれっぽい書き方をしただけですが、APIの作成をすることができました（コードの質はさておき...）。

Scalaはオブジェクト指向と関数型のマルチパラダイムですが、どちらもしっかりと学習したことがないので今回はどちらも生かせていないコードではありますが、今後はもう少し勉強してより見栄えの良いものを投稿できるように精進したいところです！
# おまけ
k8s環境でこのAPIを利用する手順
## DockerHubにイメージをPush
`$ docker build -t ユーザ名/ppap-scala:1.0 .`
`$ docker push ユーザ名/ppap-scala:1.0`
## Secretの作成
`$ docker login`
`$ kubectl create secret docker-registry regcred --docker-server=DockerhubのURL --docker-username=ユーザ名 --docker-password=パスワード`
`--docker-server`の値は`~/.docker/config.json`内にあります。
## ポッドでDockerイメージを利用
Deploymentの`spec.template.spec`に下記を記入します。

```yaml
imagePullSecrets:
  - name: regcred
```
これでk8sに作成したDockerイメージを利用したポッドをデプロイできます！
# 参考URL
- http://scalatra.org/guides/2.6/
- https://blog1.mammb.com/entry/2015/06/02/000411
- http://scalikejdbc.org/documentation/transaction.html