動機
Railsにおけるサービスクラスのオリジナルルール という記事をたまたま見つけ、「自分ならこう書くかな」と感じたことがいくつかあったので、記事にしてみました。
なお の参考記事の中でさらに参考にされている 肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳) という記事のコードを、この記事でも説明のために用いようと思います。そこで、以下は 2 つの記事をこのように呼称します。
- 参考記事 1: Railsにおけるサービスクラスのオリジナルルール
- 参考記事 2: 肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)
マイルール
ルール 1. クラス名は「動詞 (+ 目的語)」にする
参考記事 1 のものとほぼ同じルールです。末尾に Service と付けないことのみ異なります (ただの好み) 。
参考記事 2 では UserAuthenticator#authenticate
というメソッドが提供されています。これ以外でもよく UsersExcelImporter#import
, UploadedFileConverter#convert
のように、-or
, -er
という接尾語を持った名詞のクラス名 + 動詞のメソッド名という命名パターンをしばしば見かけます。これだと冗長な命名に感じるし、クラスの命名に迷う場合もあるので、僕は参考記事 1 のルールが非常に好みです。
ルール 2. 外部に公開するメソッドは call という名前のクラスメソッドのみ
これがキモです
ルール 1 でクラス名は動詞を表すものになっているので、このクラスの責務は自身が持つ名前の処理を実行することのみであるのが理想的だと考えています。そこで「実行する」や「呼び出す」といった意味の public メソッドを 1 つだけ用意するのが理想です。
僕は今まで様々なメソッド名、例えば do
や execute
, perform
などを採用しましたが、最近は call
を使っています。なぜなら Proc#call
とインターフェイスを合わせることができるからです。一定の手続きを実行するという意味で、サービスクラスの実行は Proc オブジェクトの実行と似ていると思います。
このルールに従って、参考記事 2 に記載されている のコードを
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
のように書き換えてみます。なお、参考記事 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
では自身のインスタンスを生成し、同名のインスタンスメソッドを呼び出すことで処理を委譲します。
これで、ほぼ理想のコードになりました
ただ、このままではインスタンスメソッド経由でもクラスメソッド経由でも 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
の引数が一つの場合、 のように 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"]
地味に便利です
おまけ
マイルールを気軽に適用しやすくするための Module を実装しました
# サービスクラス用のインターフェイスを提供するモジュール。
# 使い方は以下のとおりです。
#
# (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
使用例
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