Railsモデルをcomposed_ofとdelegateでValueObjectでリファクタリングする
簡単なサンプルコード
class User
def age
return nil if birthday.nil?
date_format = "%Y%m%d"
(Time.now.strftime(date_format).to_i - date.strftime(date_format).to_i) / 10000
end
end
これだけのクラスであればあまりValueObjectを実装するモチベーションもないかと思いますが、
Userクラス以外でageメソッドを使いたいなど課題はありがちなので、このageメソッドをBirthdayというValueObjectに実装して、算出できるようにしましょう。
1. Value Objectの実装
誕生日の登録と年齢の算出を行う部分をValue ObjectとしてBirthdayクラスを実装しました。
class Birthday
def initialize(date = nil)
@date = date
end
def age
return nil if date.nil?
date_format = "%Y%m%d"
(Time.now.strftime(date_format).to_i - date.strftime(date_format).to_i) / 10000
end
private
attr_reader :date
end
2.composed_ofで簡単に済ませる
値を保持するだけでなく、年齢を算出できるageメソッドを実装しておきました。
このBirthdayクラスをUserモデルのbirthdayカラムと連携して利用できるようにします。
ActiveRecordにあるcomposed_ofは複数のデータベースカラムを1つのRubyオブジェクトに組み合わせるための機能を提供しています。
これはValueObjectをActiveRecordで利用する際に使うことを想定されて実装されています。
composed_ofを利用する場合はかなり簡単で下記設定をUserモデルに記載するだけでできます。
composed_of :birthday, mapping: [%w(birthday date)]
mapping
DBのカラム名と、ValueObjectに渡す引数の名前が異なる場合に設定します。
例えば、Birthdayクラスに渡す引数の名前がbirthdayであれば省略可能です。
そのほか設定など
ValueObjectがnilであることを許容するallow_nilなどのオプションがあります。
実際にレコードに登録するとき
Value Objectなので実際にレコードに登録や更新を行う際は、DateやStringのプリミティブ型を設定するのではなくて、Birthdayクラスを設定するようにします。
この時、副作用が発生しないように、Birthdayオブジェクトを作成するようにして、birthdayに代入するようにするので、扱いやすいです。
User.create(birthday: Birthday.new(Date.parse('1991/08/13')), last_ordered_at: order.created_at, name: "Takuya Nishio")
プリミティブ型だとミスしてしまう例
Date型などのプリミティブ型とは異なり、「誕生日ではない日付」を間違って実装してしまう確率をぐっと下げられそうです。
下記コードだと、そのままorder.created_atの内容で保存されてしまいますが、birthdayがValueObjectであればエラーになります。
User.create(birthday: order.created_at, name: "Takuya Nishio")
delegate
さらに、User#ageからユーザーの年齢を取得できるようにしましょう。Birthdayクラスにageが実装されているので、これを使えるとよいでしょう。
単にageメソッドを実装して、Birthday#ageを呼び出すような方法だと下記実装になるかとおもいます。
class User
composed_of :birthday, mapping: [%w(birthday date)]
def age
birthday.age
end
end
ですが、よりシンプル済ませるため、delegateを使いましょう。
delegate :age, to: :birthday
delegateにはprefixをつけたりするオプションやnil許容のオプションもあるのですが、詳しい使い方などについてはこちらの記事を参照するとよいでしょう。
これらを反映した全体のコード
class User < ApplicationRecord
composed_of :birthday, mapping: [%w(birthday date)]
delegate :age, to: :birthday
end
class Birthday
def initialize(date = nil)
@date = date
end
def to_s
if date.nil?
"missing"
else
date.to_s
end
end
def age
return nil if date.nil?
date_format = "%Y%m%d"
(Time.now.strftime(date_format).to_i - date.strftime(date_format).to_i) / 10000
end
private
attr_reader :date
end
Value Objectを使うことでUserモデルに生年月日から年齢を計算するロジックを委譲して、実装の見通しが良くなりました。
Value ObjectをRailsで扱うために自前で実装するのはちょっと面倒なので、それをするくらいならcomposed_ofやdelegateで面倒なことをショートカットすることは個人的にはいいことかなと思っています。
いざとなれこれらを使わないで実装する方法は面倒なだけであるにはあるので、使ってしまって問題なのかなと考えています。Userクラスはかなりすっきりした形ですしね。
まとめ
Value Object自体は間違った値の代入を防いだり、振る舞いに副作用がないようにできたりと、リファクタリングにおいて、Ruby on Rails以外でも有用なテクニックなのですが、
composed_ofがActiveRecordでValue Objectを扱うには大変便利なのでした。
機能としても昔からある機能でAPIも安定しているので、これはValue Objectを活用していってほしいというメッセージだと思っているので、積極的に活用してValue Objectを使ったリファクタリングを実施していきたいと考えています。
Discussion