Model周りの複雑さがやや増してきたWebアプリのリファクタリングメモ

ほぼ Rails の話だけど、考え方は Rails や ActiveRecord に限らず適用できると思ってる。

現在の status としては適用済みのものもそうでないものも混ざっており、絶対全部オススメ!とかそういうキャッチーなものではない。

■ その1 - Interactor

□ 追加gem

collectiveidea/interactor: Interactor provides a common interface for performing complex user interactions.

□ 効果

  • いわゆる Service Object であり、記法が決まっている
  • 複数の Interactor を organize して一つの大きな Interactor を作ることができる1
  • rollback ( ≒ transaction ) を実現できる2

□ 適用範囲

  1. 複雑な処理のカプセル化(CでもMでもないいわゆるService Object)
    • 複数の処理が依存しあっていて、かつその結果を同期的にコントロールしたい、あるいはすぐにフィードバックを返したい
  2. 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 として

もあるが、やや 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 を併用するという選択肢もあると思う。

□ 参考

  1. この gem については個々の Interactor を書く際に気にしなければいけないのは context だけ 

  2. この gem に hook があるだけなので中の処理をどうするかはもちろん自分で考える必要アリ 

  3. 実行時間や副作用を閉じ込めるためのセットアップなど。RSpec側の設定でbefore(:all)でskip_callback = trueするという方法はあるけど。 

More