play-slickを使う
先日play-slickを使ってみたのでそのメモ。
play-slickとは
その名の通り、playframeworkでslickを使うためのプラグインです。
play-slickを使うと
- playframeworkのデフォルトのDB設定をそのまま使い回せる
- slickのスキーマ変更に合わせて自動でevolution用のmigrationファイルを作成してくれる
- セッション周りの扱いが楽になる
といったメリットがあります。
ちなみにplay-slickはplayframework 2.3に統合される予定とのこと。
環境
今回試した環境です。
セットアップ
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を簡単に実装するための仕組みがあり、公式サンプルに実装例があります。
- https://github.com/freekh/play-slick/blob/master/src/main/scala/play/api/db/slick/Profile.scala
- https://github.com/freekh/play-slick/tree/master/samples/play-slick-cake-sample
ただこのサンプルだと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には事前に署名しておくこと。
- http://www.playframework.com/documentation/2.2.x/Guidelines
- https://github.com/playframework/playframework/blob/master/CONTRIBUTING.md
以下、適当な要約。(英語力皆無かつちゃんと読んでないので、PR出すときには責任をもって各々原文を読んでください)
issue
- ドキュメントのみの修正の場合はissueを上げずPRを出すこと
- バグかどうか確信がもてないときはMLで聞くこと
- 新機能のリクエストについては開発者MLに議題を上げること
- バグを発見した場合は環境やコードを可能な限り詳細にまとめてissueを上げること
ワークフロー
- Typesafe CLAに署名する
- 次のガイドラインに則っているか保証する
- PRを出す。このときすでにissueが上がっているものの場合は、下記の手順でissueをPRに含める
コミットとコミットコメント
- 複数コミットは1コミットにまとめる。詳細はGit flowを参照
- コミットコメントの1行目は簡潔に。ただし、チケット番号だけとか、'minor fix'みたいな適当なやつはだめ。issueがある場合は行末に
#issue番号
を含めること。 - 小さな修正でない場合は、2行目を空行とし、3行目以降に詳細なコメントをリスト形式で書く
- 特定の誰かへのレビューや他のブランチへの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を利用した、DBObject
とcase 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の指定が必要
- pros
play(jerkson)
- pros
- Playに統合されているので暗黙の変換とマクロでそこそこ簡単に扱える
- cons
- ちょっと変わったことをしようとするとめんどくさい
- なにしているのかわかりにくい
- pros
salatの場合
salatの機能を利用する場合はModelCompanion
のメソッドを使うことでval postJson = Post.toCompactJSON(post)
という感じでシリアライズできます。また、レスポンス時にはOk(postJson).as(JSON)
といった感じでContentTypeの指定が必要です。
また、custom contextを修正することでシリアライザ全体の挙動を変えることができます。以下はObjectId
とDate
を文字列としてシリアライズする設定を書き加えた例です。
// 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シリアライザの長所短所を理解して扱うのがポイントな気がします。