1. ホーム
  2. スクリプト・コラム
  3. ルビートピックス

Rubyのデザインパターン。プログラミングにおけるストラテジーパターンの活用

2022-01-31 09:04:24

今日、あなたのリーダーはあなたを見つけると興奮し、会議に急いでいるので、あなたに小さな頼みごとをしたいと言っています。どんな頼みごとなのでしょうか?気になりますね。
彼は、あなたのプロジェクトのデータベースには、現在、非常に多くのユーザーに関するデータを保持するユーザー情報テーブルがあり、今、あなたはユーザー情報を選択的に照会する関数を完成させる必要があると教えてくれました。彼は、多くのユーザー名の配列が渡され、それらのユーザー名に基づいて対応するデータを検索する必要があると言っています。
簡単な機能なので、快諾しましたね。あなたのプロジェクトはMySQLデータベースを使用しているので、すぐに次のコードを書きました。

require 'mysql' 
 
class QueryUtil 
  def find_user_info usernames 
    @db = Mysql.real_connect("localhost","root","123456","test",3306); 
    sql = "select * from user_info where " 
    usernames.each do |user| 
      sql << "username = '" 
      sql << user 
      sql << "' or " 
    end 
    puts sql 
    result = @db.query(sql); 
    result.each_hash do |row| 
      # process data read from the database 
    end 
    # The read data should be assembled into an object and returned later, omitted here 
  ensure 
    @db.close 
  end 
end 


ここでは、渡されたユーザー名の配列に基づいて SQL 文を作成し、 データベースにアクセスして対応する行を検索しています。デバッグのために、組み立てた SQL 文を出力することもあります。
そして、このメソッドをテストするために、次のようなコードを書きました。

qUtil = QueryUtil.new 
qUtil.find_user_info ["Tom", "Jim", "Anna"] 


さて、テストコードを実行すると、プログラムがエラーを起こしていることがわかります。そこで、すぐに出力されたSQL文をチェックしてみると、案の定、問題が見つかりました。

select * from user_info where username = 'Tom' or username = 'Jim' or username = 'Anna' or  
綴られたSQL文は、最後に余分なorキーワードがある! なぜなら、forループは最後のデータにorを追加してはいけないのに、コードは愚かにも最後のデータにorキーワードを追加してしまい、結果としてSQL文の構文エラーになってしまったからです。
これはどうしたらいいのでしょうか?
あったあった! ということで、解決策を思いつきました。SQL文の組み立てが終わるのを待って、最後のorの前にインターセプトすればいいのです。そこで、コードを次のように変更します。

require 'mysql' 
 
class QueryUtil 
  def find_user_info usernames 
    @db = Mysql.real_connect("localhost","root","123456","test",3306); 
    sql = "select * from user_info where " 
    usernames.each do |user| 
      sql << "username = '" 
      sql << user 
      sql << "' or " 
    end 
    sql = sql[0 . -" or ".length] 
    puts sql 
    result = @db.query(sql); 
    result.each_hash do |row| 
      # process the data read from the database 
    end 
    # The read data should be assembled into an object and returned later, omitted here 
  ensure 
    @db.close 
  end 
end 


Stringのintercept substringメソッドを使うと、最後のorの前の部分だけが取り出されるので、テストコードを再度実行すると、すべてうまくいき、印刷されるSQL文は以下のようになります。

select * from user_info where username = 'Tom' or username = 'Jim' or username = 'Anna' 


よし、完了! 自信があるんだね。
ミーティングを終えたリーダーがやってきて、あなたの成果を見ています。しかし、あなたが使っているSQL文の組み立てアルゴリズムに何か違和感があるようで、何が問題なのかはっきりしません。そこで彼は、SQL文を組み立てるための別のアルゴリズムをあなたに教え、それをコードに追加するように頼みましたが、前のアルゴリズムは削除しないで、とりあえず残しておいてください、と言うと、また忙しくなったかのように逃げて行きました。それで、あなたは今教えてもらったアルゴリズムを追加すると、コードは次のようになります。

require 'mysql' 
 
class QueryUtil 
  def find_user_info(usernames, strategy) 
    @db = Mysql.real_connect("localhost","root","123456","test",3306); 
    sql = "select * from user_info where " 
    if strategy == 1 
      usernames.each do |user| 
        sql << "username = '" 
        sql << user 
        sql << "' or " 
      end 
      sql = sql[0 . -" or ".length] 
    elsif strategy == 2 
      need_or = false 
      usernames.each do |user| 
        sql << " or " if need_or 
        sql << "username = '" 
        sql << user 
        sql << "'" 
        need_or = true 
      end 
    end 
    puts sql 
    result = @db.query(sql); 
    result.each_hash do |row| 
      # process the data read from the database 
    end 
    # The read data should be assembled into an object and returned later, omitted here 
  ensure 
    @db.close 
  end 
end 


このように、リーダーから教わったアセンブリアルゴリズムでは、boolean変数を使用して、orキーワードを追加する必要があるかどうかを制御していることがわかります。forループを最初に実行するときは、boolean値がfalseなのでorを追加せず、ループの最後でboolean値をtrueに代入し、ループが毎回headにorキーワードを追加するようにします。SQL文の最後にorをつけるのは、headメソッドを使っているので、もう気にする必要はないのです。そして、両方のアルゴリズムを維持するために、find_user_infoメソッドにパラメータを追加し、最初のアルゴリズムを使用する場合のストラテジー値を1、2番目のアルゴリズムを使用する場合のストラテジー値を2としています。
このようにすると、テストコードも以下のように変更する必要があります。

qUtil = QueryUtil.new 
qUtil.find_user_info(["Tom", "Jim", "Anna"], 2) 


ここでは、2番目のアルゴリズムを使ってSQL文をまとめるようにパラメータで指定していますが、印刷される結果は、1番目のアルゴリズムを使った場合とまったく同じです。
あなたは早速、忙しい合間を縫ってリーダーに今の結果を確認してもらったが、彼は相変わらずうるさそうにしていた。
"このように書くと、find_user_infoメソッドのロジックは複雑すぎて非常に読みづらく、将来の拡張につながらないし、第3、第4のアルゴリズムを追加したい場合、このメソッドはまだ良く見えるでしょうか? "リーダーはこの状況を解決するのに、strategyパターンを使うように指摘しましたが、strategyパターンのコアとなる考えは、アルゴリズムを別のオブジェクトに抽出することです。
正しい方向を示すために、彼は忙しい中、strategy patternを使った最適化の方法を教え始める。
まず、get_sql メソッドを含む親クラスを定義します。これは単に例外を投げるだけのものです。

class Strategy 
  def get_sql usernames 
    raise "You should override this method in subclass." 
  end 
end 


次に、上記の親クラスを継承した2つのサブクラスを定義し、SQL文をまとめるための2つのアルゴリズムを2つのサブクラスそれぞれに追加します。

class Strategy1 
  def get_sql usernames 
    sql = "select * from user_info where " 
    usernames.each do |user| 
      sql << "username = '" 
      sql << user 
      sql << "' or " 
    end 
    sql = sql[0 . -" or ".length] 
  end 
end 

class Strategy2 
  def get_sql usernames 
    sql = "select * from user_info where " 
    need_or = false 
    usernames.each do |user| 
      sql << " or " if need_or 
      sql << "username = '" 
      sql << user 
      sql << "'" 
      need_or = true 
    end 
  end 
end 



そして、組み立てられたSQL文は、以下のコードのように、QueryUtilのfind_user_infoメソッドの中でStrategyのget_sqlメソッドを呼び出すことで取得することが可能です。

require 'mysql' 
 
class QueryUtil 
  def find_user_info(usernames, strategy) 
    @db = Mysql.real_connect("localhost","root","123456","test",3306); 
    sql = strategy.get_sql(usernames) 
    puts sql 
    result = @db.query(sql); 
    result.each_hash do |row| 
      # process the data read from the database 
    end 
    # The read data should be assembled into an object and returned later, omitted here 
  ensure 
    @db.close 
  end 
end 


最後に、テストコードでは、find_user_info メソッドを呼び出すときに、どのポリシーオブジェクトを使用する必要があるのかを表示するように指示するだけです。

qUtil = QueryUtil.new 
qUtil.find_user_info(["Tom", "Jim", "Anna"], Strategy1.new) 
qUtil.find_user_info(["Jac", "Joe", "Rose"], Strategy2.new) 


プリントアウトされるSQL文は、以下のように全く予想外のものではありません。

select * from user_info where username = 'Tom' or username = 'Jim' or username = 'Anna' 
select * from user_info where username = 'Jac' or username = 'Joe' or username = 'Rose' 


ポリシーパターンで修正すると、コードがより読みやすくなり、拡張性も高くなるので、後から新しいアルゴリズムを追加する必要があっても安心です!

strategyパターンとsimple factoryパターンを組み合わせた例

要求事項

ショッピングモールのレジのソフトウェア、単価と顧客が購入したアイテムの数量に基づいて、コストを計算し、そこにキャンペーン、20%オフ、フル300オフまたはそのような何かであろう。

1. ファクトリーモデルを使用する

# -*- encoding: utf-8 -*-

# Cash charge abstract class
class CashSuper
  def accept_cash(money)
  end
end

#normal charge subclass
class CashNormal < CashSuper
  def accept_cash(money)
    money
  end
end

#Discounted charges subclass
class CashRebate < CashSuper
  attr_accessor :mony_rebate
  
  def initialize(mony_rebate)
    @mony_rebate = mony_rebate
  end

  def accept_cash(money)
    money * mony_rebate
  end
end

# rebate fee subclass
class CashReturn < CashSuper
  attr_accessor :mony_condition, :mony_return
  
  def initialize(mony_condition, mony_return)
    @mony_condition = mony_condition
    @mony_return = mony_return
  end

  def accept_cash(money)
    if money > mony_condition
      money - (money/mony_condition) * mony_return
    end
  end
end

# Cash charge factory class
class CashFactory
  def self.create_cash_accept(type)
    case type
    when 'normal_charge'
      CashNormal.new()
    when '20% discount'
      CashRebate.new(0.8)
    when '100 off over 300'
      CashReturn.new(300,100)
    end
  end
end

cash0 = CashFactory.create_cash_accept('normal charge')
p cash0.accept_cash(700)

cash1 = CashFactory.create_cash_accept('20% off')
p cash1.accept_cash(700)

cash2 = CashFactory.create_cash_accept('100 off over 300')
p cash2.accept_cash(700)



カスタム割引のパーセンテージと完全な割引の回数を指定します。

問題点

アクティブなカテゴリ、50%オフ、500以上の200オフなどを追加する場合、ファクトリークラスにブランチ構造を追加する必要があります。

アクティビティは多彩で、100点+10点とポイントがアクティビティ賞として用意されていなければならないポイントアクティビティも追加可能で、その場合はサブクラスを追加する必要があります。

しかし、アクティビティを追加するたびにファクトリークラスを修正しに行くのはひどい処理方法です。アルゴリズムの変更に直面したときに、もっと良い方法があるはずです。

2. strategyパターン

CashSuperとサブクラスは変更せず、以下を追加します。

class CashContext
  
  attr_accessor :cs
  
  def initialize(c_super)
    @cs = c_super
  end
  
  def result(money)
    cs.accept_cash(money)
  end

end

type = '20% off'
cs=case type
  when 'normal charge'
    CashContext.new(CashNormal.new())
  when '20% off'
    CashContext.new(CashRebate.new(0.8))
  when '100 off over 300'
    CashContext.new(CashReturn.new(300,100))
  end
p cs.result(700)



CashContext クラスは異なる CashSuper サブクラスをラップし、対応する結果を返す。つまり、アルゴリズムがどのように変化しても、その結果を用いて得ることができるように、異なるアルゴリズムをラップするのである。結果はresultを使って得ることができる。
しかし、現状では、どのアルゴリズムを使うか、ユーザーが判断しに行く必要がある問題があります。これは、Simple Workshopクラスと組み合わせることができます。

3. ストラテジーとシンプルワークショップの融合

class CashContext
  
  attr_accessor :cs
  
  def initialize(type)
    case type
    when 'normal charges'
      @cs = CashNormal.new()
    when '20% off'
      @cs = CashRebate.new(0.8)
    when '100 off over 300'
      @cs = CashReturn.new(300,100)
    end
  end
  
  def result(money)
    cs.accept_cash(money)
  end

end

cs=CashContext.new('20% off')

p cs.result(700)



異なるサブクラスはCashContextでインスタンス化されます。(単純なファクトリー)
サブクラス選択のプロセスを内部に移し、アルゴリズムをカプセル化する(ポリシーパターン)。

呼び出し側は、パラメータ(アクティビティの種類、元の価格)を渡して最終結果を得るという、よりシンプルな使い方ができるようになりました。
ここでは、ユーザーは1つのクラス(CashContext)を知るだけでよいのに対して、SimpleWorksは2つのクラス(CashFactoryのaccept_cashメソッドとCashFactory)を知る必要があり、より徹底したカプセル化が行われていることが分かります。