12
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

1. Rails開発のView周りの実装

1.1 共通する悩み

Rails開発において、負債が積み重なり、View周りがしんどくなりがちというのはあるあるかと思います。

  • Viewのあちらこちらに似たコードを記載しちゃう問題
  • Viewに記載したプレゼンテーションロジックが複雑になりすぎちゃう問題
  • Modelにプレゼンテーションロジックを記載しちゃう問題
  • 上記により、FatModel、FatViewが発生しちゃう問題
  • 結局、ModelとViewのどっちに書けばええねん!と板挟みになっちゃう問題

結果、非DRYで可読性に乏しく、責務の曖昧なコードが出来上がります。私も個人開発でたまにやらかします。以下で具体的なコードを交えて説明していきます。

1.1.1 Viewのあちらこちらに似たコードを記載しちゃう問題(非DRY)

いわゆるDRYになっていないコードが存在してしまうケースです。複数人開発で、コードの全容を理解していない、習熟度様々なエンジニアが存在することから、気を抜くとどうしても発生してしまうコードになります。

<!-- users/index.html.erb -->

<% @users.each do |user| %>
  <div>
    <p>ユーザー名: <%= "#{user.first_name} #{user.last_name}" %></p>
    <!-- 他の情報 -->
  </div>
<% end %>

<!-- users/show.html.erb -->

<div>
  <h1>ユーザー詳細</h1>
  <p>ユーザー名: <%= "#{user.first_name} #{user.last_name}" %></p>
  <!-- 他の詳細情報 -->
</div>

<!-- users/edit.html.erb -->

<div>
  <h1>ユーザー編集</h1>
  <p>現在のユーザー名: <%= "#{user.first_name} #{user.last_name}" %></p>
  <!-- 編集フォーム -->
</div>

こちら、複数のファイルに<%= "#{user.first_name} #{user.last_name}" %>がちりばめられてますが、海外対応でミドルネームが追加されるなど、仕様に変更があった時に大変です。ジョン・F・ケネディですし、モンキー・D・ルフィですし、田中・エイミー・みな実です。

1.1.2 Viewに記載したプレゼンテーションロジックが複雑になりすぎちゃう問題(可読性の低下)

users/show.html.erbに少々複雑なプレゼンテーションロジックが記載されている例です。

<!-- users/show.html.erb -->

<div>
  <h1>ユーザー詳細</h1>
  
  <p>
    ユーザー名: 
    <% if user.premium? %>
      <strong><%= "#{user.first_name} #{user.last_name}" %></strong> (プレミアムユーザー)
    <% else %>
      <%= "#{user.first_name} #{user.last_name}" %>
    <% end %>
  </p>

  <p>
    アカウント状況: 
    <% if user.active? %>
      <span style="color: green;">アクティブ</span>
    <% else %>
      <span style="color: red;">非アクティブ</span>
    <% end %>
  </p>

  <p>
    最終ログイン: <%= user.last_login_at.strftime("%Y/%m/%d %H:%M") %>
  </p>

  <% if user.birthday_today? %>
    <p>今日はあなたの誕生日です!おめでとうございます!</p>
  <% end %>
</div>

皆さんのプロジェクトではこれ以上に複雑になっているコードもあるのではないでしょうか?そうなると、仕様把握・改修などが大変になり、つらみが増します。

上記の複雑なコードをリファクタリングする方法として、次項のような手法が用いられることがあります。

1.1.3 Modelにプレゼンテーションロジックを記載しちゃう問題(責務がおかしい)

早速コードを見ていきましょう。

Modelにプレゼンテーションロジックを記載
# app/models/user.rb
class User < ApplicationRecord
  # ...

  def full_name
    "#{first_name} #{last_name}"
  end

  def formatted_account_status
    active? ? 'アクティブ' : '非アクティブ'
  end

  def birthday_message
    '今日はあなたの誕生日です!おめでとうございます!' if birthday_today?
  end
end
Modelで定義したメソッドをViewで表示する
<!-- users/show.html.erb -->

<div>
  <h1>ユーザー詳細</h1>
  
  <p>ユーザー名: <%= @user.full_name %></p>
  <p>アカウント状況: <%= @user.formatted_account_status %></p>
  <p>最終ログイン: <%= @user.last_login_at.strftime("%Y/%m/%d %H:%M") %></p>

  <% if @user.birthday_today? %>
    <p><%= @user.birthday_message %></p>
  <% end %>
</div>

上記の例では、Viewで必要とされる表示形式をModel内で定義しています。

なるほど、確かにviewファイルは先程に比べてスッキリしました。これでよさそうな気もしてきます。

しかし、これによりModelがプレゼンテーションに関する責務を負うことになり、Modelの肥大化や単一責務の原則の違反に繋がる可能性があります。また、表示に関する変更がある場合、Modelのコードを変更する必要があり、これはModelとViewの間の結合を強めることになります。

1.1.4 結果的に

ViewもModelも結果的にFatになってしまういわゆるFatModel、FatView問題が発生します。仕様を把握するために1000行を超えるようなViewやModel、読みたくないですよね?なるべくなら避けて通りたいところです。

また、ModelとViewのどっちに書くの問題も発生します。先ほどの例をおさらいすると、Modelに書いてもViewに書いても怒られます。ダブルバインドです。じゃあどっちに書けばええねん!となります。では一体、責務をどこに持たせるのがいいのでしょうか?

1.2 悩みの解決法

これらの問題を解決する方法として、Helper, Decorator, Presenterが挙げられます。具体的な使い方については次項以降で説明するとして、ここではHelper, Decorator, Presenterに共通する特徴を述べていきましょう。

1.2.1 悩みの解決法として挙げられるパターン

Helper, Decorator, Presenter

1.2.2 それぞれのパターンに共通する定義

View層のコードを整理し、保守性を向上させるもの

1.2.3 なぜパターンが存在するか

Railsだけで開発してると、規模のデカさに比例してつらみ・問題が発生してしまうため。そして、それはどのプロジェクトにおいても頻出する問題のため、頻出する問題に対する解法として上記のパターンが存在している。

1.2.4 共通するメリット

  • Viewで繰り返しコードをモジュール化し、整理できる
  • FatModelやFatViewの解決ができる
  • 責務を分離することでテストが書きやすくなる

1.2.5 まとめ

Helper, Decorator, Presenterを用いると先述した

  • Viewのあちらこちらに似たコードを記載しちゃう問題
  • Viewに記載したプレゼンテーションロジックが複雑になりすぎちゃう問題
  • Modelにプレゼンテーションロジックを記載しちゃう問題
  • 上記により、FatModel、FatViewが発生しちゃう問題
  • 結局、ModelとViewのどっちに書けばええねん!と板挟みになっちゃう問題

の問題を解決することができます。気持ちエェェェェェ!!!

次項ではHelperの説明を行います。

2. Helper

2.1 特徴

軽量なメソッド群
Helperは、Viewで用いられる軽量なメソッドを集めたものです。

繰り返しコードのモジュール化(DRY)
Helperは、Viewで繰り返し使用されるコードをモジュール化し、整理するために用いられます。

全Viewからのアクセスが可能
Helperは全てのViewから呼び出すことができる、つまりグローバルなスコープを持ちます。

静的メソッドの集まり
Helperは静的メソッドの集まりであり、共通の機能(日付のフォーマット、テキスト処理など)を提供します。

Rails標準ライブラリの一部
HelperはRailsの標準ライブラリの一部であり、Railsのアプリケーション開発で広く使われています。

静的メソッドとは
オブジェクトのインスタンスを生成せずに直接呼び出すことができるメソッド。

2.2 代表的なHelperメソッド

以下は、Rails標準ライブラリに定義された、代表的なHelperメソッドです。

content_for
レイアウトやViewの特定の部分にコンテンツを動的に挿入するために使用されます。

content_tag
HTMLタグを生成し、Rubyコード内でHTMLを簡単に記述できるようにします。

form_with
フォームを作成する際に使用され、Rails 5.1以降でform_forとform_tagの機能を統合したものです。

その他ヘルパーの例
https://railsguides.jp/action_view_helpers.html

2.3 Helperのデメリット

2.3.1 グローバルスコープによる問題

Helperメソッドはグローバルスコープで定義されるため、アプリケーション全体でのメソッド名の衝突リスクがあります。
メソッド名が冗長になる可能性があり、他のHelperの存在を常に意識して開発する必要があります。ヒソカに言わせれば「(脳内の)メモリのムダ使い♡」です。

名前衝突してしまう例
# app/helpers/user_helper.rb
module UserHelper
  def format_date(date)
    date.strftime("%Y-%m-%d")
  end
end

# app/helpers/order_helper.rb
module OrderHelper
  def format_date(date)
    date.strftime("%d/%m/%Y")
  end
end
衝突回避のために、結果的に命名が冗長になってしまう例
# app/helpers/user_helper.rb
module UserHelper
  def user_format_date(date)
    date.strftime("%Y-%m-%d")
  end
end

# app/helpers/order_helper.rb
module OrderHelper
  def order_format_date(date)
    date.strftime("%d/%m/%Y")
  end
end

2.3.2 Helperの柔軟性と単一責務の問題

MVCアーキテクチャにおいて、Viewは描画、Modelはビジネスロジックを記載する役割を持ちます。

しかし、View層のHelperは柔軟で制約が少ないことから、本来Model層で定義されるはずの複雑なビジネスロジックやModel固有のロジックをHelperで定義することができます。結果として、ViewとModel間の責務が混ざり合い、MVCの原則に反する可能性があります。

MVCの原則に反する例
# app/helpers/user_helper.rb
module UserHelper
  # WARN: Userオブジェクトのbirthdateを用いて年齢を計算しており、
  # 本来Model層で行われるべきビジネスロジックがView層に存在している。
  def calculate_age(user)
    ((Time.zone.now - user.birthdate.to_time) / 1.year.seconds).floor
  end

  # WARN: Userオブジェクトの属性を用いてフルネームを生成しているが、
  # このような単純なフォーマット変更はModelの責務として適切に扱うことが望ましい。
  def full_name(user)
    "#{user.first_name} #{user.last_name}"
  end
end
一つのHelperメソッド内で異なる責務のロジックを実装する例
# app/helpers/user_helper.rb
module UserHelper
  # WARN: 一つのHelperメソッド内で異なるタイプのロジック(データのフォーマットとHTMLの生成)を実装する例
  def user_info_with_formatting(user)
    formatted_date = user.birth_date.strftime("%Y年%m月%d日")
    name = user.name

    # HTMLタグの生成とデータのフォーマットが混在している
    "<div>
      <p><strong>名前:</strong> #{name}</p>
      <p><strong>生年月日:</strong> #{formatted_date}</p>
    </div>".html_safe
  end

  # WARN: ユーザーの詳細情報を表示するための複合的なメソッドであり、
  # ビジネスロジック(年齢計算)とプレゼンテーションロジック(名前表示)が混在している。
  def user_details(user)
    "Name: #{user.name}, Age: #{calculate_age(user)}"
  end

  private

  # INFO: 現在の日付とユーザーの生年月日を基に計算している。
  # WARN: Helperに記載されているが、年齢の計算はより複雑なビジネスロジックであることから、本来Model層の仕事である。
  def calculate_age(user)
    now = Time.zone.now
    now.year - user.birth_date.year - ((now.month > user.birth_date.month || (now.month == user.birth_date.month && now.day >= user.birth_date.day)) ? 0 : 1)
  end
end

Helperにビジネスロジックを含めることで、Helperの凝集度が低下します。つまり、一つのHelperがViewのマークアップとビジネスロジックの両方を担うことになり、その機能が分散されます。

この結果、Helperが本来の目的(Viewのマークアップと簡単なプレゼンテーションロジックのサポート)から離れ、さまざまな責務を持つようになる可能性があります。

これはHelperが単一責務の原則に反して複数の責務を担うことになり、その結果、アプリケーションの構造と保守性に悪影響を及ぼす可能性があります。これを解決するためには、ビジネスロジックをModelに移行し、Helperは純粋にViewのサポートに限定するのが適切です。

Helperの柔軟性と単一責務の問題がなぜ起きるか

グローバルスコープ:
Helperはグローバルスコープ故に、特定のViewに特化しない汎用的なメソッドが作成されやすいため、結果として一つのHelperが多様な責務を持つことになります。

プレゼンテーションロジックの集中:
Helperは主にView層のプレゼンテーションロジックを支援する目的で使用されます。このため、ビジネスロジックやデータ処理のロジックがView層に混入しやすく、単一責務の原則から逸脱しやすい。

制約の緩さ:
RailsのHelperは特に制約が少なく、開発者が任意のロジックを追加できるため、単一責務の原則を維持するのが難しくなります。これは、他の部分(Modelやコントローラーなど)では、その役割や責務がより明確に区分されているため、Helperほど顕著ではない。

2.4 まとめ

RailsのView Helperは、Viewファイル内で繰り返し使用されるコードを整理し、モジュール化するための軽量な静的メソッドの集まりです。これにより、コードの再利用性が高まり、Viewの保守性と可読性が向上します。また、Rails標準ライブラリの一部として提供される多くの便利なHelperメソッドがあり、これらを利用することで開発効率が向上します。

一方で、Helper特有の制約の自由さに由来した凝集性の低いコードや責務を無視したコードが生まれてしまう問題があります。

これまでのことを踏まえると、ビジネスロジックをHelperに追加できないとのことですが、とはいえ、Modelに表示に関するメソッド(プレゼンテーションロジック)を増やすのは、責務的にどうかと思いますし、FatModel問題にもなるため嫌です。

じゃあどうすればいいんだーーーー!!!!!

Decorator「…呼んだかい」

Helper「あ、あなたは…!」

3. Decorator

Decoratorは「装飾」という意味を表し、これは特定のModelに追加の責任や振る舞いを文字通り「装飾」として付加することです。Modelに記載された表示に関するロジックをDecoratorに分離し、専用のクラスに移譲することで、FatModelやFatViewになるのを防ぐことができます。

Railsにおいては、draperやActiveDecoratorというgemを用いることで使用できます。

3.1 HelperとDecoratorの何が違うのか

HelperとDecoratorはどちらもViewを支援するための仕組みですが、役割の違いがあります。

HelperはModelから独立し、Modelに直接関係しない描画ロジックを実装します。一方、Decoratorは特定のModelに密接に関連した描画ロジックを実装し、それぞれのModelに対して独自のViewの振る舞いを定義することができます。

これにより、DecoratorはHelperが抱える名前衝突や責務の曖昧さといった問題を解決し、よりオブジェクト指向的なアプローチを提供します。

HelperはModelに直接関係しない描画ロジックを記載する
# app/helpers/application_helper.rb
module ApplicationHelper
-  # Productインスタンスの価格をフォーマットするヘルパーメソッド
-  # このメソッドはproductモデルに依存する描画ロジックのため、HelperからDecoratorに記載する必要がある
-  def formatted_product_price(product)
-    "#{product.price}円"
-  end

+  # 数値を通貨フォーマット(例:1,000円)に変換するヘルパーメソッド
+  # このメソッドは特定のモデルに依存せず、どの数値にも適用可能
+  def format_currency(value)
+    number_to_currency(value, unit: "円", format: "%n%u")
+  end
end
誤ったHelperの使い方
# app/helpers/user_helper.rb
- module UserHelper
-   # WARN: Userオブジェクトのbirthdateを用いて年齢を計算しており、
-   # 本来Model層で行われるべきビジネスロジックがView層に存在している。
-   # しかし、ここの文脈でのcalculate_ageは表示でしか使われないことからDecoratorに移管するのが適切
-   def calculate_age(user)
-     ((Time.zone.now - user.birthdate.to_time) / 1.year.seconds).floor
-   end
- 
-   # WARN: Userオブジェクトの属性を用いてフルネームを生成しているが、
-   # このような単純なフォーマット変更はModelの責務として適切に扱うことが望ましい。
-   # しかし、このメソッドはuserモデルに依存する描画ロジックのため、HelperからDecoratorに移管するのが適切
-   def full_name(user)
-     "#{user.first_name} #{user.last_name}"
-   end
- end
Decoratorを使った適切なアプローチ
# app/decorators/user_decorator.rb
+ class UserDecorator < Draper::Decorator
+   delegate_all
+ 
+   # 年齢計算をDecoratorに移動
+   def calculate_age
+     now = Time.zone.now
+     now.year - object.birth_date.year - ((now.month > object.birth_date.month || (now.month == object.birth_date.month && now.day >= object.birth_date.day)) ? 0 : 1)
+   end
+ 
+   # フルネーム生成をDecoratorに移動
+   def full_name
+     "#{object.first_name} #{object.last_name}"
+   end
+ end
DecoratorのメソッドをViewで使用する
<%# app/views/users/show.html.erb %>

+ <%# UserオブジェクトをDecoratorでラップ %>
+ <% decorated_user = @user.decorate %>
+ 
+ <div>
+   <h1><%= decorated_user.full_name %></h1>
+   <p>年齢: <%= decorated_user.calculate_age %></p>
+ </div>

3.2 まとめ

  • helperはModelから独立し直接関係していない描画ロジックを実装するのに用いる。
  • Decoratorは特定のModelに関連した描画ロジックを実装するのに用いる。
  • Decoratorは「特定のModelに関連した描画ロジックを実装する」役割を持つことから、責務がより明確に区分されているため、よりオブジェクト指向ライクに記載できる。(=Helperと違って秩序立っている)
  • Decoratorの導入により「特定のModelに関連した描画ロジック」をView層やModel層に記載せず、新設したViewModel層に移譲することができるため、FatViewやFatModel問題を解決できる。
  • DecoratorはHelperと違ってグローバルスコープではないので、名前衝突が起きない。

4. Presenter

Presenterパターンは、Decoratorと同じで「特定のModelに関連した描画ロジック」をView層やModel層に記載せず、新設したViewModel層に移譲することで、責務がより明確に区分するものである。

DecoratorとPresenterの違いとして、

Decoratorは単一のModelクラスに対応する ViewModel
Presenterは複数のModelクラスにまたがる ViewModel

を取り扱う際に使用される。

4.1 Presenterを使わずに実装するとどうなるか

Presenterを使用せずに同じ機能を実装する場合、ロジックの多くがViewテンプレート内に直接記述されることになります。以下は、Userと紐づくOrderの最新の注文データを表示するためのViewテンプレートの例です。こういったプロジェクトに携わっている人は多いかと思います。

<%# app/views/users/show.html.erb %>

<h1><%= @user.name %> の最近の注文</h1>

<ul>
  <% @user.orders.order(created_at: :desc).limit(5).each do |order| %>
    <li>
      注文番号: <%= order.id %>,
      注文日時: <%= order.created_at.strftime('%Y/%m/%d') %>,
      合計金額: <%= order.total_price %></li>
  <% end %>
</ul>

この方法では、注文に関するデータの取得とフォーマット処理がViewテンプレート内に直接含まれています。これは、以下の点でPresenterを使用するアプローチと異なります:

  • 可読性の低下
    Viewテンプレートが複雑なロジックを含むため、可読性が低下する可能性があります。
  • 再利用性の欠如
    ロジックが特定のViewに密結合しているため、他のViewで同じロジックを再利用するのが難しい。
  • テストの難易度の増加
    ロジックがViewに直接組み込まれているため、単体テストが難しくなります。

4.2 Presenterを使用した複数ModelにまたがるViewModel層の実装

model
# app/models/user.rb
class User < ApplicationRecord
  # ユーザーに関連するメソッドや関連付けが定義されている
end

# app/models/order.rb
class Order < ApplicationRecord
  # 注文に関連するメソッドや関連付けが定義されている
  belongs_to :user
end
presenter
# app/presenters/user_orders_presenter.rb
class UserOrdersPresenter
  def initialize(user)
    @user = user
  end

  def user_name
    @user.name
  end

  # NOTE: UserModelの関連するOrderを降順で最大5件取得
  # このメソッドはViewで表示するための特定の注文データセットを取得する
  # ModelではなくPresenterにこのロジックがあるのは、View特有のデータ取得要件のため
  def recent_orders
    @user.orders.order(created_at: :desc).limit(5)
  end

  # NOTE: 特定のOrderオブジェクトの詳細情報をフォーマット化
  # このメソッドは、Viewでの表示用に注文データを整形するために使われる
  def formatted_order_info(order)
    "注文番号: #{order.id}, 注文日時: #{order.created_at.strftime('%Y/%m/%d')}, 合計金額: #{order.total_price}円"
  end
end
view
<%# app/views/users/show.html.erb %>
<% presenter = UserOrdersPresenter.new(@user) %>

<h1><%= presenter.user_name %> の最近の注文</h1>

<ul>
  <% presenter.recent_orders.each do |order| %>
    <li><%= presenter.formatted_order_info(order) %></li>
  <% end %>
</ul>

となります。

5 おわりに

それぞれの違いを表にしてまとめました。

特徴 何も使わない場合 Helper Decorator Presenter
コードの重複 重複が多く、変更が困難。同じロジックが複数のViewに散見される。 重複を減らすが、全Viewで利用可能なため、時に過剰な汎用性をもたらす。 Modelに紐づいたView固有のロジックを効果的に分離。各ModelのDecoratorを通じてコードの重複を避ける。 複数のModelや複雑なViewロジックを統合し、重複を減らす。一つのViewにおける多様なデータ源を扱う。
可読性 ロジックがViewに直接記載されており、可読性が低下する。ビジネスロジックとViewロジックが混在。 Viewロジックを簡素化するが、グローバルスコープによりメソッド名が冗長になる可能性がある。 各Modelに対して明確な責務を持つため、可読性が高い。Viewロジックが明確に分離される。 View固有のロジックをカプセル化し、複数のデータソースを扱う複雑なViewロジックを簡素化する。
ViewとModelの分離 ViewとModelが密接に結合しているため、分離が不十分。 View層の軽量なロジックを担うが、ビジネスロジックを含むこともある。 明確な責務の分離により、ViewとModelがより独立する。ModelからView固有のロジックを抽出。 ViewとModel間の複雑なデータの取り扱いと表示ロジックを抽象化し、分離を促進する。
再利用性 再利用が難しい。同じロジックが異なるViewで再度書かれることが多い。 汎用的なViewロジックの再利用が可能。しかし、スコープが広いために、特定のViewに特化した再利用は難しい場合もある。 特定のModelに対するViewロジックを再利用しやすい。Decoratorパターンを通じて、View毎に特化した再利用が可能。 複数のModelやView固有のロジックにまたがる再利用性が高い。複雑なView間のデータ整合性を保ちやすい。
テストのしやすさ テストが困難。ViewロジックがViewテンプレート内に組み込まれているため。 ヘルパーメソッドは単体テストが可能だが、View全体のテストは依然として困難。 Modelに関連するViewロジックが単一のクラスに集約されるため、Decoratorの単体テストが容易。 複数のModelとViewロジックを含む複合的な機能のテストが容易。一元化されたロジックにより、整合性のあるテストが可能。

参考

12
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?