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シリアライザの長所短所を理解して扱うのがポイントな気がします。