Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: Chain of Responsibilityパターンの解説(翻訳)

概要

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

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つのトランザクションは、トランザクションを処理できるハンドラが見つかるまでチェインに沿って進みます。図にすると次のようになります。

Ruby - Chain Of Responsibility

各ハンドラには、そのハンドラがトランザクションに適用可能かどうかを判定するロジックが含まれており、適用できない場合はチェインしている次のハンドラを実行します。

このチェインの場合、ハンドラ#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開発者たちに「素晴らしい開発者になろう」と呼びかけたように😊

関連記事

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

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

[保存版]人間が読んで理解できるデザインパターン解説#3: 振舞い系(翻訳)

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。