1. AKB428

    Posted

    AKB428
Changes in title
+(60分クッキング) Play Framework (Scala)+MySQLでREST APIサーバーを作る
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,273 @@
+Scalaの勉強もかねてPlay FrameworkでDBを利用したREST APIサーバーを作ってみました。
+自分がつまずいたポイントを記述しておきます。
+
+## ソースコード
+
+https://github.com/Project-ShangriLa/sora-playframework-scala
+
+## システム環境
+* ローカル開発環境: MaxOSX + 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にコンバートするまでの処理を記述することになります。
+
+### だいたいの処理フロー
+
+1. URLからパラメーターを取得する場合はパラメーターから値を取得
+2. AnormというDBライブラリを使いselect SQLを実行 
+3. 結果をJSON型に変換
+4. Ok関数に結果を格納しレスポンスとして返却
+
+### 1テーブルを取得しJSON Map化してレスポンスを返す
+
+```scala:controllers/AnimeV1.scala
+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
+ }
+}
+```
+
+ルーティングは引数をとらないのでシンプル
+
+```:conf/routes
+GET /anime/v1/master/cours controllers.AnimeV1.masterList
+```
+
+
+
+
+### 2テーブルを結合しJSON Listでレスポンスを返す
+
+```scala:controllers/AnimeV1.scala
+ 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の値を変換しないとエラーがでます。
+
+ルーティングは引数をとる設定がミソ
+
+```:conf/routes
+GET /anime/v1/master/$year_num<[0-9]{4}+> controllers.AnimeV1.year(year_num)
+```
+
+URLには正規表現が使える、今回引数には西暦を渡すようにしているので4桁の数値とした。
+こうすると4桁の数値以外は受け付けなくなる。
+
+### DBのselect結果が複雑な場合
+
+```scala:controllers/AnimeV1.scala
+ 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ぐらいひっぱってくるのはちょっと気持ち悪い。