« MongoDBでのリレーションのパターン | トップページ | MasterRecord: Object Mapper for CSV TSV YAML »

2011年12月13日 (火)

Mongorillaでリレーション管理

Mongorillaでリレーションを管理する例として、読書管理アプリを考えてみました。
読書管理アプリは、本棚に本を登録し、どの本をどこまで読んだかを管理するツールです。
本を読む前に、本棚から本を取り出して、本を読んだ後に読んだページ数を登録することで更新します。
モデル図は下記の通り。
Bookshelf

  • UserとBookshelfは1対1の外部リレーション関係。
  • UserとReadingLogは1対多の外部リレーション関係。
  • UserとCurrentBookInfoは1対1の内部リレーション関係。
  • BookshelfとBookInfoは1対多の内部リレーション関係(Array形式)。

処理

本追加時

Bookshelfにbook_info_recordsにrecordを追加する本の数だけ追加。
book_info_recordsは、 Bookshelfのembedのため、複数の本もAtomicに同時に追加できる。

読む本を選択時

1. current_book_info_recの内容を、Bookshelfのbook_info_recordsのうち同じbook_idのレコードのデータに上書きする。
2. Bookshelfのbook_info_recordsのうちユーザが選択したbook_idのレコードで、 current_book_info_recの内容を上書きする。
2.の処理が失敗しても、1.の処理を何度実行しても同じ結果のため、問題がない。

読書情報記述時

1. ReadingLogに読んだユーザと本を読んだページ数を登録する。  
2. Userのtotal_read_page_numとcurrent_book_info_recのpage_num,updated_atを更新する。  
これらは1レコードのためatomicに更新できる。
また、2が失敗しても1.の情報や過去のReadingLog を元にデータを補正できる。

注意点

current_book_info_recの本のレコードは、bookshelfのbook_info_recordsの同じ本のデータと差異があります。
読む本を変更した際に、book_info_recordsにcurrent_book_info_recのデータを反映することで更新されます。
current_book_info_recを作成せず、直接bookshelfのbook_info_recordsを更新すればいいと感じる人も 多いと思いますが、あえてcurrent_book_info_recと分けている理由は、ユーザの一動作につき、できるだけ少ない documentの読み込みと更新を実現するためです。
もし、bookshelfのbook_info_recordsを直接更新しようとすると、読書後にUserのtotal_read_page_num の更新とBookshelfのbook_info_recordsの更新という2回のdocumentの更新が必要となります。
トランザクション のないMongoDBでは、片方のdocumentしか更新されなかい不整合の状態が発生し得ます。
そのため、不整合が起きないように単一のdocumentのみの更新にするか、起きてもリカバリーできるような documentの更新順を意識して、アプリケーションを作成する必要があります。

実装

github上のソース

user.rb

#coding: utf-8
require 'mongorilla'
require File.expand_path("./current_book_info", File.dirname(__FILE__))
require File.expand_path("./bookshelf", File.dirname(__FILE__))
require File.expand_path("./reading_log", File.dirname(__FILE__))
class User
  UserFields = [:_id,:name,:total_read_page_num,:current_book_info_rec]
  include Mongorilla::Document

  def self.build(name)
    User.create( :name => name,
              :total_read_page_num => 0,
              :current_book_info_rec => {}
              )
  end

  def current_book_info
    @current_book_info ||= CurrentBookInfo.new(self,self.current_book_info_rec)
  end

  def current_reading_logs
    ReadingLog.find({:user_id => self.id},{:sort => [["created_at","desc"]],:limit=>5})
  end

  def bookshelf
    return @bookshelf  if @bookshelf
    @bookshelf = Bookshelf.find_one("user_id" => self.id)
    unless @bookshelf
      @bookshelf = Bookshelf.build(self.id)
    end
    return @bookshelf
  end
end

self.build

デフォルト値をセットするために、呼び出し元にcreateではなく、buildを呼んでもらうようにする

current_book_info

current_book_infoはcurrent_book_info_recのHashを使ってModelを作成し、Userと1対1の内部リレーションを形成。 Userの使用元には、current_book_info_recのフォーマットを意識させない。

current_reading_logs

ReadingLogはUserと1対多の外部リレーション。 すべて取ってくると大きすぎるので、直近5件のみを取得

bookshelf

BookshelfはUserと1対1の外部リレーション。 Bookshelfのデータは大きくなることが予測されるので、外出しすることでUserのfindの処理を軽減させる。 まだ該当ユーザのBookshelfが作成されていない場合は、参照時に作成することで、Userのレコードしかない 状態では、必ずBookshelfが作成されるため、整合性がとれた状態になるようになる。

book_info_field.rb

#coding: utf-8
module BookInfoFields
  Fields = [:book_id,:page_num,:created_at,:updated_at]

  Fields.each do |f|
    define_method(f) { @info[f.to_s] }
  end

  def renewal(new_info)
    Fields.each do |f|
      @info[f.to_s] = new_info.send(f)
    end
  end
end

renewal

current_book_info_recとbook_info_recordsの各レコードは同じフィールドを持つので、共通の処理をモジュールとして作成していて、renewalは、current_book_info_recのレコードをbook_info_recordsの該当レコードに反映する時、またはbook_info_recordsのレコードをcurrent_book_info_recに反映する際に使用。

current_book_info.rb

#coding: utf-8
require File.expand_path("./book_info_fields", File.dirname(__FILE__))
class CurrentBookInfo
  include BookInfoFields

  def change(new_info)
    @user.changes["$set"] ||= {}
    renewal(new_info)
    @user.changes["$set"]["current_book_info_rec"] = @info
  end

  def inc(num)
    now = Time.now
    ReadingLog.create(:book_id=>self.book_id,:user_id=>@user.id,:page_num => num,:created_at => now)
    @user.changes["$inc"] ||= {}
    @user.changes["$inc"]["current_book_info_rec.page_num"] = num
    @user.changes["$set"] ||= {}
    @user.changes["$set"]["current_book_info_rec.updated_at"] = now
    @info["page_num"] += num
    @info["updated_at"] = now
  end

  def initialize(user,rec)
    @user = user
    @info = rec
  end
end

change

current_info_recの内容をnew_infoに更新し、save時にDBに反映されるようにしている。HashやArrayは要素の変更をMongorillaが認識しないため、Mongoドキュメントのオブジェクトのchangeを自分で編集している。@user.current_book_info_rec = @info.dupにすればMongorillaが変更を認識するため、代替可能。

inc

読書した情報をReadingLogに書き込み、current_book_info_recを更新して、更新した内容をsave時にDBに反映されるようにしている。incを使うことで、ページ数の書き込みが競合しても上書きされない。

initialize

embedのモデルは、親への参照を持ち、自身のレコードが変更された際は、親(Mongoドキュメントオブジェクト)のchangesにその内容を反映させる。

bookshelf.rb

#coding: utf-8
require 'mongorilla'
require File.expand_path("./book_info", File.dirname(__FILE__))
class Bookshelf
  BookshelfFields = [:_id,:user_id,:book_num,:book_info_records]
  include Mongorilla::Document

  alias save_orig save

  def self.build(user_id)
    Bookshelf.create( :user_id => user_id,
              :book_num => 0,
              :book_info_records => []
              )
  end

  def book_infos
    self.book_info_records.map{|r| BookInfo.new(self,r)}
  end

  def book_info(book_id)
    rec = self.book_info_records.find{|r| r["book_id"] == book_id}
    return nil unless rec
    BookInfo.new(self,rec)
  end

  def add_book(book_id)
    @new_books ||= []
    @new_books.push(book_id)
    BookInfo.add(self,book_id)
  end

  def update_book(rec)
    book_info(rec.book_id).update(rec.page_num,rec.updated_at)
  end

  def save_new(condition={},opt={})
    if @new_books
      self.inc("book_num",@new_books.count)
      save_orig(condition.merge!({"book_info_records.book_id" => {"$nin" => @new_books}}),opt)
    else
      save_orig(condition,opt)
    end
  end
  alias save save_new
end

self.build

デフォルト値をセットするために、呼び出し元にcreateではなく、buildを呼んでもらうようにする

book_infos

book_infosはbook_info_recordsのArrayを使ってModelを作成し、Bookshelfと1対多の内部リレーションを形成。 Bookshelfの使用元には、book_info_recordsのフォーマットを意識させない。Arrayではなく、Hashでもeachを 使いほぼ同じ処理で実現できる。

book_info

指定したbook_idの本があれば、Modelにマッピングして返却。なければnilを返すのでその本を所有しているかの 調査にも使える。

add_book

Bookshelfに本を追加する際のメソッド。本自体の情報はBookInfoが持つためBookInfoに移譲しているが、複数の本を 追加した際に何冊追加したか、また登録済の本を更新しない条件をsave時に認識できるように 便宜的にnew_booksと いう配列に追加した本のIDを登録

update_book

recの内容を同じidを持つbook_infoに更新するよう移譲

save_new

本の追加時にすでに別プロセス等により登録されている本を追加できないようにするため、saveに条件を指定している。 bookshelfのsaveの呼び出し側はそのことを意識しないですむようにするため、saveをwrapperしている。 book_numのレコードは、配列のサイズは$size => 5などの等比の条件はできるが、$gtなどの比較は使えないため便宜的に 配列のサイズを別レコードで持つことで、 $ltを使えるようにして、要素が10個未満の場合は登録できるようにするなどの 処理が行えるようになる。

book_info.rb

#coding: utf-8
require File.expand_path("./book_info_fields", File.dirname(__FILE__))
require File.expand_path("./bookshelf", File.dirname(__FILE__))
class BookInfo
  include BookInfoFields

  def update(page_num,updated_at)
    idx = @bookshelf.book_info_records.index{|r| r["book_id"] == @info["book_id"] }
    @bookshelf.changes["$set"] ||={}
    @bookshelf.changes["$set"]["book_info_records.#{idx}.page_num"] = page_num
    @bookshelf.changes["$set"]["book_info_records.#{idx}.updated_at"] = updated_at
    @info["page_num"] = page_num
    @info["updated_at"] = updated_at
  end

  def self.add(bookshelf,book_id)
    bookshelf.changes["$pushAll"] ||= {}
    bookshelf.changes["$pushAll"]["book_info_records"] ||= []
    if bookshelf.origin["book_info_records"].find{|r| r["book_id"] == book_id}
      raise "already exists!!"
    end
    if bookshelf.changes["$pushAll"]["book_info_records"].find{|r| r["book_id"] == book_id}
      raise "already push!!"
    end
    now = Time.now
    record = {"book_id" => book_id,"page_num" => 0,"created_at" => now,"updated_at" => now}
    bookshelf.changes["$pushAll"]["book_info_records"].push(record)
    bookshelf.book_info_records.push(record)
  end

  def initialize(bookshelf,rec)
    @bookshelf = bookshelf
    @info = rec
  end
end

update

page_num,updated_atの内容をbook_infoに更新し、save時にDBに反映されるようにしている。配列の更新は.添字.フィールドで アクセスできる。

self.add

更新時に、複数のbook_infoが登録できるように$pushAllを使用している。Mongoドキュメントオブジェクトには、originというメソッドで DBから取得した時点でのレコードにアクセスできる。

initialize

embedのモデルは、親への参照を持ち、自身のレコードが変更された際は、親(Mongoドキュメントオブジェクト)のchangesにその内容を反映させる。

reading_log.rb

#coding: utf-8
require 'mongorilla'
class ReadingLog
  ReadingLogFields = [:_id,:book_id,:user_id,:page_num,:created_at]
  include Mongorilla::Document
end

bookshelf_spec.rb

#coding: utf-8
require 'rspec'
require 'logger'
require File.expand_path("./user", File.dirname(__FILE__))

describe  Bookshelf do
  before do
    Mongorilla::Collection.build(File.expand_path("./mongo.yml", File.dirname(__FILE__)))
    @user = User.build(:name => "morita")
  end

  context "bookshelf" do
    it{@user.bookshelf.should_not be_nil}
  end

  describe "本を追加(正常)" do
    before do
      @user.bookshelf.add_book("1")
      @user.bookshelf.add_book("2")
      @user.bookshelf.save
      @bookshelf = Bookshelf.find(@user.bookshelf.id)
    end
    it{ @bookshelf.book_infos.map(&:book_id) =~ ["1","2"]}
    it{ @bookshelf.book_num.should == 2}
  end

  describe "本を追加(異常)" do
    before do
      @user.bookshelf.add_book("1")
      @bookshelf = Bookshelf.find(@user.bookshelf.id)
      @bookshelf.add_book("1")
      @user.bookshelf.save
      @ret = @bookshelf.save
      @bookshelf.reload
    end
    it{ @ret.should == false}
    it{ @bookshelf.book_infos.map(&:book_id) =~ ["1"]}
    it{ @bookshelf.book_num.should == 1}
  end

  describe "読書結果を反映" do
    before do
      @user.bookshelf.add_book("1")
      @user.bookshelf.add_book("2")
      #本の追加を更新
      @user.bookshelf.save
      @user.current_book_info.change(@user.bookshelf.book_infos[0])
      #手元の本をBookID1の本にする
      @user.save
      #本を10ページ読みすすめる
      @page_num=10
      @user.inc("total_read_page_num",@page_num)
      @user.current_book_info.inc(@page_num)
      @user.save
      @user.reload
    end
    it{@user.total_read_page_num.should == @page_num}
    it{@user.current_book_info.page_num.should == @page_num}
    it{@user.current_reading_logs.count.should == 1}
    describe "本を変更" do
      before do
        @user.bookshelf.update_book(@user.current_book_info)
        #本棚の本の情報を手元の本の情報で更新
        @user.bookshelf.save
        @user.current_book_info.change(@user.bookshelf.book_infos[1])
        #手元の本をBookID2の本にする
        @user.save
        @user.reload
      end
      it{@user.current_book_info.page_num.should == 0}
    end
  end
  after do
    User.remove()
    Bookshelf.remove()
  end
end

mongo.yml

database: bookshelf

BookMaster.yml(未使用)

"1":
  author: "Robert C. Jacke"
  title: "Coop Save money"
"2":
  author: "中島かも"
  title: "イベリコ豚"
マスターデータは、JoinがないMongoDBの場合、静的ファイルで持ち、アプリ起動時に読みこむようにするなど、DBに登録しないほうがいい。

まとめ

上記のアプリを仕様検討も含め、5時間弱で作成できたので、Mongorillaは記述しやすいミドルウェアだと思います。 アプリでリレーションを管理することにより、煩雑になっている部分はありますが、その変わりDBへのアクセス数を減らし、 不整合を起こさないような作りができます。

|

« MongoDBでのリレーションのパターン | トップページ | MasterRecord: Object Mapper for CSV TSV YAML »

MongoDB」カテゴリの記事

コメント

コメントを書く



(ウェブ上には掲載しません)




トラックバック

この記事のトラックバックURL:
http://app.cocolog-nifty.com/t/trackback/68673/53472132

この記事へのトラックバック一覧です: Mongorillaでリレーション管理:

« MongoDBでのリレーションのパターン | トップページ | MasterRecord: Object Mapper for CSV TSV YAML »