LoginSignup
126
110

More than 5 years have passed since last update.

Rails のサービスクラスでのマイルールとちょっとしたコツ

Last updated at Posted at 2016-09-15

動機

Railsにおけるサービスクラスのオリジナルルール という記事をたまたま見つけ、「自分ならこう書くかな」と感じたことがいくつかあったので、記事にしてみました。
なお :point_up_2: の参考記事の中でさらに参考にされている 肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳) という記事のコードを、この記事でも説明のために用いようと思います。そこで、以下は 2 つの記事をこのように呼称します。

マイルール

ルール 1. クラス名は「動詞 (+ 目的語)」にする

参考記事 1 のものとほぼ同じルールです。末尾に Service と付けないことのみ異なります (ただの好み) 。
参考記事 2 では UserAuthenticator#authenticate というメソッドが提供されています。これ以外でもよく UsersExcelImporter#import, UploadedFileConverter#convert のように、-or, -er という接尾語を持った名詞のクラス名 + 動詞のメソッド名という命名パターンをしばしば見かけます。これだと冗長な命名に感じるし、クラスの命名に迷う場合もあるので、僕は参考記事 1 のルールが非常に好みです。

ルール 2. 外部に公開するメソッドは call という名前のクラスメソッドのみ

これがキモです :cupid:

ルール 1 でクラス名は動詞を表すものになっているので、このクラスの責務は自身が持つ名前の処理を実行することのみであるのが理想的だと考えています。そこで「実行する」や「呼び出す」といった意味の public メソッドを 1 つだけ用意するのが理想です。
僕は今まで様々なメソッド名、例えば doexecute, perform などを採用しましたが、最近は call を使っています。なぜなら Proc#call とインターフェイスを合わせることができるからです。一定の手続きを実行するという意味で、サービスクラスの実行は Proc オブジェクトの実行と似ていると思います。

このルールに従って、参考記事 2 に記載されている :point_down: のコードを

class UserAuthenticator
  def initialize(user)
    @user = user
  end

  def authenticate(unencrypted_password)
    return false unless @user

    if BCrypt::Password.new(@user.password_digest) == unencrypted_password
      @user
    else
      false
    end
  end
end

:point_down: のように書き換えてみます。なお、参考記事 1 の「引数は出来る限り new で渡してインスタンス化する」というルールに従って実装しています。

class AuthenticateUser
  def initialize(user, unencrypted_password)
    @user = user
    @unencrypted_password = unencrypted_password
  end

  def call
    return false unless @user

    if BCrypt::Password.new(@user.password_digest) == @unencrypted_password
      @user
    else
      false
    end
  end
end
# 呼び出し元の例
AuthenticateUser.new(User.find(1), 'password').call #=> true/false

ここで僕が感じるのが、1 度だけ call するためだけにわざわざ new でインスタンス化するのも冗長だなということです。そこで AuthenticateUser#call のクラスメソッド版である AuthenticateUser.call を追加します。

class AuthenticateUser
  def self.call(user, unencrypted_password)
    new(user, unencrypted_password).call
  end

  ### 略 ###
end
# 呼び出し元の例
AuthenticateUser.call(User.find(1), 'password') #=> true/false

AuthenticateUser.call では自身のインスタンスを生成し、同名のインスタンスメソッドを呼び出すことで処理を委譲します。

これで、ほぼ理想のコードになりました :blush:

ただ、このままではインスタンスメソッド経由でもクラスメソッド経由でも call を呼び出すことが可能です。これでは一貫性がなく呼び出し方を統一できないので、クラスメソッド経由での呼び出しに限定します。

class AuthenticateUser
  private_class_method :new

  def self.call(user, unencrypted_password)
    new(user, unencrypted_password).send(:call)
  end

  private

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

  def call
    return false unless @user

    if BCrypt::Password.new(@user.password_digest) == @unencrypted_password
      @user
    else
      false
    end
  end
end

AuthenticateUser#call を private 化することで外部からの呼び出しを禁止します。また private_class_method :new を記述することで、インスタンス化も禁止します。

以上でマイルールに従った実装は完了です。しかし場合によっては、サービスクラスをさらに使いやすくするためにとあるメソッドを追加します。これはコツとして説明したいと思います。

コツ

to_proc というクラスメソッドを実装する

call の引数が一つの場合、 :point_down: のように to_proc というクラスメソッドを実装することで

class AuthenticateUser
  private_class_method :new

  def self.call(user)
    new(user).send(:call)
  end

  def self.to_proc
    method(:call).to_proc
  end

  private

  def initialize(user)
    @user = user
  end

  def call
    return false unless @user

    if BCrypt::Password.new(@user.password_digest == 'master_password'.freeze
      @user
    else
      false
    end
  end
end

Enumerable#each などのメソッドの引数に Proc オブジェクトとして渡すことが可能になります。

# AuthenticateUser.to_proc の例
users.map(&AuthenticateUser) #=> [false, false, #<User id: ...>, false, #<User id: ...>]
users.any?(&AuthenticateUser) #=> true

# 比較: Symbol#to_proc の例
(1..3).map(&:to_s) #=> => ["1", "2", "3"]

地味に便利です :innocent:

おまけ

マイルールを気軽に適用しやすくするための Module を実装しました :wink:

# サービスクラス用のインターフェイスを提供するモジュール。
# 使い方は以下のとおりです。
#
# (1) サービスクラスに Procedural を include する。
# (2) initialize を実装する。
# (3) private なインスタンスメソッド call を実装する。
#
# そうすると call というクラスメソッドのみを外部に公開したサービスクラスを作成できます。
# なお、クラスメソッド call の引数は initialize の引数と同じになります。
#
module Procedural
  extend ActiveSupport::Concern

  included do
    private_class_method :new
  end

  class_methods do
    def call(*args)
      instance = new(*args)
      yield(instance) if block_given?
      instance.send(:call)
    end

    def to_proc
      method(:call).to_proc
    end
  end

  private

  def initialize(*args)
    return if args.empty?

    raise(NotImplementedError, 'You must implement #{self.class}##{__method__}')
  end

  def call
    raise(NotImplementedError, 'You must implement #{self.class}##{__method__}')
  end
end

:point_down: 使用例

class AuthenticateUser
  include Procedural

  def initialize(user)
    @user = user
  end

  private

  def call
    return false unless @user

    if BCrypt::Password.new(@user.password_digest == 'master_password'.freeze
      @user
    else
      false
    end
  end
end
126
110
2

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
126
110