yujiro's blog

エンジニアリング全般の事書きます

Scala PlayFramework でアプリケーション作ってみた。② 【Controller(View)編】

Scala PlayFramework でアプリケーション作ってみた。① 【環境構築編】 - yujiro's blog

の続きです。

ソースコード github.com

※ 今回はPlayframework に出現する Scala言語の文法や機能などの解説も含めてしていきますが、私がScala 初心者なのでクオリティはご了承いただき、誤りがあればご指摘いただきたいです。(もちろん自分なりに調べて理解してから解説していきます。)

ルーティング

ファイルは conf/routes です。

GET / controllers.MainController.index

# 以下ログインなしでも使えるアクション
GET /users/new  controllers.UsersController._new

POST /users/create controllers.UsersController.create

GET /users/login  controllers.UsersController.login

POST /users/result  controllers.UsersController.result

# 以下ログインしないと使えないアクション

GET /users/logout  controllers.UsersController.logout

GET /memos  controllers.MemosController.index

GET /memos/new  controllers.MemosController._new

POST /memos/create  controllers.MemosController.create

GET /memos/:id/edit  controllers.MemosController.edit(id: Int)

POST /memos/:id/update  controllers.MemosController.update(id: Int)

GET /memos/:id/show  controllers.MemosController.show(id: Int)

POST /memos/:id/destroy  controllers.MemosController.destroy(id: Int)

コメントを入れていますが、アクセスするのにログインが必要になるものとそうでないものがあります。

それらはコントローラで振り分けします。

基本的にはこちらは解説はいらないかと思います。

コントローラー (View, Form)

UsersController をメインに解説していきます。

package controllers

import javax.inject._
import play.api._
import play.api.mvc._

import models.User

import forms.UserForm
import play.api.data.Form
import play.api.data.Forms._
import play.api.data.validation.{Constraint, Constraints, Invalid, Valid}

import controllers.components.actions.AuthTrait

import play.api.i18n.I18nSupport
import play.api.i18n.MessagesApi
import play.api.i18n.Messages
import play.api.i18n.Messages.Implicits._

@Singleton
class UsersController @Inject() (val messagesApi: MessagesApi) extends Controller with AuthTrait with I18nSupport {

  private var _user:Option[User] = None

  val userUnique: Constraint[String] = Constraint("constraints.userIsUnique")({
    plainText =>
      val user:Option[User] = User.where('username -> plainText).apply().headOption
      user match {
        case Some(u) => Invalid("User already exists.")
        case None => Valid
      }
  })

  val createForm = Form(
    mapping (
      "username" -> nonEmptyText(minLength = 4).verifying(userUnique),
      "password" -> nonEmptyText(minLength = 8)
    )(UserForm.apply)(UserForm.unapply)
  )

  val loginForm = Form(
    tuple(
      "username" -> text,
      "password" -> text
    ) verifying ("Invalid email or password", result => result match {
      case (username, password) => checkIdPassword(username, password)
    })
  )

  def checkIdPassword(username: String, password: String):Boolean = {
    val user:Option[User] = User.where('username -> username).where('password -> password).apply().headOption
    _user = user
    user match {
      case Some(u) => true
      case None => false
    }
  }

  def _new = Action {
    Ok(views.html.users._new(createForm))
  }

  def create() = Action { implicit request =>
    createForm.bindFromRequest().fold(
      errorForm => {
        Ok(views.html.users._new(errorForm))
      },
      requestForm => {
        User.createWithAttributes('username -> requestForm.username, 'password -> requestForm.password)
        Redirect("/users/login").flashing("success" -> Messages("ユーザーを作成しました。"))
      }
    )
  }

  def login = Action { implicit request =>
    Ok(views.html.users.login(loginForm))
  }

  def logout = Auth {
    Action {
      Redirect("/").withNewSession.flashing("success" -> Messages("ログアウトしました。"))
    }
  }

  def result() = Action { implicit request =>
    loginForm.bindFromRequest().fold(
      errorForm => {
        Ok(views.html.users.login(errorForm))
      },
      requestForm => {
        Redirect("/memos").withSession("user_id" -> _user.get.id.toString)
      }
    )
  }

}

まず、コントローラ定義部分の

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

ですが、@Inject()アノテーションで、DI(依存性)注入するよ〜っていう印。なくてもOK。IDE使う時に色々便利になるんだと思います。

依存性注入 参考

トレイトの応用編:依存性の注入によるリファクタリング · Scala研修テキスト


また、 class で play.api.i18n.I18nSupport というトレイトを継承すると val messagesApi: MessagesApi という抽象メンバーの定義が必須になります。

https://github.com/playframework/playframework/blob/2.5.x/framework/src/play/src/main/scala/play/api/i18n/I18nSupport.scala#L24

ここでは、MessagesApi というDependency(依存性) を UsersController に Injection(注入)するって言うことになるかと思います。

こうすることで play.api.i18n.Messages() が利用可能になります。

ちなみに play.api.i18n.Messages()は国際化のために利用されるAPIです。

Playframework 2.3 までは

play.api.Play.current

をimport し、@Inject() (val messagesApi: MessagesApi) とすることなく Messages を使えていたのですが、

この play.api.Play.current はバージョン2.3 で廃止になりました。

method current in object Play is deprecated: This is a static reference to application, use DI instead

アクション

各メソッドを見ていきます。

_new create login logout result のメソッドは全てアクションです。Action 関数を利用し、その中に処理を記述しています。

logoutアクションのみ

def logout = Auth {
  Action {
    Redirect("/").withNewSession.flashing("success" -> Messages("ログアウトしました。"))
  }
}

Auth {} となっていますが、これはフィルターです。

logout はログインしていないと見られないアクションなので、まず Auth を利用し、ログインしていなかったらリダイレクトさせます。

この AuthUsersControllerが継承している with AuthTrait の中にあります。

app/controllers/components/actions/AuthTrait.scala にあります。

package controllers.components.actions

import javax.inject._
import play.api._
import play.api.mvc._

import models.User

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import play.api.libs.concurrent.Execution.Implicits.defaultContext

import play.api.i18n.I18nSupport
import play.api.i18n.MessagesApi
import play.api.i18n.Messages
import play.api.i18n.Messages.Implicits._


/**
 * 参考 : http://play-gf.blogspot.jp/2015/03/filter.html
 */
trait AuthTrait extends Controller with I18nSupport {

  class AuthRequest[A](val currentUser: Option[User], request: Request[A]) extends WrappedRequest[A](request)

  def redirectToLogin = Action{
    Redirect("/").flashing("success" -> Messages("ログインしてください。"))
  }

  case class Auth[A] (action: Action[A], redirect: Boolean = true) extends Action[A] {

    def apply(request: Request[A]): Future[Result] = {
      val currentUser: Option[User] = request.session.get("user_id") match{
        case Some(v) => User.findById(v.toLong)
        case None => None
      }
      if (currentUser == None) {
        if(redirect){ redirectToLogin(request.asInstanceOf[Request[play.api.mvc.AnyContent]]) } else { action(new AuthRequest(None, request)) }
      }else{
        action(new AuthRequest(currentUser, request))
      }
    }
    lazy val parser = action.parser
  }

}

参考

認証のFilter処理の作成 | Play2.GrAFR

めちゃくちゃ参考になりました。

ここではSessionをみて user_id (resultアクションでセットしています)があれば User を探しに行き、currentUser に代入します。パターンマッチで値がなかった場合、そこでリダイレクトしてもよいのですが、User.findById(v.toLong) でユーザー見つからない場合(Noneが帰る)を考え、その後で None 判定しています。

ここでaction(new AuthRequest(currentUser, request)) としたものですが、MainController で解説します。

@Singleton
class MainController @Inject()(val messagesApi: MessagesApi) extends Controller with AuthTrait {

  def index = Auth ({
    Action { implicit request =>
      val user = request.asInstanceOf[AuthRequest[AnyContent]].currentUser
      Ok(views.html._main.index(user))
    }
  }, false)

}

ここでも Auth{} としています。 AuthTrait で定義したものは request.asInstanceOf[AuthRequest[AnyContent]].currentUser のようにして取得します。

このMainControllerindexアクションは、ユーザーがなくてもリダイレクトせず、View側で表示を変えたかったので、 Auth クラスに別に引数を渡して分岐しました。

MainController

Auth ({~},false)

AuthTrait

(action: Action[A], redirect: Boolean = true)
if(redirect){ redirectToLogin(request.asInstanceOf[Request[play.api.mvc.AnyContent]]) } else { action(new AuthRequest(None, request)) }

の部分です。View側では

app/views/_main/index.scala.html

@user.map{ u =>
  ようこそ @u.username さん
.getOrElse { 
  ログイン
}

としています。

Form

UsersController に戻ってFormをみていきます。

Form は play.api.data.Form を利用します。

val createForm = Form(
  mapping (
    "username" -> nonEmptyText(minLength = 4).verifying(userUnique),
    "password" -> nonEmptyText(minLength = 8)
  )(UserForm.apply)(UserForm.unapply)
)

Form の引数に渡すタイプにmappingtuple があるのですが、mapping の場合、展開するための自作クラスののapply が必要です。

mapping と tuple の違いなどは以下にあります。

参考

ScalaForms - 2.6.x

自作フォームには mapping のキーになっているものに合わせてその型を指定します。

app/forms/UserForm.scala

package forms

case class UserForm(
  username: String,
  password: String
)

ここではバリデーションは既存で用意されている nonEmptyText と自分で定義したuserUnique というメソッド(verifying(userUnique)として使用)を使用しました。

val userUnique: Constraint[String] = Constraint("constraints.userIsUnique")({
  plainText =>
    val user:Option[User] = User.where('username -> plainText).apply().headOption
    user match {
      case Some(u) => Invalid("User already exists.")
      case None => Valid
    }
})

最終的にPlayFramework の play.api.data.Forms にある、Invalid, Valid を返してあげればよいっぽいです。

View を見てみます。Formの部分のみ抜粋。

app/views/users/_new.html.scala

@import helper._
@import forms.UserForm
@(requestForm: Form[UserForm])(implicit messages: Messages)
@implicitFieldConstructor = @{ FieldConstructor(common.f) }
〜
@form(action = routes.UsersController.create()) {
    <div>
        <label for="">ユーザー名</label>
        @inputText(requestForm("username"), '_showConstraints -> false, '_showErrors -> false)
        @if(requestForm.hasErrors) {
            @for(v <- requestForm.errors.collect {
                case v if (v.key == "username") => Messages(v.message, v.args:_*)
            }){
                <div>@v</div>
            }
        }
    </div>
    <div>
        <label for="">パスワード</label>
        @inputPassword(requestForm("password"), '_showConstraints -> false, '_showErrors -> false)
        @if(requestForm.hasErrors) {
            @for(v <- requestForm.errors.collect {
                case v if (v.key == "password") => Messages(v.message, v.args:_*)
            }){
                <div>@v</div>
            }
        }
    </div>
    <div>
        <input type="submit" value="ログイン" class="dimention-btn">
    </div>
}
〜

@implicitFieldConstructor = @{ FieldConstructor(common.f) }

は自分独自のformを使用したいときに利用するものです。 デフォルトだとdd, dt タグが出力されてしまったのでいれました。

app/views/helper/common.scala.html を定義します。

とりあえずシンプルに出したかったので、

@(elements: helper.FieldElements)
<div>@elements.input</div>

とだけ入れておきました。

'_showConstraints -> false, '_showErrors -> false

こちらの記述はformのルール(ここで言えば「4文字以上」とかそういうやつ)である Constraints と バリデーションエラーである Errors の出力をどうするかです。これも余計なタグが入ってしまって嫌だったので、出力されないようにしました。

エラーは requestForm.errors に全部入るのでループで回して出力するようにしました。

例えばパスワードのinputだったら、

requestForm.errors.collect {
    case v if (v.key == "password") => Messages(v.message, v.args:_*)
}

とし、key(name属性) がpassword のもののみ List にしてしまってfor で出力しています。

f:id:bambookun:20170917181503p:plain

Controller 側では エラーがあった場合とそうでない場合の振り分けは下記のようにします。

def create() = Action { implicit request =>
  createForm.bindFromRequest().fold(
    errorForm => {
      Ok(views.html.users._new(errorForm))
    },
    requestForm => {
      User.createWithAttributes('username -> requestForm.username, 'password -> requestForm.password)
      Redirect("/users/login").flashing("success" -> Messages("ユーザーを作成しました。"))
    }
  )
}

play.api.data.Form オブジェクトの bindFromRequest().fold というメソッドを使用します。

そうすると

エラーが有った場合、つまりbindできなかった場合、第一引数に渡した関数が呼び出され、うまくbindできた場合、第二引数に渡した関数が実行されます。


次回に続きます。

Scala PlayFramework でアプリケーション作ってみた ③ 【モデル(Skinny-ORM)編】 - yujiro's blog