Play Frameworkを使ったWebアプリケーション作成入門2日目
Webアプリケーション作成の2日目は、表示にHTMLを使うようなWebアプリケーションを作成してみましょう。
掲示板を作る
今回作るのは、HTMLのフォームからメッセージを送信すると、HTMLでメッセージが読むことができるアプリケーションにします。掲示板に表示する内容は送信時間と本文だけです。
サンプルコードは http://github.o-in.dwango.co.jp/Scala/textbook/tree/master/src/example_projects/web-app-2nd-day-textboard-without-db にあります。
ルーティングの設定
今回のルーティングは /
に対する GET
で掲示板の表示をおこない /
に対する POST
で掲示板の投稿を受け付けるようにします。
また、ビューの表示で使われるCSSファイルのために public
のファイルを /assets
で参照できるようにします。
conf/routes
GET / controllers.TextboardController.get()
POST / controllers.TextboardController.post()
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
投稿されたメッセージのクラスを作る
まず投稿されたメッセージのクラスを作りましょう。
app/controllers/Post.scala
package controllers
import java.time.OffsetDateTime
case class Post(body: String, date: OffsetDateTime)
保存されるのは本文と送信時間だけなので body
と date
という2つのフィールドを持つケースクラスを作ります。
投稿されたメッセージの保存と取得
投稿されたメッセージの保存と取得をおこなう PostRepository
というオブジェクトを作ります。
app/controllers/PostRepository.scala
package controllers
object PostRepository {
var posts: Seq[Post] = Vector()
def findAll: Seq[Post] = posts
def add(post: Post): Unit = { posts = posts :+ post }
}
データを格納するのは、本来データベースなどを用いたほうがよいのですが、ここではとりあえず var
を使った変数に格納することにします。
コントローラー
次にコントローラーを作っていきます。
コントローラーのコード全体は以下のようになります
app/controllers/TextboardController.scala
package controllers
import java.time.OffsetDateTime
import javax.inject.Inject
import play.api.mvc.Action
import play.api.mvc.Controller
import play.api.data.Form
import play.api.data.Forms._
import play.api.i18n.I18nSupport
import play.api.i18n.MessagesApi
case class PostRequest(body: String)
class TextboardController @Inject()(val messagesApi: MessagesApi) extends Controller with I18nSupport {
private[this] val form = Form(
mapping(
"post" -> text(minLength = 1, maxLength = 10).withPrefix("hogeika")
)(PostRequest.apply)(PostRequest.unapply))
def get = Action { implicit request =>
Ok(views.html.index(PostRepository.findAll, form))
}
def post = Action { implicit request =>
form.bindFromRequest.fold(
error => BadRequest(views.html.index(PostRepository.findAll, error)),
postRequest => {
val post = Post(postRequest.body, OffsetDateTime.now)
PostRepository.add(post)
Redirect("/")
}
)
}
}
PlayのForm
まずPlayのForm部分の説明をします。
private[this] val form = Form(
mapping(
"post" -> text(minLength = 1, maxLength = 10)
)(PostRequest.apply)(PostRequest.unapply))
PlayのFormは、HTTPのフォームリクエストのデータのバリデーションをおこない、値を取り出すことができる機能です。またエラー値をHTMLテンプレートに渡す役割も持ちます。
このコントローラーでは、以下のような目的でFormを使っています。
post
というフォームパラメーターを受け付けるpost
は最低1文字、最高10文字の文字列であるというバリデーションをおこなうPostRequest
に変換する- バリデーションエラーだった場合は、エラーをHTMLテンプレートに伝える
GETのときの動作
GETのリクエストがきた場合は、投稿されたメッセージを全部読み出して、HTMLテンプレートを呼び出します。
def get = Action { implicit request =>
Ok(views.html.index(PostRepository.findAll, form))
}
views.html.index
はHTMLテンプレートの関数になっています。これはPlayで使われているTwirlというテンプレートライブラリによるものです。
Twirlは app/views/index.scala.html
というファイルを views.html.index
という関数に変換します。テンプレートの詳細については後で見ていきます。この views.html.index
に今まで投稿されたメッセージ全部と、エラー情報のない form
を渡すことでHTMLをレスポンスとして返しています。
POSTのときの動作
POSTのリクエストが来た場合は form
の bindFromRequest
メソッドを使ってリクエストからパラメーターを読み取ります。そして fold
メソッドを使って結果を処理しています。
def post = Action { implicit request =>
form.bindFromRequest.fold(
error => BadRequest(views.html.index(PostRepository.findAll, error)),
postRequest => {
val post = Post(postRequest.body, OffsetDateTime.now)
PostRepository.add(post)
Redirect("/")
}
)
}
fold
の第1引数はバリデーションが失敗したときに呼び出す関数を指定します。この関数にはエラー情報が入っている Form
が渡されるので、ここではそれをそのままHTMLテンプレートに渡しています。
第2引数はバリデーションに成功したときに呼び出す関数を指定します。この関数にはPOSTされたデータを PostRequest
に変換したものが渡されます。ここでは PostRepository.add
を使って投稿されたメッセージを保存し GET /
にリダイレクトしています。
HTMLテンプレート
そして、いよいよHTMLテンプレートの部分を見ていきます。
PlayのHTMLテンプレートの詳しい説明は、公式ドキュメントの ScalaTemplates のところにあるので、こちらも合わせて参照してください。
app/views/index.scala.html
@import controllers.Post
@import java.time.format.DateTimeFormatter
@(posts: Seq[Post], form: Form[PostRequest])(implicit messages: Messages)
<!DOCTYPE html>
<html lang="ja">
<head>
<title>Scala Text Textboard</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<link rel="stylesheet" href="/assets/textboard.css">
</head>
<body>
<div class="container">
<h1>@Messages("textboard.title")</h1>
@for(error <- form.errors){
<p class="text-danger" id="error">@Messages(error.message)</p>
}
<form method="POST" action="/" class="form-inline">
<input type="text" class="form-control" id="post" name="post">
<button type="submit" class="btn btn-default">@Messages("textboard.send")</button>
</form>
<table class="table">
<thead>
<tr><th>@Messages("textboard.dateTime")</th><th>@Messages("textboard.message")</th></tr>
</thead>
<tbody>
@for(post <- posts.reverse){
<tr>
<td class="post-date">@{
val formatter = DateTimeFormatter.ofPattern(Messages("textboard.dateFormat"), messages.lang.toLocale)
post.date.format(formatter)
}</td>
<td class="post-body">@post.body</td>
</tr>
}
</tbody>
</table>
</div>
</body>
</html>
テンプレートの引数
コントローラーのところで述べたように、PlayのTwirlというHTMLテンプレートは、1つのファイルが1つの関数に変換されます。
index.scala.html
というファイルは views.html.index
という関数に変換されます。テンプレートの以下の部分はこの関数のパラメーターになっています。
@(posts: Seq[Post], form: Form[PostRequest])(implicit messages: Messages)
つまり、この関数は投稿されたメッセージの集まりの Seq[Post]
とバリデーションエラーが入っていることがある Form
を受け取り、また、国際化のための Messages
を implicit
で受け取っています。
国際化メッセージの表示
このHTMLの文章は、1日目でやったHello Worldの国際化のように、すべてMessagesを使って表示するようにしてます。
<h1>@Messages("textboard.title")</h1>
Twirlは @Messages
のようにHTMLの中で @
が付いている場所をScalaのコードだと解釈します。この記法によりHTMLの中でScalaのプログラミングができるわけです。
Form
に入っているエラーの表示
Form
にはバリデーションに失敗した場合にエラーが入っていることがあるという説明をこれまでしてきましたが、その表示をおこなっているのが以下の部分です。
@for(error <- form.errors){
<p class="text-danger" id="error">@Messages(error.message)</p>
}
@for
により form.errors
にエラーがある場合には error
が取り出され、エラーメッセージを表示します。
@for
はTwirl独自の構文で、基本的にはScalaの for
式と同様の動作をしますが、しばしば直観的でない構文エラー(スペースの有無の違いでエラーになるなど)を引き起こすので注意してください。@if
も同様です。
投稿されたメッセージの表示
掲示板に投稿されたメッセージの表示は以下の部分でおこなっています。
@for(post <- posts.reverse){
<tr>
<td class="post-date">@{
val formatter = DateTimeFormatter.ofPattern(Messages("textboard.dateFormat"), messages.lang.toLocale)
post.date.format(formatter)
}</td>
<td class="post-body">@post.body</td>
</tr>
}
投稿されたメッセージすべてに対してテーブルのカラムが表示されるようになっています。
日時の表示の部分は @{ }
という構文を使い、Scalaのコードで記述しています。
@{ }
は複数行のScalaのコードを書く場合に便利な記法です。
これでHTMLテンプレートの部分は終わりです。
国際化メッセージの設定
最後にメッセージの設定をしましょう。
conf/messages
textboard.title = Textboard
textboard.dateTime = Date
textboard.dateFormat = EEE, dd MMM yyyy HH:mm:ss
textboard.message = Message
textboard.send = Send
error.minLength = Please enter a message.
error.maxLength = The message is too long.
conf/messages.ja
textboard.title = 掲示板
textboard.dateTime = 日時
textboard.dateFormat = yyyy年MM月dd日 HH:mm:ss
textboard.message = メッセージ
textboard.send = 送信
error.minLength = メッセージを入力してください
error.maxLength = メッセージが長すぎます
ここで error.minLength
と error.maxLength
はコントローラーのFormの text(minLength = 1, maxLength = 10)
の条件に違反した場合のメッセージです。Play Framework側で決まっているメッセージなので、この制約を使う場合は設定しておきましょう。
テスト
以上で、HTMLを表示するような掲示板ができました。次はこの掲示板の動作をPlayのSeleniumを使ったテストで確認してみます。
PlayのSeleniumを使ったテストの詳しい説明は、公式ドキュメントの ScalaFunctionalTestingWithScalaTest のところにあるので、こちらも合わせて参照してください。
Seleniumはブラウザを使ってHTMLやJavaScriptをテストするためのライブラリです。ここではHtmlUnitというJavaのテストフレームワークを使ってブラウザの代用をしていますが、実際にGoogle ChromeやFirefoxでテストをおこなうこともできます。ここで注意したいのは、Seleniumは実際にブラウザを使ってテストをおこなうため、テストの実行時に場合によっては時間がかかったり、動作が不規則になったりすることがあるということです。たとえばJavaScriptを実行してHTMLの要素を表示したいときにJavaScriptの実行に時間がかかり、なかなか要素が表示されないということもあります。 Seleniumにある各種wait機能を使って対処するなどしてください。
それではテストを見ていきます
test/TextboardSpec.scala
import org.scalatestplus.play._
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 {
import org.openqa.selenium.htmlunit.HtmlUnitDriver
override def createWebDriver() = {
val driver = new HtmlUnitDriver {
def setAcceptLanguage(lang: String) =
this.getWebClient().addRequestHeader("Accept-Language", lang)
}
driver.setAcceptLanguage("en")
driver
}
"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)
}
}
}
"POST /" should {
"投稿したものが表示される その2" in {
go to s"http://localhost:$port/"
eventually {
val posts = findAll(className("post-body")).toSeq
assert(posts.length === 1)
}
}
"空のメッセージは投稿できない" 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.")
}
}
}
}
GET /
のテスト
"GET /" should {
"何も投稿しない場合はメッセージを表示しない" in {
go to s"http://localhost:$port/"
assert(pageTitle === "Scala Text Textboard")
assert(findAll(className("post-body")).length === 0)
}
}
アプリケーションを立ち上げて、最初にページを取得した場合のテストになっています。ここではページのタイトルを確認し、投稿されたメッセージが1件も表示されてないことを確認しています。
go to
のような表現はSeleniumに対するDSLになっています。様々な表現があるので、わからない表現があったら公式ドキュメントを確認するようにしてください。
POST /
のテスト
"投稿したものが表示される" 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)
}
}
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)
}
eventually
は表示されるまでwaitする書き方です。前に述べたようにSeleniumはブラウザを使ったテストのため、POSTから実際に表示されるまで、こういう書き方で待つ必要があります。
残りのテストは同様なので、説明は省略します。
演習問題:予定表アプリケーション
予定表のアプリケーションを作成してみましょう。作成する予定表には以下の機能があります。
- 予定の作成をする
- 指定した日の予定の一覧を見る
予定は以下の情報が含まれます。
- 予定の名前
- 予定の開始日時
- 予定の終了日時
予定には以下の制約があります。
- 予定の名前は1文字から30文字まで
- 予定の終了日時は予定の開始日時より後でなければならない
予定表には以下のページがあります。
トップ画面
- http://localhost:9000/
- クエリパラメーターがない場合→今日の予定を表示します
- date=yyyy-mm-ddのようなクエリパラメーターがある場合→その日の予定を表示します
予定投稿画面
- http://localhost:9000/add
- 予定を投稿できます
- 投稿された予定が制約を満たしていない場合はエラー表示をしてください
機能が完成したらPlayのSeleniumを使ったテストを作成してみましょう。