Help us understand the problem. What is going on with this article?

(60分クッキング) Play Framework (Scala)+MySQLでREST APIサーバーを作る

More than 3 years have passed since last update.

Scalaの勉強もかねてPlay FrameworkでDBを利用したREST APIサーバーを作ってみました。
自分がつまずいたポイントを記述しておきます。

ソースコード

https://github.com/Project-ShangriLa/sora-playframework-scala

システム環境

  • ローカル開発環境: 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にコンバートするまでの処理を記述することになります。

だいたいの処理フロー

  1. URLからパラメーターを取得する場合はパラメーターから値を取得
  2. AnormというDBライブラリを使いselect SQLを実行 
  3. 結果をJSON型に変換
  4. Ok関数に結果を格納しレスポンスとして返却

1テーブルを取得しJSON Map化してレスポンスを返す

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でレスポンスを返す

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結果が複雑な場合

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ぐらいひっぱってくるのはちょっと気持ち悪い。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away