Rails:出力処理をViewModel層に切り分ける!

コントローラをスリム化させるために取り入れた「ViewModel層」について整理します。

コントローラのスパゲッティ化・肥大化

仕様変更や機能追加を繰り返した結果、

コントローラがすっかりスパゲティ状態になってしまった。

コントローラの行数がすごいことになっている

こんな経験ありませんか?

私はあります。

ダイエットしているのに…

それなりのダイエットはしていました。

例えば、

ビジネスロジック(入力処理)を Service層に切り出したり、

簡単な出力処理を ViewHelper や Decorators に書いたりしていました。

それでも、コントローラのスパゲッティ化・肥大化が起こってしまったんですね。

複雑な出力処理が原因!

その原因は「行き場のない複雑な出力処理」でした。

複雑な出力処理が求められる場合、これらのロジックを ViewHelper や Decorators に収めるのは難しいです。

また、Service層は「入力処理」に専念した方が、設計上望ましいとされているので、ここに複雑な「出力処理」を書くのは微妙。

ということで、行き場のない「複雑な出力処理」は、コントローラに残されてしまい、

結果としてスパゲッティ化・肥大化を招いていました。

ViewModel層に切り出して解決!

この状況を解消すべく、『 変更に強いアーキテクチャについてIT業界19年目の僕が超ザックリ説明する』を参考に、複雑な出力処理は「ViewModel層」に書いていくことにしました。

コンセプトは Service層と同じで、違う点は「出力処理」を切り出す場所という点だけです。

また、命名規則は『 View models for Rails』を参考に次のようにしています。

  • app/view_models:ViewModel のディレクトリ
  • View:全てのクラスの接尾辞に「View」をつける

複雑な出力処理を扱う ViewModel は、フロントエンドにVue JS などのフレームワークを導入した時にも活用できるアーキテクチャです。

ビューモデルを使うアーキテクチャとして MVVM 5 があるが、まさにそれと同じようなイメージだ。この構成は Ajax を使うときにもよくマッチする。 View がブラウザ側で動いているような構成になるが、ViewModel をそのまま Json エンコードしてフロントに返してやれば、VueJS などで直接バインディングするだけで画面に値が反映される。とても分かりやすくなるしラクになるので、オススメのアーキテクチャパターンの一つだ。( 変更に強いアーキテクチャについてIT業界19年目の僕が超ザックリ説明する

サンプルコード

実際のコードはこんな感じのイメージです(View models for Rails より 引用)。


# app/model_views/product_view.rb
require 'active_support/core_ext/module/delegation'

class ProductView
  def initialize(product, view_context)
    @product ||= product
    @variants ||= product.variants
    @v ||= view_context
  end

  # Set attr_readers for initialized values.
  attr_reader :product, :variants, :v

  # Delegate methods from `product`.
  delegate :name, to: :product

  # `link_to` expects passed in object to have `to_param` and `model_name` defined.
  # `render` expects passed in collection objects to have `to_partial_path` defined.
  # Our `Product` model inherits from ` ActiveRecord::Base` which already define these methods for us.
  delegate :to_param, :model_name, :to_partial_path to: :product

  # Initialize collection
  def self.collection(products, view_context)
    products.map { |p| self.new(p, view_context) }
  end

  # We use this image everywhere!
  def default_image(size = :thumbnail)
    product.image_url(size)
  end

  # Show min price or both prices if they are different.
  def price
    min, max = variants.map(&:unit_price).minmax
    output = v.number_to_currency(min)
    output = "#{output} - #{v.number_to_currency(max)}" if min != max
    output
  end
end

コントローラでの呼び出し例は、こんな感じ。


module Store
  class ProductsController < StoreController
    def index
      # Pass in a collection of products to the `self.collection` method defined in our view model.
      products ||= Product.all.sorted.visible
      @products ||= ProductView.collection(products, view_context)
    end

    def show
      # We have access to `view_context` here.
      product ||= Product.find_by(slug: params[:slug])
      @product_view ||= ProductView.new(product, view_context)
    end
  end
end

実際にコントローラがスリムに!

上のお手本のように綺麗に書けてませんが、今回あるコントローラをリファクタリングした結果、

コード行数は、「85行 → 39行」に削減しました。

コード行数だけでなく、出力ロジックの方向性が一方向になったので、メンテナンスもしやすくなりました。

出力処理が複雑になってきたら、「ViewModel層」に逃してあげましょう!

兵庫県西宮市生まれのフリーランスRailsエンジニア。海外を拠点にデジタルノマド生活中。/ 前職・資格:公認会計士 / プログラミング言語:Ruby, JavaScript, HTML, CSS / 日本語・英語
コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です