Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Value Objectで「基本データ型への執着」と戦う(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

週刊Railsウォッチ20210510『Value Objectをクラスで定義してプリミティブな値と戦う』もどうぞ。

参考: Primitive-obsession(基本データ型への執着) - Qiita

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の最もよくある例は、PriceMonetaryValueBigDecimal型と通貨を表す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>

これで以下の結果が得られました。

  • オブジェクトがイミュータブルになり、何らかの操作を行うたびに新しいオブジェクトが返されるようになった
  • 概念がくっきりと示されるようになった
  • AnswerScoreScoreSumに固有の振る舞いをもたせられるようになった: たとえばスコアの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は無用の長物となり、mapreduceを使うようになりましたとさ。

お知らせ

ARKADEMY.DEVに参加してArkencyのトップクラス教育プログラムコースにアクセスしましょう!「Railsアーキテクトマスタークラス」「アンチ"IF"コース」「忙しいプログラマーのためのブログ執筆コース」「Async Remoteコース」「TDD動画クラス」「ドメイン駆動Rails動画コース」以外にもさまざまなコースが新設中です。

関連記事

Rails: Value Objectを検討してみよう(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。