Play Frameworkを使ったWebアプリケーション作成入門3日目

Webアプリケーション作成の3日目は、2日目に作成したアプリケーションにデータベースの処理を組み込んでみましょう。 3日目までの内容を理解できればPlayの基本的な機能を理解したと言えると思います。

サンプルコードは http://github.o-in.dwango.co.jp/Scala/textbook/tree/master/src/example_projects/web-app-3rd-day-textboard にあります。

掲示板の内容をデータベースに保存する

2日目に作成したアプリケーションでは投稿されたメッセージを変数に入れていましたが、この方法にはいくつか問題があります。

  • アプリケーションを終了したらデータが消えてしまう
  • アプリケーションのメモリの容量ぶんしかメッセージを記憶できない
  • 単一のアプリケーションでしかデータを扱うことができない
    • 大量のアクセスを受け付けるために1つのデータベースに対して、複数のフロントサーバーを用意することはよくあります

そこで投稿されたメッセージをデータベースに保存することにします。どのデータベースを使うかですが、今回は簡易的なアプリケーションのため、MySQLやPostgreSQLなどのミドルウェアではなく、H2というJavaのライブラリを使います。 MySQLやPostgreSQLはインストールなどのセットアップに手間がかかりますが、H2はライブラリなので、sbtの依存ライブラリに加えるだけで使うことができます。 SQLにある程度互換性があるので、本格的に開発する場合はMySQLやPostgreSQLに移行することも難しくないでしょう。

データベースを使うために必要なライブラリの追加

まず、データベースを使うため必要なライブラリを build.sbt に追加します。

build.sbt

lazy val root = (project in file(".")).enablePlugins(PlayScala)

scalaVersion := "2.11.11"

val scalikejdbcVersion = "2.5.2"

libraryDependencies ++= Seq(
  jdbc,
  evolutions,
  "org.scalikejdbc"        %% "scalikejdbc"                  % scalikejdbcVersion,
  "org.scalikejdbc"        %% "scalikejdbc-config"           % scalikejdbcVersion,
  "org.scalikejdbc"        %% "scalikejdbc-jsr310"           % scalikejdbcVersion,
  "org.scalikejdbc"        %% "scalikejdbc-play-initializer" % "2.5.1",
  "org.scalatestplus.play" %% "scalatestplus-play"           % "2.0.0" % Test
)

jdbcevolutions はデータベースを扱うためにPlayが提供しているライブラリです。

jdbc は、JavaのデータベースライブラリのJDBCを使ってデータベースにアクセスするための機能や、データベースをテストをするためのライブラリです。

evolutions はデータベースのマイグレーションをおこなうためのライブラリです。データベースのマイグレーションというのはテーブル定義の変更の履歴などを管理する機能で、特にサービスの本番運用開始後に役に立つ機能です。今回はデータベースの初期化のために使います。

データベースへのアクセスは ScalikeJDBC というライブラリを使います。 Play標準では Slick というデータベースライブラリが提供されていますが、ScalikeJDBCのほうがSQLをそのまま記述できてわかりやすいので、今回はScalikeJDBCを使っていきます。

データベースの設定

次に conf/application.conf にデータベースを使うために必要な設定を足します。

conf/application.conf

play.i18n.langs = [ "en", "ja" ]

db.default.driver=org.h2.Driver
db.default.url="jdbc:h2:file:./textboarddb;MODE=MYSQL"

play.modules.enabled += "scalikejdbc.PlayModule"

db.defaultからはじまる設定項目はJDBCでH2を使うための設定です。ここではSQLの解釈などをMySQLと互換で動作するようにしてあります。この設定ではデータベースの内容が textboarddb.mv.db というファイルに保存されます。

また play.modules.enabledscalikejdbc.PlayModule を追加しているのはScalikeJDBCをPlayで使うための設定です。 Playアプリケーションが起動するとそれに合わせてScalikeJDBCが初期化され、使えるようになります。

データベースのテーブル定義

次に conf/evolutions/default/1.sql にデータベースのテーブル定義のSQLを作成します。このSQLはマイグレーションツールのEvolutionによってH2のデータベースに適用されます。

conf/evolutions/default/1.sql

# --- !Ups
CREATE TABLE `posts` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `date` DATETIME NOT NULL,
  `body` VARCHAR(256) NOT NULL,
  PRIMARY KEY (`id`)
);

# --- !Downs
DROP TABLE `posts`;

!Ups にある CREATE TABLE 文によって掲示板に投稿されたメッセージを保存するための posts テーブルを作成します。 posts テーブルはID(id)と日付(date)と本文(body)を保存します。 IDは AUTO_INCREMENT によって自動的に付与されるようにします。

また !Downs には !Ups の逆の動作を巻き戻すような記述を書いておきます。今回は CREATE TABLE だったので DROP TABLE を書きます。

PostRepository の実装をデータベースにする

2日目で作った掲示板の PostRepository は投稿されたメッセージを変数に入れるだけでしたが、ScalikeJDBCを使ってデータベースに保存し、データベースから読み込むように修正します。

package controllers

import scalikejdbc._
import scalikejdbc.jsr310._

object PostRepository {

  def findAll: Seq[Post] = DB readOnly { implicit session =>
    sql"SELECT id, body, date FROM posts".map { rs =>
      Post(rs.long("id"), rs.string("body"), rs.offsetDateTime("date"))
    }.list().apply()
  }

  def add(post: Post): Unit = DB localTx { implicit session =>
    sql"INSERT INTO posts (body, date) VALUES (${post.body}, ${post.date})".update().apply()
  }
}

保存

保存に使う add メソッドはScalikeJDBCを使うと以下のようになります。

  def add(post: Post): Unit = DB localTx { implicit session =>
    sql"INSERT INTO posts (body, date) VALUES (${post.body}, ${post.date})".update().apply()
  }

投稿されたメッセージは INSERT INTO posts (body, date) VALUES (${post.body}, ${post.date}) というSQLで保存されます。 ScalikeJDBCはSQLインターポレーションという機能により、Scalaの値をSQLに埋め込むことができます。またこのSQLインターポレーションにより、Scalaの値に不正なSQLを埋め込むようなSQLインジェクションという攻撃を防止することができます。

更新するようなSQLをScalikeJDBCで実行する場合 DB localTx のブロックで囲む必要があります。これはデータベースのトランザクションを表現しており、複数のSQLを実行する場合でも、ブロックの中で例外が発生したらすべてのSQLがロールバックされます。

読み込み

  def findAll: Seq[Post] = DB readOnly { implicit session =>
    sql"SELECT id, body, date FROM posts".map { rs =>
      Post(rs.long("id"), rs.string("body"), rs.offsetDateTime("date"))
    }.list().apply()
  }

読み込みは SELECT id, body, date FROM posts というSQLでおこないます。このSQLは posts テーブルから全部のレコードの idbodydate のカラムを読み込みます。ここでは全件読み込んでいますが、通常は WHERE 節を使い、取得するデータを制限すると思います。

読み込みのSQLをScalikeJDBCで実行する場合 DB readOnly のブロックで囲む必要があります。

他のコードは2日目のまま動作するので、これで掲示板アプリケーションのデータベース対応は終わりです。

app/controllers/Post.scala にIDを追加する

テーブル定義で投稿されたメッセージにIDを追加したので、Scalaのコードにも追加します。

package controllers

import java.time.OffsetDateTime

case class Post(id: Long, body: String, date: OffsetDateTime)

object Post {
  def apply(body: String, date: OffsetDateTime): Post =
    Post(0, body, date)
}

最初に保存する場合はIDの値を0にしてINSERTするとデータベースのカラムの AUTO_INCREMENT 属性により自動的にIDに値が付与されます。

テストを修正する

テストも2日目のものから少し修正する必要があります。

import org.scalatestplus.play._
import org.scalatestplus.play.guice.GuiceOneServerPerSuite
import org.scalatestplus.play.guice.GuiceOneServerPerTest
import play.api.mvc._
import play.api.inject.guice._
import play.api.routing._
import play.api.routing.sird._

class TextboardSpec extends PlaySpec with GuiceOneServerPerTest with OneBrowserPerSuite with HtmlUnitFactory {

  override def fakeApplication() =
    new GuiceApplicationBuilder()
      .configure(
        "db.default.driver" -> "org.h2.Driver",
        "db.default.url" -> "jdbc:h2:mem:test;MODE=MYSQL")
      .build()

  "GET /" should {
    "何も投稿しない場合はメッセージを表示しない" in {
      go to s"http://localhost:$port/"
      assert(pageTitle === "Scala Text Textboard")
      assert(findAll(className("post-body")).length === 0)
    }
  }

  "POST /" should {
    "投稿したものが表示される" in {
      val body = "test post"

      go to s"http://localhost:$port/"
      textField(cssSelector("input#post")).value = body
      submit()

      eventually {
        val posts = findAll(className("post-body")).toSeq
        assert(posts.length === 1)
        assert(posts(0).text === body)
        assert(findAll(cssSelector("p#error")).length === 0)
      }
    }

    "空のメッセージは投稿できない" in {
      val body = ""

      go to s"http://localhost:$port/"
      textField(cssSelector("input#post")).value = body
      submit()

      eventually {
        val error = findAll(cssSelector("p#error")).toSeq
        assert(error.length === 1)
        assert(error(0).text === "Please enter a message.")
      }
    }

    "長すぎるメッセージは投稿できない" in {
      val body = "too long messages"

      go to s"http://localhost:$port/"
      textField(cssSelector("input#post")).value = body
      submit()

      eventually {
        val error = findAll(cssSelector("p#error")).toSeq
        assert(error.length === 1)
        assert(error(0).text === "The message is too long.")
      }
    }
  }
}

以下の部分でPlayアプリケーションの設定を変更し、H2のデータベースをメモリ内にしています。

  override def fakeApplication() =
    new GuiceApplicationBuilder()
      .configure(
        "db.default.driver" -> "org.h2.Driver",
        "db.default.url" -> "jdbc:h2:mem:test;MODE=MYSQL")
      .build()

これによりテスト毎にデータベースが初期化されるようになるので、正しくテストが動作するようになります。

これでアプリケーションの修正は完了です。

演習問題:予定表アプリケーションをデータベース化する

2日目に作成した予定表アプリケーションの予定をデータベースに保存するようにしてみましょう。

results matching ""

    No results matching ""