ActiveRecordはやっぱりActive Recordなのか
RoRで採用されているActiveRecord(AR)を利用してドメインモデルを作成してみました。
シナリオはハンバーガショップモデルを利用し、基本的な部分を実装してみました。
感想としてはARはやはりActive Recordであり、リッチなドメインモデルを作成するには向かないのではないかと感じました。理由は
- オブジェクトキャッシュ機能の不足
- データアクセス最適化の柔軟性が不足
です。
前者のオブジェクトキャッシュ機能の不足は、あるドメインモデルのオブジェクトが複数で参照されている場合必ず同一のオブジェクトが参照できる仕組みがARには不足しているということで、ARは非常にリッチな機能をもつがやはりActive Recordであって、Data Mapperで得られるドメインモデルやデータアクセスの実装自由度はないということで認識しました。
具体的な点としては、お買い上げ明細(OrderItem)のオプションが指定された時点でお買い上げ(Order)の総額を更新するモデルを作成したのですが、OrderItemから親のOrderを参照するとOrderItemごとにOrderが読みだされインスタンス化されてしまいます。このため、複数のOrderオブジェクトが生成されてしまいOrderの総額更新が意図した通りに動作しません。ARは非常にリッチであるのでこのあたりも上手く処理してくれると期待したのですが残念です*1。とりあえず、お買い上げ明細(OrderItem)作成時に明示的に親のお買い上げ(Order)を渡すようにして対応しています。
また、複数のエンティティをセット指向的に効率的に取り出す方法が良く分からずデータアクセス処理を最適化するための方法が提供されていないのではと感じました。
追記
Object-Relational Mapping as a Persistence Mechanism for Object-Oriented Applications
Active Record does not include any caching to speak of. This, of course, hampers
performance, but it is not without any advantage. The lack of object caching is
in line with Active Record’s database-oriented philosophy.
ARはキャッシュ機能なしでデータベース指向だそうだ。なるほど、割り切っているということですね。
さすがRoRで生まれた技術ということか。
A common complaint is that ActiveRecord is very chatty with the database
コード
以下今回作成したコードです。Ruby&RoR脳になっていないので、もしこの認識が誤っているのであれば突っ込んでください。もちろん、ARを利用すると非常に簡潔にコードが書けるのメリットは納得しています。
今回のお買い上げシナリオ
def test_simple_order puts "オーダー開始" o = shops(:osaka).add_order() puts "チーズバーガ追加" i1 = o.add_item(Product.find_by_title('チーズバーガー'), 3) puts "合計確認" assert_equal 300, o.total_price puts "ハンバーガーセット追加" i2 = o.add_item(products(:burgerSet), 2) puts "合計確認" assert_equal 800, o.total_price puts "ハンバーガーセット コーヒー指定" i2.add_option(products(:coffeeS)) puts "合計確認" assert_equal 800, o.total_price puts "ハンバーガーセット ポテトM指定" i2.add_option(products(:potatoM)) puts "合計確認" assert_equal 900, o.total_price end
ARを利用したドメインモデル
class Product < ActiveRecord::Base has_many :product_items has_many :product_item_options acts_as_tree :order => "id" attr_reader :items def after_find @items = Hash.new product_items.each { |t| @items[t.name] = t } end def group?(p) false end end class SaleProduct < Product def necessary_options options = [] self.product_items.each { |t| options << t.necessary_option if t.necessary? } options end def additional_price(option) self.product_items.each { |t| return t.additional_price(option) if t.additional_price(option) } end end class AttachmentProduct < Product end class GroupProduct < Product def group?(p) children.each do |c| return true if c == p || c.group?(p) end return false end end class ProductItem < ActiveRecord::Base belongs_to :product has_many :options, :class_name => "ProductItemOption" def necessary?() self.options.size == 1 end def necessary_option() self.options[0].product if necessary? end def additional_price(option) self.options.each do |o| return o.additional_price if o.product == option || o.product.group?(option) end return nil end end class ProductItemOption < ActiveRecord::Base belongs_to :product_item belongs_to :product, :class_name => "Product", :foreign_key => "option_product_id" end class Order < ActiveRecord::Base has_many :items, :class_name => "OrderItem" belongs_to :shop def add_item(product, amount) item = self.items.create(:product => product, :amount => amount, :unit_price => product.price, :parent => self) product.necessary_options.each {|o| item.add_option(o) } update_total_price() item end def update_total_price total = 0 self.items.each { |item| total += item.sub_total_price } self.total_price =total end end class OrderItem < ActiveRecord::Base belongs_to :order belongs_to :product has_many :options, :class_name => "OrderItemOption" attr_accessor :parent def add_option(product) self.options.create(:product => product) update_unit_price() end def sub_total_price self.unit_price * self.amount end def update_unit_price self.unit_price = self.product.price + option_additional_price self.parent.update_total_price if self.parent end def option_additional_price total = 0 self.options.each { |option| total += self.product.additional_price(option.product) } total end end class OrderItemOption < ActiveRecord::Base belongs_to :order_item belongs_to :product end
ちなみに発行されたSQL文を含めたログ
オーダー開始 SHOW FIELDS FROM shops SELECT * FROM shops WHERE (shops.`id` = 1) SHOW FIELDS FROM orders INSERT INTO orders (`order_as`, `total_price`, `shop_id`) VALUES('2007-07-20 23:26:46', 0, 1) チーズバーガ追加 SHOW FIELDS FROM products SELECT * FROM products WHERE (products.`title` = 'チーズバーガー') LIMIT 1 SHOW FIELDS FROM products SELECT * FROM product_items WHERE (product_items.product_id = 1) SHOW FIELDS FROM order_items INSERT INTO order_items (`order_id`, `product_id`, `amount`, `unit_price`) VALUES(105, 1, 3, 100) SELECT * FROM order_items WHERE (order_items.order_id = 105) 合計確認 ハンバーガーセット追加 SELECT * FROM products WHERE (products.`id` = 2) SELECT * FROM product_items WHERE (product_items.product_id = 2) SHOW FIELDS FROM product_items INSERT INTO order_items (`order_id`, `product_id`, `amount`, `unit_price`) VALUES(105, 2, 2, 250) SHOW FIELDS FROM product_item_options SELECT count(*) AS count_all FROM product_item_options WHERE (product_item_options.product_item_id = 1) SELECT count(*) AS count_all FROM product_item_options WHERE (product_item_options.product_item_id = 1) SELECT * FROM product_item_options WHERE (product_item_options.product_item_id = 1) SELECT * FROM products WHERE (products.`id` = 3) SELECT * FROM product_items WHERE (product_items.product_id = 3) SELECT count(*) AS count_all FROM product_item_options WHERE (product_item_options.product_item_id = 2) SELECT count(*) AS count_all FROM product_item_options WHERE (product_item_options.product_item_id = 3) SHOW FIELDS FROM order_item_options INSERT INTO order_item_options (`order_item_id`, `product_id`) VALUES(155, 3) SELECT * FROM order_item_options WHERE (order_item_options.order_item_id = 155) SELECT * FROM products WHERE (products.`id` = 3) SELECT * FROM product_items WHERE (product_items.product_id = 3) 合計確認 ハンバーガーセット コーヒー指定 SELECT * FROM products WHERE (products.`id` = 8) SELECT * FROM product_items WHERE (product_items.product_id = 8) INSERT INTO order_item_options (`order_item_id`, `product_id`) VALUES(155, 8) SELECT * FROM product_item_options WHERE (product_item_options.product_item_id = 2) SELECT * FROM products WHERE (products.`id` = 4) SELECT * FROM product_items WHERE (product_items.product_id = 4) SELECT * FROM products WHERE (products.`id` = 5) SELECT * FROM product_items WHERE (product_items.product_id = 5) SELECT * FROM products WHERE (products.`id` = 6) SELECT * FROM product_items WHERE (product_items.product_id = 6) SELECT * FROM product_item_options WHERE (product_item_options.product_item_id = 3) SELECT * FROM products WHERE (products.`id` = 14) SHOW FIELDS FROM products SELECT * FROM product_items WHERE (product_items.product_id = 14) SELECT * FROM products WHERE (products.parent_id = 14) ORDER BY id SELECT * FROM product_items WHERE (product_items.product_id = 7) SELECT * FROM product_items WHERE (product_items.product_id = 8) 合計確認 ハンバーガーセット ポテトM指定 SELECT * FROM products WHERE (products.`id` = 5) SELECT * FROM product_items WHERE (product_items.product_id = 5) INSERT INTO order_item_options (`order_item_id`, `product_id`) VALUES(155, 5) 合計確認
*1:何か設定または実装することで上手く対応できる方法があれば誰か教えてください