Rails5「中級」チュートリアル(3-7)投稿機能: Service Object(翻訳)

概要

概要

原著者の許諾を得て翻訳・公開いたします。

Rails5中級チュートリアルはセットアップが短めで、RDBMSにはPostgreSQL、テストにはRSpecを用います。
原文が非常に長いので分割します。章ごとのリンクは順次追加します。

注意: Rails中級チュートリアルは、Ruby on Railsチュートリアル(https://railstutorial.jp/)(Railsチュートリアル)とは著者も対象読者も異なります。

目次

Rails5「中級」チュートリアル(3-7)投稿機能: Service Object(翻訳)

前述のとおり、コントローラ内にロジックを配置するとあっという間に複雑になってしまい、テストが複雑になってしまいます。そういうわけで、こうしたロジックを他の場所に切り出すのはよい考えです。私はそのためにデザインパターンを用いています。具体的にはService Objectと呼ばれるデザインパターンです(単にServiceとも呼ばれます)。

現時点のPostsControllerには以下のメソッドがあります(Gist)。

# controllers/posts_controller.rb
def get_posts
  branch = params[:action]
  search = params[:search]
  category = params[:category]

  if category.blank? && search.blank?
    posts = Post.by_branch(branch).all
  elsif category.blank? && search.present?
    posts = Post.by_branch(branch).search(search)
  elsif category.present? && search.blank?
    posts = Post.by_category(branch, category)
  elsif category.present? && search.present?
    posts = Post.by_category(branch, category).search(search)
  else
  end
end

Serviceを使ってこの大量の条件ロジックを取り除きたいと思います。Service Object(Service)デザインパターンは、単なる基本的なRubyのクラスです。Service Objectは、処理したいデータをこれに渡して、定義済みのメソッドを呼び出し、欲しい戻り値を受け取るという非常にシンプルなものです。

RubyではClassのinitializeメソッドにデータを渡します。これは他の言語で言う「コンストラクタ」に相当します。そしてクラス内で、定義済みのすべてのロジックを扱うメソッドを作成します。実際に作ってコードの様子を見てみましょう。

appディレクトリの下にservicesディレクトリを作成します。

app/services

このディレクトリの下にposts_for_branch_service.rbファイルを以下の内容で作成します(Gist)。

# services/posts_for_branch_service.rb
class PostsForBranchService
  def initialize(params)
    @search = params[:search]
    @category = params[:category]
    @branch = params[:branch]
  end

  # get posts depending on the request
  def call
    if @category.blank? && @search.blank?
      posts = Post.by_branch(@branch).all
    elsif @category.blank? && @search.present?
      posts = Post.by_branch(@branch).search(@search)
    elsif @category.present? && @search.blank?
      posts = Post.by_category(@branch, @category)
    elsif @category.present? && @search.present?
      posts = Post.by_category(@branch, @category).search(@search)
    else
    end
  end

end

前述したように、これはRubyの普通のクラスであり、パラメータを受け取るinitializeメソッドと、ロジックを扱うcallメソッドがあります。このロジックは、get_postsから持ってきたものです。

後は、get_postsメソッド内でこのクラスのオブジェクトを作成し、callメソッドで呼び出します。get_postsメソッドは次のような感じになります(Gist)。

# controllers/posts_controller.rb
  def get_posts
    PostsForBranchService.new({
      search: params[:search],
      category: params[:category],
      branch: params[:action]
    }).call
  end

変更をcommitします。

git add -A
git commit -m "Create a service object to extract logic
from the get_posts method"

spec

Serviceなどのデザインパターンのありがたい点は、単体テストが書きやすいことです。callメソッドのspecを書いて条件ごとにテストすればよいのです。

specディレクトリの下にservicesディレクトリを作成します。

spec/services

そのディレクトリの下に、posts_for_branch_service_spec.rbファイルを以下の内容で作成します(Gist)。

# spec/services/posts_for_branch_service_spec.rb
require 'rails_helper'
require './app/services/posts_for_branch_service.rb'

describe PostsForBranchService do

  context '#call' do
    let(:not_included_posts) { create_list(:post, 2) }
    let(:category) { create(:category, branch: 'hobby', name: 'arts') }
    let(:post) do
      create(:post,
              title: 'a very fun post', 
              category_id: category.id)
    end
    it 'returns posts filtered by a branch' do
      not_included_posts
      category
      included_posts = create_list(:post, 2, category_id: category.id)
      expect(PostsForBranchService.new({branch: 'hobby'}).call).to(
        match_array included_posts
      )
    end

    it 'returns posts filtered by a branch and a search input' do
      not_included_posts
      category
      included_post = [] << post
      expect(PostsForBranchService.new({branch: 'hobby', search: 'fun'}).call).to(
        eq included_post
      )
    end

    it 'returns posts filtered by a category name' do
      not_included_posts
      category
      included_post = [] << post
      expect(PostsForBranchService.new({branch: 'hobby', category: 'arts'}).call).to(
        eq included_post
      )
    end

    it 'returns posts filtered by a category name and a search input' do
      not_included_posts
      category
      included_post = [] << post
      expect(PostsForBranchService.new({name: 'arts', 
                                        search: 'fun', 
                                        branch: 'hobby'}).call).to eq included_post
    end
  end
end

このファイルの冒頭でposts_for_branch_service.rbファイルが読み込まれ、callメソッドの各条件がテストされます。

変更をcommitします。

git add -A
git commit -m "Add specs for the PostsForBranchService"

関連記事

Railsで重要なパターンpart 1: Service Object(翻訳)

新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ