Ruby
Rails
107
どのような問題がありますか?

この記事は最終更新日から5年以上が経過しています。

投稿日

更新日

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

動機

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

新規登録して、もっと便利にQiitaを使ってみよう

  1. ユーザーやタグをフォローできます
  2. 便利な情報をストックできます
  3. 記事の編集提案をすることができます
ログインすると使える機能について
QUANON
あんた、マジなんだな?

コメント

この記事にコメントはありません。
あなたもコメントしてみませんか :)
新規登録
すでにアカウントを持っている方はログイン
記事投稿イベント開催中
PHP強化月間~開発する上で知っておくべき知見を共有しよう~
~
フロントエンドの開発効率を向上するヒントを教え合おう!
~
107
どのような問題がありますか?
新規登録して、Qiitaをもっと便利に使ってみませんか

この機能を利用するにはログインする必要があります。ログインするとさらに下記の機能が使えます。

  1. ユーザーやタグのフォロー機能であなたにマッチした記事をお届け
  2. ストック機能で便利な情報を後から効率的に読み返せる
新規登録ログイン
ストックするカテゴリー