設計と開発の原則
WEBサイト制作・WEBシステム開発で設計をせずにコードを作成してしまうと、仕様の変更に追従できなかったり影響範囲がわからず保守できない、もしくは不具合の多いWEBサイト・WEBシステムになってしまうことがあります。
しかし、設計・開発にもベストプラクティスが存在します。
ベストプラクティスに従うことで、仕様の変更に柔軟に対応できる保守性の高いWEBサイト・WEBシステムをつくることができます。
この記事では、コードを作成する際に意識すべきとよく言われる代表的な原則について紹介します。
SOLID
SOLIDとはオブジェクト指向プログラミングでソフトウェアを設計・開発する際に守るべき原則です。
以下の原則の頭文字を取って、SOLIDと呼ばれています。
- Single responsibility principle(単一責任の原則)
- Open–closed principle(開放閉鎖の原則)
- Liskov substitution principle(リスコフの置換原則)
- Interface segregation principle(インターフェイス分離の原則)
- 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など通知先を増やしたい場合にswitch
のcase
を都度追加しなければいけません。
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('通知を送信できませんでした'); } }
NotifierInterface
でnotify
メソッドの返り値を明示的に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システムを開発しやすくなります。