PHPのコード例でSOLID原則を理解する(SRP・OCP編)~保守性の高いソフトウェア開発の実現を目指して~
ご無沙汰しております。
前回の記事では「2023年はフロントエンドに関する記事を書きたいな」と言ってたのですが、ちょっと違うことに関する記事を書きたくなってしまいました。技術の移り変わりが激しいと言われる昨今ですが、もしかしたら筆者の関心の移り変わりの方が激しいかもしれません。
話を戻しまして、今回から複数回に分けてコード設計に関する記事を書いていきます。
というのも、只今、中規模のシステム開発が始まる直前の時期で、前回のシステム案件よりも良いコードを書きたくてうずうずしております。そこで理解しているつもり、使えているつもりになっているかもしれない「SOLID原則」について、再確認も込めて復習してみたいと思います。
他にも様々な原則があると思いますが、基本中の基本だけど少し理解し辛さもあるSOLID原則について振り返っていきたいと思います。
今回の記事では、SOLID原則のうちの “S” “O” にあたる、
- SRP(Single Responsibility Principle、単一責任の原則)
- OCP(Open-Closed Principle、解放閉鎖の原則)
の2つについて解説していきたいと思います。
それぞれ概要を説明し、原則違反コードを原則準拠コードに修正していくという過程で説明をします。
INDEX
前提
PHP8.1.6
SRP(Single Responsible Principle、単一責任原則)
「Clean Architecture 達人に学ぶソフトウェアの構造と設計」によると、
モジュールはたったひとつのアクターに対して責務を負うべきである。
と書かれています。
アクターとは、変更を望む人たちをひとまとめにしたグループのことです。モジュールとは、ソースファイル全体を指す場合もあれば、関数やデータをまとめた関連のあるひとつのかたまり、を指す場合もあります。つまり「ひとつのモジュールはひとつのアクターにだけ責任を持とうね」ということですかね。
では、コードを見て理解していきましょう。
原則違反コード
今、ある従業員の給与と勤務時間を計算するメソッドを持つ Employee クラスがあります。ここでは、モジュールは calculatePay() メソッドやreportHours()メソッドに当たります(モジュールは関数単位)。アクターはそれぞれ、経理部と人事部です。
<?php
class Employee
{
public string $name;
public string $department;
// 給与計算するメソッド
// 経理部に対して責任を負っているモジュール(メソッド)=アクターは経理部門
public function calculatePay()
{
$this->getRegularHours();
}
// 勤務時間を集計するメソッド
// 人事部に対して責任を負っているモジュール(メソッド)=アクターは人事部
public function reportHours()
{
$this->getRegularHours();
}
// 所定労働時間を算出するメソッド
private function getRegularHours()
{
}
}
原則違反箇所
例えば、人事部から勤務時間を集計する仕様を変更したい、という依頼があるとします。すると、エンジニアは reportHours() メソッドのロジックを依頼に合わせて変更しようとします。またそれに伴い、所定労働時間を算出する getRegularHours() メソッドも変更しないといけなくなりました。この場合、同じく getRegularHours() メソッドを使用している、経理部の給与計算をする calculatePay() メソッドにも影響してしまうかもしれないですよね?( calculatePay() メソッド内でも getRegularHours() メソッドを使用しているため)
つまり、 getRegularHours() メソッドは人事部と経理部の2つの異なるアクターに対して影響をもってしまっている(=責務をもってしまっている)ということです。これが原則違反に当たります。
では、原則に則るように修正してみましょう。
原則準拠コード
まずはコードです。
<?php
// 従業員のデータを持つクラス
class EmployeeData
{
public function __construct(
public string $name,
public string $department
){}
}
// 経理部に対して責任を負っているモジュール(クラス)=アクターは経理部門
class PayCalculator
{
public function __construct(
public EmployeeData $employee
){}
// 給与計算するメソッド
public function calculatePay()
{
$this->getRegularHours();
}
private function getRegularHours(){}
}
// 人事部に対して責任を負っているモジュール(クラス)=アクターは人事部
class HourReporter
{
public function __construct(
public EmployeeData $employee
){}
// 勤務時間を集計するメソッド
public function reportHours()
{
$this->getRegularHours();
}
private function getRegularHours(){}
}
やったこと
- 従業員のデータを持つ EmployeeData クラス、経理部門に対して責務を負っている PayCalculator クラス、人事部に対して責務を負っている HourReporter クラス、の3つに分割した。
- 問題になっていた getRegularHours() メソッドは、それぞれのモジュール(クラス)に合わせた処理を持たせる。
思考の流れ
- getRegularHours() メソッドは人事部と経理部の2つの異なるアクターに対して影響をもってしまっているのが原則違反。
- この例では人事部と経理部がアクターなので、それぞれに責務を持つモジュール(今回はクラスという単位)に分割してみる。 PayCalculator クラスと HourReporter クラスを作成。
- 問題となっていた、 getRegularHours() メソッドをそれぞれのモジュールに持たせる。こうすれば片方のアクターの依頼で仕様変更しても、もう片方のアクターに責任をもつモジュールには影響しない。
- データ部分は別のクラスに切り出しておく(この例では EmployeeData )
ポイント
アクターが何かを考え、そのアクターそれぞれ1つだけに責務をもつようにモジュールを作成する。これにより、各アクターから仕様追加や変更の依頼がきても、そのアクターに責務を持つモジュールを扱うだけで済みます。この時、他のアクターに責務を持つモジュールには関心を払わずに済みますよね。
※ SRPに準拠したことによってクラスが増えた分、インスタンス化する際に手間を感じてしまうという弱点があるみたです。その場合に、一般的に使われるのがFacadeパターンらしいですが、今回は割愛します。
※ getRegularHours() 内の処理を、 calculatePay() 、 reportHours() のそれぞれに持たせて getRegularHours() に切り出さない書き方の方がDRYで良いですが、今回は残しています。
OCP(Open Close Principle、解放閉鎖原則)
同じく、「Clean Architecture 達人に学ぶソフトウェアの構造と設計」によると、
ソフトウェアの振る舞いは、既存の成果物を変更せず拡張できるようにすべきである
と書かれています。
仕様や機能を追加する際に、新しくクラスや関数等を追加するだけでそれを実現でき、その時既存のコードの修正はできるだけ発生しないように設計しておく、ということですね。
文章だけだと分かったような分からないような、、、以下実際にコードで見ていきましょう。
原則違反コード
それぞれの会員のグレードによって合計金額の割引率が変更されて計算されるという仕様になってます。
<?php
enum Grade
{
case Platinum;
case Gold;
case Silver;
case Bronze;
}
class Member
{
public function __construct(
public Grade $grade,
public int $price
) {}
}
class DiscountManager
{
public function calculate(Member $member)
{
$discountPrice = match ($member->grade) {
Grade::Platinum => $member->price * 0.3,
Grade::Gold => $member->price * 0.2,
Grade::Silver => $member->price * 0.1,
Grade::Bronze => $member->price * 0.05,
};
return $member->price - $discountPrice;
}
}
$memberA = new Member(grade: Grade::Platinum, price: 10000);
$memberB = new Member(grade: Grade::Gold, price: 10000);
$memberC = new Member(grade: Grade::Silver, price: 10000);
$memberD = new Member(grade: Grade::Bronze, price: 10000);
$discountManager = new DiscountManager();
$discountManager->calculate($memberA); // => 7000
$discountManager->calculate($memberB); // => 8000
$discountManager->calculate($memberC); // => 9000
$discountManager->calculate($memberD); // => 9500
原則違反箇所
例えば、会員のグレードに Special というグレードを増やしたい場合(つまり拡張)、 DiscountManager クラス calculate() メソッドのmatch文で条件分岐している部分を修正する必要があります。これは拡張の際に、既存のコードを修正しないといけない、という状況になっているため原則違反だと考えられます。
では修正してみましょう。
原則準拠コード
<?php
enum Grade
{
case Platinum;
case Gold;
case Silver;
case Bronze;
}
interface MemberInterface
{
public function discount();
}
class Platinum implements MemberInterface
{
public Grade $grade = Grade::Platinum;
private const DISCOUNT_RATE = 0.3;
public function __construct(
public int $price
){}
public function discount()
{
return $this->price * $this::DISCOUNT_RATE;;
}
}
class Gold implements MemberInterface
{
public Grade $grade = Grade::Gold;
private const DISCOUNT_RATE = 0.2;
public function __construct(
public int $price
){}
public function discount()
{
return $this->price * $this::DISCOUNT_RATE;;
}
}
class Silver implements MemberInterface
{
public Grade $grade = Grade::Silver;
private const DISCOUNT_RATE = 0.1;
public function __construct(
public int $price
){}
public function discount()
{
return $this->price * $this::DISCOUNT_RATE;;
}
}
class Bronze implements MemberInterface
{
public Grade $grade = Grade::Bronze;
private const DISCOUNT_RATE = 0.05;
public function __construct(
public int $price
){}
public function discount()
{
return $this->price * $this::DISCOUNT_RATE;;
}
}
class DiscountManager
{
public function calculate(MemberInterface $member)
{
return $member->price - $member->discount();
}
}
$memberA = new Platinum(price: 10000);
$memberB = new Gold(price: 10000);
$memberC = new Silver(price: 10000);
$memberD = new Bronze(price: 10000);
$discountManager = new DiscountManager();
echo $discountManager->calculate($memberA); // => 7000
echo $discountManager->calculate($memberB); // => 8000
echo $discountManager->calculate($memberC); // => 9000
echo $discountManager->calculate($memberD); // => 9500
やったこと
- calculate() メソッドだけを持つ MemberInterface というインターフェースを作成。
- MemberInterface を実装したそれぞれのグレードのクラスを作成。そのクラスは割引率とグレードのデータを持つようにした。
- DiscountManager クラスは単に MemberInterface 型を引数として受け取る calculate() メソッドだけを持つようにした。
思考の流れ
- DiscountManager クラス calculate() メソッドのmatch文で条件分岐しているのが原則違反。
- match文を取り除いて、 DiscountManager クラス calculate() メソッドは単に元の価格から割引かれた価格を引いた価格を返すだけにする。
- Member クラスに discount() メソッドを持たせたが、この関数内でもグレードによって条件分岐するような実装はしたくない。
- Member クラスをインターフェースにして、関数の中身の実装、グレード毎の割引率はそれぞれのグレードのクラスを作ってそれらに任せる。
- DiscountManager クラスの calculate() メソッドは MemeberInterface 型の引数を受け取るようにする。
ポイント
実際に Special グレードを増やす場合、他のグレードクラスに倣って MemberInterface を実装した Special クラスを作るだけで済みます。この時、追加したクラス以外に修正は発生しないですよね?これが拡張に対しては開いていて(クラス・関数等の追加のみで仕様追加可能)、修正に対しては閉じている(既存部分の修正不要)ということです。
おわりに
いかがでしたでしょうか?
今回はSOLID原則のうちのSとOに当たるSRPとOCPに関して説明しました。
「原則違反コード」→「原則準拠コード」の順で追うと結構理解しやすいのではないかと思います。
次回はLとIに当たるLSPとISPに関して同じように説明出来ればと思います。
この記事を書いた人
-
大学4年時春に文系就職を辞め、エンジニアになることを決意し、独学でRuby、Ruby on Railsを学習。
約1年間の独学期間を経てアーティスへWebエンジニアとして入社。現在はWebエンジニアとして、主にシステムの開発・運用に従事している。
抽象的なもの、複雑なものを言語化して文章にするのが好きで得意。
この執筆者の最新記事
- 2024年10月8日WEBVue3でjQueryのdatepickerを使いたい!実装手順と注意点を解説します。
- 2024年8月21日WEBVue3の非同期コンポーネントを使ってみる
- 2024年5月28日WEBLaravel×Inertia×Vue3でファイルアップロード機能を作ってみた
- 2024年4月15日WEBLaravel×Inertia×Vue3でCRUD機能を持つSPAを作ってみた
関連記事
最新記事
FOLLOW US
最新の情報をお届けします
- facebookでフォロー
- Twitterでフォロー
- Feedlyでフォロー