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
)
jdbc
と evolutions
はデータベースを扱うために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.enabled
に scalikejdbc.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
テーブルから全部のレコードの id
、 body
、 date
のカラムを読み込みます。ここでは全件読み込んでいますが、通常は 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日目に作成した予定表アプリケーションの予定をデータベースに保存するようにしてみましょう。