Scalaの勉強もかねてPlay FrameworkでDBを利用したREST APIサーバーを作ってみました。
自分がつまずいたポイントを記述しておきます。
ソースコード
システム環境
- ローカル開発環境: MacOSX + MySQL5.5
- 本番環境: CenOS6 + MariaDB5.5
- Play Framework 2.3
- JDK8 (Scala 2.11ではJDK8はテストサポート扱いらしい 標準サポートは JDK6 <= JDK7 )
- Scala 2.11
- IDE: IntelliJ IDEA 14 Ultimate + Scalaプラグイン (Ultimateは有償だがお薦め)
データベースで管理するもの
- 2014〜2015年で放送されているアニメ作品を管理しているデータベース
インストール&プロジェクト作成
最新のPlay Frameworkではactivator経由でインストールすることになっているので公式サイトからactivatorをダウンロードしインストールしておく。
https://www.playframework.com/
インストールが終わったらプロジェクトを作成する(Scalaプロジェクトを選択)
build.sbtにMySQLドライバを追加
name := """sora_scala"""
version := "1.0-SNAPSHOT"
lazy val root = (project in file(".")).enablePlugins(PlayScala)
scalaVersion := "2.11.1"
libraryDependencies ++= Seq(
jdbc,
anorm,
cache,
ws,
"mysql" % "mysql-connector-java" % "5.1.35"
)
Seq( の所に "mysql" % "mysql-connector-java" % "5.1.35" を追加
conf/application.conf に DB接続設定を追加
db.default.driver=com.mysql.jdbc.Driver
db.default.url="jdbc:mysql://localhost/anime_admin_development?characterEncoding=UTF8"
db.default.user="root"
db.default.password=""
構築してあるMySQL or MariaDBの設定を記述する。
anime_admin_development は データベース名
コントローラーを実装する
今回はREST APIサーバーなので返却値はJSONになります。
つまりDBの値からJSONにコンバートするまでの処理を記述することになります。
だいたいの処理フロー
- URLからパラメーターを取得する場合はパラメーターから値を取得
- AnormというDBライブラリを使いselect SQLを実行
- 結果をJSON型に変換
- Ok関数に結果を格納しレスポンスとして返却
1テーブルを取得しJSON Map化してレスポンスを返す
package controllers
import java.util.Date
import anorm._
import play.api.Play.current
import play.api.db._
import play.api.libs.json.{JsString, JsNumber, Json}
import play.api.mvc._
import play.api.Logger
object AnimeV1 extends Controller {
def masterList = Action {
DB.withConnection { implicit c =>
val records = SQL("SELECT * FROM cours_infos ORDER BY id")().map {
row => (row[Int]("id").toString,
Map("id" -> row[Int]("id"), "year" -> row[Int]("year"), "cours" -> row[Int]("cours")))
}.toMap
Ok(Json.toJson(records))
}
}
JSONの一階層をMapで返したい場合はtoMap、配列で返したい場合はtoListにする。
今回はMapで返すのでtoMapにしている。
DBのレコードのPK-IDをMapのキーとしてバリューをレコードデータにしている。
以下のようなデータが返却される。
{
"4": {
"id": 4,
"year": 2014,
"cours": 4
},
"5": {
"id": 5,
"year": 2015,
"cours": 1
}
}
ルーティングは引数をとらないのでシンプル
GET /anime/v1/master/cours controllers.AnimeV1.masterList
2テーブルを結合しJSON Listでレスポンスを返す
def year(year_num: String) = Action {
DB.withConnection { implicit c =>
val cours_infos_records = SQL("SELECT * FROM cours_infos WHERE YEAR = {year_num} ORDER BY id").on("year_num" -> year_num)().map {
row => row[Int]("id")
}.toList
Logger.debug(cours_infos_records.toString())
if (cours_infos_records.size == 0) {
Logger.warn(s"Execute no data request year=$year_num")
Ok(Json.toJson(cours_infos_records))
}
else {
val seq_cours_infos_records: Seq[Int] = cours_infos_records
val bases_records = SQL("SELECT * FROM bases WHERE cours_id IN ({IDS}) ORDER BY id").on("IDS" -> seq_cours_infos_records)().map {
row =>
Map(
"id" -> JsNumber(row[Int]("id")),
"title" -> JsString(row[String]("title")))
}.toList
Ok(Json.toJson(bases_records))
}
}
}
ポイント1 メソッドに引数を渡す
year_numをメソッドの引数として受け取るように実装しています。
この引数はルーティングの設定をすると受け取れるようになるのでコントローラー側では特にパースは不要です。
ポイント2 scalaにreturnはありません
returnはrubyのように省略できるというわけではなく、scalaに記法自体が存在しません。
今回2テーブルをJOINせず、2回SELECTしています。
1回目のSELECTの結果が0件だった場合、そこで処理をやめたいのですがreturnがありません。
ポイント3 PlayのOkメソッドはそこで処理が終わるわけではない
OK(hogehoge)でレスポンスを返すのがPlayフレームワークなのですが、別にOKがreturnの役目を担ってるわけではありません。
一度目のSELECTの結果が0だったらOK(blankJSON)を実行し空のJSON配列を返却しても後続のっ処理は実行されます。
今回は面倒だったのでif ~ else で済ませましたがcase文などもっとスマートな記法はあるようです。
ポイント4 JSONの値がStringやNumberなど混合する場合はJsXXXXで変換する必要がある
今回返すレスポンスは以下のJSONです
[
{
"id": 187,
"title": "魔法少女リリカルなのはViVid"
},
{
"id": 188,
"title": "やはり俺の青春ラブコメはまちがっている。続"
}
]
idは数値、titleは文字列で、結果の型が混合になっています。
この場合JsXXXXメソッド(JsNumberやJsString)でDBの値を変換しないとエラーがでます。
ルーティングは引数をとる設定がミソ
GET /anime/v1/master/$year_num<[0-9]{4}+> controllers.AnimeV1.year(year_num)
URLには正規表現が使える、今回引数には西暦を渡すようにしているので4桁の数値とした。
こうすると4桁の数値以外は受け付けなくなる。
DBのselect結果が複雑な場合
def yearCours(year_num: String, cours: String) = Action {
DB.withConnection { implicit c =>
val cours_infos_records = SQL("SELECT * FROM cours_infos WHERE YEAR = {year_num} AND cours = {cours} ").
on("year_num" -> year_num, "cours" -> cours)().map {
row => row[Int]("id")
}.toList
Logger.debug(cours_infos_records.toString())
if (cours_infos_records.size == 0) {
Logger.warn(s"Execute no data request year=$year_num cours=$cours")
Ok(Json.toJson(cours_infos_records))
}
else {
val seq_cours_infos_records: Seq[Int] = cours_infos_records
val bases_records = SQL("SELECT * FROM bases WHERE COURS_ID IN ({IDS}) ORDER BY ID").on("IDS" -> seq_cours_infos_records)().map {
row =>
Map(
"id" -> JsNumber(row[Int]("id")),
"title" -> JsString(row[String]("title")),
"title_short1" -> JsString(row[String]("title_short1")),
"title_short2" -> JsString(row[String]("title_short2")),
"title_short3" -> JsString(row[String]("title_short3")),
"public_url" -> JsString(row[String]("public_url")),
"twitter_account" -> JsString(row[String]("twitter_account")),
"twitter_hash_tag" -> JsString(row[String]("twitter_hash_tag")),
"cours_id" -> JsNumber(row[Int]("cours_id")),
"sex" -> JsNumber(BigDecimal(row[Option[Int]]("sex").getOrElse(0))),
"sequel" -> JsNumber(BigDecimal(row[Option[Int]]("sequel").getOrElse(0))),
"created_at" -> JsString(row[Date]("created_at").toString),
"updated_at" -> JsString(row[Date]("updated_at").toString)
)
}.toList
Ok(Json.toJson(bases_records))
}
}
}
ポイント5 カラムにNULLを許容している場合の対応
カラムのsexとsequelはNULLを許容しているためNULLが返却されるとAnormがIntに変換しようとしてエラーになります。
これを防ぐにはOptionをかぶせる必要があります。
また、JsNumberはBigDecimalを引数にとるのでクラスを作成するようラップしています。
ポイント6 日付型の対応
おなじみのcreated_at updated_at ですがJSONで返す場合はUNIXTIMEにするか、Stringにするかなどあると思いますが今回はStringで返します。
DBの型クラスはDateとし、import java.util.Dateを加える必要があります。
Play Framework + ScalaでREST APIサーバー作ってみて所感
- 一度REST APIサーバー作るとマイテンプレートとして使えるし入門には最適だと思う
- 慣れると本当に1時間でDB->JSONのRESTサーバーはバシバシ作れそう。
- DBライブラリのAnormが使いずらい。別のもあるらしいけど標準のライブラリ使いたいよね。
- Play Frameworkの日本語の情報少ない
- Play Framework自体の仕組みは素晴らしい、Java/Scalaベースとは思えないほど楽にアプリケーション・サーバーが作れる
- 手軽さではRuby Sinatraなどのスクリプト言語には負けるがJavaの資産を使いたい場合はPlay一択
- Activatorはいろいろjarライブラリを300Mぐらいひっぱってくるのはちょっと気持ち悪い。