Ruby on Railsアプリケーションを標準のディレクトリ構成の下で開発していくと、次第に肥大化し、開発しづらい状況に陥ってしまいます。 この記事では、モデル、コントローラ、ビューをリファクタリングする各パターンについて解説します。

パターン一覧

この記事では、以下の9つのパターンについて紹介します。 各パターンについて、概要と備考、簡単な実装例を示します。

  1. Validator
  2. Callback
  3. Observer
  4. Value
  5. Policy
  6. Finder
  7. Service
  8. Form
  9. Decorator

なお、HelperやMailer、Jobといった、Rails側で用意されているものについては言及しません。

Validator

Validatorパターンは、モデルのバリデーションをまとめるパターンです。

いくつかの属性で共通する独自のバリデーションや、複雑な条件のバリデーションを切り出すことで、再利用可能にしたり、可読性を向上できます。

app/validators/leave_validator.rb:

class LeaveValidator < ActiveModel::Validator
  def validate(record)
    events = record.events.where('start_at > ?', Time.now)

    if events.exists?
      record.errors.add :base, 'You must cancel all events before leave.'
    end
  end
end

app/models/user.rb:

class User < ActiveRecord::Base
  has_many :events

  validates_with LeaveValidator
end

Callback

Callbackパターンは、レコードの状態が変化するタイミングで実行する処理をまとめるパターンです。

ここには、モデルの一貫性を保つのに必要な処理をまとめます。

app/callbacks/users/default_name_callback.rb:

class Users::DefaultNameCallback
  def before_create(user)
    user.name = default_name(user)
  end

  private

  def default_name(user)
    user.email.split('@').first
  end
end

app/models/user.rb:

class User < ActiveRecord::Base
  before_create Users::DefaultNameCallback.new
end

Observer

Observerパターンは、Callbackパターンと同じ働きですが、モデルの一貫性に寄与しない処理をまとめます。

例として、ユーザの投稿に応じて運営側にSlackで通知する、などが考えられます。 Observerをオフにしてもアプリケーションに影響を及ぼしません。

これはrails-observerを利用することで実現できます。

app/observers/comment_observer.rb:

class CommentObserver < ActiveRecord::Observer
  def after_create(comment)
    CommentMailer.created(comment).deliver_later
  end
end

Value

Valueパターンは、値として比較可能なオブジェクトに関するロジックをまとめるパターンです。

以下は、記事(Post)の閲覧数(count)からランクを算出し、また関連するロジックを実装した例です。

app/values/rank.rb:

class Rank
  include Comparable

  attr_accessor :count

  def initialize(count)
    @count = count
  end

  def <=>(other)
    @count - other.count
  end

  def to_s
    case @count
    when 0..99
      'E'
    when 100..999
      'D'
    when 1000..9999
      'C'
    when 10000..99999
      'B'
    else
      'A'
    end
  end
end

app/models/post.rb:

class Post < ActiveRecord::Base
  def rank
    @rank ||= Rank.new(count)
  end
end

Policy

Policyパターンは、ユーザに対する認可をまとめるパターンです。

Punditを用いることで、シンプルに実装できます。

app/policies/post_policy.rb:

class PostPolicy
  attr_reader :user, :post

  def initialize(user, post)
    @user = user
    @post = post
  end

  def update?
    user.admin? && !post.published?
  end
end

Finder

Finderパターンは、いくつかのロジックが絡むなどした、複雑な検索をまとめるパターンです。

コントローラでActiveRecordのクエリの記述が長くなった場合など、Finderに委譲することでシンプルに実装できます。

app/finders/posts_finder.rb:

class PostsFinder
  def initialize(posts, order, user)
    @posts = posts
    @order = order
    @user = user
  end

  def posts
    case @order
    when 'hot'
      @posts.hot
    when 'recommended'
      @posts.recommend_to(@user)
    else
      @posts
    end
  end
end

app/controllers/posts_controller.rb:

class PostsController < ApplicationController
  def index
    ...
    @posts = PostsFinder.new(@posts, params[:order], current_user).posts
  end
end

Service

Serviceパターンは、副作用のある複雑な操作をまとめるパターンです。

コントローラの記述が長い場合、モデルに対する一連の操作をServiceに委譲することでシンプルに実装できます。

app/services/users/omniauth_service.rb:

module Users
  class OmniauthService
    def initialize(auth)
      @auth = auth
    end

    def find_or_create
      user = User.find_or_initialize_by(uid: @auth.uid, provider: @auth.provider)

      if user.new_record?
        user.assign_attributes(
          email: dummy_email,
          password: Devise.friendly_token[0, 20]
        )
        user.skip_confirmation!
        user.save
      end

      user
    end

    private

    def dummy_email
      "#{@auth.uid}-#{@auth.provider}@example.com"
    end
  end
end

Form

Formパターンは、ユーザの入力を検証/整形するロジックをまとめるパターンです。

コンテキストによって異なるバリデーションを適用したり、受け取った入力を永続化するために整形の必要がある場合などに適用します。

app/forms/sign_up_form.rb:

class SignUpForm
  include ActiveModel::Model

  attr_reader :group_name, :user_name

  validates :group_name, presence: true
  validates :user_name, presence: true

  def save
    if valid?
      group = Group.find_or_create_by(name: group_name)
      group.users.create(name: user_name)
    else
      false
    end
  end
end

app/controllers/users_controller.rb:

class UsersController < ApplicationController
  def create
    @form = SignUpForm.new(params[:sign_up])

    if @form.save
      ...
    end
  end
end

Decorator

Decoratorパターンは、Viewに表示するための、モデルの状態に応じたロジックをまとめるパターンです。

Helperだとグローバルな名前空間に定義されてしまいますが、Decoratorだと名前空間を汚染せず、またシンプルに実装できます。

ActiveDecoratorを用いると、以下のように記述できます。

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

おわりに

開発当初から、すべてのパターンを適用する必要はないと思います。 実装していく中で、適切なタイミングでパターンの導入を検討しましょう。

Railsアプリケーションのリファクタリングをする際に、参考にしてみてください。