Mongorillaでリレーション管理
Mongorillaでリレーションを管理する例として、読書管理アプリを考えてみました。
読書管理アプリは、本棚に本を登録し、どの本をどこまで読んだかを管理するツールです。
本を読む前に、本棚から本を取り出して、本を読んだ後に読んだページ数を登録することで更新します。
モデル図は下記の通り。
- 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の更新順を意識して、アプリケーションを作成する必要があります。
実装
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: "イベリコ豚"
まとめ
上記のアプリを仕様検討も含め、5時間弱で作成できたので、Mongorillaは記述しやすいミドルウェアだと思います。 アプリでリレーションを管理することにより、煩雑になっている部分はありますが、その変わりDBへのアクセス数を減らし、 不整合を起こさないような作りができます。
| 固定リンク
| コメント (0)
| トラックバック (0)
最近のコメント