Scala
PlayFramework
slick

PlayFramework 2.6.7, play-slick 3.0.2(Slick 3.2) でCRUDを実装する

この記事はウェブクルー Advent Calendar 2017の1日目の記事です。

はじめに

今年も Advent Calendar が始まりました。
1日目は @kouares がつとめます!!

1日目から長い内容ですが、どうぞお付き合いください。

概要

4月から PlayFramework, Scala を業務で使い始め、公式ドキュメントや先人の方々が書いた記事やコードにとても助けられました。本当にありがとうございます。
今回は私も PlayFramework, Slick に入門する方の助けになれればと思ったのと自身の振り返りも兼ねて、CRUDを実装したメモアプリ(Knowledgeのすごく簡単な感じのやつ)を作成する記事を作成しました。
単純な CRUD 以外に join や transaction 等も実装したので入門にしてはボリュームがありますが、その分 PlayFramework, Slick に入門する方の助けになると思いますので参考にして頂けたらと思います。

環境

  • OS : MacOS
  • JVM : Java8
  • Scala : 2.12.3
  • sbt : 1.0.3
  • PlayFramework : 2.6.7
  • Database : Mysql

テーブル構成

  • Memo
    • タイトル、メモ内容を格納しています。primary key が auto_increment になっています。
  • TagMst
    • メモに付けられるタグのマスタです。primary key が auto_increment になっています。
  • TagMapping
    • メモとタグのひも付きを格納しています。

プロジェクト作成

まずはプロジェクトを作成します。
Play2.5.x までは Typesafe activator を使っていましたが、Play2.6 では sbt を使います。
プロジェクトを作りたいディレクトリに移動して以下のコマンドを実行します。

sbt new playframework/play-scala-seed.g8

すると jar のダウンロードが始まり、最後に以下の内容を聞かれます。
特に変更しない場合は [] の中に表示されている内容でプロジェクトが作成されます。

name [play-scala-seed]: play_example
organization [com.example]:
play_version [2.6.7]:
sbt_version [1.0.2]:
scalatestplusplay_version [3.1.2]:

設定

依存するライブラリやビルドの設定等を行います。

ライブラリ依存性

DB の操作に Slick を使うので build.sbt に以下のライブラリ依存性を追加します。

build.sbt
libraryDependencies ++= Seq(
  "com.typesafe.play" %% "play-slick" % "3.0.2",
  "com.typesafe.slick" %% "slick-codegen" % "3.2.1",
  "mysql" % "mysql-connector-java" % "5.1.42"
)

sbtプラグインの設定

eclipse を使って開発しましたので、以下の plugin を追加します。

plugins.sbt
addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "5.2.3")

これで、sbt eclipse が使えるようになり、eclipse にプロジェクトをインポート出来るようになります。

ビルドの設定

buildに使うsbtのバージョンを設定します。
sbt.version=1.0.2 がプロジェクト作成時に設定されていましたが、sbt 1.0.3が出ているので1.0.3に変更しました。

build.properties
sbt.version=1.0.3

DB接続設定

slick で DB に接続する設定を application.conf に書きます。
今回は接続するのに必要な設定のみですが、タイムアウトやコネクションの最大接続数なども設定できます。

application.conf
slick.dbs {
    default {
        profile = "slick.jdbc.MySQLProfile$"
        db {
            driver = com.mysql.jdbc.Driver
            url = "jdbc:mysql://localhost:3306/memo?characterEncoding=UTF-8"
            user = memo
            password = "memo"
        }
    }
}

Model と Dao

Model と Dao を作成します。
Model は build.sbt で設定した slick-codegen を用いて生成します。
以下が slick-codegen を使って Model を生成するscalaアプリケーションになります。

ModelのGenerator

SlickModelGenerator.scala
package genarator

import scala.concurrent.Await
import scala.concurrent.ExecutionContext
import scala.concurrent.duration.Duration

import slick.codegen.SourceCodeGenerator
import slick.jdbc.meta.MTable
import slick.model.Model

object SlickModelGenerator extends App {

  // 接続先
  val url = "jdbc:mysql://localhost/memo"
  // 出力するディレクトリ
  val outputDir = "app"
  // 出力するパッケージ
  val pkg = "models"
  // traitの名前
  val topTraitName = "Tables"
  // ファイル名
  val scalaFileName = "Tables.scala"
  // 生成するテーブルを指定、今回は全テーブルModelを作成するのでNone
  val tableNames: Option[Seq[String]] = None

  val slickProfile = "slick.jdbc.MySQLProfile"
  val profile = slick.jdbc.MySQLProfile
  val db = profile.api.Database.forURL(url, driver = "com.mysql.jdbc.Driver", user = "memo", password = "memo")

  try {
    import scala.concurrent.ExecutionContext.Implicits.global
    val mTablesAction = MTable.getTables.map { _.map { mTable => mTable.copy(name = mTable.name.copy(catalog = None)) } }

    val allModel = Await.result(db.run(profile.createModel(Some(mTablesAction), false)(ExecutionContext.global).withPinnedSession), Duration.Inf)

    val modelFiltered = tableNames.fold(allModel) { tableNames =>
      Model(tables = allModel.tables.filter { aTable =>
        tableNames.contains(aTable.name.table)
      })
    }

    new SourceCodeGeneratorEx(modelFiltered).writeToFile(slickProfile, outputDir, pkg, topTraitName, scalaFileName)
  } finally db.close

  class SourceCodeGeneratorEx(model: Model) extends SourceCodeGenerator(model) {
    override def Table = new Table(_) {
      //auto_incrementを識別できるようにする
      //生成されるモデルはOption型になる
      override def autoIncLastAsOption = true
      override def Column = new Column(_) {
        override def rawType = model.tpe match {
          case "java.sql.Blob" =>
            "Array[Byte]"
          case _ =>
            super.rawType
        }
      }
    }
  }
}

生成されたModel

Tables.scala
package models
// AUTO-GENERATED Slick data model
/** Stand-alone Slick data model for immediate use */
object Tables extends {
  val profile = slick.jdbc.MySQLProfile
} with Tables

/** Slick data model trait for extension, choice of backend or usage in the cake pattern. (Make sure to initialize this late.) */
trait Tables {
  val profile: slick.jdbc.JdbcProfile
  import profile.api._
  import slick.model.ForeignKeyAction
  // NOTE: GetResult mappers for plain SQL are only generated for tables where Slick knows how to map the types of all columns.
  import slick.jdbc.{GetResult => GR}

  /** DDL for all tables. Call .create to execute. */
  lazy val schema: profile.SchemaDescription = Memo.schema ++ TagMapping.schema ++ TagMst.schema
  @deprecated("Use .schema instead of .ddl", "3.0")
  def ddl = schema

  /** Entity class storing rows of table Memo
   *  @param title Database column title SqlType(VARCHAR), Length(200,true)
   *  @param mainText Database column main_text SqlType(VARCHAR), Length(10000,true), Default(None)
   *  @param upadtedAt Database column upadted_at SqlType(DATETIME), Default(None)
   *  @param createdAt Database column created_at SqlType(DATETIME)
   *  @param id Database column id SqlType(INT), AutoInc, PrimaryKey */
  case class MemoRow(title: String, mainText: Option[String] = None, upadtedAt: Option[java.sql.Timestamp] = None, createdAt: java.sql.Timestamp, id: Option[Int] = None)
  /** GetResult implicit for fetching MemoRow objects using plain SQL queries */
  implicit def GetResultMemoRow(implicit e0: GR[String], e1: GR[Option[String]], e2: GR[Option[java.sql.Timestamp]], e3: GR[java.sql.Timestamp], e4: GR[Option[Int]]): GR[MemoRow] = GR{
    prs => import prs._
    val r = (<<?[Int], <<[String], <<?[String], <<?[java.sql.Timestamp], <<[java.sql.Timestamp])
    import r._
    MemoRow.tupled((_2, _3, _4, _5, _1)) // putting AutoInc last
  }
  /** Table description of table memo. Objects of this class serve as prototypes for rows in queries. */
  class Memo(_tableTag: Tag) extends profile.api.Table[MemoRow](_tableTag, "memo") {
    def * = (title, mainText, upadtedAt, createdAt, Rep.Some(id)) <> (MemoRow.tupled, MemoRow.unapply)
    /** Maps whole row to an option. Useful for outer joins. */
    def ? = (Rep.Some(title), mainText, upadtedAt, Rep.Some(createdAt), Rep.Some(id)).shaped.<>({r=>import r._; _1.map(_=> MemoRow.tupled((_1.get, _2, _3, _4.get, _5)))}, (_:Any) =>  throw new Exception("Inserting into ? projection not supported."))

    /** Database column title SqlType(VARCHAR), Length(200,true) */
    val title: Rep[String] = column[String]("title", O.Length(200,varying=true))
    /** Database column main_text SqlType(VARCHAR), Length(10000,true), Default(None) */
    val mainText: Rep[Option[String]] = column[Option[String]]("main_text", O.Length(10000,varying=true), O.Default(None))
    /** Database column upadted_at SqlType(DATETIME), Default(None) */
    val upadtedAt: Rep[Option[java.sql.Timestamp]] = column[Option[java.sql.Timestamp]]("upadted_at", O.Default(None))
    /** Database column created_at SqlType(DATETIME) */
    val createdAt: Rep[java.sql.Timestamp] = column[java.sql.Timestamp]("created_at")
    /** Database column id SqlType(INT), AutoInc, PrimaryKey */
    val id: Rep[Int] = column[Int]("id", O.AutoInc, O.PrimaryKey)
  }
  /** Collection-like TableQuery object for table Memo */
  lazy val Memo = new TableQuery(tag => new Memo(tag))

  /** Entity class storing rows of table TagMapping
   *  @param memoId Database column memo_id SqlType(INT), Default(None)
   *  @param tagId Database column tag_id SqlType(INT), Default(None) */
  case class TagMappingRow(memoId: Option[Int] = None, tagId: Option[Int] = None)
  /** GetResult implicit for fetching TagMappingRow objects using plain SQL queries */
  implicit def GetResultTagMappingRow(implicit e0: GR[Option[Int]]): GR[TagMappingRow] = GR{
    prs => import prs._
    val r = (<<?[Int], <<?[Int])
    import r._
    TagMappingRow.tupled((_1, _2)) // putting AutoInc last
  }
  /** Table description of table tag_mapping. Objects of this class serve as prototypes for rows in queries. */
  class TagMapping(_tableTag: Tag) extends profile.api.Table[TagMappingRow](_tableTag, "tag_mapping") {
    def * = (memoId, tagId) <> (TagMappingRow.tupled, TagMappingRow.unapply)

    /** Database column memo_id SqlType(INT), Default(None) */
    val memoId: Rep[Option[Int]] = column[Option[Int]]("memo_id", O.Default(None))
    /** Database column tag_id SqlType(INT), Default(None) */
    val tagId: Rep[Option[Int]] = column[Option[Int]]("tag_id", O.Default(None))

    /** Index over (memoId) (database name memo_id) */
    val index1 = index("memo_id", memoId)
    /** Index over (tagId) (database name tag_id) */
    val index2 = index("tag_id", tagId)
  }
  /** Collection-like TableQuery object for table TagMapping */
  lazy val TagMapping = new TableQuery(tag => new TagMapping(tag))

  /** Entity class storing rows of table TagMst
   *  @param name Database column name SqlType(VARCHAR), Length(100,true), Default(None)
   *  @param id Database column id SqlType(INT), AutoInc, PrimaryKey */
  case class TagMstRow(name: Option[String] = None, id: Option[Int] = None)
  /** GetResult implicit for fetching TagMstRow objects using plain SQL queries */
  implicit def GetResultTagMstRow(implicit e0: GR[Option[String]], e1: GR[Option[Int]]): GR[TagMstRow] = GR{
    prs => import prs._
    val r = (<<?[Int], <<?[String])
    import r._
    TagMstRow.tupled((_2, _1)) // putting AutoInc last
  }
  /** Table description of table tag_mst. Objects of this class serve as prototypes for rows in queries. */
  class TagMst(_tableTag: Tag) extends profile.api.Table[TagMstRow](_tableTag, "tag_mst") {
    def * = (name, Rep.Some(id)) <> (TagMstRow.tupled, TagMstRow.unapply)
    /** Maps whole row to an option. Useful for outer joins. */
    def ? = (name, Rep.Some(id)).shaped.<>({r=>import r._; _2.map(_=> TagMstRow.tupled((_1, _2)))}, (_:Any) =>  throw new Exception("Inserting into ? projection not supported."))

    /** Database column name SqlType(VARCHAR), Length(100,true), Default(None) */
    val name: Rep[Option[String]] = column[Option[String]]("name", O.Length(100,varying=true), O.Default(None))
    /** Database column id SqlType(INT), AutoInc, PrimaryKey */
    val id: Rep[Int] = column[Int]("id", O.AutoInc, O.PrimaryKey)
  }
  /** Collection-like TableQuery object for table TagMst */
  lazy val TagMst = new TableQuery(tag => new TagMst(tag))
}

Dao

引き続き Dao を作成していきます。
Dao にはメモの検索、メモの id指定検索、登録、更新、削除を実装しました。

検索

検索条件の mainText が指定されていればメモを like 検索、指定されていなければ全検索します。
メモに付いているタグも取得します。

  • 以下がコードで使っているslickのメソッドになります。
    • Memo.sortBy(_.id) ORDER BY に当たります。Memo テーブルを id の照準でソートしています。
    • Memo.filter WHERE に当たります。
    • (_.mainText like s"%${mainText}%" LIKE に当たります。
      上の filter と合わせて LIKE検索を実装しています。
    • result.map これは SQL に当たるものはありません。
      クエリの実行結果を後続処理で使いやすいように変換を行っています。
    • join(TagMapping).on((m, t) => m.id === t.memoId) INNER JOIN に当たります。
      Memo と TagMapping を結合しています。
    • .map(result => (result._1._1.id, result._2)).result SELECT に当たります。
      ここまでで取得しているデータに射影操作を行っています。
    • db.run DB 操作を実行します。
MemoDao.scala
def search(mainText: Option[String]) = {
  // クエリを作成
  val query = for {
    // 検索条件が指定されていれば条件を使って検索、指定されていないなら全件取得
    memos <- mainText.fold(Memo.sortBy(_.id))(mainText => Memo.filter(_.mainText like s"%${mainText}%").sortBy(_.id)).result
      .map(_.map(memo => MemoItem(memo.id.getOrElse(-1), memo.title, memo.mainText.getOrElse(""))))

    // 取得したメモに紐づくタグを取得する
    tags <- Memo.filter(_.id.inSetBind(memos.map(_.id)))
      .join(TagMapping).on((m, t) => m.id === t.memoId)
      .join(TagMst).on((mt, tm) => mt._2.tagId === tm.id)
      .map(result => (result._1._1.id, result._2)).result
  } yield memos.map(memo =>
    MemoInfo(memo.id, memo.title, memo.mainText, tags.filter(tagMap => tagMap._1 == memo.id).map(_._2)))

  db.run(query)
}

登録

入力内容を元にメモの登録、タグの登録、メモとタグのひも付きを登録します。

  • 以下がコードで使っているslickのメソッドになります。
    • Memo returning Memo.map(_.id) += MemoRow(省略) INSERT INTO に当たります。
      この実装以外にも INSERT INTO を実装する方法はありますが、この実装だと登録したレコードの ID(AUTO INCREMENT) を取得できます。
    • DBIO.sequence Seq[DBIO[T]] 型を DBIO[Seq[T]] 型に変換します。
      1テーブルに複数件のデータ登録を行う時などに使用します。発行しているクエリによりますが
      for 式で展開することで登録された ID のリストなどを取得できます。
    • _.name.inSetBind(form.tags) IN に当たります。この場合は Traversable[String] を引数にとります。
    • transactionally トランザクションを生成します。
      以下のコードだと for 式の中で発行しているクエリが一つのトランザクションになります。
MemoDao.scala
def create(form: MemoForm) = {
  // トランザクションを作成
  val transaction = (for {
    // メモを登録してidを取得
    memoId <- Memo returning Memo.map(_.id) += MemoRow(form.title, form.mainText, None, new Timestamp(System.currentTimeMillis()))

    // ひも付けられたタグがマスタに存在しない場合、タグを登録
    _ <- DBIO.sequence(form.tags.map(tag =>
      TagMst.filter(_.name === tag).exists.result.flatMap {
        case true => DBIO.successful(0)
        case false => (TagMst returning TagMst.map(_.id) += TagMstRow(Some(tag))).map(DBIO.successful(_)).flatten
      }))

    // メモとタグのマッピングを登録
    _ <- TagMst.filter(_.name.inSetBind(form.tags)).result.flatMap(tags =>
      TagMapping ++= tags.map(tag => TagMappingRow(Some(memoId), tag.id)))
  } yield memoId).transactionally

  db.run(transaction)
}

更新

入力内容をもとにメモの更新、タグの登録、メモとタグのひも付きを削除・登録します。

  • 以下がコードで使っているslickのメソッドになります。
    • update((form.title, form.mainText, Some(new Timestamp(System.currentTimeMillis())))) UPDATE に当たります。
      update の前に filter で更新対象を絞り込んでいます。
MemoDao.scala
def update(id: Int, form: MemoForm) = {
  // トランザクションを作成
  val transaction = (for {
    // メモをアップデートして更新したメモのidを取得
    updatedMemoId <- Memo.filter(memo => memo.id === id)
      .map(result => (result.title, result.mainText, result.upadtedAt))
      .update((form.title, form.mainText, Some(new Timestamp(System.currentTimeMillis())))).map(_ match {
        case updated if updated == 1 => Some(id)
        case _ => None
      })

    // メモとタグのマッピング前にタグがマスタに存在していない場合はタグを登録
    _ <- DBIO.sequence(form.tags.filter(!_.endsWith("-remove")).map(tag =>
      TagMst.filter(_.name === tag)
        .exists.result.flatMap {
          case true => DBIO.successful(0)
          case false => (TagMst returning TagMst.map(_.id) += TagMstRow(Some(tag))).map(DBIO.successful(_)).flatten
        }))

    // メモとタグのマッピングの削除と登録
    _ <- DBIO.sequence(form.tags.map(tag => tag.endsWith("-remove") match {
      case true => TagMst.filter(_.name === tag.replace("-remove", "")).result.headOption.flatMap {
        case Some(tagMstRow) => TagMapping.filter(tagMap => tagMap.memoId === updatedMemoId && tagMap.tagId === tagMstRow.id).delete
        case None => DBIO.successful(0)
      }
      case false => TagMst.filter(_.name === tag).result.headOption.flatMap(_.map(tagMstRow =>
        TagMapping.filter(tagMap => tagMap.memoId === updatedMemoId && tagMap.tagId === tagMstRow.id).exists.result.flatMap {
          case true => DBIO.successful(0)
          case false => TagMapping += TagMappingRow(updatedMemoId, tagMstRow.id)
        }).getOrElse(DBIO.successful(0)))
    }))
  } yield updatedMemoId).transactionally

  db.run(transaction)
}

削除

指定されたIDのメモとタグのひも付きを削除します。

  • 以下がコードで使っているslickのメソッドになります。
    • delete DELETE に当たります。deleteの前に filter で削除対象を絞り込んでいます。
dao.MemoDao.scala
def delete(id: Int) = {
  // メモとタグのひも付きを削除
  db.run(TagMapping.filter(_.memoId === id).delete.flatMap(_ => Memo.filter(_.id === id).delete))
}

View

検索、登録、更新の3画面で構成しています。

検索画面

list.png

list.scala.html
@* list Template File *@
@(memoForm: Form[controllers.forms.Memo.MemoForm], memos: Seq[controllers.forms.Memo.MemoForm])(implicit request: Request[Any], messages: Messages)

@import helper._

@main("一覧") {
<div class="row">
  <div class="form-group">
    <div class="col-md-6">
      <input type="text" name="mainText" class="form-control" placeholder="フリーワード例:Play" value="@memoForm("mainText").value">
    </div>
    <div class="col-md-6">
      <!-- <button type="submit" class="btn btn-primary">検索</button> -->
      <a href="@routes.MemoController.search(None)" class="btn bntn-default" role="button">検索</a>
    </div>
  </div>
</div>

<div class="row">
  <div class="col-md-12">
    <a href="@routes.MemoController.showCreate()" class="btn btn-default" role="button">作成</a>
  </div>
</div>

<div class="row">
  <div class="col-md-12">
    <table class="table table-hover">
      <thead>
        <tr>
          <th>タイトル</th>
          <th>タグ</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        @memos.map { memo =>
          <tr>
            <td><a href="@routes.MemoController.showUpdate(memo.id.getOrElse(-1))">@memo.title</a></td>
            <td>
              @memo.tags.map { tag =>
                <span class="label label-info">@tag</span>
              }
            </td>
            <td>
              @helper.form(CSRF(routes.MemoController.delete(memo.id.getOrElse(-1)))) {
                <input type="submit" value="削除" class="btn btn-danger btn-xs"/>
              }
            </td>
          </tr>
        }
      </tbody>
    </table>
  </div>
</div>
}

登録画面

create.png

create.scala.html
@* create Template File *@
@(memoForm: Form[controllers.forms.Memo.MemoForm])(implicit rs: Request[Any], messages: Messages)

@import helper._

@main("登録") {
  <script src="@routes.Assets.versioned("javascripts/viewhelper.js")"></script>
  <h1>登録</h1>
  @form(CSRF(routes.MemoController.create), 'class -> "container", 'role -> "form") {
  <fieldset>
    <div class="row">
      <div class="form-group">
        @inputText(memoForm("title"), '_label -> "タイトル", 'placeholder -> "例:Play でハマった所", 'class -> "form-control", '_help->"")
      </div>
    </div>
    <div class="row">
      <div class="form-group">
        @textarea(memoForm("mainText"), '_label -> "メモ", 'placeholder -> "メモ", 'class -> "form-control", 'rows -> "10")
      </div>
    </div>
    <div class="row">
      <div class="form-group">
        <dl>
          <dt>
            <label for="tag">タグ</label>
          </dt>
          <dd>
            <div id="tagdisp" class="tagdisp"></div>
            <input type="text" id="tag" placeholder="タグ" class="form-control">
          </dd>
        </dl>
      </div>
    </div>
    <div class="row">
      <input type="submit" value="登録" class="btn btn-primary" />
    </div>
  </fieldset>
  }
}

更新画面

update.png

update.scala.html
@* update Template File *@
@(memoForm: Form[controllers.forms.Memo.MemoForm])(implicit request:Request[Any], messages: Messages)

@import helper._

@main("更新") {
  <script src="@routes.Assets.versioned("javascripts/viewhelper.js")"></script>
  <h1>更新</h1>
  @form(CSRF(routes.MemoController.update(memoForm("id").value.getOrElse("-1").toInt)), 'class -> "container", 'role -> "form") {
    <fieldset>
      <div class="row">
        <div class="form-group">
          @inputText(memoForm("title"), '_label -> "タイトル", 'class -> "form-control", '_help -> "")
        </div>
      </div>
      <div class="row">
        <div class="form-group">
          @textarea(memoForm("mainText"), '_label -> "メモ", 'class -> "form-control", 'rows -> "10")
        </div>
      </div>
      <div class="row">
        <div class="form-group">
          <dl>
            <dt>
              <label for="tag">タグ</label>
            </dt>
            <dd>
              <div id="tagdisp" class="tagdisp">
                @memoForm.apply("tags").indexes.map { i =>
                  <span class="label label-info">@memoForm(s"tags[$i]").value<span class="init-tag-remove">x</span></span>
                }
              </div>
              <input type="text" id="tag" class="form-control">
            </dd>
          </dl>
        </div>
      </div>
      @memoForm("title").value.map { value =>
        <input type="hidden" name="title" value="@value"/>
      }
      <div class="row">
        <input type="submit" value="更新" class="btn btn-primary" />
      </div>
    </fieldset>
  }
}

routesとController

routes

uri と Controller の処理を関連付けます。

routes
# Routes
# This file defines all application routes (Higher priority routes first)
# https://www.playframework.com/documentation/latest/ScalaRouting
# ~~~~

# An example controller showing a sample home page
#GET     /                           controllers.HomeController.index

# Map static resources from the /public folder to the /assets URL path
GET    /assets/*file       controllers.Assets.versioned(path="/public", file: Asset)

# Memo
# list
GET    /memos              controllers.MemoController.search(mainText: Option[String])

# create
GET    /memos/             controllers.MemoController.showCreate
POST   /memos/             controllers.MemoController.create

# update
GET    /memos/:id          controllers.MemoController.showUpdate(id: Int)
POST   /memos/:id          controllers.MemoController.update(id: Int)

# delete
POST   /memos/delete/:id   controllers.MemoController.delete(id: Int)

Controller

Controller は一つです。
MemoDao を DI を使って取得し、DAO の処理を実行してその処理結果をもとにレスポンスを決定します。

MemoController.scala
package controllers

import scala.concurrent.{ ExecutionContext, Future }

import controllers.forms.Memo.{ MemoForm, memoForm }
import dao.MemoDao
import javax.inject.Inject
import play.api.Logger
import play.api.i18n.I18nSupport
import play.api.mvc.{ AbstractController, ControllerComponents }

class MemoController @Inject() (cc: ControllerComponents, memoDao: MemoDao)(implicit ec: ExecutionContext) extends AbstractController(cc) with I18nSupport {

  private val logger = Logger(classOf[MemoController])

  def search(mainText: Option[String]) = Action.async { implicit rs =>
    memoDao.search(mainText).map(result =>
      Ok(views.html.memo.list(memoForm.fill(MemoForm(None, "", mainText, Seq.empty[String])), result.map(MemoForm(_)))))
  }

  def showCreate = Action.async { implicit rs =>
    Future { Ok(views.html.memo.create(memoForm.fill(MemoForm(None, "", None, Seq.empty[String])))) }
  }

  def create = Action.async { implicit rs =>
    memoForm.bindFromRequest().fold(errors => Future {
      BadRequest(views.html.memo.create(errors))
    }, form => memoDao.create(form).map(_ => Redirect(routes.MemoController.search(None))))
  }

  def showUpdate(id: Int) = Action.async { implicit rs =>
    memoDao.findById(id).map(_.fold(
      Redirect(routes.MemoController.search(None)))(memo => {
        Ok(views.html.memo.update(memoForm.fill(MemoForm(memo))))
      }))
  }

  def update(id: Int) = Action.async { implicit rs =>
    memoForm.bindFromRequest().fold(errors => Future {
      BadRequest(views.html.memo.update(errors))
    }, form => memoDao.update(id, form).map(_ => Redirect(routes.MemoController.search(None))))
  }

  def delete(id: Int) = Action.async { implicit rs =>
    memoDao.delete(id).map(_ => Redirect(routes.MemoController.search(None)))
  }
}

DaoのDI

DI は @Inject() (cc: ControllerComponents, memoDao: MemoDao) で行っています。
Dao は google guice の eager singleton binding を使ってインスタンスを生成しています。

DaoModule.scala
package modules

import com.google.inject.AbstractModule
import dao.MemoDao
import dao.MemoDaoImpl

class DaoModule extends AbstractModule {
  override def configure() = {
    bind(classOf[MemoDao]).to(classOf[MemoDaoImpl]).asEagerSingleton()
  }
}

上記のモジュールは設定ファイルにて有効にしています。

application.conf
play.modules {
    enabled += modules.DaoModule
}

まとめ

以上、一通り CRUD を実装しましたがいかがでしたでしょうか?
上記以外にも PlayFramework の機能は数多くありますが公式ドキュメントがとても充実しており、PlayFramework を始めるのに十分な情報量があります。分からないことがあった時やハマったときも、公式ドキュメントで解決することがありますので是非一度目を通してみてください。
また、この記事で使っている PlayFramework のプロジェクトは Github にありますので、参考にしていただけたらと思います。

以上、PlayFramework を始めた OR 始める予定のエンジニアの助けになれば幸いです。

明日は @hahegawa さんです。よろしくお願いします!

参考文献

Play 2.6.x documentation
slick-doc-ja 3.0
Slick 3.2.0 manual
Slick 3.2.0 api doc
Guice’s eager singleton binding

最後に

ウェブクルーでは一緒に働いていただける方を随時募集しております。
お気軽にエントリーくださいませ。

開発エンジニアの募集
フロントエンドエンジニアの募集
データベースエンジニアの募集