見出し画像

value_semantics gemを使ってValueObjectをいい感じに実装する

こんにちは bosyu 開発メンバーの @lulu-ulul です。
bosyu Advent Calendar 2019 の 6日目の記事になります!

この記事は ValueObject (正確には ValueObject を生成する ValueClass )を手軽に実装でき、必要となれば自由度も高いgem value_semantics の紹介です。

CleanArchitectureの話題が頻繁に観測される様になって(※主観です)早数年、既存のRailsアプリケーションにもRailsWayから外れすぎない範囲でいくつかの概念を導入してみたケースも多いのではないでしょうか。
(ActiveRecordパターンが依存方向の単方向化と食い合わせ悪いのでがっつり取り入れるのは大変ですよね…) 

その中でもValueObjectは取り入れやすくメリットも実感しやすい概念の一つじゃないかなーと思います。

RubyでValueObjectを実装する

ValueObject を実装するには大体以下の様な選択肢でしょうか。

1. `Struct` をベースに不変にしたり色々実装頑張る
2. 素のRubyで頑張って全部書く
3. 何かしらの gem を使う

1や2で実装自体はできてしまうのですが、そのValueObjectが扱う値自体に関するロジック以外にも ValueObject を実現するためのロジックを意識し保守していく必要が出てきてしまいますね。

そこで今回は value_semantics という gem を簡単に紹介したいと思います。

value_semantics

DSLを覚える必要はあるのですが、種類や量もそれ程なく癖も少ないのでとっつきやすい方かなーと思います。

作者の方が用意している紹介記事 がよくまとまっていますし、作者の方のコンセプト・設計思想・類似gemの比較などもあるのでおすすめです。

class Person
  include ValueSemantics.for_attributes {
    name default: 'John Due'
    job default: 'Programmer'
    birthplace default: 'Japan'
  }
end
Person.new
=> #<Person name="John Due" job="Programmer" birthplace="Japan">

lulu_a = Person.new(name: 'lulu')
=> #<Person name="lulu" job="Programmer" birthplace="Japan">

lulu_a2 = Person.new(name: 'lulu')
=> #<Person name="lulu" job="Programmer" birthplace="Japan">

lulu_b = Person.new(name: 'lulu', job: 'Student', birthplace:'Britannia')
=> #<Person name="lulu" job="Student" birthplace="Britannia">

lulu_a == lulu_b
=> false

lulu_a == lulu_a2
=> true

lulu_a.name
=> "lulu"

# with で元のValueObjectをベースとした新たなValueObjectを生成
new_lulu_a = lulu_a.with(job:'Mage', birthplace: 'Runeterra')
=> #<Person name="lulu" job="Mage" birthplace="Runeterra">

# 不変
lulu_a
=> #<Person name="lulu" job="Programmer" birthplace="Japan">

new_lulu_a
=> #<Person name="lulu" job="Mage" birthplace="Runeterra"> 

最低限のValueObjectが少ない記述量で実現できました。

new_lulu_a = lulu_a.with(job:'Mage', birthplace: 'Runeterra')
=> #<Person name="lulu" job="Mage" birthplace="Runeterra">

# 不変
lulu_a
=> #<Person name="lulu" job="Programmer" birthplace="Japan">

# 交換可能
new_lulu_a
=> #<Person name="lulu" job="Mage" birthplace="Runeterra">

Validation 

value_semantics は validation の設定がシンプルなものなら簡単に指定でき、必要であれば柔軟に指定できます。

型によるValidation

class Person
  include ValueSemantics.for_attributes {
    name String, default: 'John Due'
    job String, default: 'Programmer'
    birthplace String, default: 'Japan'
  }
end

lulu_a = Person.new(name: :lulu)
=> ArgumentError (Value for attribute 'name' is not valid: :lulu)

組み込みのValidatorによるValidation

class LightSwitch
  include ValueSemantics.for_attributes {
    # Boolean のみ許容
    on? Bool()

    # 引数に渡したClassのArrayのみ許容。便利〜☆ミ
    light_ids ArrayOf(Integer)

    # 引数に渡したいずれかのValidator通れば許容、便利〜♪
    color Either(Integer, String, nil)

    # これらのValidator同時組み合わせる事ができる、便利〜✨
    wierd_attr Either(Bool(), ArrayOf(Bool()))
  }
end

カスタムValidatorによるValidation

この gem において、 Validator は .=== メソッドを持ち、その戻り値の boolean 値によって valid か invalid か判別する様になっています。
なので、 self.===(value) メソッドを定義したModule等を用意する事で簡単にカスタムバリデータを定義する事ができます

module Odd
  def self.===(value) # ValueObject生成時の入力値を受け取る
    value.odd?
  end
end

class Person
  include ValueSemantics.for_attributes {
    age Odd
  }
end

Person.new(age: 9)  # value.odd? => true
Person.new(age: 8)  # value.odd? => false
#=> ArgumentError:
#=>     Value for attribute 'age' is not valid: 8


Callable Objects

coerce オプションに渡す事で、呼び出し可能なオブジェクト(ex. Proc, Method, FunctionalGems ) に入力値を渡し、その戻り値を ValueObjectの属性値として生成することができます。

これにより入力値の加工の実装が簡単にできますし、入力値の型変換やショートハンドの提供等もできるため取り回しの良いインターフェースにする事が可能です。

class Whatever
 include ValueSemantics.for_attributes {
   # 既存のclassのメソッドに渡す
   updated_at coerce: Date.method(:parse)

   # lambdaに渡す
   some_json coerce: ->(x){ JSON.parse(x) }

   # Symbol#to_proc
   some_string coerce: :to_s.to_proc

   # Hash#to_proc
   dunno coerce: { a: 1, b: 2 }.to_proc
 }
end

Whatever.new(
  updated_at: '2018-12-25',
  some_json: '{ "hello": "world" }',
  some_string: [1, 2, 3],
  dunno: :b,
)
#=> #<Whatever
#     updated_at=#<Date: 2018-12-25 ((2458942j,0s,0n),+0s,2299161j)> 
#     some_json={"hello"=>"world"}
#     some_string="[1, 2, 3]"
#     dunno=2
#     >

また、 coerce: true を指定すると  self.coerce_XXX というクラスメソッドに渡るため、サービスの成長等により複雑なロジックが必要になった際にスムーズに移行していく事ができます。(複雑な proc オブジェクト等を用意する必要はありません)

class Lottery
  include ValueSemantics.for_attributes {
    prize coerce: true
  }

  PRIZE_MAP = {
    a: 'Book',
    b: 'Toy',
    c: 'Ticket'
  }.freeze

  def self.coerce_birthplace(value)
    if value.is_a? Symbol
      PRIZE_MAP[value] || 'Candy'
    else
      value
    end
  end
end

Lottery.new(prize: :c)
=> #<Lottery prize="Ticket">

Lottery.new(prize: 'Scourer')
=> #<Lottery prize="Scourer">

Lottery.new(prize: :x)
=> #<Lottery prize="Candy">


まとめ

・ValueClassがサクッとかける
・型単位のValidationなら軽快に、必要ならカスタムValidatorで柔軟に検証できる
・Coerces を利用してObject生成時の入力を加工・変換したり簡易記法を提供できる
・DSLがあまり複雑ではない

サクッと導入して必要に応じて育てていけるのが気に入ってます。





この記事が気に入ったら、サポートをしてみませんか?
気軽にクリエイターの支援と、記事のオススメができます!
3

こちらでもピックアップされています

キャスター x bosyu プロダクトチームブログ
キャスター x bosyu プロダクトチームブログ
  • 41本

キャスターとbosyuのプロダクトチームが書いてるブログです 🍛

コメントを投稿するには、 ログイン または 会員登録 をする必要があります。