Model周りの複雑さがやや増してきたWebアプリのリファクタリングメモ
ほぼ Rails の話だけど、考え方は Rails や ActiveRecord に限らず適用できると思ってる。
現在の status としては適用済みのものもそうでないものも混ざっており、絶対全部オススメ!とかそういうキャッチーなものではない。
■ その1 - Interactor
□ 追加gem
□ 効果
- いわゆる Service Object であり、記法が決まっている
- 複数の Interactor を organize して一つの大きな Interactor を作ることができる1
- rollback ( ≒ transaction ) を実現できる2
□ 適用範囲
- 複雑な処理のカプセル化(CでもMでもないいわゆるService Object)
- 複数の処理が依存しあっていて、かつその結果を同期的にコントロールしたい、あるいはすぐにフィードバックを返したい
- Callback を分解して明示的な処理の流れに書き起こす
- まず 1 があって C や M の外にやりたいことが明示されることが大事
□ メモ
伝統的なサーバサイドWeb開発アンチパターンとその対策 - C, V編 - - あーありがち(2019-01-20) で Fat Controller への対処としてとにかく Model を作れと書いたが、例えば複数の外部 API を叩くようなことがあるような場合、シンプルな PORO で実現しようとするとエラーハンドリングが面倒くさくなってしまう。
cf. Fowler EAA の Notification
そこで Interactor を入れることで処理全体の成否や rollback などの記述しやすさを期待できる。また Interactor という名前がいかにも外部 API やユーザーのアクションとの間に立ちそうで、少なくとも単なる Service Object という捉え方よりよいと考えている。
また Callback の分解については、例えば処理A, 処理B, 処理C があって、画面1からは処理AとCを、画面2からは処理AとBを行う、
画面の種類 | 画面1 | 画面2 | 画面3 |
処理の流れ | 処理A | 処理A | 処理B |
↓ | 処理C | 処理B |
みたいなものがあった時に Fat Controller を避けるために 処理 A の callback で処理を流していく、みたいなことをやると callback に条件分岐が入って変に複雑になってしまう。
これを素直に Interactor で書き起こすと分岐が減って読みやすくなるし callback が減ると暗黙の依存が減る。結果、メンテナンス性を高く維持しやすくなるはず。
□ 参考
似た gem として
- AaronLasseigne/active_interaction: Manage application specific business logic.
- Trailblazer: Operation API
- Rails: Waterfall gemでコントローラとモデルを整理(翻訳)
もあるが、やや Flow Control の DSL 色が強すぎると感じた。
■ その2 - CQS
□ 追加gem
なし
□ 考え方
- Command と Query を分ける
- Command の分離は上の Inetractor を使って明示的に外にくくり出す
- Query は Thin Read Layer の class に分離する
□ 適用範囲
- Model
□ メモ
Fat Model は Fat Controller よりはるかにマシではあるが、Model には様々な役割があり、その役割によって異なる複雑さが一つの Model の中に収まってしまうことで、どこを変更したらどこに影響が出るかが分かりにくくなっていく。
一口に Model の複雑さと言っても read と write ( 副作用 ) で異なることが多いと考えている。
Read の複雑さ
- よく似た微妙に異なる Query の組み立てが画面や操作ごとに分かれやすいこと
- 結果、Query の組み立て時に条件分岐が入りまくる
- 関連する Model の呼び出しもどんどん増え、他の Model への依存がどんどん増える
これに対して、C でも M でもない場所で目的ごとに明示的に分離された Query の組み立てを行うことで複雑怪奇な named scope を減らすことができる。
class Model < AR
scope :items, -> {|args1, args2|
rel = self.where(.....
if ( args1 )
rel = rel.merge(AnotherModel.where(...
end
}
end
を
class ItemsWhenConditionSpecified
def call(param)
Model.where(....).merge(AnotherModel.where(...
end
end
こうする。
Write の複雑さ
上にもあるが
- 関連する他の Model や外部 API の芋づる呼び出しが多くなりやすい
- 結果 Callback など暗黙的な複雑な操作が埋め込まれやすい
これは Interactor の導入で対応できるはず。
□ 参考
上のリンク先は CQS ではなく CQRS なので「時間やイベントの概念が入ってきて現在の snapshot を取得しにくいので Read Model を導入しましょう」なんだけど、ベースとなっている CQS でも Read 用の Model のようなもの、Thin Data Layer を導入するのはアリだと思う。少なくともこれで Fat Controller も Fat Model も避けることができる。
■ その3 - PubSub
□ 追加gem
krisleech/wisper: A micro library providing Ruby objects with Publish-Subscribe capabilities
□ 効果
依存のうち、同期的に結果が分かる必要のないもの、即時に rollback する必要のないものを非同期メッセージングで分離することで結合度を下げる。要は decoupling の一つ。
□ 適用範囲
Callback
例えば Model の Callback は使いすぎると暗黙の依存が増えて目的の Model のテストが重くなる3という問題がある。これを PubSub に持っていくことで、テストをメッセージの Publish のテストと Subscriber の実際の動作のテストに分離することができ、Model をテストしやすくなるし、callback を condition で複雑にせずに済む。
その他 C, M が長くなってしまう処理のうち非同期に分けられるもの。
□ デメリット
処理の流れが書いた順番通りにならない。イベントドリブンの考え方に不慣れだと混乱が増す。
Interactor の利用が適しているところに Wisper を持ち出さないように注意したい。結合度は下がるけど複雑さが上がる可能性がある。同期、非同期をきちんと見分けることが大事。result をきっちり活用したいなら Interactor.
ただし、Backend を ActiveJob にすると GlobalID が付与されてトレーサビリティが生まれる。イベントソーシング的に扱える部分を差し込むために Interactor と Wisper を併用するという選択肢もあると思う。