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)

保存されるのは本文と送信時間だけなので bodydate という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のリクエストが来た場合は formbindFromRequest メソッドを使ってリクエストからパラメーターを読み取ります。そして 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 を受け取り、また、国際化のための Messagesimplicit で受け取っています。

国際化メッセージの表示

この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.minLengtherror.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を使ったテストを作成してみましょう。

results matching ""

    No results matching ""