FOUR WINDS TECH BLOG

WEBサイト制作・WEBアプリケーション開発が得意な会社の技術ブログ

設計と開発の原則

WEBサイト制作・WEBシステム開発で設計をせずにコードを作成してしまうと、仕様の変更に追従できなかったり影響範囲がわからず保守できない、もしくは不具合の多いWEBサイト・WEBシステムになってしまうことがあります。

しかし、設計・開発にもベストプラクティスが存在します。

ベストプラクティスに従うことで、仕様の変更に柔軟に対応できる保守性の高いWEBサイト・WEBシステムをつくることができます。

この記事では、コードを作成する際に意識すべきとよく言われる代表的な原則について紹介します。

SOLID

SOLIDとはオブジェクト指向プログラミングでソフトウェアを設計・開発する際に守るべき原則です。

以下の原則の頭文字を取って、SOLIDと呼ばれています。

  1. Single responsibility principle(単一責任の原則)
  2. Open–closed principle(開放閉鎖の原則)
  3. Liskov substitution principle(リスコフの置換原則)
  4. Interface segregation principle(インターフェイス分離の原則)
  5. Dependency inversion principle(依存性逆転の原則)

それぞれの詳細について説明します。

Single responsibility principle(単一責任の原則)

Single responsibility principle(単一責任の原則)は、ひとつのクラスに複数に責任を負わせてはいけないという原則です。

NGの例

<?php
class Post {
    /**
     * Postの内容をHTMLのtableタグで出力する
     */
    public function outputHtml()
    {
        // 処理...
    }

    /**
     * Postをデータベースに保存する
     */
    public function save()
    {
        // 処理...
    }
}

この例ではPostクラスにHTMLを出力する責任とデータベースを操作する責任があります。

ひとつのクラスに複数の責任を負わせると、コードの複雑性が上がりプログラムを修正する際に影響範囲が読みづらくなります。

OKの例

<?php
class PostHelper {
    /**
     * Postの内容をHTMLのtableタグで出力する
     */
    public function outputHtml(Post $post)
    {
        // 処理...
    }
}

class PostModel {
    /**
     * Postをデータベースに保存する
     */
    public function save()
    {
        // 処理...
    }
}

このように、ひとつのクラスはひとつの責任を持つような設計にすることで、コードの見通しが良くなり影響範囲を少なくできます。

Open–closed principle(開放閉鎖の原則)

あるクラスに対して、機能を追加する度に処理を変更する必要がないような設計にするべきという原則です。

NGの例

<?php
class Notifier {
    /**
     * Slackで通知を送信する
     */
    public function notifyBySlack()
    {
        // 処理...
    }

    /**
     * メールで通知を送信する
     */
    public function notifyByEmail()
    {
        // 処理...
    }

    /**
     * Chatworkで通知を送信する
     */
    public function notifyByChatwork()
    {
        // 処理...
    }
}

function sendNotification ($channel) {
    $notifier = new Notifier();
    switch ($channel) {
        case 'slack':
            $notifier->notifyBySlack();
            break;
        case 'email':
            $notifier->notifyByEmail();
            break;
        case 'chatwork':
            $notifier->notifyByChatwork();
            break;
        default:
            // 想定していないchannelを指定された場合
    }
}

この例ではLINEやDiscordなど通知先を増やしたい場合にswitchcaseを都度追加しなければいけません。

OKの例

<?php
interface NotifierInterface {
    /**
     * 通知を送信する
     */
    public function notify();
}

class SlackNotifier implements NotifierInterface {
    public function notify()
    {
        // Slackに通知を送信する処理
    }
}

class EmailNotifier implements NotifierInterface {
    public function notify()
    {
        // Slackに通知を送信する処理
    }
}

class ChatworkNotifier implements NotifierInterface {
    public function notify()
    {
        // Slackに通知を送信する処理
    }
}

function sendNotification ($notifier) {
    $notifier->notify();
}

sendNotification関数をNotifierInterfaceを実装したクラスのインスタンスを受け取るように変更することで、通知先が増える度にsendNotification関数の処理を修正しなくても良くなります。

Liskov substitution principle(リスコフの置換原則)

同じinterfaceを実装したり、classを継承したりしている場合、各クラスでの振る舞い(処理内容)が同じになっているべきという原則です。

NGの例

<?php
interface NotifierInterface {
    /**
     * 通知を送信する
     */
    public function notify();
}

class SlackNotifier implements NotifierInterface {
    public function notify($message = '')
    {
        $response = Slack::send($message);
        if ($response->status === 'OK') {
            return '1';
        } else {
            return '0';
        }
    }
}

class EmailNotifier implements NotifierInterface {
    public function notify($message = '')
    {
        $response = Email::send($message);
        if ($response->status === 'OK') {
            return true;
        } else {
            return false;
        }
    }
}

function sendNotification ($notifier) {
    $success = $notifier->notify();
    if ($success) {
        Session::message('通知を送信しました');
    } else {
        Session::message('通知を送信できませんでした');
    }
}

この例ではNotifierInterfaceを実装しているSlackNotifierクラスとEmailNotifierで返り値の型が異なっています。

呼び出し元が振る舞いや返り値の違いを考慮する必要があると、クラスを呼び出す際に都度内部の仕様まで確認する必要があるため、効率が悪くバグの発生にもつながります。

OKの例

<?php
interface NotifierInterface {
    /**
     * 通知を送信する
     */
    public function notify(): bool;
}

class SlackNotifier implements NotifierInterface {
    public function notify($message = '')
    {
        $response = Slack::send($message);
        if ($response->status === 'OK') {
            return true;
        } else {
            return false;
        }
    }
}

class EmailNotifier implements NotifierInterface {
    public function notify($message = '')
    {
        $response = Email::send($message);
        if ($response->status === 'OK') {
            return true;
        } else {
            return false;
        }
    }
}

function sendNotification ($notifier) {
    $success = $notifier->notify();
    if ($success) {
        Session::message('通知を送信しました');
    } else {
        Session::message('通知を送信できませんでした');
    }
}

NotifierInterfacenotifyメソッドの返り値を明示的にboolであることを指定すると、実装クラスに返り値を強制できる(異なる型を返却するとエラーになる)ため、sendNotification関数の処理ではnotifyメソッドの返り値をboolである前提で扱えるようになります。

Interface segregation principle(インターフェイス分離の原則)

Interfaceで実装を定義する際に、実装クラス側で必要のないメソッドの定義を強制してはいけないという原則です。

NGの例

<?php
interface NotifierInterface {
    /**
     * 通知を送信する
     */
    public function notify(): bool;
    
    /**
     * SlackのAPIキーを取得する
     */
    protected function getSlackApiKey(): string;
}

class SlackNotifier implements NotifierInterface {
    public function notify($message = '')
    {
        $apiKey = $this->getSlackApiKey();
        // 処理...
    }

    protected function getSlackApiKey()
    {
        // APIキーを取得する
    }
}

class EmailNotifier implements NotifierInterface {
    public function notify($message = '')
    {
        $response = Email::send($message);
        if ($response->status === 'OK') {
            return true;
        } else {
            return false;
        }
    }

    protected function getSlackApiKey()
    {
        // 何もしない
    }
}

function sendNotification ($notifier) {
    $notifier->notify();
}

EmailNotifierクラスではgetSlackApiKeyメソッドは使わないので定義する必要はありませんが、interfaceでメソッドの定義を強制しているため、不要なメソッドを定義しています。

OKの例

<?php
interface NotifierInterface {
    /**
     * 通知を送信する
     */
    public function notify(): bool;
}

class SlackNotifier implements NotifierInterface {
    public function notify($message = '')
    {
        $apiKey = $this->getSlackApiKey();
        // 処理...
    }

    protected function getSlackApiKey()
    {
        // APIキーを取得する
    }
}

class EmailNotifier implements NotifierInterface {
    public function notify($message = '')
    {
        $response = Email::send($message);
        if ($response->status === 'OK') {
            return true;
        } else {
            return false;
        }
    }
}

function sendNotification ($notifier) {
    $notifier->notify();
}

この例ではgetSlackApiKeyメソッドはSlackNotifierクラスの内部でしか利用しない、つまり共通化する必要がないので、SlackNotifierクラスの内部で定義するだけで十分でしょう。

Dependency inversion principle(依存性逆転の原則)

あるクラスにメソッドが定義されていることを、クラス定義ではなくInterfaceで担保するべきという原則です。

NGの例

<?php
class SlackNotifier {
    public function notify()
    {
        // 処理...
    }
}

function sendNotification (SlackNotifier $notifier) {
    $notifier->notify();
}

この例ではsendNotification関数がSlackNotifierクラスのインスタンスに依存しており、別の通知先に送信するにはsendEmailNotificationのような関数を追加しなければいけません。

OKの例

<?php
interface NotifierInterface {
    /**
     * 通知を送信する
     */
    public function notify(): bool;
}

class SlackNotifier implements NotifierInterface {
    public function notify()
    {
        // 処理...
    }
}

function sendNotification (NotifierInterface $notifier) {
    $notifier->notify();
}

SlackNotifierなどのような通知先ごとのクラスにnotifyメソッドがあることをNotifierInterfaceに担保させることで、sendNotification関数はNotifierInterfaceを実装したインスタンスとして$notifierを受け取れるようになり、通知先の違いを意識せずにnotifyメソッドを呼び出すことができます。

KISSの原則

Keep it simple, stupid.

の略です。DeepLで翻訳すると

シンプルにしとけよ、アホ。

になります。

ソフトウェアを設計する際には何事もシンプルにするべきという原則です。

プログラムで例を示しますが、システムアーキテクトなど広い範囲に当てはまる考え方です。

NGの例

<?php
class Post {
    public static function getAll($user = null, $id = null)
    {
        // 処理...
    }
}

$posts = Post::getAll();

この例ではgetAllメソッドにユーザーを指定するとそのユーザーと紐づくPostデータ、さらにIDを指定すると特定のIDを持つPostデータを取得できることを想定しています。

しかし、例えばユーザーは指定せずにIDだけを指定したい、といったユースケースが出てきてしまうと、途端にこの処理は複雑化します。

OKの例

<?php
<?php

class Post {
    public static function getAll()
    {
        // 処理...
    }

    public static function getByUser()
    {
        // 処理...
    }

    public static function getById()
    {
        // 処理...
    }
}

$posts = Post::getAll();

実際にはクエリビルダーなどを利用することで更にシンプルに記述できますが、このようにメソッドを分けることでNGの例よりも各メソッドの処理をシンプルにできます。

YAGNI

「You ain't gonna need it」の略で、(機能などが)必要になるまでは実装すべきでないという原則です。

前述のSOLIDで示した例で言えば、様々な通知チャンネルを作成できるようにNotifierInterfaceを実装したSlackNotifierクラスやEmailNotifierクラスを定義しました。

もし仕様として、様々な通知チャンネルを作成する必要があると明確に決まっている場合は示したような実装をするとSOLID的には良いのですが、80%不要にも関わらず将来的に必要になるかもしれない、といった理由でChatworkNotifierクラスやDiscordNotifierクラスなどを作成してしまうと、使わなかった場合作成にかかった時間は無駄になってしまいます。

また、コードが増えれば増えるほどバグが生まれる可能性も上がります。

拡張性を意識することは重要ですが、過度に拡張性を重視してしまうと必要以上に時間がかかったり、無用のバグを生み出したりします。

本当に拡張性が必要な機能かどうか、検討が必要です。

DRY

「Don't repeat yourself」の略で、同じコードを繰り返し書いてはいけないという原則です。

同じコードを何度も記述すると、例えば仕様に変更が発生した際に多くの修正が発生するかもしれません。

同じ処理であればクラスのメソッドや関数に処理を記述し、メソッド・関数を呼び出すように共通化することで何度も同じ記述をせずに済みます。

ただし、同じ記述であってもSOLIDの原則(特に単一責任の原則)を満たさない形で共通化してはいけません。

無闇に共通化すると、ひとつの修正で思わぬ部分に影響が出ることもあるので、同じ処理でもコンテキストが異なる場合はあえて共通化しない方が保守しやすいプログラムになる場合もあります。

GOF Design Patterns

GOFとは

オブジェクト指向における再利用のためのデザインパターンという書籍を執筆した4人の著者をよく「Gang of Four」と呼びます。

GOF Design Patternsとは

上記の書籍で紹介されている、オブジェクト指向プログラミングの設計パターンのことです。

オブジェクト指向プログラミングでよく採用される設計のパターンが紹介されていて、問題に直面した時にこの書籍で紹介されているパターンを知っていれば時間をかけずに良い設計で解決できるようになります。

domain-driven design(ドメイン駆動設計)

Eric Evans氏のDomain-Driven Design: Tackling Complexity in the Heart of Softwareで紹介されたソフトウェアの設計手法です。よくDDDと略されます。

かなり内容が濃く、人によって理解が若干異なる場合があります。

ドメイン駆動設計 - Wikipediaによれば、次のような要素から表現されるそうです。

ドメイン駆動設計では、ドメインモデルを表現する要素として、下記のものを挙げている。

  • エンティティ (参照オブジェクト): ドメインモデル内のオブジェクトであり、その属性によってではなく、連続性と識別性によって定義される。
  • 値オブジェクト: 事物の特性を記述するオブジェクトである。特に識別する情報はなく、通例、読み出し専用のオブジェクトであり、Flyweight パターンを用いて共有できる。
  • サービス: 操作がオブジェクトに属さない場合に、問題の自然な解決策として、操作をサービスとして実現することができる。サービスの概念は、GRASPにおいて"純粋人工物"と呼ばれるものである。
  • リポジトリ:ドメインオブジェクトを取得するメソッドは、記憶域の実装を簡単に切り替えられるようにするため、専門のリポジトリオブジェクトに処理を委譲するべきである。
  • ファクトリー : ドメインオブジェクトを生成するメソッドは、実装を簡単に切り替えられるようにするため、専門のファクトリーオブジェクトに処理を委譲するべきである。

Test-Driven Development(テスト駆動開発)

プログラムを作成する際に、プログラムよりも先にテストコードを作成し、テストコードが正常に終了するようにプログラムを作成し、その後にプログラムを修正していく開発手法をテスト駆動開発と言います。英語の頭文字を取って、TDDとも呼ばれます。

テスト駆動開発のメリット

テスト駆動開発では実装よりも前にテストコードを書くため、実装前に仕様を設計してテストコードとして具体的に定義できます。テストコードで明確な仕様を定義できるため、実装漏れを防ぐのに役立ちます。また、一般的なテスト駆動開発ではモジュール単位でテストコードを作成するため、疎結合なモジュール設計になりやすいです。

テスト駆動開発のデメリット

プロトタイピングのような開発の場合、機能の開発後に大幅な仕様変更が発生することがあります。仕様が大きく変わる場合作成したテストコードがすぐに使えなくなるため、テスト駆動開発には向いていません。また、テストコードを実行する環境をテストの度にビルドするような設計の場合、規模の大きいモノリシックなシステムではビルドに数時間以上かかることがあり、修正の度に数時間の待ち時間が発生し効率が悪くなる場合もあります。

まとめ

この記事では最低限知っておくべき設計、開発の原則を紹介しました。

これらの原則を意識して設計・開発することで、継続的に改善していくことが可能なWEBサイト・WEBシステムを開発しやすくなります。