Rails: Value Objectで「基本データ型への執着」と戦う(翻訳)
前回のRead Modelの記事では別のお題を取り上げましたが、今回はRead Modelそのものに焦点を当てることにし、それ以外については今後の別記事に回したいと思います。ただ、この実装で1つ気に入らない点は、スコアの集計にプリミティブ型(基本データ型)を使っていることです。
Projection
def calculate_scores(test_id, participant_id)
RailsEventStore::Projection
.from_stream(stream_name(test_id, participant_id))
.init(-> { Hash.new { |scores, skill_id| scores[skill_id] = { score: 0, number_of_scores: 0 } })
.when(
SurveyExecution::AnswerRegistered,
->(state, event) do
skill_id = event.data.fetch(:skill_id)
state[skill_id][:score] += event.data.fetch(:score)
state[skill_id][:number_of_scores] += 1
end
)
.run(Rails.configuration.event_store)
.reduce({}) do |scores, (skill_id, values)|
scores[skill_id] = values[:score] / values[:n]
scores
end
end
指定のスキル範囲でスコアを集計することで、平均値などをカウントできるようになります。ご想像のとおり、このコード例は簡略化したものであり、オリジナルのコードはもっと複雑です。
よりよい方法はある
もっとマシな方法はないものでしょうか?そう、Value Objectを導入することです。コードを見ていく前に、Value Objectの正しい定義を固めておく必要があります。私はEric Evansが『Domain-Driven Design: Tackling Complexity in the Heart of Software』で述べているValue Objectの特徴が気に入っています。
- ドメイン内にあるものを測定、定量化、または記述する。
- イミュータブルのまま維持可能。
- 互いに関連する属性をひとつにまとめることで概念上の全体をモデリングする。
- 測定や記述が変更された場合に完全に入れ替え可能。
- 「値の等価性」によって他と比較可能
- 「副作用のない振る舞い」を持つ、自身のコラボレーターを提供する
Value Objectの最もよくある例は、Price
やMonetaryValue
(BigDecimal
型と通貨を表すString
型の組み合わせ)でしょう。それでは以下のように少し変えてみましょう。
class AnswerScore
def initialize(skill_id, score)
@skill_id = skill_id
@score = BigDecimal(score.to_s)
end
attr_reader :skill_id, :score
def ==(other)
other.class === self &&
other.hash == hash
end
alias eql? ==
def hash
[skill_id, score].join.hash
end
end
これで、2つのAnswerScore
の違いを独自の==
、eql?
、hash
メソッドで値ごとに比較可能になりました。
irb(main):069:0> AnswerScore.new(123, 0) == AnswerScore.new(123, 0)
#=> true
irb(main):070:0> AnswerScore.new(123, 0) == AnswerScore.new(123, 1)
#=> false
irb(main):071:0> AnswerScore.new(123, 0) == BigDecimal("0")
#=> false
irb(main):072:0> AnswerScore.new(123, 0) == AnswerScore.new(456, 0)
#=> false
eql?
演算子は==
のエイリアスになっているので、同じ結果を得られます。
Value Objectを2つ追加する
なるほど、2つのオブジェクトは比較できますが、次はどうすればよいのでしょうか?ここにはidもありますが、これをEntity
にするのはよくないでしょうか?そのとおり、Entity
にすべきではありません。このidは、さまざまなスキルのスコアを区別するのに用いるものです。スキルが異なるスコア同士を足してもあまり意味がありませんよね?ドルやポンドといった通貨を区別せずに足したらどうなるかを考えればおわかりでしょう。
次はこのオブジェクトに+
演算子を実装しましょう。
class AnswerScore
def initialize(skill_id, score)
@skill_id = skill_id
@score = BigDecimal(score.to_s)
end
attr_reader :skill_id, :score
def +(other)
raise ArgumentError unless self.class === other
raise ArgumentError if self.skill_id != other.skill_id
score + other.score
end
def ==(other)
other.class === self &&
other.hash == hash
end
alias eql? ==
def hash
[skill_id, score].join.hash
end
end
これで、スコアに間違ったものを足すことは不可能になりました。
# 同じスキルで異なるスコアを足す場合
irb(main):123:0> AnswerScore.new(123, 0) + AnswerScore.new(123, 1)
=> 0.1e1
# オブジェクトが異なる場合
irb(main):124:0> AnswerScore.new(123, 0) + 5
Traceback (most recent call last):
5: from /Users/fidel/.rbenv/versions/2.7.3/bin/irb:23:in `<main>'
4: from /Users/fidel/.rbenv/versions/2.7.3/bin/irb:23:in `load'
3: from /Users/fidel/.rbenv/versions/2.7.3/lib/ruby/gems/2.7.0/gems/irb-1.2.6/exe/irb:11:in `<top (required)>'
2: from (irb):124
1: from (irb):107:in `+'
ArgumentError (ArgumentError)
# 異なるスキルのスコアを足す場合
irb(main):126:0> AnswerScore.new(123, 0) + AnswerScore.new(456, 1)
Traceback (most recent call last):
5: from /Users/fidel/.rbenv/versions/2.7.3/bin/irb:23:in `<main>'
4: from /Users/fidel/.rbenv/versions/2.7.3/bin/irb:23:in `load'
3: from /Users/fidel/.rbenv/versions/2.7.3/lib/ruby/gems/2.7.0/gems/irb-1.2.6/exe/irb:11:in `<top (required)>'
2: from (irb):124
1: from (irb):107:in `+'
ArgumentError (ArgumentError)
うまくいきました。しかし返されるのがBigDecimal
ではうれしくないので、AnswerScore
オブジェクトをもっと足してProjectionを整頓し、シンプルにしたいと思います。
def calculate_scores(test_id, participant_id)
RailsEventStore::Projection
.from_stream(stream_name(test_id, participant_id))
.init(-> { NullScore.new( })
.when(
SurveyExecution::AnswerRegistered,
->(state, event) do
state += AnswerScore.new(
skill_id: event.data.fetch(:skill_id),
score: event.data.fetch(:score)
)
end
)
.run(Rails.configuration.event_store)
.reduce(&:+)
.average_score
end
この時点ではNullScore
がないので動きません。NullScore
を実装しましょう。
class NullScore
def +(other)
raise ArgumentError unless AnswerScore === other
other
end
def ==(other)
NullScore === other
end
alias eql? ==
def hash
'NullScore'.hash
end
end
NillScore
を追加すると、本物のValue Objectが初めて返るようになりました。Projectionでこの振る舞いを得るためには、AnswerScore
の内部をハックするよりもここを出発点とする方が優れています。
イミュータブルにする
話をAnswerScore
に戻します。AnswerScore
から返して欲しいのは、生のBigDecimal
値ではなくValue Objectです。2つのスコアを足したものはもうスコアとは呼べないので、おそらくScoreSum
を返すべきです。
class AnswerScore
def initialize(skill_id, score)
@skill_id = skill_id
@score = BigDecimal(score.to_s)
end
attr_reader :skill_id, :score
def +(other)
raise ArgumentError unless self.class === other
raise ArgumentError if self.skill_id != other.skill_id
ScoreSum.new(skill_id: skill_id, sum: score + other.score, n: 2)
end
def average_score
score.round(2)
end
def ==(other)
other.class === self &&
other.hash == hash
end
alias eql? ==
def hash
[skill_id, score].join.hash
end
end
class ScoreSum
def initialize(skill_id:, sum:, n:)
@skill_id = skill_id
@sum = BigDecimal(sum.to_s)
@n = Integer(n)
end
attr_reader :skill_id, :sum, :n
def +(other)
raise ArgumentError unless AnswerScore === other
raise ArgumentError if self.skill_id != other.skill_id
ScoreSum.new(sum: sum + other.score, skill_id: skill_id, n: n+1)
end
def average_score
(score / n).round(2)
end
def ==(other)
other.class === self &&
other.hash == hash
end
alias eql? ==
def hash
[skill_id, sum, n].join.hash
end
end
動作を確かめてみましょう。
irb(main):254:0> AnswerScore.new(123, 0) + AnswerScore.new(123, 1)
#=> #<ScoreSum:0x00000001137b3770 @skill_id=123, @sum=0.1e1, @n=2>
irb(main):255:0> AnswerScore.new(123, 0) + AnswerScore.new(123, 1) + AnswerScor
e.new(123, 1)
#=> #<ScoreSum:0x0000000112030a30 @skill_id=123, @sum=0.2e1, @n=3>
irb(main):256:0> [AnswerScore.new(123, 0), AnswerScore.new(123, 1), AnswerScore
.new(123, 1)].reduce(&:+)
#=> #<ScoreSum:0x00000001137a8938 @skill_id=123, @sum=0.2e1, @n=3>
これで以下の結果が得られました。
- オブジェクトがイミュータブルになり、何らかの操作を行うたびに新しいオブジェクトが返されるようになった
- 概念がくっきりと示されるようになった
AnswerScore
やScoreSum
に固有の振る舞いをもたせられるようになった: たとえばスコアのaverage_score
は単なるスコアのままですが、スコアのScoreSum
は合計を要素の個数で割ったものになります。
残念なお知らせ
私たちのProjectionが機能しなくなりました。理由は、弊社のRails Event Storeフレームワークの現在の実装ではできないからです。初期の実装では、ステートをHash
で保存し、その同じインスタンスを改変していたので動いたのです😱
しかし光明が見えた
WeDontDoThatHere = Class.new(StandardError)
def calculate_scores(test_id, participant_id)
Rails
.configuration
.event_store
.read
.stream(stream_name(test_id, participant_id))
.map do |event|
case event.event_type
when 'SurveyExecution::AnswerRegistered'
AnswerScore.new(
skill_id: event.data.fetch(:skill_id),
score: event.data.fetch(:score)
)
else
raise WeDontDoThatHere
end
end
.reduce(&:+)
.average_score
end
やっていることは先ほどと同じですが、今度はマジックも少な目になりました、少なくとも私の目には。それとともにNullScore
は無用の長物となり、map
やreduce
を使うようになりましたとさ。
お知らせ
ARKADEMY.DEVに参加してArkencyのトップクラス教育プログラムコースにアクセスしましょう!「Railsアーキテクトマスタークラス」「アンチ"IF"コース」「忙しいプログラマーのためのブログ執筆コース」「Async Remoteコース」「TDD動画クラス」「ドメイン駆動Rails動画コース」以外にもさまざまなコースが新設中です。
概要
原著者の許諾を得て翻訳・公開いたします。
サイト: arkency
週刊Railsウォッチ20210510『Value Objectをクラスで定義してプリミティブな値と戦う』もどうぞ。
参考: Primitive-obsession(基本データ型への執着) - Qiita