概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Chain of Responsibility Pattern – Ruby
- 原文公開日: 2017/11/20
- 著者: Sergii Makagon — Ruby/Rails開発者であり、RuboCopのcontributorでもあります。
Ruby: Chain of Responsibilityパターンの解説(翻訳)
本記事ではChain of Responsibilityパターンについて説明します。このパターンのRubyでの実装方法と、どのような場合にRubyアプリに適用可能かを判別する方法について学びます。
Chain of Responsibilityパターンの目的は、1つのリクエストを複数のオブジェクトで扱えるようにすることで、リクエストの送信側を受信側から切り離す(結合を断つ)ことである。
この定義では堅苦しすぎると思った方も心配は無用です。わかりやすいいくつかの例を使って考察します。
例#1
顧客からの送金を受け付けるアプリを書いているとしましょう。金額や通貨に応じてお金を扱う支払いプロバイダを切り替えたいと思います。
特定のトランザクションごとに支払いプロバイダを定義するには、いくつかの条件ロジックを実行する必要があります。このコードは次のような感じになります。
if ...some logic for transaction
use payment provider 1
elsif ...logic
use payment provider 2
elsif ...logic
use payment provider 3
end
ロジックが複雑になるとコードは扱いにくくなり、リファクタリングも面倒になります。
Chain of Responsibilityパターンを使うことで、ハンドラをチェインできるようになります。それぞれのハンドラには、ハンドラでトランザクションを処理するかどうかを定義するロジックが含まれます。
1つのトランザクションは、トランザクションを処理できるハンドラが見つかるまでチェインに沿って進みます。図にすると次のようになります。
各ハンドラには、そのハンドラがトランザクションに適用可能かどうかを判定するロジックが含まれており、適用できない場合はチェインしている次のハンドラを実行します。
このチェインの場合、ハンドラ#1が最初にトランザクションを処理しようとします。トランザクションを処理できない場合、ハンドラ#2を実行します。#2でもトランザクションを実行できない場合は、ハンドラ#3を実行します。
この方法には次のメリットがあります。
- ハンドラの順序を定義できる
- ハンドラごとに独自のロジックを含めることができる
- ハンドラを簡単に追加できる
- 特殊なハンドラから一般性の高いハンドラへと処理を進められる
この例に沿ってChain of Responsibilityを実装してみましょう。
最初に、トランザクション用のシンプルなクラスを作成します。
class Transaction
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = amount
@currency = currency
end
end
次にハンドラのインターフェイスを決定します。ハンドラはcan_handle?
メソッドとhandle
メソッドに応答するのがよさそうです。ハンドラがトランザクションを扱えない場合は、次のハンドラを呼び出す必要がありますので、チェインのsuccessor
で次のハンドラを呼び出します。私はこのロジックをBaseHandler
クラスに切り出すことにしました。各ハンドラはこのクラスを継承します。
class BaseHandler
attr_reader :successor
def initialize(successor = nil)
@successor = successor
end
def call(transaction)
return successor.call(transaction) unless can_handle?(transaction)
handle(transaction)
end
def handle(_transaction)
raise NotImplementedError, 'Each handler should respond to handle and can_handle? methods'
end
end
だいぶコード量が増えましたが、動作を1行ずつ理解してみましょう。
def initialize(successor = nil)
@successor = successor
end
初期化中にsuccessor
を受け取ることで、チェインを形成できるようになります。たとえば次のように書けます。
chain = StripeHandler.new(BraintreeHandler.new)
chain.call(transaction)
call(transaction)
メソッドを呼び出すための実装は次のとおりです。
def call(transaction)
return successor.call(transaction) unless can_handle?(transaction)
handle(transaction)
end
最初のハンドラでcall(transaction)
を呼ぶと、このトランザクションを扱ってよいかどうかをチェックし、扱えない場合はsuccessor.call(transaction)
を呼んでチェインの次のハンドラにフローを進めます。
以上で、各ハンドラがBaseHandler
を継承することと、can_handle?
メッセージやhandle
メッセージに応答することを理解できました。ハンドラをいくつか実装してみましょう。
class StripeHandler < BaseHandler
private
def handle(transaction)
puts "トランザクションをStripe支払いプロバイダで扱います"
end
def can_handle?(transaction)
transaction.amount < 100 && transaction.currency == 'USD'
end
end
class BraintreeHandler < BaseHandler
private
def handle(transaction)
puts "トランザクションをBraintree支払いプロバイダで扱います"
end
def can_handle?(transaction)
transaction.amount >= 100
end
end
transaction = Transaction.new(100, 'USD')
chain = StripeHandler.new(BraintreeHandler.new)
chain.call(transaction)
# => トランザクションをBraintree支払いプロバイダで扱います
トランザクションを2つ作成しました。ハンドラがこのトランザクションを扱ってよいかどうかを決定するロジックはcan_handle?
メソッドにあります。支払い処理はhandle
メソッドにあります。
上の例では、BraintreeHandler
クラスのオブジェクトでStripeHandler
のオブジェクトを作成し、リストの次のハンドラを表しています。
続いてcall
を呼び出します。StripeHandler
にはcall
は実装されていないため、BaseHandler
に進んで以下のコードが実行されます。
def call(transaction)
return successor.call(transaction) unless can_handle?(transaction)
handle(transaction)
end
StripeHandler
クラスのオブジェクトのcan_handle?(transaction)
を実行すると、トランザクションの総数が99を超えたので応答はfalse
になります。
このようにしてsuccessor.call(transaction)
が実行され、次のハンドラはBraintreeHandler
クラスのオブジェクトになります。このハンドラはトランザクションを扱えるので、handle(transaction)
が実行されます。
例#2
Chain of Responsibilityパターンというアイデアの理解に役立つ別の例を考えてみましょう。
あるオンラインストアを運営していて、ある顧客の個人ディスカウント額の算出が必要になりました。ディスカウント額は、顧客の忠実度(loyalty)、前回の注文数といった多くの要素によって決まります。
これは顧客の最終的なディスカウント額を算出するハンドラのチェインを作成するのにうってつけの機会です。ディスカウントの種類によっては適用できないものがあります。たとえばブラックフライデー割引は年に1回まで、忠実な顧客は5回以上購入した場合にディスカウントが効くという具合です。
これを実装してみましょう。このアイデアをそのまま写し取ったシンプルなクラスを作成します。
class Customer
attr_reader :number_of_orders
def initialize(number_of_orders)
@number_of_orders = number_of_orders
end
end
話を簡単にするため、1人の顧客については注文数(number_of_orders
)のみをトラックすることにします。
先ほどの例と同様にBaseDiscount
クラスを作成し、他のディスカウントはこれを継承することにします。
class BaseDiscount
attr_reader :successor
def initialize(successor = nil)
@successor = successor
end
def call(customer)
return successor.call(customer) unless applicable?(customer)
discount
end
end
続いて、ディスカウントを必要な分追加します。
class BlackFridayDiscount < BaseDiscount
private
def discount
0.3
end
def applicable?(customer)
# ... 当日がブラックフライデーかどうかをチェック
end
end
class LoyalCustomerDiscount < BaseDiscount
private
def discount
0.1
end
def applicable?(customer)
customer.number_of_orders > 5
end
end
class DefaultDiscount < BaseDiscount
private
def discount
0.05
end
def applicable?(customer)
true
end
end
これで、非常に特殊なディスカウントから一般的なディスカウントまで自由に扱えるようになりました。
chain = BlackFridayDiscount.new(LoyalCustomerDiscount.new(DefaultDiscount.new))
顧客にとってディスカウント額が最も大きいのはブラックフライデーなので、最初にブラックフライデーを処理します。続いて忠実な顧客向けのディスカウント適用を試みて、2つとも適用できない場合はデフォルトのディスカウントを使います。Chain of Responsibilityは「特殊な条件から一般的な条件」の順に処理されるよう実装すべきです。
たとえばブラックフライデーのディスカウントを廃止するという業務命令が下されても大丈夫。チェインからブラックフライデーのハンドラを削除するだけでおしまいです。
chain = LoyalCustomerDiscount.new(DefaultDiscount.new)
これでチェインは2つになりました。簡単ですね。
このパターンは、アプリがクライアントからの問い合わせに回答しなければならないようなシステムで、特殊な回答から一般的な回答までをチェインするのに適しています。問い合わせがこのチェインを進むと、システムは最も適した回答を見つけます。特定の質問には特定の回答を返し、他によい回答がなければ一般的な回答を返します。
お読みいただきありがとうございました。このパターンがうまくはまる場所がアプリで見つかって、皆さまのコードが改善されることを願っています。
追伸: 私はニューオリンズで開催されたRubyConfに参加して、多くの素晴らしいエンジニアの皆さまに感謝を伝える機会に恵まれました。Rubyコミュニティの素晴らしさを改めて実感したこと、そしてRuby言語、gem、ツールなどを支えているすべての人たちへの感謝の気持ちをここに述べたいと思います。MatzがRuby開発者たちに「素晴らしい開発者になろう」と呼びかけたように