ぷりぷりあぷりけーしょんずインフラ担当による ぷりぷりあぷりけーしょんず Advent Calendar 2019 の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クライアントを利用する際に必要となるものを定義します。
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番ポートでアクセスできるようにします。
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のフィールドの型を定義
package com.ppap.model
case class User(id: Int, name: String, pswd: String)
- テーブルの初期設定やCRUDの定義
project-root/src/main/scala/com/ppap/mysql
以下に下記のファイルを追加していきます。
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")
)
}
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()
}
}
}
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の接続情報を定義
# 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に指定する形でリクエストを投げています。
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の操作をするオブジェクトに引数で渡しています。
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
を呼び出しています。
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)
}
}
package com.ppap.controller
import org.scalatra.ScalatraServlet
class HealthCheck extends ScalatraServlet {
get("/") {
views.html.hello()
}
}
Postリクエストなども以下のようにすることで定義できます。
post("/") {
val user = parsedBody.extract[User]
val mysql = new Mysql()
mysql.insert(user.name, user.pswd)
}
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
で行います。
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に下記を追加します。
resolvers += Classpaths.typesafeReleases
mainClass in assembly := Some("com.ppap.app.Launcher")
assemblyJarName in assembly := "ppap.jar"
また下記ファイルを追加します。
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ファイルを実行するだけです。
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
に下記を記入します。
imagePullSecrets:
- name: regcred
これでk8sに作成したDockerイメージを利用したポッドをデプロイできます!