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があまり複雑ではない
サクッと導入して必要に応じて育てていけるのが気に入ってます。
この記事が気に入ったらサポートをしてみませんか?