0
0

ある処理の実行条件がコンテキスト毎に異なる場合のリファクタリング作業で、
ポリシーパターンの「条件をクラス化する」という考え方がとても役立ちました。

実際のリファクタリングを通じて、「拡張性に優れる」「コードが読みやすくなる」といったメリットを感じています。

この記事では、「条件をクラス化」することでこれらのメリットがどのように実現されるのか、コード例を示しながら解説します。

  1. コード例はポリシーパターンの一部を採用したもので、厳密にはポリシーパターンではありません
  2. 記事の内容は「リファクタリングを通じて私が実感したメリット」に関する解説が中心です。参考書籍には書かれていない内容が多く含まれています。内容に誤りがある可能性にご注意ください
  3. コード例は ruby で書かれています

参考書籍↓
良いコード/悪いコードで学ぶ設計入門 ―保守しやすい 成長し続けるコードの書き方
6.3.1 ポリシーパターンで条件を集約
https://amzn.asia/d/065vcay7

例)キャンペーン機能

「条件をクラス化する」題材として、キャンペーンの参加条件を取り上げます。

キャンペーン機能は以下の特徴があります。

  1. キャンペーンはカテゴリー(←コンテキスト)で分類される
  2. キャンペーンに関連する処理 (ここでは参加条件)はカテゴリーごとに異なるが、部分的に一致する点もある。

カテゴリーが normal に分類されるキャンペーンのロジックはCampaign::Normalクラスに実装されるとします。joinable? メソッドはユーザーがキャンペーンの参加条件を満たしているかどうかを真偽値で返します。以下の条件を全て満たすときに true を返します。

  • ユーザーは有料会員である
  • ユーザーはキャンペーンに参加していない
  • ユーザーはキャンペーンを達成していない
class Campaign::Normal
  def initialize campaign
    @campaign = campaign
  end

  def joinable? user
    return false unless user.subscribed?
    return false if user_joined? user
    return false if user_achieved? user
    true
  end

  private
	
  def user_joined? user
      # 省略
  end
  
  def user_achieved? user
      # 省略
  end
end

それぞれの条件文をクラス化して、以下のように実装を修正します。
メソッド名をjoinable?からjoin_policy_ok?に変更しています。ルールの集合がポリシーであるため、メソッド名にpolicyを含める形としました。

class Campaign::Normal
  def initialize campaign
    @campaign = campaign
  end

  def join_policy_ok? user
    rules = []
    rules << Campaign::Rules::OnlySubscribedUser.new(user)
    rules << Campaign::Rules::NotAchievedTheCampaign.new(user,@campaign)
    rules << Campaign::Rules::NotJoinedTheCampaign.new(user,@campaign)
    rules.all?(&:ok?)
  end
end

以下は条件をクラス化した一例です。
条件クラスの再利用性を向上させるために、一つの条件評価だけを責務として持つように設計します。
条件評価に必要なデータはクラスの初期化時に渡します。
ok?メソッドで条件を評価する実装にします。

class Campaign::Rules::OnlySubscribedUser
  def initialize user
    @user = user
  end

  def ok?
    @user.subscribed?
  end
end

条件をクラス化するメリット

拡張性に優れる

例えば、来月に新しいキャンペーンを開催することになりました。これまでのどのカテゴリーにも属さないキャンペーンです。

新規キャンペーンの要件
カテゴリー 参加条件
premium ユーザーは normal の参加条件を満たし、プレミアムプランで登録していて、これまでの支払い金額の合計が5万円以上

条件を評価するメソッドをどこに実装するべきでしょうか?

premium の参加条件なので、Campaign::Premiumに実装するでしょうか?この場合、条件が将来に渡って premium カテゴリー限定であれば問題なさそうですが、カテゴリー間で同じ条件が必要な場合、それぞれのカテゴリークラスで条件を記述する必要があります。今回の例では、「ユーザーがキャンペーンを達成したか」を評価するuser_achieved? メソッドが normal と premium で共通しています。

# Campaign::Normal
private

def user_achieved? user
end
# Campaign::Premium
private

def user_achieved? user
end

同じコードが二箇所に記述されているため、条件を修正する際に片方を更新し忘れると、バグが生じるリスクがあります。

user_achieved? をCampaignに実装することで重複コードを回避できます。しかしこれも適切な方法ではないと考えます。Campaignクラスに多くの条件評価メソッドが実装され、いずれクラスが肥大化して、Campaignクラスの全貌を把握するのが難しくなるからです。

class Campaign
	#...
	def user_joined?
	end
	
	def user_achieved?
	end
	
	# 以降も条件評価メソッドが実装される。
	# def rule_1
	# end
	
	# def rule_2
	# end
	
	# def rule_3
	# end

end

条件をクラス化することで、クラスの肥大化を防ぎつつ、再利用性を高い水準で維持できます。
新しいカテゴリーが必要になったとしても、既存の条件クラスを再利用するか、必要に応じて新しく条件クラスを作成するだけです。これらの条件を rules 配列に追加するだけで対応できます。

class Campaign::Premium
  def initialize campaign
    @campaign = campaign
  end

  def join_policy_ok? user
    rules = []
    rules << Campaign::Rules::OnlySubscribedUser.new(user)
    rules << Campaign::Rules::NotAchievedTheCampaign.new(user,@campaign)
    rules << Campaign::Rules::NotJoinedTheCampaign.new(user,@campaign)
    rules << Campaign::Rules::Premium::OnlyPremiumPlanUser.new(user)
    rules << Campaign::Rules::Premium::PaidOver.new(user,50000)
    rules.all?(&:ok?)
  end
end

premium カテゴリー固有の条件も条件クラスとして定義します。こうすることで、すべての参加条件を特定のディレクトリ(ここでは /campaign/rules/)に集約することが可能となり、コードを管理しやすくなります。

コードが読みやすくなる

条件クラスには、条件を評価するインスタンスメソッドが実装されます。

rule.ok?

条件は配列で管理されます。条件クラスはそのロジックを表現するように命名されており、それぞれが縦に整列されるので、条件の把握が簡単です。

rules = []
rules << Campaign::Rules::OnlySubscribedUser.new(user)
rules << Campaign::Rules::NotAchievedTheCampaign.new(user,@campaign)
rules << Campaign::Rules::NotJoinedTheCampaign.new(user,@campaign)

コンテキスト間での条件の違いは、クラス名を比較するだけで確認できるので、実装を簡単に把握できます。

スクリーンショット 2024-06-30 18.10.44.png

それぞれの条件をどのように満たせば true を返すのか、直感的な記述になります。

rules.all?(&:ok?)

条件をクラス化しない場合、コードベースで設計アプローチが表現されていないため一貫性を欠きやすい構造です。条件を呼び出す側のメソッド(ここでは joinable?)に複雑なロジックを記述することが可能であり、可読性低下の原因となります。

  def joinable? user
    return false unless user.subscribed?
    return false if user_joined? user
    return false if condition1 || condition2 || condition3 # 長いロジックが続く...
    true
  end

条件をクラス化する場合、以下の設計アプローチがコードベースで表現されており、結果としてコードの一貫性が促され、可読性の高いコードを維持しやすい構造となります。

  • 条件クラスが条件判定を担う
    • 条件を呼び出す側のメソッドにロジックを記述されるのを防ぐ。シンプルさを保つ
  • 条件を rules 配列に追加する
    • 条件がリスト形式で一覧化される。コードがドキュメントのような役割を果たす

注意点

  1. 条件クラスの名前はその条件をよく表すように命名する。名前がわかりづらいとロジックを確認する手間が発生する
  2. 条件クラスを初期化するため実行コストがかかる。特に、多くの条件を必要とする場合パフォーマンスに影響が出る可能性がある
  3. 適切な粒度で条件をクラス化する。粒度が大きいと条件の再利用性が損なわれる
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0