85
64

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Rails における値オブジェクトと ActiveRecord の composed_of

Posted at

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 クラスも作成しておこう。これは前述のものと全く同じクラスだ。

app/models/location.rb
class Location < Struct.new(:country, :city)
end

生成された Person クラスに composed_oflocation フィールドを追加しよう。フィールド名から暗黙的に Location クラスが紐付けられる。class_name オプションで明示的にクラス名を指定することもできる。mapping オプションで「エンティティの属性」から「値オブジェクトの属性」への紐付けを指定する。また mapping オプションに指定した属性の順序で値オブジェクトのコンストラクタに属性が渡されるので属性の順序には注意してほしい。

app/models/person.rb
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

85
64
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
85
64

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?