パッケージ設計の原則とSOLID原則についてまとめる
はじめに
先日、前から気になっていた ちょうぜつソフトウェア設計入門
を読み終えました。
ソフトウェアを設計するうえで、知っておくとよい知識を網羅的にまとめている良書でした。
とくにパッケージ設計の原則と SOLID 原則については、一部知ってはいるものの他は大まかな内容しか知らないという状態だったので勉強になりました。
今回は、 2 章と 5 章に出てくるパッケージ設計の原則と SOLID 原則についてまとめようと思います。
パッケージ設計の原則
エンジニアが設計ならびに開発していくうえで、ありとあらゆるコードを自分で書くことは不可能です。
たとえば、ウェブアプリ開発の場合は以下のように機能ごとに何かしらのパッケージを利用すると思います。
- Web Framework: Express
- 仮想 DOM: React
- ORM: Prisma
- Build: Webpack
このように、ある一機能を提供するためにそれぞれパッケージが作成されています。
そして、利用者は自分が利用したいパッケージを好きな数だけ選定し、存在しない場合は社内でオリジナルのパッケージを開発します。
これら 機能ごとにまとめられた再利用されたコード
が パッケージ
であり、このパッケージをどのような基準でまとめていくのかがパッケージ設計の原則です。
設計の原則
とあるように、パッケージ利用者が考えるべき原則というよりは、パッケージを提供する開発者側の原則です。
Reuse-Release Equivalent Principle (REP)
リリースされたものだけを再利用しなさい。
再利用させたいのであればリリースしなさい。
上記を表しているものが REP です。
何かしらコードを書いていて、それをユーザに利用してもらいたいのであればリリースしてください。
ユーザはリリースされたパッケージのコードをすべて利用することになります、というものです。
たとえば、仮に自分が React の開発者だった場合を考えます。
このとき、新しい useXXX
機能を提供したい場合は、それを含めたパッケージとして新たなバージョン (19.x.x
など)を作成する必要があります。
新たなバージョンを切らない限り、ユーザは安心してパッケージを利用できません。
どのようにパッケージ(再利用の単位)をきめるかどうかは、Common Reuse Principle
と Common Closure Principle
を参考にします。
Common Reuse Principle (CRP)
ユーザはパッケージ内にあるコードをすべて利用するか、もしくはすべて利用しないかを選択するしかない。
そのためパッケージが一つの責務のみ持つようになるまで、パッケージを分けるべきである。
パッケージに含まれるコードはすべて利用されるというものです。
ここで、利用される
という表現は分かりづらいので、ユーザはパッケージのコードをすべて利用するしかない
と捉えるとよいかと思います。
React を v16.x.x を利用している状態で、 v18.x.x へアップデートしたい場合を考えます。
このとき、v18.x.x の Suspense のみ利用してそれ以外は v.16.x.x を利用する
ということはできません。
v18.x.x の Suspense を利用したい場合、 v18.x.x から deprecated になるコードは削除し、すべて v18.x.x に移行する必要があります。
また、 React Router のような React を利用する外部パッケージライブラリについても v18.x.x で動作するようにうまく調整する必要があります。
(メンテナンスされたおらず、v16.x.x の React でしか利用できないパッケージも多く、仕方なく --legacy-peer-deps
を使うことも多々ありますね。)
このように、ユーザはパッケージにあるコードをすべて利用する(バージョンをうまく選択する)必要があります。
よって、開発者側は パッケージがある 1 つの債務(機能)を提供する
コードのみを 1 つのパッケージとして提供するべきです。
たとえば、React の場合は、 UI を仮想 DOM として提供することだけに専念したものになっています。
ルーティングについては React Router、サーバ側の DOM レンダリングについては react-dom など機能ごとにそれぞれパッケージ分割されています。
できるだけパッケージは小さな 1 つの機能のみを提供することを目指し、その機能のみを提供できるようになるまで小さなパッケージに分割しましょう。
Common Closure Principle (CCP)
ひとつの変更が必要なとき、できるだけ一つのパッケージのみ交換すればよい形にする
よって、 1 つのパッケージに 1 つの責務(機能)を提供するコードのみをまとめあげる。
オブジェクト指向における SOLID 原則の単一責任の原則に近い原則となっています。
パッケージ内のある 1 箇所を変更することによって、他のパッケージも変更ならびに交換する必要がでてきた場合、パッケージ分割がうまくできていないといえます。
パッケージには、ある 1 つだけの責務を持つように、関連する機能のコードのみをまとめあげましょう。
凝集度と結合度
Common Reuse Principle(分けるべきものは分ける) と Common Closure Principle(まとめるべきものはまとめる) 両方を守ることによって
- 関係ないコードは他のパッケージに
- 関係あるコードは同じパッケージに
分割できます。
これによって、パッケージ内の凝縮度が高くなり、パッケージごとの結合度が小さくなります。
Acyclic Dependencies Principle (ADP)
パッケージの依存が循環してはならない。
ADP はパッケージ間の関係性を示した原則です。
パッケージ同士に循環依存が発生するとすべてのパッケージを利用する必要があり、パッケージを 1 つだけ交換する、ということができません。
循環依存が発生するような強い結びつきについては、異なるパッケージにするのではなく同じパッケージにまとめあげるべきです。
たとえば、 Go 言語においては異なるパッケージが互いに循環参照になるとコンパイルエラーになります。
しかし、同じパッケージ内であれば struct interface が互いに循環参照しても問題ありません。
Stable Dependencies Principle (SDP)
パッケージの依存は常により安定したパッケージに向くべきである。
より変更が少ない、安定したパッケージに対して依存するようにするべき、というものです。
React Hook Form ライブラリは、より安定した React ライブラリに依存します。
そして、 React ライブラリはより安定した JSX の仕様ならびに JavaScript の仕様に依存します。
Clean Architecture の Entity Business Rule と同様に、不安定なものが安定なものに依存するべきという原則です。
Stable Abstractions Principle
安定度が高いパッケージであるためには抽象度が高い必要がある。
安定度の引くパッケージは抽象度が低く、具体的でよい。
オブジェクト指向においては 抽象
であるほど安定度が高いといえます。
interface はオブジェクト間の API のみを取り決めており、具体的な実装はありません。
interface を実装したクラスやメソッドは、仕様の変更によって実装内容も変更されます。
PHP の PSR という共通仕様は抽象度が非常に高いといえます。
PSR-7 では HTTP Message が満たすべき interface のみが定められており、具体的な実装は一切ありません。
具体的な実装は Slim や Falcon など、各サードパーティフレームワークに委ねられています。
このように、抽象であればあるほど変更可能性は低く、変更に開けています。
このような抽象なパッケージは安定度が高いため、 SDP もふまえると、できるだけ抽象なパッケージに依存するべきともいえます。
SOLID 原則
オブジェクト指向プログラミングにおける原則が SOLID 原則です。
5 つの原則からなり、それらの頭文字を取ると SOLID
となります。
Single Responsibility Principle (SRP)
あるクラスは 1 つの責務のみ担うべきである。
SRP は、 1 つのクラスが 1 つの責務のみ担うべきである、ということを表しています。
1 つのクラスが複数の責務を担っている、 God Class になっている場合、異なる小さなクラスに分割すべきといえます。
ここで、 責務
をどの基準・レイヤーで分割するかは非常に難しいと考えます。
1 つのクラスが担うべき責務の大きさは扱うドメインや仕様によって異なります。
本書で紹介されている責務の切り分け方としては以下です。
将来、異なる開発者が改修ならびに再利用を行う場合、扱いやすい責務になっている。
Open Close Principle (OCP)
拡張にたいしてオープンである。
変更にたいしてクローズである。
拡張しやすく、変更しやすい設計にしよう、というのが Open Close Principle です。
将来的にやってくる仕様変更や機能変更があったときに、できるだけ既存の挙動が壊れないようにするべきです。
また、簡単に機能が追加できるようになっているべきである、というものです。
個人としては OCP を守るために 抽象
ならびにデザインパターンを意識し、拡張しやすい状態にするのが大事だと考えています。
具体的な実装をそのまま密結合で実装してしまうと将来的に変更することが非常に難しいです。
しかし、抽象である interface api を用意しておくことで、将来的に変更しやすい設計にできます。
クリーンアーキテクチャにおける Infrastructure 層に対して、 XXXRepositoryInterface を用意するのは抽象の例であると考えます。
具体的なデータベース(MySQL)にドメイン側が依存するのではなく、抽象である RepositoryInterface に依存させることによって、将来的にデータベースを変更できるようになります。
Liskov Substitution Principle (LSP)
サブクラスの振る舞いは、基底クラスの振る舞いを壊してはいけない。
サブクラスを作成する際は、基底クラスの振る舞いを壊してはいけません。
これは型に関するものと、機能に関するものがあると考えています。
型に関するものは以下の記事を参照ください。
機能については、親クラスの既存の挙動をすべて破壊するような変更をできるだけ行うべきではない、というものです。
とくにそれを親クラス側の仕様を変更してはいけない、するのであれば子クラス側で吸収すべき、というものです。
たとえば、画像を順番にダウンロードするクラス A があったときに、そのクラスを継承して動画をダウンロードするクラス B があったとします。
このとき、動画のダウンロードに時間がかかるため、クラス A 側のタイムアウト設定を変更してはいけません。
(この例は当たり前過ぎてやるわけないだろ、という感じですが。)
親クラスが行っていた処理をあくまでも拡張するだけに留めるべきです。
Interface Separation Principle
インターフェースの利用者にとって不要な API への依存を強要させないようにする。
小さな機能を提供するインターフェースを利用者に公開すべきである。
開発者がインターフェース API を提供するときは、小さな機能をもつ Interface を提供するべきであるというものです。
(大きな Interface を提供してはいけない、というものではないです。)
たとえば、 go の io
package では、なにかしらの標準入力を扱う Reader があります。
また、標準入出力の両方を扱う ReadWriter
もあります。
この場合、ユーザは自分が利用したいレベル・範囲の Interface を適宜選択できます。
これによって、将来的な変更範囲を狭めることや、責務の範囲を小さくできます。
type Reader interface {
Read(p []byte) (n int, err error)
}
type ReadWriter interface {
Reader
Writer
}
Dependency Inversion Principle
より抽象であり安定であるものに依存させるべきである。
不安定なものに依存するべきではない。もし不安定なものに依存している場合、その依存性を逆転させるべきである。
下記の記事の図がわかりやすいです。
CleanArchitecture における Infrastructure 層とドメイン層の依存性の逆転が身近な例かと思います。
UseCase が特定の MySQL/UserCharacterRepository
に依存してしまうのはよくありません。
MySQL はプロダクトや予算の都合によって、他のデータベースに変更されることが多々あるためです。
そこで、 UserCharacterRepositoryInterface
をドメイン側で用意してあげます。
そして、 MySQL/UserCharacterRepository
が UserCharacterRepositoryInterface
へ依存するようにします。
これによって、より抽象度の高く重要度の高いドメイン層に対して、 Infrastructure 層が依存するようになります。
Discussion