yujiro's blog

webエンジニアをしています。

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

Scala PlayFramework でアプリケーション作ってみた。② 【Controller(View)編】 - yujiro's blog の続きです。

今回はモデルとSkinny-ORM がメインになると思います。

ソースコード github.com

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

最初は playframework に標準についている slick を使用しDB操作していたのですが、こちらはORMではないよう(FRM)であまり馴染みがないものだったので、Skinny-ORM に切り替えました。

Slick (“Scala Language-Integrated Connection Kit”)はTypesafe社によってリレーショナルデータベースを簡単に扱うための、ScalaのFRM (Functional Relational Mapping)ライブラリである。まるでScalaのコレクションを扱うかのような操作でデータベースにアクセスし、データを操作出来る。SQLを直接扱うことも可能である。

slick-doc-ja 3.0 — Combined Pages

Skinny-ORM の使い方は以下に全部のってます。これ以外全く見ていません。

参考

ORM - Skinny Framework

DB構造

以下の感じです。

f:id:bambookun:20170917191559p:plain

※ alembic_version と言うのは気にしないでください。

モデル

以下は app/models/Memo.scala のコードです。

package models

import scalikejdbc._
import skinny._
import skinny.orm._, feature._
import org.joda.time.DateTime
import scala.math.ceil

import models.MemoTag
import models.Tag

case class Memo(
  id: Int,
  title: String,
  content: String,
  createdAt: DateTime,
  updatedAt: DateTime,
  userId: Int,
  user: Option[User] = None,
  memo_tags: Seq[MemoTag] = Nil,
  tags: Seq[Tag] = Nil
) {

  def tagStr:String = {
    tags.map(v =>
      v.name
    ).mkString(",")
  }

  def postDate: String = {
    createdAt.toString("yyyy/MM/dd HH:mm:ss")
  }

}

object Memo extends SkinnyCRUDMapper[Memo] with TimestampsFeature[Memo] { self =>
  override def defaultAlias = createAlias("m")
  override def tableName = "memos"
  override def extract(rs: WrappedResultSet, n: ResultName[Memo]) = {
    new Memo(
      id = rs.int(n.id),
      title = rs.string(n.title),
      content = rs.string(n.content),
      createdAt = rs.jodaDateTime(n.createdAt),
      updatedAt = rs.jodaDateTime(n.updatedAt),
      userId = rs.int(n.userId)
    )
  }

  lazy val memoTagsRef = hasMany[MemoTag](
    // association's SkinnyMapper and alias
    many = MemoTag -> MemoTag.defaultAlias,
    // defines join condition by using aliases
    on = (m, mt) => sqls.eq(m.id, mt.memoId),
    // function to merge associations to main entity
    merge = (memo, memo_tags) => memo.copy(memo_tags = memo_tags)
  )

  lazy val tagsRef = hasManyThrough[Tag](
    through = MemoTag,
    many = Tag,
    merge = (memo, tags) => memo.copy(tags = tags)
  )

  val perPageNum = 5

  belongsTo[User](User, (m, u) => m.copy(user = u)).byDefault

  def getForTop(userId:Int, currentPage:Int): Map[String,Any] = {
    val items = paginate(Pagination.page(currentPage).per(perPageNum)).where('user_id -> userId).orderBy(defaultAlias.id.desc).apply()
    val count = where('user_id -> userId).count()
    val sumPageNum = ceil(count.toDouble / perPageNum.toDouble).toInt
    Map(
      "items" -> items,
      "paging" -> Map(
        "page" -> currentPage,
        "perPage" -> perPageNum,
        "count" -> count,
        "sumPageNum" -> sumPageNum
      )
    )
  }

  def tagUpdate(id:Int, tagStr:String):Unit = {
    MemoTag.deleteBy(sqls.eq(MemoTag.column.memoId, id))
    tagStr.split(',').foreach({v =>
      var tag = Tag.where('name -> v).apply().headOption
      val tagId = if(tag == None){ Tag.createWithAttributes('name -> v) }else{ tag.get.id }
      MemoTag.createWithAttributes('memo_id -> id, 'tag_id -> tagId)
    })
  }

}
case class Memo

には、テーブルのカラムを定義します。またオブジェクトから使用するメソッドもここに定義して大丈夫そうです。

object Memo

はstatic なメソッドや(Memo.getForTop(~)のように使用する)、アソシエーションやテーブル名などのテーブル定義を記述します。

実際にコントローラ等から取得したobject が持っているアトリビュート

override def extract(rs: WrappedResultSet, n: ResultName[Memo]) = {
  new Memo(
    id = rs.int(n.id),
    title = rs.string(n.title),
    content = rs.string(n.content),
    createdAt = rs.jodaDateTime(n.createdAt),
    updatedAt = rs.jodaDateTime(n.updatedAt),
    userId = rs.int(n.userId)
  )
}

の中で case class Memo に対して渡したものになります。

また、

case class Memo

内にメソッドを定義するとattributeのように使えます。

例えば

case class Memo(
  id: Int,
  title: String,
  content: String,
  createdAt: DateTime,
  updatedAt: DateTime,
  userId: Int,
  user: Option[User] = None,
  memo_tags: Seq[MemoTag] = Nil,
  tags: Seq[Tag] = Nil
) {

  def tagStr:String = {
    tags.map(v =>
      v.name
    ).mkString(",")
  }

  def postDate: String = {
    createdAt.toString("yyyy/MM/dd HH:mm:ss")
  }

}

として

MemosControllerで以下のようにすると

def show(id: Int) = Auth {
  Action { implicit request =>
    val memo = Memo.joins(Memo.tagsRef).where('id -> id).apply().headOption.get
    println(memo.tagStr)
    println(memo.postDate)
    Ok(views.html.memos.show(memo))
  }
}
aaa,aaaa
2017/09/16 09:33:55

となります。

ページング

ページング実装はモデル側(object Memo)で一覧取得メソッドを定義して、そこでページングさせるために必要な情報を返してあげるようにしました。

def getForTop(userId:Int, currentPage:Int): Map[String,Any] = {
  val items = paginate(Pagination.page(currentPage).per(perPageNum)).where('user_id -> userId).orderBy(defaultAlias.id.desc).apply()
  val count = where('user_id -> userId).count()
  val sumPageNum = ceil(count.toDouble / perPageNum.toDouble).toInt
  Map(
    "items" -> items,
    "paging" -> Map(
      "page" -> currentPage,
      "perPage" -> perPageNum,
      "count" -> count,
      "sumPageNum" -> sumPageNum
    )
  )
}

まず、このメソッドの返り値の型ですが、構造が異なるMap を返すため、Map[String,Any]としています。あまり望ましくないのかもしれません。


paginate(Pagination.page(currentPage).per(perPageNum))

の記述は、Skinny-ORMの公式にも載っているので大丈夫でしょう。

コントローラにいきます。

このメソッドを呼び出すにはログインしているユーザーのIDとページ番号を渡してあげないといけません。

ページ番号は以下のようにして取得しました。

val pageNum = request.queryString.map({ case(k,v) =>
  (k, v.head.toInt)
}).getOrElse("page", 1)
request.queryString

これでパラメータとれるのですが、Map[String,List[String]] でとれます。

例えば ?p=2 となっている場合、

Map(p -> List(2))

という具合です。なのでmapをつかって整形してから取得します。パラメータにpage がなかったら 1 がとれるようにします。あとは pagingMap という変数にページングに必要な情報を代入し、Viewに渡してあげます。

View 側では単純に以下のようにループを使用して、タグをレンダリングするようにしました。

@for(i <- 1 to pagingMap.get("sumPageNum").get) {
    @if(i == pagingMap.get("page").get) {
        <a href="javascript:void(0);" class="current"><span>@i</span></a>
    }else{
        <a href="/memos?page=@i" class=""><span>@i</span></a>
    }
}

以上です!

Skinny-ORMについては基本的に全部リンク先に乗っていたので実装が楽でした。