play-slickを使う

先日play-slickを使ってみたのでそのメモ。

play-slickとは

その名の通り、playframeworkでslickを使うためのプラグインです。

play-slickを使うと

  • playframeworkのデフォルトのDB設定をそのまま使い回せる
  • slickのスキーマ変更に合わせて自動でevolution用のmigrationファイルを作成してくれる
  • セッション周りの扱いが楽になる

といったメリットがあります。

ちなみにplay-slickはplayframework 2.3に統合される予定とのこと。

環境

今回試した環境です。

  • playframework 2.2.1
  • scala 2.10
  • slick 2.0.0
  • play-slick 0.6.0.1
  • mariadb 10.0.8

セットアップ

playframeworkのセットアップは省略。

プラグインの追加

まずbuild.sbtに利用するプラグインを追記していきます。

// build.sbt

libraryDependencies ++= Seq(
  // other plugins
  // ...
  "com.typesafe.slick" %% "slick" % "2.0.0",
  "org.slf4j" % "slf4j-nop" % "1.6.4",
  "com.typesafe.play" % "play-slick_2.10" % "0.6.0.1"
  "org.jumpmind.symmetric.jdbc" % "mariadb-java-client" % "1.1.1",
)

ここで注意が必要なのはplay-slickのバージョンで、playframeworkとslickのバージョンによって使えるバージョンが異なるようです。

playframework slick play-slick
2.3.x 2.0.x 0.7.x
2.2.x 2.0.x 0.6.x
2.2.x 1.0.x 0.5.x
2.1.x 1.0.x 0.4.x

(2014-07-27編集: 更新された公式ドキュメントに合わせて修正しました)

DBの設定

通常のplayframeworkアプリケーションと同様に、application.confにDBの接続情報を追記します。

mariadb用の設定となっているところは適宜変更してください。

# conf/application.conf

db.default.driver=org.mariadb.jdbc.Driver
db.default.url="jdbc:mariadb://localhost/dbname"
db.default.user=dbuser
db.default.password=dbpass
slick.default="models.*"

ここでslick.defaultというパラメタを指定していますが、これはevlolution用にmigrationファイルを作成する対象のクラスを指定するために利用されるそうです。

今回はmodelsパッケージ以下にslickの用クラスを定義するためそのように指定しています。

modelの実装

idと名前を持つユーザ用のテーブルとそれを操作するクラスを考えたときに、基本形はこんな形になると思います。

// app/models/user.scala
package models

import play.api.db.slick.Config.driver.simple._

case class User(id: Option[Int], name: String)

class Users(tag: Tag) extends Table[User](tag, "users") {
  def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
  def name = column[String]("name", O.NotNull)

  def * = (id.?, name) <> (User.tupled, User.unapply _)
}

object Users extends DAO {
  def findById(id: Int)(implicit s: Session): Option[User] = {
    Users filter { _.id === id } firstOption
  }

  def insert(user: User)(implicit s: Session) {
    Users += user
  }
}

まずユーザ情報をUserケースクラスとして実装し、usersテーブルの表現としてTable[User]を継承するUsersクラスを実装しています。これはslickのLifted Embeddingの書式そのままです。

今回はここでUsersクラスのコンパニオンオブジェクトとしてDAOを定義しています。 controllerはこのDAOを利用することでロジックをcontrollerに持ち込ませなくて済みます。

このコンパニオンオブジェクトが継承しているDAOは以下のように定義しています。

// app/models/DAO.scala

import scala.slick.lifted.TableQuery

private[models] trait DAO {
  val Users = TableQuery[Users]
  val Posts = TableQuery[Posts]
  ...
}

こうすることで他のテーブルを跨ぐクエリ発行するメソッドを実装できるようになります。

controllerの実装

// app/Application.scala

package controllers

import models._
import play.api.db.slick._
import play.api.mvc._

object Application extends Controller {
  def index = DBAction { implicit rs =>
    Ok(views.html.index(Users.findAll))
  }
}

controllerではDBActionを使うことによりセッションの生成を自動で行ってくれます。 そのためDAO側ではimplicit session: Sessionを受け取るよう定義する必要があります。

まとめ

以上のようにセッション周りを考えずにDBを扱うことができるので、ロジックに集中して実装できると思います。

おまけ: slick-playとCake Pattern

play-slickにはProfileというcake patternを簡単に実装するための仕組みがあり、公式サンプルに実装例があります。

ただこのサンプルだとcontrollerで直接slickのクエリを利用していてあまりイケてない感じだったのでDAOを使う方式に書き直してみました。 が、DAOを継承するクラスが増えるたびにインスタンスを作らなければならなくなり、微妙な感じに。

良いやり方があったら教えてください!

# conf/application.conf

slick.default="models.current.dao.*"
// app/models/user.scala

package models

import play.api.db.slick.{Profile, Session}

case class User(id: Int, name: String)

trait UserComponent { this: Profile =>
  import profile.simple._

  class Users(tag: Tag) extends Table[User](tag, "users") {
    def id = column[Int]("id", O.PrimaryKey)
    def name = column[String]("name", O.NotNull)

    def * = (id, name) <> (User.tupled, User.unapply _)
  }
}

class UsersDAO(implicit dao: DAO) {
  import dao._
  import profile.simple._

  def findByName(id: Int)(implicit s: Session): Option[User] = {
    Users filter { _.id === id } firstOption
  }

  def insert(user: User)(implicit s: Session) {
    Users += user
  }
}
// app/models/DAO

package models

import scala.slick.driver.JdbcProfile
import scala.slick.lifted.TableQuery
import play.api.db.slick.{Profile, DB}

class DAO(override val profile: JdbcProfile) extends UserComponent with Profile {
  val Users = TableQuery[Users]
}

object current {
  implicit val dao = new DAO(DB(play.api.Play.current).driver)
  val Users = new UsersDAO
}

playframeworkにpull requestを出す

メモ。あとから追記していきます。

基本

以下のドキュメントをよく読む。TCLAには事前に署名しておくこと。

以下、適当な要約。(英語力皆無かつちゃんと読んでないので、PR出すときには責任をもって各々原文を読んでください)

issue

  • ドキュメントのみの修正の場合はissueを上げずPRを出すこと
  • バグかどうか確信がもてないときはMLで聞くこと
  • 新機能のリクエストについては開発者MLに議題を上げること
  • バグを発見した場合は環境やコードを可能な限り詳細にまとめてissueを上げること

ワークフロー

  1. Typesafe CLAに署名する
  2. 次のガイドラインに則っているか保証する
    • DRY原則ボーイスカウトルールを遵守する
    • 包括的なテストを行う
    • コードのドキュメント性
    • ロックとかスレッドローカルとかいろいろ気をつける
    • javaとscalaのためのフレームワークなので両方に配慮する
    • 新規ファイルにはコピーライトを含める
  3. PRを出す。このときすでにissueが上がっているものの場合は、下記の手順でissueをPRに含める

コミットとコミットコメント

  1. 複数コミットは1コミットにまとめる。詳細はGit flowを参照
  2. コミットコメントの1行目は簡潔に。ただし、チケット番号だけとか、'minor fix'みたいな適当なやつはだめ。issueがある場合は行末に#issue番号を含めること。
  3. 小さな修正でない場合は、2行目を空行とし、3行目以降に詳細なコメントをリスト形式で書く
  4. 特定の誰かへのレビューや他のブランチへのcherry-pickが必要な場合は追記する

ビルド

これを参照しておく。

cd framework
./build

するとsbtが立ち上がるので以下のコマンドを入力。

compile
test
publish-local

ドキュメントの修正

documentation/manual以下がドキュメントとなる。

書き方は以下を参照する。

修正したら以下コマンドで確認を行う。

cd playframework/documentation
./build run # ドキュメントサーバの起動
./build test # テスト
./build validate-docs # バリデーション

このとき

unresolved dependency for com.typesafe.play#sbt-plugin;2.3-SNAPSHOT

というエラーが出る場合は、上記のplayframeworkのビルド手順を実施すること。 (参考: https://groups.google.com/forum/#!topic/play-framework/zfKRKntj4vA)

playとmongodbでrest apiを作る

タイトルの通り、playとmongoでrest apiを実装してみようと思います。

apiの実装ならsprayなどを使う手もあると思いますが、以前playで実装したときにだいぶ残念な感じになってしまったので、リベンジとしてplay-salatのサンプルを咀嚼しつついろいろ書き直してみました。

要件

サンプルとして以下のようなブログの記事を操作するAPIを作成します。 作りたいのはこんな感じの物です。

API
HTTPメソッド エンドポイント 概要
GET /api/posts 記事一覧の取得
POST /api/posts 記事の作成
GET /api/posts/:id 記事の取得
PUT /api/posts/:id 記事の更新
DELETE /api/posts/:id 記事の削除
レスポンスのイメージ
[
    {
        'id':'123456ABCDEF`,
        'title': '今日の晩ご飯',
        'content': '餃子を食べました。',
        'category': '日記'
        'createdAt': '2013-10-05 20:00:00',
    },
]

環境

今回はplay framework 2.2.0と、mongohqというIaaSのmongodb 2.6を利用します。playとmongohqのセットアップは省略。

play-salatの設定

salatはmongodbのscala用公式ドライバであるcasbahを利用した、DBObjectcase classを相互変換するライブラリです。play-salatは読んで字のごとくplay用のsalatです。今回はplay-salatを利用します。

build.sbtの修正

build.sbtに以下を追記します。play 2.2.0以前だとproject/Build.scalaが代わりにデフォルトで作成されるビルド定義だと思いますが、どちらに書いても動くはず。

libraryDependencies ++= Seq(
  "se.radley" %% "play-plugins-salat" % "1.3.0"
)

routesImport += "se.radley.plugin.salat.Binders._"

templatesImport += "org.bson.types.ObjectId"
play.pluginsの作成

conf/play.pluginsを作成し、以下を書き込みます。これはplay-salatがplayのプラグインとして実装されているために必要です。

# プラグインの優先度:プラグインのクラス名
500:se.radley.plugin.salat.SalatPlugin
application.confの修正

conf/application.confに以下の内容を追記します。

# DB周りのプラグインを無効化
dbplugin = disabled
evolutionplugin = disabled
ehcacheplugin = disabled

# mongodb周りの設定 (値は環境に合わせて変更してください)
mongodb.default.host     = "hoge.mongohq.com"
mongodb.default.port     = 100**
mongodb.default.db       = "test-db"
mongodb.default.user     = "test-user"
mongodb.default.password = "test-password"

modelの実装

custom salat context

まず、playのクラスをsalatで扱うためには、salatのmongo contextにplayのクラスローダを追加する必要があります。そのため、salatが用意しているglobal context(com.novus.salat.global)ではなくcustom contextを用意します。

今回は以下のように実装しました。 クラスローダの追加と、mongoのオブジェクトIDのキー名である_idを変換時にidにリマップするように設定しています。

(2013-12-13 追記: id:ryugate さんからのご指摘で一部修正しました)

// app/models/customContext.scala
package models

import com.novus.salat._
import com.novus.salat.dao._
import com.mongodb.casbah.Imports._
import play.api.Play
import play.api.Play.current

package object customContext {
  implicit val context = {
    val context = new Context {
      val name = "custom"
    }
    context.registerGlobalKeyOverride(remapThis = "id", toThisInstead = "_id")
    context.registerClassLoader(Play.classloader)
    context
  }
}

custom contextについては以下のページがわかりやすいです。

case class

いよいよmodelの実装に取りかかります。まず、ブログの投稿データをcase classとして定義します。

// app/models/Post.scala
package models

import com.mongodb.casbah.Imports._
import java.util.Date

case class Post(
  id: ObjectId = new ObjectId,
  title: String,
  content: String,
  category: Option[String] = None,
  createdAt: Date = new Date()
)
DAO

次に実際にクエリを発行するDAOをコンパニオンオブジェクトとして実装します。実際のいろいろなメソッドはここに生やしていく感じになると思いますが、ModelCompanionを継承しておくだけで基本的な操作ができるようになるので楽ちんです。

また、SalatDAOの使い方は以下のページにまとまっています。

// app/models/Post.scala
import com.novus.salat._
import com.novus.salat.dao._
import play.api.Play.current
import se.radley.plugin.salat._

import customContext._

object Post extends PostDAO

trait PostDAO extends ModelCompanion[Post, ObjectId] {
  def collection = mongoCollection("posts")
  val dao = new SalatDAO[Post, ObjectId](collection) {}

  def findByCategory(category: String): SalatMongoCursor[Post] =
    dao.find(MongoDBObject("category" -> category))
}
jsonへの変換

レスポンスはjsonにしたいのでシリアライズできるようにします。 実装方法は大きく分けて2つ、salatのjson変換機能を使うか、playのjson変換機能を使うか、です。 salatの方はjson4sを利用していて、playの方はjerksonを利用して実装されています。

すこし使ってみると、それぞれ以下のような感想を持ちました。

  • salat(json4s)

    • pros
      • 実装が簡単(ModelCompanionを継承するだけ)
      • よくやることはStrategyとして実装されているのでかゆいところに手が届く
    • cons
      • レスポンス時にContentTypeの指定が必要
  • play(jerkson)

    • pros
      • Playに統合されているので暗黙の変換とマクロでそこそこ簡単に扱える
    • cons
      • ちょっと変わったことをしようとするとめんどくさい
      • なにしているのかわかりにくい
salatの場合

salatの機能を利用する場合はModelCompanionのメソッドを使うことでval postJson = Post.toCompactJSON(post)という感じでシリアライズできます。また、レスポンス時にはOk(postJson).as(JSON)といった感じでContentTypeの指定が必要です。

また、custom contextを修正することでシリアライザ全体の挙動を変えることができます。以下はObjectIdDateを文字列としてシリアライズする設定を書き加えた例です。

// app.models.customContext.scala
package models

import com.novus.salat._
import com.novus.salat.dao._
import com.novus.salat.json.{StringDateStrategy, JSONConfig, StringObjectIdStrategy}
import com.mongodb.casbah.Imports._
import org.joda.time.format.ISODateTimeFormat
import org.joda.time.DateTimeZone
import play.api.Play
import play.api.Play.current

package object customContext {
  implicit val context = {
    val context = new Context {
      val name = "custom"
      override val jsonConfig = JSONConfig(
        dateStrategy = StringDateStrategy(
          dateFormatter = ISODateTimeFormat.dateTime.withZone(DateTimeZone.forID("Asia/Tokyo"))
        ),
        objectIdStrategy = StringObjectIdStrategy
      )
    }
    context.registerGlobalKeyOverride(remapThis = "id", toThisInstead = "_id")
    context.registerClassLoader(Play.classloader)
    context
  }
}
playの場合

playの機能を利用する場合は、定義したコンパニオンオブジェクトを以下のように修正するとJson.toJson(user)といった感じにシリアライズすることができます。

// app/models/Post.scala
import play.api.libs.json._
import se.radley.plugin.salat.Binders._

object Post extends PostDAO with PostJson

trait PostJson {
  // 簡単な方法
  implicit val postReads = Json.format[Post]
  implicit val postWrites = Json.writes[Post]

  // 汎用的な方法
  implicit val postWrites = new Writes[Post] {
    def writes(p: Post): JsValue = {
      Json.obj(
        "id" -> p.id,
        "title" -> p.title,
        "content" -> p.content,
        "category" -> p.category,
        "createdAt" -> p.createdAt
      )
    }
  }
  implicit val postReads = (
    (__ \ 'id).read[ObjectId] ~
    (__ \ 'title).read[String] ~
    (__ \ 'content).read[String] ~
    (__ \ 'category).readNullable[String] ~
    (__ \ 'createdAt).read[Date]
  )(Post.apply _)
}

controllerの実装

最後にcontrollerの実装です。以下はすべてplayのjsonシリアライザを使っているコード例となります。

ルーティング

conf/routesに以下を追記します。これでapi用のルーティングを外に出すことができ、routesの見通しが良くなります。

->      /api                      api.Routes

api用のルーティングはconf/api.routesに記述します。

GET     /posts                 controllers.api.Posts.index()
POST    /posts                 controllers.api.Posts.create()
GET     /posts/:id             controllers.api.Posts.show(id: ObjectId)
PUT     /posts/:id             controllers.api.Posts.update(id: ObjectId)
DELETE  /posts/:id             controllers.api.Posts.delete(id: ObjectId)
JsonAction

Jsonを受け取るcreateやupdateのようなメソッドのバリデーションのために、JsonActionという便利Actionを実装します。

// app/controllers/Actions.scala
package controllers

import models._
import play.api.mvc._
import play.api.libs.json._

object Actions extends Results with BodyParsers {

  def JsonAction[A](action: A => Result)(implicit reader: Reads[A]): EssentialAction = {
    Action(parse.json) { implicit request =>
      request.body.validate[A].fold(
        valid = { json =>
          action(json)
        },
        invalid = { e =>
          BadRequest(JsError.toFlatJson(e)).as(JSON)
        }
      )
    }
  }
}
そしてすべてが動きだす

今まで実装してきたmodelなどの集大成としてcontrollerを実装します。 今回の例のようにシンプルなものだとモデルの操作はModelCompanionから継承したメソッドだけで十分ですね。

// app/controllers/api/Posts.scala
package controllers.api

import play.api.mvc._
import play.api.libs.json._
import models._
import controllers.Actions._

import com.mongodb.casbah.WriteConcern
import se.radley.plugin.salat.Binders._

object Posts extends Controller {

  def index() = Action {
    val posts = Post.findAll().toList
    Ok(Json.toJson(posts))
  }

  def create = JsonAction[Post] { post =>
    Post.save(post, WriteConcern.Safe)
    Ok(Json.toJson(post))
  }

  def show(id: ObjectId) = Action {
    Post.findOneById(id).map { post =>
      Ok(Json.toJson(post))
    } getOrElse {
      NotFound
    }
  }

  def update(id: ObjectId) = JsonAction[Post] { requestPost =>
    val post = requestPost.copy(id)
    Post.save(post, WriteConcern.Safe)
    Ok(Json.toJson(post))
  }

  def delete(id: ObjectId) = Action {
    Post.removeById(id)
    Ok("")
  }
}

まとめ

ちょっと長くなってしまいましたが以上で実装完了です。 ライブラリでラップされているおかげでコード量は少なくなるものの、各レイヤでやっていることが異なるためドキュメントを漁るのが大変でした。 また書いていて感じましたが、playとsalatそれぞれのJSONシリアライザの長所短所を理解して扱うのがポイントな気がします。