読者です 読者をやめる 読者になる 読者になる

太りがちなViewHelperをActiveDecoratorで改善した話

Misoca開発チームの黒曜です。 先日、城崎温泉に行ってきました。 ちょうど大寒波が来ていた週末で、雨に降られて凍えながら外湯巡りをしたり、雪が吹き込むなか屋外のイルカショーを見たり、と若干修行の様相を呈していたことをご報告します。

f:id:kokuyouwind:20160204181747j:plain

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をシンプルに保ちつつ、切り出したロジックをわかりやすく記述することができます。 いくつかハマりどころはありましたが、仕組みがシンプルなため原因も調べやすく、導入しやすいのではないかと思います。