オープンクローズドの原則

オープンクローズドの原則とは「クラス・モジュール・関数などのソフトウェアの構成要素は拡張に対して開いていて、修正に対しては閉じているべきである」と言う原則である。 アプリケーション層に対して適応するのであれば、既存のコードを変更して機能を実現するのではなく、追加で実現していくイメージである。

変更が必要なコードとは?

通知クラスを例に出してみる。

  • email
  • slack
  • sms の通知処理をもつNotiferクラスが存在する
class Notifier
{
    public function send(
        string $type,
        int $user_id,
        string $message,
    ): void {
        switch ($type) {
            case 'email':
                $this->sendEmail(user_id: $user_id, message: $message);
                break;
 
            case 'slack':
                $this->sendSlack(user_id: $user_id, message: $message);
                break;
 
            case 'line':
                $this->sendLine(user_id: $user_id, message: $message);
                break;
 
            default:
                throw new InvalidArgumentException("Unknown notification type: {$type}");
        }
    }
 
    private function sendEmail(int $user_id, string $message): void
    {
        echo "[EMAIL] user={$user_id} msg={$message}\n";
    }
 
    private function sendSlack(int $user_id, string $message): void
    {
        echo "[SLACK] user={$user_id} msg={$message}\n";
    }
 
    private function sendLine(int $user_id, string $message): void
    {
        echo "[LINE] user={$user_id} msg={$message}\n";
    }
}
 
// 呼び出し
$notifier = new Notifier();
 
$notifier->send(
    type: 'slack',
    user_id: 42,
    message: 'Hello!'
);

ここで、送信方法を追加したい場合に構成を崩さずに変更を加えるとNotiferクラスのsendメソッドの条件分岐に値を追加することになる。 ここではpush通知とする。 この場合、sendPushメソッドも必要になるだろう。

class Notifier
{
    public function send(
        string $type,
        int $user_id,
        string $message,
    ): void {
        switch ($type) {
            case 'email':
                $this->sendEmail(user_id: $user_id, message: $message);
                break;
 
            case 'slack':
                $this->sendSlack(user_id: $user_id, message: $message);
                break;
 
            case 'line':
                $this->sendLine(user_id: $user_id, message: $message);
                break;
            // 追加
            case 'push':
                $this->sendPush(user_id: $user_id, message: $message);
                break;
 
            default:
                throw new InvalidArgumentException("Unknown notification type: {$type}");
        }
	}
 
	// 追加
    private function sendPush(int $user_id, string $message): void
    {
        echo "[PUSH] user={$user_id} msg={$message}\n";
    }
  
    // ・・・その他の送信メソッド
}

これが、既存のコードを変更した場合の機能追加となる。 この場合のデメリットは

  • 通知の種類が増えるたびにcase追加とメソッドの追加が必要
  • 様々な通知処理が記載されてしまうため、可読性が低い
  • 変更が必要ということは、既存のコードに思わぬバグを入れ込んでしまう可能性を高める 今回の例ではコード量が少ないため、デメリットを感じにくいかもしれないが、 コード量が増加していくことに比例して1クラスあたりの複雑性が増していく。 そうするとバグ混入リスクも増加していき、変更容易性が下がっていく。

追加で機能を実現するには?

端的に記載すると抽象に寄せていくことが必要になる。 また実際の通知を表現するメソッド達はクラスごとに分割する必要が出てくる。 こうすることで呼び出し側からDIすることで既存コードを変更することなく、追加という形で機能を拡張することができる。

まず抽象を作成するためインターフェースを定義する。

interface NotificationInterface
{
    public function send(int $user_id, string $message): void;
}

abstractクラスや継承でも実現できるが、インターフェースで十分なケースが多いため、こちらで実装を行う。 継承はすごくパワフルな反面、親クラスに具象的な処理を持たせることができてしまう。 そのため親クラスとの不要な依存関係やオーバーライドで子クラスの振る舞いを変えることが必要ケースが出てきてしまう。 そうなると、「ここの処理は子クラスが行い、ここのクラスは親クラスが行なっている」などコードの見通しが悪くなる可能性が出てきてしまう。 影響範囲の不明瞭さや既存コードの変更を迫られてしまう状況になることもある。 また最初は親クラスに具象的なコードが書かれていなくても、誰かが記載してしまうこともありうる。 こうしたリスクを設計から潰していくことを考えるとインターフェースを使用した方が良いケースは多いと思う。

少し脱線したが抽象クラスを受け入れるNotiferクラスを作成する。

class Notifier
{
    public function __construct(
        private NotificationInterface $notification,
    ) {}
 
    public function send(
        int $user_id,
        string $message,
    ): void {
        $this->notification->send(
            user_id: $user_id,
            message: $message
        );
    }
}

このクラスが抽象であるNotificationInterfaceを引数にとることで、具象クラス達も引数として渡せるようになる。 あとは具象クラスを作成していくだけである。

class EmailNotification implements NotificationInterface
{
    public function send(int $user_id, string $message): void
    {
        echo "[EMAIL] user={$user_id} msg={$message}\n";
    }
}
 
class SlackNotification implements NotificationInterface
{
    public function send(int $user_id, string $message): void
    {
        echo "[SLACK] user={$user_id} msg={$message}\n";
    }
}
 
class LineNotification implements NotificationInterface
{
    public function send(int $user_id, string $message): void
    {
        echo "[LINE] user={$user_id} msg={$message}\n";
    }
}

呼び出し例としては

// Slack送信を呼び出し側でNotifierクラスにDIする
$notifier = new Notifier(
    notification: new SlackNotification()
);
 
$notifier->send(
    user_id: 42,
    message: 'Hello!'
);

プッシュ通知の機能拡張を行う

プッシュ通知機能を追加するにはNotificationInterfaceを実装したクラスを作成するだけで良い。

class PushNotification implements NotificationInterface
{
    public function send(int $user_id, string $message): void
    {
        echo "[PUSH] user={$user_id} msg={$message}\n";
    }
}
 
// 呼び出し
$notifier = new Notifier(
    notification: new PushNotification()
);
 
$notifier->send(
    user_id: 42,
    message: 'Hello!'
);

クラスを1つ追加しただけで既存コードを一切変更せずに、機能追加が実現できた。 これがオープンクローズドの原則が目指すコードである。

ちなみに上記に示してきたのはデザインパターンの1つである、strategyパターンでオープンクローズドの原則を実現した。

strategyパターンはアルゴリズム(振る舞い)を入れ替えるデザインパターンであり、今回は通知方法を入れ替えていたことになる。

オープンクローズドの原則を満たすことができるデザインパターンをもう1パターン見てみる。

デコレータパターンで実現してみる

既存のクラスに新しい機能や振る舞いを動的に追加することを可能にするパターンである。

要は元クラスは変更せずに機能をラップして拡張していく。

デコレータパターンは入れ子と積み上げをイメージできるが、ここでは入れ子で見ていく。

先ほどの`NotificationInterfaceと具象クラスを使用する。

インターフェース

interface NotificationInterface
{
    public function send(int $user_id, string $message): void;
}

具象クラス

class EmailNotification implements NotificationInterface
{
    public function send(int $user_id, string $message): void
    {
        echo "[EMAIL] user={$user_id} msg={$message}\n";
    }
}
 
class SlackNotification implements NotificationInterface
{
    public function send(int $user_id, string $message): void
    {
        echo "[SLACK] user={$user_id} msg={$message}\n";
    }
}
 
class LineNotification implements NotificationInterface
{
    public function send(int $user_id, string $message): void
    {
        echo "[LINE] user={$user_id} msg={$message}\n";
    }
}

デコレータを実装する

メッセージ送信前にログを出力するように拡張していくこととする。

class LoggingNotifierDecorator implements NotificationInterface
{
    public function __construct(
        private NotificationInterface $notifier
    ) {}
 
    public function send(int $user_id, string $message): void
    {
        echo "[LOG] sending...\n";
        $this->notifier->send($user_id, $message);
    }
}

さらにメッセージの暗号化も行うするように拡張する。

class EncryptNotifier implements NotificationInterface
{
    public function __construct(
        private NotificationInterface $notifier
    ) {}
 
    public function send(int $user_id, string $message): void
    {
        $encrypted = base64_encode($message);
        $this->notifier->send($user_id, $encrypted);
    }
}

あとはクライアントで呼び出しを行えば良い。

$notifier = new LoggingNotifier(
    new EncryptNotifier(
        new EmailNotification()
    )
);
 
$notifier->send($user_id: 1, $message: "Hello World");

これで既存コードを変更せずに追加で通知前にログと暗号化の処理を付与することができた。

若干、NotificationInterfaceが使い方に違和感があるかもしれないが、説明のために簡略化した。

また前述ではabstractクラスの使用について言及したが、LoggingNotifierDecoratorEncryptNotifierはコンストラクタの引数を設計的にNotificationInterfaceにロックできていない。また同一の処理となるため、abstractクラスの使用も検討の余地はあるかもしれない。

入れ子のデコレータパターンのイメージができたかと思う。(Laravelのミドルウェアなどもデコレータパターンを使用している)

一方、積み上げ式は価格計算などに使用されたりする。

以下のサイトがわかりやすいと思うのでリンクを貼っておく

Decoratorパターン

まとめ

既存のコードを変更して機能を実現するのではなく、追加によって機能を拡張する設計を心がけることで、既存コードへの影響やリスクを最小限に抑えながら、素早く安全に新しい要求へ対応できるようになる。 また、条件分岐の switch 文がなくなることで可読性が向上し、変更箇所が明確になる。 結果として、不要なバグを仕込む可能性も大きく減らすことができる。 このような変更に強い構造に寄せていくと、 オープンクローズドの原則だけでなく、単一責任の原則も自然と満たす ことが多く、 コードベース全体としてクリーンで保守性の高いアプリケーションに近づいていく。

個人的には SOLID 原則の中でもオープンクローズドの原則が最も重要だと考えている。 理由は、この原則が徹底されているコードは必然的に変更に強く、機能追加のたびに影響範囲を意識しなくて済むようになるからだ。コードを読み進め、影響反映の切り分けを行い。実装後もバグはないかを調べる工数は圧倒的に下がっていく。 オープンクローズドの原則を意識した設計は、アプリケーションのコードの劣化を抑えてくれる。 チーム開発でも、時間が経ってからの保守でも、設計としてロックが効いているため、誰が触っても壊れにくいコードが実現しやすくなる。