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