🐟

[Ruby]責任と依存関係

2021/11/14に公開約4,600字

オブジェクト指向の概念や各キーワードはなんとなく勉強していたのですが理解がぼんやりしており、今一度体系的に学んでみようと「オブジェクト指向設計実践ガイド」を読みました。自分が設計に迷ったときに立ち返るものを残せればと思い書いてみました。

疎結合高凝集

継続的に変更が容易なアプリケーションが作るためには、疎結合で高凝集なプログラムを書かなければならないとよく聞きます。プログラムにおける結合と凝集とは何か、順番に見ていきます。

高凝集と責任

オブジェクト指向設計実践ガイドに以下の記載があります。

クラス内のすべてがそのクラスの中心的な目的に関連していれば、そのクラスは凝集度が高い、

高凝集とはクラス内のプログラムの目的が統一されている状態といえそうです。

単一責任の原則

続いて責任という言葉について見ていきます。オブジェクト指向設計実践ガイドではSOLIDについても紹介されており、単一責任の原則に関して以下の記載がありました。

クラスがすることはすべて、そのクラスの目的に関連することを求めるのです

上記から、 高凝集なクラス == 単一責任のクラス ということが分かりました。
それでは単一責任なクラスとそうでないクラスの違いについて考えていきます。
クラスが単一責任であるかの見極め方について、こちらもオブジェクト指向設計実践ガイドから抜粋します。後者が自分としては分かりやすくしっくりきたので、本記事ではそちらを判定基準に採用します。

あたかもそれに知覚があるかのように仮定して問いただすことです。

1文でクラスを説明してみることです。

ActiveRecordを使用したシンプルなクラス設計の例で考えていきます。
単一責任の話をしているのに、クラスの主語がでかすぎるなとも思いますが、一旦そこは考えずで。
「商品プランのマスタ情報を扱うクラス」として、Planクラスを作成しました。
商品タイプを扱う type_id と 日数を扱う days の2つのカラムを保持しています。
また、Planクラスは type_id と days の値をもとに文字列を返す、name メソッドを持っています。
現時点で「商品プランのマスタ情報を扱うクラス」から逸脱していないため、 単一責任のクラスである と言えそうです。

class Plan < ApplicationRecord
  validates :days, inclusion: { in: [7, 30] }
  enum type_id: { premium: 0, standard: 1 }

  def name
    "#{type_id.capitalize} #{days}" # Ex. 「Premium 7」
  end
end

ここで、注文情報を取り扱うOrderクラスを追加してみます。
Orderクラスは、状態を取り扱う status カラムを持ち、注文ごとにプランを選択できるようPlanクラスに紐付けられています。

Planクラスに、キャンセルされていない注文を取得する not_canceled_orders を追加しました。
PlanクラスにOrderクラスの責任範囲が入り込んでおり、「商品プランのマスタ情報を扱うクラス」から逸脱してしまいました。
つまりこの状態は 単一責任のクラスではない と言えそうです。

class Plan < ApplicationRecord
  has_many :orders, dependent: :destroy
  validates :days, inclusion: { in: [7, 30] }
  enum type_id: { premium: 0, standard: 1 }

  def name
    "#{type_id.capitalize} #{days}" # Ex. 「Premium 7」
  end

  def not_canceled_orders
    orders.where(status: [:in_process, :fixed])
  end
end

class Order < ApplicationRecord
  belongs_to :order
  enum status: { in_process: 0, fixed: 1,  canceled: 99 }
end

例えばOrderクラスにもう1つステータスが追加された場合に、 Plan#not_canceled_orders にも変更を加える必要が出てきます。(where.not を使うこともできますが、あくまでサンプルなので一旦そこまでは考えないでおきます。)
1つの変更要件に対して、余計な変更点が増えてしまうため、変更が容易なプログラムとは言えません。

普通に書くとこうはならんだろと言いたくなる、かなり拙いサンプルになってしまいましたが、実例を通して原則を概念として理解できました。

疎結合と依存

疎結合についても、オブジェクト指向設計実践ガイドに以下の記載がありました。

依存を減らすための具体的なコーディング技法をそれぞれ説明します。これらの技法は、結合を切り離すことにより依存を減らしていきます。

疎結合とはクラスの依存先が減らされた状態のことを指していそうです。
依存先を適切に管理することで疎結合なプログラムを書くことができることが分かりました。依存先の管理方法について考えてみます。

こちらもActiveRecordを使用したクラス設計を例に考えていきます。
企業とそれに所属するユーザを表す、 CompanyクラスとUserクラスを作成しました。
Companyクラスは企業名を表す、 name カラムを保持しています。
Userクラスは名前を表すため、 姓(last_name)と名(first_name)をそれぞれ保持しており、role_type でユーザのロールが定義されています。

class Company < ApplicationRecord
  has_many :users, dependent: :destroy
  validates :name, presence: true
end

class User < ApplicationRecord
  belongs_to :company
  validates :last_name, presence: true
  validates :first_name, presence: true
  enum role_type: { member: 0, owner: 1 }
end

依存関係を整理する

企業がオーナーの名前を取得するために、owner_nameメソッドをCompanyクラスに追加しました。また、ユーザが所属している企業の企業名を取得するために、company_nameメソッドをUserクラスに追加しました。

class Company < ApplicationRecord
  has_many :users, dependent: :destroy
  validates :name, presence: true

  def owner_name
    owner = users.owners.find_by(role_type: :owner)
    "#{owner.last_name} #{owner.first_name}"
  end
end

class User < ApplicationRecord
  belongs_to :company
  enum role_type: { member: 0, owner: 1 }

  def company_name
    company.name
  end
end

まずは依存の方向について考えます。UserはCompanyクラスのインスタンスに依存しています。ユーザが所属企業に依存することは正しい依存方向と言えます。
一方、CompanyクラスもUserクラスに依存しています。Userがlast_namefirst_nameを持つことをCompanyクラスが知ってしまっている状態です。この依存方向は正しいとは言えなそうです。
依存方向を見極める観点として、 ActiveRecordの場合はbelongs_to xxx == 親モデルに依存する と考えているのですが、明確に言語化ができていません。もし、抽象化した定義を言語化できる方がいたら教えていただけますと幸いです。

Company#owner_nameの部分をリファクタリングして、User#last_nameUser#first_name への依存を解消します。
owner_nameではなく、owner に変更し、User#full_name を追加しました。

class Company < ApplicationRecord
  has_many :users, dependent: :destroy
  validates :name, presence: true

  def owner
    owner = users.owners.find_by(role_type: :owner)
  end
end

class User < ApplicationRecord
  belongs_to :company
  enum role_type: { member: 0, owner: 1 }

  def company_name
    company.name
  end

  def full_name
    "#{last_name} #{first_name}"
  end
end

この修正によりUserクラスのnameに関する変更に対して、Companyクラスが影響を受けなくなりました。
現状のサンプルコードだと、企業に対してオーナーが1名であることはどこで制限するのかという疑問も出てきますが、今回の記事では触れません。

ちなみに、 Company.owner の部分は以下のようにも書き換えられると思います。(動作確認はしてません)

has_one :owner, -> { find_by(role_type: :owner) }

実際のドメインでここまでシンプルに解決できる問題はなかなかないかもしれませんが、実例を通して依存関係の概念に付いても掴むことができました。


記事の内容に関して間違えている部分や、これも覚えておくといい!などありましたら教えていただけると嬉しいです。

GitHubで編集を提案

Discussion

ログインするとコメントできます