Misoca開発チームの黒曜です。 先日、城崎温泉に行ってきました。 ちょうど大寒波が来ていた週末で、雨に降られて凍えながら外湯巡りをしたり、雪が吹き込むなか屋外のイルカショーを見たり、と若干修行の様相を呈していたことをご報告します。
ViewHelperが太りがちな話
さて、Railsで開発する際、Viewで必要になるロジックをViewHelperに切り出すのは常套手段かと思います。 しかしながら、何でもかんでもHelperに切り出しているとFat Helperになりがちです。
# 送信した文書に関するHelper module DistributionsHelper # 送信先の敬称付き名を取得する def distribution_recipient_info(distribution) distribution.recipient_name + ' ' + distribution.recipient_title end # 発注状況を表す文言を取得する def distribution_order_info(distribution) message = ordered_message(distribution) if distribution.ordered_at message = rejected_message(distribution) if distribution.rejected_at return message end def order_addon_link_message "発注機能を#{current_user.enabled_addon?(Addon::ADDON_ORDER_BUTTON) ? '無効' : '有効'}にする" end // ...その他いろいろなメソッド private: def ordered_message(distribution) action_human_name = distribution.sender?(current_user) ? 'は発注されました' : 'を発注しました' "#{localize(distribution.ordered_at, format: :only_date)}にこの見積#{action_human_name}" end def rejected_message(distribution) action_human_name = distribution.sender?(current_user) ? 'はお断りされました' : 'をお断りしました' "#{localize(distribution.rejected_at, format: :only_date)}にこの見積#{action_human_name}。" end end
上記は直接DOMを触るなどしていないため比較的分かりやすい部類ですが、それでもいろいろと問題点があります。
まず、Helperのメソッド名にいちいちdistribution_
が入っており非常に冗長になっています。
これはHeperのメソッドが全てのViewでグローバルに有効になるためで、一般的な名前をつけると他のHelperメソッドと被ってしまうためです。
モデルの情報を使う場合は引数として受け取ることになるため、Helper側もView側も記述が冗長になってしまいます。
またHelperに書ける内容には制約がないため、上記のorder_addon_link_message
のようにDistribution
と関係のないものもDistributionHelper
に書くことができてしまいます。
このためHelperに集約されるメソッドは凝集度が低くなりがちで、メソッドを読む際に「それがどこで使われるか」「引数のクラスは何か」といったことを念頭に置かないといけなくなってしまいます。
Presenterを作って切り出す
こうした問題を解決するため、「特定のモデルに紐づくViewLogicを集約するクラス」としてPresenterクラスを作る取り組みを試していました。 (ここでいうPresenterは、ModelとView Contextの両方を持つとするThe Rails Viewの定義とは異なります)
例えばDistributionに必要になるViewLogicを切り出したDistributionPresenterは以下のような形になります。
class DistributionPresenter def initialize(distribution, current_user = nil) @distribution = distribution @current_user = current_user end # 送信先の敬称付き名を取得する def recipient_info @distribution.recipient_name + ' ' + @distribution.recipient_title end # 発注状況を表す文言を取得する def order_info message = ordered_message if @distribution.ordered_at message = rejected_message if @distribution.rejected_at return message end private: def ordered_message action_human_name = @distribution.sender?(@current_user) ? 'は発注されました' : 'を発注しました' "#{localize(@distribution.ordered_at, format: :only_date)}にこの見積#{action_human_name}" end def rejected_message action_human_name = @distribution.sender?(@current_user) ? 'はお断りされました' : 'をお断りしました' "#{localize(@distribution.rejected_at, format: :only_date)}にこの見積#{action_human_name}。" end end
先ほどに比べると、メソッド名に含まれていたdistribution_
という接頭辞や、引き回されていた引数が消え、だいぶシンプルになったことがわかります。
またコンストラクタで利用するモデルを受け取るようになったことから責務範囲が明確になり、order_addon_link_message
を含むべきではない雰囲気が出ています。(書けないわけではないですが)
しかしながらDistributionのコンテキストを持たないため、相変わらずコード内部では@distribution
がそこら中に表れており読みにくくなっています。
またViewのコンテキストも持たないため、link_to
などのViewHelperを使うこともできません。
さらに、Presenterは明示的に初期化が必要なので、Distributionを使うところでは@distribution
と@distribution_presenter
の両方のインスタンス変数をいちいち作る必要があります。(これが一番面倒くさい)
ActiveDecoratorを使って切り出す
そこで、ActiveDecoratorを導入してみることにしました。 ActiveDecoratorはModelごとに対応するViewのためのDecoratorを利用しやすくするgemです。 似たようなものにDraperがあります。
ActiveDecoratorを使うと、さきほどまでの処理は以下のように書き換えられます。
module DistributionDecorator # 送信先の敬称付き名を取得する def recipient_info recipient_name + ' ' + recipient_title end # 発注状況を表す文言を取得する def order_info message = ordered_message if ordered_at message = rejected_message if rejected_at return message end private: def ordered_message action_human_name = sender?(current_user) ? 'は発注されました' : 'を発注しました' "#{localize(ordered_at, format: :only_date)}にこの見積#{action_human_name}" end def rejected_message action_human_name = sender?(current_user) ? 'はお断りされました' : 'をお断りしました' "#{localize(rejected_at, format: :only_date)}にこの見積#{action_human_name}。" end end
Decoratorはモデルのコンテキストを持つため、モデルの属性にアクセスする際に変数を介する必要がなくなります。 まだリファクタリングの余地はありますが、当初から比べると引数や接頭辞が消えたことで全体的に読みやすくなっていることがわかります。
Decoratorに定義したメソッドはControllerからViewにインスタンス変数が渡される際に、モデルに自動でdecorateされます。
使うときには@distribution.recipient_info
といったように、Distributionモデル自身が持っているメソッドのように利用することができます。
Presenterのようなクラスを作る方法に比べると、個別に初期化して別の変数で持つ必要がないことから、管理が楽になります。
このように、ActiveDecoratorを使うことでViewのロジックをシンプルに切り出すことができるようになりました。
ActiveDecoratorの注意点
便利なActiveDecoratorですが、使っていく中で挙動が理解できずにハマることがいくつかありました。
一点目として、ActiveDecoratorが適用される対象はインスタンス変数に設定されたモデルインスタンスやコレクションのみとなります。 この際、それらのインスタンス変数から辿れる関連クラスにはActiveDecoratorが適用されていません。 そのためViewの中で関連を手繰ってDecoratorのメソッドを使おうと思うとMethod Missingになります。 READMEにも書かれていますが、この場合はpartialに切り出しローカル変数に割り当てることでDecoratorを適用することができます。
二点目として、モデル間にSingle Table Inheritanceによる継承関係がある場合は、基本的に子に対応するDecoratorのみが適用されます。
例えばAdmin < User
といったクラスがあり、Admin
クラスのインスタンスを@admin
に設定した場合、ViewではAdminDecorator
のメソッドのみが利用でき、UserDecorator
のメソッドは利用できません。
ただし、AdminDecorator
が存在しない場合に限りUserDecorator
が適用されます。
やや癖のある挙動なので、継承関係のあるモデルでは子にのみDecoratorを作るか、常に親のDecoratorをIncludeするようにすると良いかもしれません。
まとめ
ActiveDecoratorを利用することで、Viewをシンプルに保ちつつ、切り出したロジックをわかりやすく記述することができます。 いくつかハマりどころはありましたが、仕組みがシンプルなため原因も調べやすく、導入しやすいのではないかと思います。