Scala * PlayでWebアプリ制作 ~バージョンの罠にはまる~

始めまして、Adways Engineers Diaryでお世話になっているさんちゃんです。
今回は、業務でScalaを使うことになったので、チュートリアルも兼ねて、
イベント管理Webアプリを作成しました。
基本的には下記のサイトを参考にさせて頂きました。

tech-sketch.jp

作成にあたり、バージョンの違いなどで個人的にハマった箇所があったので、
今回はそのあたりを中心に紹介していこうと思います。

今回の環境

  • Scala 2.11.8
  • Slick 3.1.1
  • Play 2.5.10
  • Docker 1.12.5
  • Docker-compose 1.8.0

イベント管理Webアプリ

機能としては大きく分けて以下の4つです。

  • イベント登録
  • イベント検索
  • イベント編集
  • イベント削除

その中でも、参考にしたサイトと異なるポイントが多かったイベント作成機能と
イベント検索機能を中心に紹介していきます。
最後には今回開発したコードも載せておきますので、興味がある方はご覧ください。

イベント登録

さて、イベントを管理するにあたり、最初にイベントを登録しないといけません。
その為、イベントを登録できる機能を開発しました。
そこで、ハマったポイントをここで紹介します。
/app/controllers/event/EventCreate.scala

import play.api.i18n.{I18nSupport, MessagesApi}

class EventCreate @Inject() (val messagesApi: MessagesApi) extends Controller with I18nSupport {

/app/views/eventCreate.scala.html

@(eventForm: Form[EventForm])(implicit messages: Messages)

まずはこの部分。
参考にしたサイトでは特に記載がありませんでしたが、これはPlayの2.4以降から
国際化APIに対応したので、その為の記述です。
私の実行している環境はPlay2.5になるので、この部分を見つけ出すのに
結構苦労しましたorz

qiita.com

こちらに詳しく載っているので良かったらご覧ください。

また、viewの部分でも苦労しました。
/app/views/eventCreate.scala.html

@import helper.twitterBootstrap._

参考にしたサイトでは、helperメソッドのtwitterBootstrapというものを使って
いましたが、Playのバージョン2.3以降は使えないようで、今回は使いませんでした。
https://www.playframework.com/documentation/2.3.x/Migration23#Twitter-Bootstrap
2.3への移行ガイドに載っていました。

バージョンに翻弄され、イベント登録するのも一苦労でしたorz

イベント検索

続いては登録したイベントを確認するために、イベント検索機能が必要です。
イベント検索機能でも、落とし穴がありました・・・
/app/models/Event.scala (参考サイト)

  def find(eventId: String, eventNm: String): List[Event] = database.withTransaction { implicit session: Session =>
    var q = events.sortBy(_.eventNm)
    q = if (!(eventId.isEmpty)) q.filter(_.eventId === eventId) else q
    q = if (!(eventNm.isEmpty)) q.filter(_.eventNm like ("%" + eventNm + "%")) else q
    return q.invoker.list
  }

参考にしたサイトのこの部分ですが、invokerが罠でした。
まったく分からなかったので、とりあえず ‘Slick3 invoke’ で検索w
するとアップグレードガイドがありました!

http://krrrr38.github.io/slick-doc-ja/v3.0.out/%E3%82%A2%E3%83%83%E3%83%97%E3%82%B0%E3%83%AC%E3%83%BC%E3%83%89%E3%82%AC%E3%82%A4%E3%83%89.html

これによると、invokerはSlick3以降では使えないようです。
Slick3からはクエリの実行を非同期で行うことが出来るようになったので、
その影響かも知れませんね。
/app/models/Event.scala (自分)

  // 検索
  def find(eventId: String, eventNm: String): Seq[Event] = {
    var q = events.sortBy(_.eventNm)
    q = if (!(eventId.isEmpty)) q.filter(_.eventId === eventId) else q
    q = if (!(eventNm.isEmpty)) q.filter(_.eventNm like ("%" + eventNm + "%")) else q
    Await.result(database.run(q.result), Duration.Inf)
  }

先ほどの問題を解決するべく、このようなコードで実装しました。
ほとんど同じですが、実際にクエリを実行するrunの部分で、resultを呼んでいます。
これは、クエリから実際に実行するアクションを生成する為のものです。
また、runだけではなく、Awaitを先頭に付けているのは、Slick3で非同期になったので、結果が返ってくるまで待機する為です。

ここを乗り越えたらイベント検索機能は完成です。

実行&挙動確認

イベント管理Webアプリにはほかにも更新、削除がありましたが、ここまでで紹介した内容を
うまく使えば、それほど苦労することは無かったので割愛します。
ということで、いよいよ今回のイベント管理Webアプリ動作確認です。

早速実行しましょう。

f:id:AdwaysEngineerBlog:20170203124514p:plain

ここで使用した、activator ~runですが、ここにもバージョンの違いが・・・
playのバージョン2.3以降からはactivatorを使うようになったようです。

www.task-notes.com

こちらに詳しい導入方法等があります。
~run に関しては、プロジェクトが更新されたタイミングで自動的に再読み込み
してくれるやつです。
さて、successが出たので早速登録してみましょう。

f:id:AdwaysEngineerBlog:20170203124537p:plain

これで検索すると・・・

f:id:AdwaysEngineerBlog:20170203124547p:plain

いい感じですね。
しっかりと登録されていました。
今回は検索条件を絞っていなかったので、全件表示されています。
なので、検索条件を絞って検索してみます。

f:id:AdwaysEngineerBlog:20170203124605p:plain

インターンを含むものにヒットするので、上手く絞れています。
ついでに、このインターンを2回目に編集してみます。
編集は、×アイコンの隣にある、鉛筆マークのアイコンを押下することで可能です。

f:id:AdwaysEngineerBlog:20170203124615p:plain

更新ボタンを押下したら、本当に変更されているか検索で見てみます。

f:id:AdwaysEngineerBlog:20170203124625p:plain

アドウェイズインターン2回目になっていますね。
最後は、アドウェイズインターン2回目を削除します。 先ほどの編集アイコンの隣にある×アイコンです。
本当に削除されたか検索で確認してみましょう。

f:id:AdwaysEngineerBlog:20170203124634p:plain

条件は絞っていませんが、最初から入っていたエンジニアダイアリー・・・
だけになっていますね。上手く削除出来たようです。

ということで、無事に挙動が確認できました。

おわりに

さて、いかがだったでしょうか?
今までは業務でPerlを使っていたので、Scalaは今回初めてでした。
関数型というところで文法的な難しさもありましたが、それ以上に
バージョン違いで、なかなか上手く実行できないことが苦しかったですw
資料に関してもそれほど多くなく、最新の環境での資料が少なかった印象です。
今回私が試した環境に近い環境でScala * Playフレームワークを試そう!
と思われた方の参考になれば幸いです。
今後も ADWAYS ENGINEERS BLOGとAdways Engineers Diaryを
よろしくお願いしますっ!

今回のコード

/app/controllers/event/EventCreate.scala

package controllers.event

import play.api._
import play.api.mvc._
import play.api.data.Form
import play.api.data.Forms._
import javax.inject.Inject
import play.api.i18n.{I18nSupport, MessagesApi}
import models.{ EventForm, Event, Events }


class EventCreate @Inject() (val messagesApi: MessagesApi) extends Controller with I18nSupport {

  // イベントフォーム
  val eventForm = Form(
    mapping(
      "eventId"   -> nonEmptyText,
      "eventNm"   -> nonEmptyText)(EventForm.apply)(EventForm.unapply))

  // 初期表示
  def index = Action {
    Ok(views.html.eventCreate(eventForm))
  }

  // 登録
  def create = Action { implicit request =>
    eventForm.bindFromRequest.fold(
      hasErrors = { form =>
        Ok(views.html.eventCreate(form))
      },
      success = { form =>
        val event = Event(0, form.eventId, form.eventNm)
        Events.create(event)
        Redirect(controllers.event.routes.EventCreate.index())
      }
    )
  }

  // テーブル作成
  def createTable = Action {
    Events.createTable
    Ok(views.html.eventCreate(eventForm))
  }

  // テーブル削除
  def dropTable = Action {
    Events.dropTable
    Ok(views.html.eventCreate(eventForm))
  }
}

/app/controllers/event/EventSearch.scala

package controllers.event

import javax.inject.Inject
import play.api.data.Form
import play.api.data.Forms._
import play.api.i18n.{I18nSupport, MessagesApi}
import play.api.mvc._
import models.{EventForm, Events}


class EventSearch @Inject() (val messagesApi: MessagesApi) extends Controller with I18nSupport {

  // イベントフォーム
  val eventForm = Form(
    mapping(
      "eventId" -> text,
      "eventNm" -> text)(EventForm.apply)(EventForm.unapply))

  // 初期表示
  def index = Action {
    Ok(views.html.eventSearch(eventForm, null))
  }

  // 検索
  def search = Action { implicit request =>
    val form = eventForm.bindFromRequest
    val events = Events.find(form.get.eventId, form.get.eventNm)
    Ok(views.html.eventSearch(form, events))
  }

  // 削除
  def delete(id: Int) = Action { implicit request =>
    Events.delete(id)
    Ok(views.html.eventSearch(eventForm, null))
  }

}

/app/controllers/event/EventUpdate.scala

package controllers.event

import javax.inject.Inject
import models.{EventForm, Event, Events}
import play.api.data.Form
import play.api.data.Forms._
import play.api.i18n.{I18nSupport, MessagesApi}
import play.api.mvc._


class EventUpdate @Inject()(val messagesApi: MessagesApi) extends Controller with I18nSupport {

  // イベントフォーム
  val eventForm = Form(
    mapping(
      "eventId" -> text,
      "eventNm" -> text)(EventForm.apply)(EventForm.unapply))

  // 初期表示
  def index(id: Int) = Action {
    val event = Events.findById(id)
    val form  = EventForm(event.eventId, event.eventNm)
    Ok(views.html.eventUpdate(eventForm.fill(form), id))
  }

  // 更新
  def update(id: Int) = Action { implicit request =>
    val form  = eventForm.bindFromRequest.get
    val event = Event(id, form.eventId, form.eventNm)
    Events.update(event)
    Ok(views.html.eventUpdate(eventForm, id))
  }

}

/app/models/Event.scala

package models

import slick.driver.MySQLDriver.api._
import scala.concurrent.{Await, Future}
import scala.concurrent.duration.Duration
import scala.language.postfixOps


case class Event(id: Int, eventId: String, eventNm: String)

object Events {

  // DBコネクション
  val database = Database.forConfig("db_master")

  // テーブル定義
  class EventTag(tag: Tag) extends Table[Event](tag, "EVENT") {
    def id      = column[Int]("ID", O.PrimaryKey, O.AutoInc)
    def eventId = column[String]("EVENT_ID")
    def eventNm = column[String]("EVENT_NM")
    def * = (id, eventId, eventNm) <> (Event.tupled, Event.unapply)
  }
  val events = TableQuery[EventTag]

  // 登録
  def create(e: Event) = {
    Await.result(database.run(events += Event(0,e.eventId, e.eventNm)), Duration.Inf)
  }

  // テーブル作成
  def createTable = {
    val schema = events.schema
    Await.result(database.run(schema.create), Duration.Inf)
  }

  // テーブル削除
  def dropTable = {
    val schema = events.schema
    Await.result(database.run(schema.drop), Duration.Inf)
  }

  // 検索
  def find(eventId: String, eventNm: String): Seq[Event] = {
    var q = events.sortBy(_.eventNm)
    q = if (!(eventId.isEmpty)) q.filter(_.eventId === eventId) else q
    q = if (!(eventNm.isEmpty)) q.filter(_.eventNm like ("%" + eventNm + "%")) else q
    Await.result(database.run(q.result), Duration.Inf)
  }

  // ID検索
  def findById(id: Int): Event = {
    val q = events.filter(_.id === id).result.head
    Await.result(database.run(q), Duration.Inf)
  }

  // 更新
  def update(e: Event) {
    val q = events.filter(_.id === e.id).update(e)
    Await.result(database.run(q), Duration.Inf)
  }

  // 削除
  def delete(id: Int): Unit = {
    val q = events.filter(_.id === id)
    Await.result(database.run(q.delete),Duration.Inf)
  }

}

/app/models/Form.scala

package models

case class EventForm(eventId: String, eventNm: String)

/app/views/eventCreate.scala.html

@(eventForm: Form[EventForm])(implicit messages: Messages)
@import helper._

@header("イベント登録"){
  @helper.form(action = controllers.event.routes.EventCreate.create()){
    <div class="container">
      <ul class="nav nav-tabs">
        <li>
          <a href="@controllers.event.routes.EventSearch.index()" data-toggle="tab">検索</a>
        </li>
        <li class="active">
          <a href="#" data-toggle="tab">登録</a>
        </li>
      </ul>
      <fieldset>
        <legend>イベントの情報</legend>
        @helper.inputText(eventForm("eventId"))
        @helper.inputText(eventForm("eventNm"))
        <input type="submit" value="登録" class="btn btn-primary">
      </fieldset>
      <a href="@controllers.event.routes.EventCreate.createTable()" class="btn btn-default">
        CREATE TABLE
      </a>
      <a href="@controllers.event.routes.EventCreate.dropTable()" class="btn btn-default">
        DROP TABLE
      </a>
    </div>
  }
}

/app/views/eventSearch.scala.html

@(eventForm: Form[EventForm], events: Seq[Event])(implicit messages: Messages)

@import helper._

@header("イベント検索"){
  @helper.form(action = controllers.event.routes.EventSearch.search()){
    <div class="container">
      <ul class="nav nav-tabs">
        <li class="active">
          <a href="#" data-toggle="tab">検索</a>
        </li>
        <li>
          <a href="@controllers.event.routes.EventCreate.index()" data-toggle="tab">登録</a>
        </li>
      </ul>
      <fieldset>
        <legend>検索条件</legend>
        @helper.inputText(eventForm("eventId"))
        @helper.inputText(eventForm("eventNm"))
        <button type="submit" value="登録" class="btn btn-primary">検索</button>
      </fieldset>

      @if(events) {
        <legend>イベント一覧</legend>
        <table class="table table-striped table-bordered table-hover">
          <thead>
            <tr>
              <th>イベントID</th>
              <th>イベント名</th>
            </tr>
          </thead>
          <tbody>
            @for((event) <- events) {
              <tr>
                <td>@event.eventId</td>
                <td>@event.eventNm</td>
                <td>
                  <a href="@controllers.event.routes.EventUpdate.index(event.id)">
                    <i class="glyphicon glyphicon-edit"></i>
                  </a>
                  <a href="@controllers.event.routes.EventSearch.delete(event.id)">
                    <i class="glyphicon glyphicon-remove"></i>
                  </a>
                </td>
              </tr>
            }
          </tbody>
        </table>
      }
    </div>
  }
}

/app/views/eventUpdate.scala.html

(eventForm: Form[EventForm], id: Int)(implicit messages: Messages)

@import helper._

@header("イベント情報更新"){
  @helper.form(action = controllers.event.routes.EventUpdate.update(id)){
    <div class="container">
      <ul class="nav nav-tabs">
        <li>
          <a href="@controllers.event.routes.EventSearch.index()" data-toggle="tab">検索</a>
        </li>
        <li>
          <a href="@controllers.event.routes.EventCreate.index()" data-toggle="tab">登録</a>
        </li>
        <li class="active">
          <a href="#" data-toggle="tab">更新</a>
        </li>
      </ul>
      <fieldset>
        <legend>イベントの情報</legend>
        @helper.inputText(eventForm("eventId"))
        @helper.inputText(eventForm("eventNm"))
        <input type="submit" value="更新" class="btn btn-primary">
      </fieldset>
    </div>
  }
}

/app/views/header.scala.html

@(title: String)(content: Html)

<!DOCTYPE html>

<html>
<head>
    <title>@title</title>
    <link href="//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet">
    <link href="//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap-glyphicons.css" rel="stylesheet">
  </head>
  <body>
    @content
  </body>
</html>

/app/conf/routes

GET     /                           controllers.SampleController.index
GET     /event/                     controllers.event.EventSearch.index
POST    /event/search               controllers.event.EventSearch.search
GET     /event/:id/delete/          controllers.event.EventSearch.delete(id: Int)
GET     /event/create               controllers.event.EventCreate.index
POST    /event/create/create        controllers.event.EventCreate.create
GET     /event/create/createTable   controllers.event.EventCreate.createTable
GET     /event/create/dropTable     controllers.event.EventCreate.dropTable
POST    /event/:id/                 controllers.event.EventUpdate.update(id: Int)
GET     /event/:id/update/          controllers.event.EventUpdate.index(id: Int)

/app/conf/messages

constraint.required = 必須
error.required = 必須項目です。

eventId = イベントID
eventNm = イベント名