2010年12月16日木曜日

Scala+Lift+MongoDBでのタイムライン&購読機能実装

(この記事はScala Advent Calendar jp 2010の10日目です。というか今日誕生日だった、、、)

今個人的に作っているサービスで実装している機能なんですが、MongoDBを利用していてあまり事例がないケースかもしれないので細かく書いてみます。テスト環境上で稼働させているので実際にどんな動作をするかも確認できます。

前提条件が多いのでScalaの話になるまで少しかかりますがご了承ください。。。

説明編

作る機能
まず、機能の概要から。
  1. テーマ毎に投稿のタイムラインがある
  2. ユーザは好きなテーマを購読できる
  3. 購読したテーマの投稿をまとめたタイムラインがホームに表示される
  4. ポストにはテーマと投稿者の名前とアイコンが表示される
ざっくり上記4つの要件を満たすものを作ります。
MongoDBの特徴とか
まず簡単にMongoDBの説明から。Scalaを活用されてる皆様ならみんな詳しいと思うのでざっくり&個人的にいいと思う点など。
  • スキーマレスで事前定義なしでコレクションを保存できる。
    ただこのメリットはO/Rマッパが強力でテーブル定義を自動でやってくれるSchemifierがあるLiftではそこまで価値は無い気もする
  • Replicaset + Shardingによる高スケーラビリティ
  • 配列を含むレコードなど複雑なデータ構造を保存できる。
    配列内の要素にマッチするfindを使えるなど豊富なクエリ機能があってこれを活用します。
  • JoinおよびTransactionはできない。これが要件(4)で問題になるので工夫が必要になります。
サンプルアプリ
http://dev-ct3.catary.net/
id : scalaadvent
pass : mongodb

で動きを確認できます。登録されてるデータ等については深く気にしないでください。。。
適当なデータで新規登録してから使ってもらってもOKです。
データの構造
この機能で使っているテーブル(コレクション)は4種類で、うち2種類でMongoDBを利用しています。
  • [RDB]User:ユーザの認証とNickname、Icon等を格納
  • [RDB]Theme:テーマの情報(名前、Icon)等を格納
  • [MongoDB]Subscriber:UserとThemeの関連(購読情報)を格納
  • [MongoDB]Post:投稿の情報を格納
MongoDB側の構造と関連するインターフェイスは以下図のようになっています。
このインターフェイス部分をScala+Liftで実装していきます。

処理のポイント
  • 購読者の情報は投稿1つ1つに配列で持つ。MongoDBはドキュメント内の配列もインデックスと検索をかけられるので後から参照するのが楽
  • ニックネームやサムネイル等もそれぞれ持つ。MongoDBはjoinができないので常にキャッシュを持っている感じ。

実装編

LiftのmongoDBサポート
Liftには標準でMongoDB関連のサポートがあるため、ありがたく利用させててもらいます。
http://www.assembla.com/wiki/show/liftweb/lift-mongodb
を見れば大体わかるというかほぼそのまま使ってます。
1.dependencyの追加
Mavenを使っているのでPOM.xmlのdependencyに必要なモジュールを追加します。sbtの場合もきっと似たような事をする必要があるかと。
<dependency>
<groupId>net.liftweb</groupId>
<artifactId>lift-mongodb_2.8.0</artifactId>
<version>2.1</version>
</dependency>
<dependency>
<groupId>net.liftweb</groupId>
<artifactId>lift-mongodb-record_2.8.0</artifactId>
<version>2.1</version>
</dependency>
必要なimportは以下5つほど。JsonDSLはいい感じにJsonを記述できるようになるパーサのようです。
import net.liftweb.mongodb._
import net.liftweb.mongodb.record._
import net.liftweb.json._
import net.liftweb.json.JsonAST.JObject
import net.liftweb.json.JsonDSL._
2.MongoDocumentを利用してモデル定義
チュートリアルにあるように、trait MongoDocumentとtrait MongoDocumentMetaを利用してモデルを定義します。このへんなMapperと似たような感じです。
import net.liftweb.mongodb._
import net.liftweb.mongodb.record._

case object CataryMongoIdentifier extends MongoIdentifier {
val jndiName = "catary"
}

case class mTheme( id: String, cataryId:String, name: String, thumb: String )

case class mUser( id: String, cataryId:String, name: String, thumb: String )

case class mCtSubscriber(_id: String, theme: mTheme, user: mUser, regdate: String )
extends MongoDocument[mCtSubscriber] {
def meta = mCtSubscriber
}

object mCtSubscriber extends MongoDocumentMeta[mCtSubscriber] {
override def mongoIdentifier = CataryMongoIdentifier
override def collectionName = "subscriber"
}

case class mSubscribers( user:String )

case class mCtPost(_id: String, theme: mTheme, user: mUser, catagory: String, text:String, time:String , subscribers:List[String] )
extends MongoDocument[mCtPost] {
def meta = mCtPost
}

object mCtPost extends MongoDocumentMeta[mCtPost] {
override def mongoIdentifier = CataryMongoIdentifier
override def collectionName = "post"
}
MapperよりもScalaのオブジェクトそのものっぽく定義できるので気分がいいです。List[String]を持ってしまえるあたりとか。
mThemeとmUserはmCtPostとmCtSubscriber両方が持っているなど構造的にも自然です。
3.投稿(Insert)の実装
ドキュメント(RDBでいうレコード)を保存するには、2で作ったモデルを作ってsaveするだけで終わります。が今回は「購読したものだけのタイムライン」を実装するためにSubscriberコレクションから現時点での購読者を取得して各投稿データに持たせます。
問題なのはその際にmongoDBのdistinct(特定のフィールド、条件の値を配列で返す構文)を使いたいのですがどうやらLiftのMongoDocumentでは提供されていない、、、。ただ、Javaのcom.mongodbを利用すれば使えるのでそちらを経由してどうにかします。

MongoDB.use(CataryMongoIdentifier) ( db => {
val coll = db.getCollection("subscriber")
val search = new BasicDBObject
search.put("theme.id",theme.id.toString)
/*
* List展開部分は要改善。このままだと非効率。。。
*/
var subscribers:List[String] = List()
coll.distinct("user.id",search).foreach(v => subscribers = subscribers.::(v.toString) )

//ここからドキュメントの保存。長く見えるものの実際はインスタンスを生成してsaveするだけ
val post = mCtPost(
ObjectId.get.toString,
mTheme(
theme.id.toString,
theme.cataryId.toString,
theme.themeName.toString,
UrlHelper.themeThumb(theme)
),
mUser(
user.id.toString,
user.cataryId.toString,
user.nickname.toString,
UrlHelper.userThumb(user)
),
"post",
text,
(new Date()).getTime().toString,
subscribers
)
post.save
4.テーマの購読
テーマ購読時はSubsucrierのインスタンスを生成+saveした上で、既存Postの購読者にも追加します。
updateの第一引数は検索条件、第二引数がupdateの内容になるのですが、ここで"$addToSet"を使うと配列に上書きすることができます。"$push"も似たような動きですが"$addToSet"は同じ値が複数入らない、はず。。。
第三引数で渡しているMultiは条件に一致したドキュメントを全て更新するための指示です。MongoDBのupdateはデフォルトだと発見された1件目のみの更新をします。
val subsucribe = mCtSubscriber(
ObjectId.get.toString,
mTheme(
theme.id.toString,
theme.cataryId.toString,
theme.themeName.toString,
UrlHelper.themeThumb(theme)
),
mUser(
user.id.toString,
user.cataryId.toString,
user.nickname.toString,
UrlHelper.userThumb(user)
),
(new Date()).getTime().toString
)
subsucribe.save
//既存データの更新
mCtPost.update(("theme.id",theme.id.toString),("$addToSet" -> ("subscribers"->user.id.toString)),Multi)
5.テーマの購読解除
解除する際はfindを利用して1件データを取得し、deleteを呼び出します。購読時とは逆に、"$pull"を指定して購読者を取り除きます。こういった「ドキュメント内配列のあるデータだけ操作する」実装が楽にできるのがMongoDBのいいところです。
mCtSubscriber.find(("user.id" -> user.id.toString) ~ ( "theme.id" -> theme.id.toString)) match {
case Some(subscriber) => subscriber.delete
case _ =>
}

//既存データの更新
mCtPost.update(("theme.id",theme.id.toString),("$pull" -> ("subscribers"->user.id.toString)),Multi)
6.タイムラインの取得
問題の「購読しているテーマの投稿をまとめる」機能ですが、ここが簡単でfindAll一行で終わりです。
これを書くためにMongoDBにしているといっても過言ではないくらいのあっけなさ。
第一引数で「subscribers内にuser.idが含まれるドキュメント」という抽出をしています。
第二引数はsort順で、時間の降順を指定しています。またSkip、Limitを使うと取得範囲を指定できるのでPaginationの処理で利用します。(今回はとりあえず先頭100件)
あとはflatMapでSnippetに適当にバインドしましょう。
mCtPost.findAll(("subscribers" -> user.id.toString),("time" -> -1),Skip(0),Limit(100)).flatMap( item =>
bind("item", xhtml,
// Snippetへのbindを書く
// "name" -> data
)
)
7.プロフィール更新時の処理
購読者コレクション、投稿コレクションともにある時点のニックネームやアイコンをそのまま保持しているのでマスタが更新された場合は合わせて更新してあげる必要があります。これも1行ずつ書けば終わりです。
mCtSubscriber.update(("theme.id",theme.id.toString),("$set" -> ("theme.thumb" -> UrlHelper.themeThumb(theme)) ),Multi)
mCtPost.update(("theme.id",theme.id.toString),("$set" -> ("theme.thumb" -> UrlHelper.themeThumb(theme)) ),Multi)
8.課題
とりあえず動いていますが色々と課題はあり。
  • distinct時の効率が悪い。そもそも一度リストを取得しているのがばかばかしいので、ここはストアードファンクションにしてしまった方がいいかもしれない
  • 冗長なデータが多いのでデータが多くなるとディスクを大量に消費しそう
  • この実装でパフォーマンスがどれだけ出るのか未知数。記述は楽なんだけど・・・
  • 既存データへのupdate系は非同期に処理させるようにしたい。Scalaの場合spawnを使えばいいんだろうか?

まとめ

さくっと書こうとしたものの長いうえに説明しきれてない感じになってしまった、、、すみません。
アプリに組み込んでしまってるのでMongoDB部分のコードだけ見てもわかりにくい部分もあるかと思います。自分で作ってるものなのでTwitterで声かけてもらえればより詳しい話しや他のコードを見せられます。

MongoDBに関しては、RDBでは面倒だった処理が簡単に書けたり、逆にRDBで簡単にできることがまったくできなかったり使いどころは難しいですがうまく使えば面白いことができそうです。

Lift+MongoDBの組み合わせがそもそも事例少なすぎなので色々試してフィードバックできたらと思っています。という感じで明日の人にバトンタッチ!

おまけ

今作ってるアプリの資料。近々Openするつもり。

0 件のコメント:

コメントを投稿