Rails の ActiveRecord には値オブジェクト (Value Object) を便利に使うための composed_of
というものがあるらしいのでメモっとく。
エンティティ (Entity) と値オブジェクト
エンティティと値オブジェクトはドメイン駆動設計におけるモデルを表現する3パターンの要素のうちの2つだ。
エンティティ
本からエンティティの説明を引用しておこう。
多くのオブジェクトは、本質的に、その属性によってではなく、連続性と同一性 (identity) によって定義される。
例えば Person クラスがあり、その属性は firstname・lastname・age だとしよう。Person クラスの2つのインスタンスがあり、それらが同姓同名同年齢だったとしても、その属性が一致しているからといって同一の人であるとは言えない。なので Person クラスのインスタンスはエンティティということになる。
Rails では ActiveRecord::Base
を継承しているクラスのインスタンスはほとんどがエンティティを表現することになる。ID で同一性を識別するからね。
値オブジェクト
本から値オブジェクトの説明を引用しておこう。
多くのオブジェクトには概念的な同一性がない。そういうオブジェクトは、物事の特徴を記述する。
例えば Location クラスがあり、その属性は country・city だとしよう。Location クラスの2つのインスタンスがあり、それらが同国同市だったとしたら、その属性が一致しているので同一の場所と言える。なので Location クラスのインスタンスは値オブジェクトということになる。
Rails には値オブジェクトを表現する専用の機能はないようだ。値オブジェクトを表現する方法は次項で説明する。
値オブジェクトの表現
Rails で値オブジェクトを表現する方法にはいくつかある。DDD for Rails Developers. Part 2: Entities and Values を参考にすると下記の3パターンがあるようだ。
- Struct のインスタンスを継承して表現する
-
ActiveRecord::Base
を継承して表現する - Plain Old Ruby Objects (PORO) で表現する
今回は「Struct のインスタンスを継承して表現する」方法で実装してみる。なお「ActiveRecord::Base
を継承して表現する」方法は DDD for Rails Developers. Part 2: Entities and Values に例がある。また「Plain Old Ruby Objects (PORO) で表現する」方法は ActiveRecord::Aggregations::ClassMethods に例がある。
Location クラス
Location
クラスを Struct
を利用して定義してみる。これで Location
クラスのインスタンスは値オブジェクトだ。
class Location < Struct.new(:country, :city)
end
irb
で試してみるとこんな感じ。
> tokyo = Location.new('Japan', 'Tokyo')
=> #<struct Location country="Japan", city="Tokyo">
> paris = Location.new('France', 'Paris')
=> #<struct Location country="France", city="Paris">
> edo = Location.new('Japan', 'Tokyo')
=> #<struct Location country="Japan", city="Tokyo">
> tokyo == paris
=> false
> tokyo == edo
=> true
クラスと属性が一致していれば同一になる。Struct
の便利な使い道はこんなところにある。
Person クラス
エンティティが値オブジェクトを持つ例を見るために Person
クラスも定義してみる。ここで Location
クラスのインスタンスが入る location
インスタンス変数も定義しておいた。
class Person
attr_accessor :firstname, :lastname, :age, :location
def initialize(firstname, lastname, age, location)
@firstname, @lastname, @age, @location = firstname, lastname, age, location
end
end
irb
で試してみるとこんな感じ。
> tokyo = Location.new('Japan', 'Tokyo')
=> #<struct Location country="Japan", city="Tokyo">
> takuya = Person.new('Takuya', 'Tsuchida', 30, tokyo)
=> #<Person:0x007fa5538d42a8 @firstname="Takuya", @lastname="Tsuchida", @age=30, @location=#<struct Location country="Japan", city="Tokyo">>
> takuya.location == tokyo
=> true
問題なく動作している。
composed_of
さて本題だ。ご存知のとおり ActiveRecord は O/R マッパーだ。ここまではオブジェクトモデルで考えてきたけど、ここからはリレーショナルモデルのことも考えなければならない。
ActiveRecord で前述の Person
クラスを表現して永続化する場合には Location
クラスのインスタンス(値オブジェクト)をどう扱うかが問題になる。この問題を Rails では composed_of
というクラスメソッドで解決する。
この composed_of
を用いた解決策は Person
クラスの構成要素として Location
クラスを埋め込んでしまうというものだ。Location
クラスのインスタンス(値オブジェクト)をリレーショナルモデル上で people
テーブルの country
カラムと city
カラムとして表現し、オブジェクトモデル上に戻ってきたときに Location
クラスのインスタンス(値オブジェクト)として再構築する。これによって値オブジェクトを含むオブジェクトモデルとリレーショナルモデルの相互変換が実現できるということになる。
実例を見た方がわかりやすいと思うので、ここからはコードを見ていこう。
まずは rails generate
で Person モデルを生成する。このときに country
カラムと city
カラムに対応するフィールドも指定しておこう。
$ rails g model person firstname:string lastname:string age:integer country:string city:string
$ rails db:migrate
Location
クラスも作成しておこう。これは前述のものと全く同じクラスだ。
class Location < Struct.new(:country, :city)
end
生成された Person
クラスに composed_of
で location
フィールドを追加しよう。フィールド名から暗黙的に Location
クラスが紐付けられる。class_name
オプションで明示的にクラス名を指定することもできる。mapping
オプションで「エンティティの属性」から「値オブジェクトの属性」への紐付けを指定する。また mapping
オプションに指定した属性の順序で値オブジェクトのコンストラクタに属性が渡されるので属性の順序には注意してほしい。
class Person ActiveRecord::Base
composed_of :location, mapping: [ %w(country country), %w(city city) ]
end
rails console
で試してみるとこんな感じになる。
irb(main):001:0> tokyo = Location.new('Japan', 'Tokyo')
=> #<struct Location country="Japan", city="Tokyo">
irb(main):002:0> takuya = Person.new(firstname: 'Takuya', lastname: 'Tsuchida', age: 30, location: tokyo)
=> #<Person id: nil, firstname: "Takuya", lastname: "Tsuchida", age: 30, country: "Japan", city: "Tokyo", created_at: nil, updated_at: nil>
irb(main):003:0> takuya.save
(0.2ms) begin transaction
SQL (0.9ms) INSERT INTO "people" ("firstname", "lastname", "age", "country", "city", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?, ?, ?) [["firstname", "Takuya"], ["lastname", "Tsuchida"], ["age", 30], ["country", "Japan"], ["city", "Tokyo"], ["created_at", 2016-08-17 10:59:50 UTC], ["updated_at", 2016-08-17 10:59:50 UTC]]
(0.9ms) commit transaction
=> true
irb(main):004:0> tsuchida = Person.first
Person Load (0.3ms) SELECT "people".* FROM "people" ORDER BY "people"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<Person id: 2, firstname: "Takuya", lastname: "Tsuchida", age: 30, country: "Japan", city: "Tokyo", created_at: "2016-08-17 10:59:50", updated_at: "2016-08-17 10:59:50">
irb(main):005:0> takuya.location == tsuchida.location
=> true
composed_of
の使い方を理解してもらえただろうか。
ちなみに composed_of
の実装は本の下記の説明とリンクしていると思う。ただし、Rails では値オブジェクトをオブジェクトモデルとリレーショナルモデルの間で相互変換する方法であり、本ではパフォーマンスチューニングの方法である。
関係データベースでは、特定の値を独立したテーブルに入れて関連を生成するよりも、その値オブジェクトを所有するエンティティのテーブルに入れたいことがあるかもしれない。
なお「ActiveRecord::Base
を継承して表現する」方法であれば Person
クラスと Location
クラスのアソシエーションとして解決できるはずだ。しかしながら、値オブジェクトを「Struct のインスタンスを継承して表現する」方法で実現し composed_of
で ActiveRecord と関連させる方がコードの上ではシンプルだと思う。
今回は値オブジェクトを「Struct のインスタンスを継承して表現する」方法で実現し composed_of
で ActiveRecord と関連させる方法について説明した。Rails に乗っかりつつもかっこいい設計を目指したいものだね。
参考文献
エリック・エヴァンスのドメイン駆動設計
https://www.sitepoint.com/ddd-for-rails-developers-part-2-entities-and-values/
http://api.rubyonrails.org/classes/ActiveRecord/Aggregations/ClassMethods.html