« 2011年11月 | トップページ | 2012年3月 »

2011年12月

2011年12月18日 (日)

MasterRecord: Object Mapper for CSV TSV YAML

MasterRecordについて

CSVやTSV,YAMLなど任意のレコードをObjectにmapperするためのライブラリ。
各レコードをオブジェクトとして.アクセスできるようになり、find(id)や
find(条件のHash) 例 find(:name => "morita",:age=>10)
find_by_項目名 例find_by_name("morita"),find()で全件取得できる。
クラスのフィールドの定義も宣言的なため、Modelの管理が楽。
Join機能がないMongoDBを使う際に、MasterRecordをDBではなく、Memory
上に持つようにした。

install

gem install MasterRecord

repository

https://github.com/takeshy/MasterRecord

sample


# coding: utf-8
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')

class User
  UserFields = {
    :name => MasterRecord.string,
    :age => MasterRecord.integer,
  }
  include MasterRecord
end
class Item
  ItemFields = {
    :name => MasterRecord.string,
    :price => MasterRecord.integer,
  }
  include MasterRecord
end
class Country
  CountryFields = {
    :name => MasterRecord.string,
    :population => MasterRecord.integer,
    :salutation => lambda{|r| "'#{r}!!'" }
    :now => lambda{|r|"lambda{ Time.now.localtime('" + r + "')}"},
  }
  include MasterRecord
end
describe "Masterrecord" do
  describe "csv" do
    before do
      #id,name,age
      #1,ひろし,10
      #2,たけし,20
      #3,まこと,30
      #4,けん,40
      User.load_data(MasterRecord::CSV.load_file(File.expand_path("../data/user.csv", File.dirname(__FILE__)),true))
    end
    it{ User.find().count.should == 4}
    it{ User.find("1").name.should == "ひろし"}
    it{ User.find_by_name("ひろし")[0].age.should == 10}
    it{ User.find(:name => "たけし",:age => 21).should == []}
    it{ User.find(:name => "たけし",:age => 20).count.should == 1}
    it{ User.find_one(:name => "たけし",:age => 20).id.should == "2"}
  end
  describe "tsv" do
    before do
      #1  あめ  30
      #2  チョコレート  40
      #3  ガム  50
      Item.load_data(MasterRecord::TSV.load_file(File.expand_path("../data/item.tsv", File.dirname(__FILE__))))
    end
    it{ Item.find().count.should == 3}
    it{ Item.find_by_price(50)[0].name.should == "ガム"}
  end
  describe "yml" do
    before do
      #1:
      #  name: "Japan"
      #  population: 120000000
      #  salutation: "こんにちは"
      #  now: "+09:00"
      #2:   
      #  name: "China"
      #  population: 500000000
      #  salutation: "您好"
      #  now: "+08:00"
      Country.load_data(
        MasterRecord::YAML.load_file(Country.fields,File.expand_path("../data/country.yml", File.dirname(__FILE__))))
      @now = Time.new(2011,12,18,1,1,0)
      Time.stub!(:now).and_return(@now)
    end
    it{ Country.find().count.should == 2}
    it{ Country.find_one_by_population(500000000).name.should == "China"}
    it{ Country.find().map(&:salutation).should == ["こんにちは!!","您好!!"]}
    it{ Country.find("1").now.call.to_s.should == "2011-12-18 01:01:00 +0900"}
    it{ Country.find("2").now.call.to_s.should == "2011-12-18 00:01:00 +0800"}
  end
end

使い方

1.Masterデータ(静的なデータ)を使うクラスにクラス名+Filedsの名前で{:フィールド名 => 読み込んだ値を変換するlambda,...}のレコードを作成し、MasterRecordをincludeする。単純な文字列、及び数値に変換するlambdaはMasterRecord.string,MasterRecord.integerとして用意されている。このlambdaはデータアクセス時ではなく、データ作成時に呼ばれるため、文字列の場合は、データを''でくくった文字列を返すlambdaを作成する。
文字列や数値だけでなく、任意のObjectをセットすることができる。

2.アプリ起動時にMasterデータ(静的なデータ)を使うクラス.load_data(定義したフィールド順に並んだ配列の配列)を呼び出してデータをロードさせる。
便宜的にCSV,TSV,YAMLに関しては下記の関数を呼び出すことで、定義したフィールド順に並んだ配列の配列を作成できる。*厳密に言うと、配列の配列でなくても、eachが定義され、eachの呼び出し時にフィールド順に並んだ配列を返却するようなオブジェクト
CSVの場合: MasterRecord::CSV.load_file(ファイルのパス、ヘッダ行のあり/なし(デフォルトなし))
TSVの場合: MasterRecord::TSV.load_file(ファイルのパス、ヘッダ行のあり/なし(デフォルトなし))
YAMLの場合: MasterRecord::YAML.load_file(フィールドのHash,ファイルのパス)

API

MasterデータClass.find(id | Hash )

findに何も渡されなかった場合MapperしたObjectの配列がは全レコード分返る
findにidが渡された場合は、指定したIDをもつレコードをMapperしたObjectが返る
findにHashが渡った場合は、Hashのキーと値の組み合わせに一致したレコードをMapperしたObjectの配列が返る
ない場合は、[](空配列)が返る。

MasterデータClass.find_one(id | Hash )

findに何も渡されなかった場合は、最初のレコードに対してMapperしたObjecが返る
findにidが渡された場合は、指定したIDをもつレコードをMapperしたObjectが返る
findにHashが渡った場合は、Hashのキーと値の組み合わせに一致したレコードをMapperしたObjectが返る
ない場合は、nilが返る。

MasterデータClass.find_by_フィールド名(value )

指定したフィールドに対して、引数で渡されたvalueと一致したレコードをMapperしたObjectの配列が返る
ない場合は、[](空配列)が返る。

MasterデータClass.find_one_by_フィールド名(value )

指定したフィールドに対して、引数で渡されたvalueと一致したレコードをMapperしたObjectが返る
ない場合は、nilが返る。

| | コメント (0) | トラックバック (0)

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へのアクセス数を減らし、 不整合を起こさないような作りができます。

| | コメント (0) | トラックバック (0)

MongoDBでのリレーションのパターン

1対1の外部リレーション

embedの機能があるMongoDBで1対1の外部リレーションを使いたい場合というのは、そんなにないと思います。 しいて挙げるなら、あまり参照しないがデータサイズが大きいため、毎回読み込むのは避けたい場合ぐらい。 ただ、その場合でもfind時に必要なフィールドのみを取得できるオプション があり、それを使えば1対1で外部リレーションさせた場合と同じ効果が得ら れます。

1対多で他Documentとのリレーション

ログ等無限にどんどん増えるデータは、embedには向かないため、外部リレーションの関係になります。また細かい条件で検索したいレコードも 外部リレーションの1対多となります。例えば、10個以上20個未満のアイテムを探したいと思い、 find({:item.num => {"$gte" => 10,"$lte" => 20}})としても、1レコード全体を対象にするため、下記のレコードがHitしてしまいます。
{:item => [{:num => 5,:name => "りんご"},{:num => 25,:name => "みかん"}]}

1対1の内部リレーション

Atomicに扱いたいものの、直接のAttributeにしたくはないような レコードの場合。FiledをHashにして管理します。

1対多の内部リレーション

無限に増えそうもないデータ(持ち物情報等)は、embedにすることで、 Atomicに更新ができ、クエリも1回でデータを取得できます。
フィールドがArrayで値をHash形式で持つパターンとフィールドがHashで値もHash形式にするパターンとがあります。

フィールドがArrayのパターン

indexが貼りやすい反面、配列のデータの更新と追加が同時にできません。
例えば {name:"takeshi",age: 35, children: [{name: "takeru" ,age: 18}]}の レコードに対し、name:"takeru"のnumを19に変更すると同時に{name: "yui",num: 2}の レコードを追加といったことがatomicにできません。
indexは簡単で、[[children.name,Mongo::ASCENDING],[children.age,Mongo::DESCENDING]] を貼ることでfind({:children.name => "takeru",:children.age => 18})のような検索にindexを使います。
更新はchildren.0.ageのように添字を使って更新するため、削除があるようなレコードには あまり向いていません。他のプロセスが削除を行なった場合、添字で指定したレコードが 違うレコードを指す可能性があるためです。ただしArrayの内、1つのレコードのみ更新したい場合は、update({:children.name => "takeru"},{"children.$.age" => 19})のように検索条件で一致したレコードの添字の変わりに$が使えます。
削除は$pullを使いますが、一致するレコードが複数件存在する場合は、一致するものすべて削除されてしまうため、一致するレコード1件のみ削除したい場合は、$unsetで一致する レコードをnullにした後、$pullでnullのレコードを削除するという2段階踏む必要があります。

フィールドがHashの場合

レコードの更新の追加がatomicにできるのに対し、indexが各項目に対して作成する必要があります。 Hashの場合は、{name: "takeshi,items: {りんご: 1}}というレコードの値を2にすると同時 に{みかん: 1}のレコード追加が同時にAtomicできます。 ただし、indexは[["items.りんご",Mongo::ASCENDING],["items.みかん",Mongo::ASCENDING]] のように、アイテムの種類が増えればその度にindexを作成する必要があります。 削除は、$unsetを使用します。

まとめ

1対多のEmbedをArrayにするかHashにするかはよく悩みます。
基本的には、削除がなく、レコードの更新と追加が同時に必要にならないレコードであれば Arrayにし、削除があったり、レコードの更新と追加が同時に必要な場合はHashにするといいと思います。
ただ、indexの兼ね合いもあり、削除があっても論理削除をすることによって、 Arrayでも更新の不整合を防ぐことができます。
長くなったので、Mongorillaを使ったリレーションの管理は次回。

| | コメント (0) | トラックバック (0)

« 2011年11月 | トップページ | 2012年3月 »