『クリーンアーキテクチャ』再読(4):コンポーネントの原則

Tommy Cheese | · 読了目安:13 分

『Clean Architecture』再読(四)コンポーネント構築の原則

第三回では、主にモジュールとクラスの設計を説明しました。今回はさらに一歩進み、コンポーネントをどのように設計するかを説明します。

コンポーネントはソフトウェアのデプロイ単位であり、システム全体のデプロイにおいて独立してデプロイできる最小の実体です。コンポーネントは個別に開発でき、コンポーネント化されたプラグインアーキテクチャは、すでにごく一般的なソフトウェアの構築方法になっています。

コンポーネントの凝集性

コンポーネントの凝集性は、どのモジュールやクラスをまとめて一つのコンポーネントにするべきかを示します。主に三つの原則があります:

  • 再利用・リリース等価の原則
  • 共通閉鎖の原則
  • 全再利用の原則

再利用・リリース等価の原則 REP

REPによると、ソフトウェアの再利用の最小単位は、リリースの最小単位と等しくあるべきです。

REPはコード再利用の観点から考える原則です。同じテーマと機能を持つコードをまとめてコンポーネントにし、ソフトウェアをコンポーネント単位で再利用することを勧めます。

わかりやすく言うと、コンポーネントをパッケージ化してリリースすると、バージョン番号や一意な識別子が付きます。既存のライブラリを取り込んでコードを再利用する際、取り込む単位はコンポーネントのパッケージであり、指定する手がかりはバージョン番号です。

共通閉鎖の原則 CCP

CCPによると、同じ目的で同時に変更されるクラスを同じコンポーネントに置き、同じ目的では変更されず、同時にも変更されないクラスは別のコンポーネントに置くべきです。

CCPはコード保守の観点から考える原則です。同じ理由や目的で変更されるコードをコンポーネントとしてまとめることで、リリース、検証、デプロイに伴う作業負担を効果的に減らせます。

前述のとおり、CCPはSRPのコンポーネント版です。SRPとCCPは、どちらも次のようにまとめられます:

同じ理由で変更され、同時に変更する必要がある「もの」をまとめる。異なる理由で変更され、同時には変更されない「もの」を分ける。

  • SRPにおける「もの」は、関数や、クラスまたはモジュールを構成する他の要素です。
  • CCPにおける「もの」は、コンポーネントを構成するクラスとモジュールです。

全再利用の原則 CRP

CRPによると、コンポーネントの利用者に、必要のないものへの依存を強いてはいけません。

CRPは、利用場面や利用頻度が異なるコードを、別々のコンポーネントへ分けることを求めます。不要な分割を避けるための原則でもあり、インターフェース分離の原則ISPを一般化したものです。

CRPはインターフェース分離を一般化した原則です。ISPとCRPは、どちらも次のようにまとめられます:

使う必要がない「もの」に依存しない。

  • ISPにおける「もの」は、不要なメソッドを含む関数/クラスです。
  • CCPにおける「もの」は、不要な関数を含むクラス/モジュールです。

考えてみる

では、REP、CCP、CRPの三つには、どのような関係があるのでしょうか。

実際には、この三つは競合する関係にあります。

REPとCCPはまとめる方向の原則であり、どのクラス/モジュールを同じコンポーネントに入れるべきかを示します。一方CRPは分ける方向の原則であり、どのクラス/モジュールを一緒にしてはいけないかを示します。

image-20240706143525187

  • REPとCCPだけに注目すると、依存先のコンポーネントに不要な部分が多く含まれ、不要なリリースが増えてしまいます。
  • CRPとCCPだけに注目すると、ソフトウェアの再利用が非常に難しくなります。
  • REPとCRPだけに注目すると、一部のクラス/モジュールを変更する際、関連する多くのモジュールも変更せざるを得なくなります。

アーキテクトの仕事は、これら三つの原則の間でバランスを取ることです。コンポーネントの構成は、プロジェクトの重点や、開発しやすさと再利用しやすさの重みに応じて進化するべきです。どの方向へ進むかは、状況に合わせて判断する必要があります。

コンポーネントの結合

コンポーネントの結合は、コンポーネント同士の関係をどのように構成するかを示します。三つの原則があります:

  • 非循環依存の原則 ADP
  • 安定依存の原則 SDP
  • 安定度・抽象度の原則 SAP

非循環依存の原則 ADP

ADPは、コンポーネントの依存関係グラフに循環があってはならないと教えています。

バージョン番号による管理は「翌朝症候群」を解決しますが、その仕組みはADPに従う必要があります。まず、著者が翌朝症候群をどう説明しているかを見ましょう:

丸一日かけてようやくコードを動かせたのに、翌日出勤すると、なぜかまた動かなくなっていることがあります。たいていは、他の担当者が、プロジェクトの依存先のコンポーネントを変更したためです。

この問題の解決には、一般に二つの方法があります:

  • 週次ビルド
  • バージョン番号による管理

週次ビルド:各自が自分のリポジトリで作業し、毎週決まった時間、たとえば金曜日にプロジェクトをビルドして、発生した競合を解消します。

この方法の限界は明らかです:

  • プロジェクトが大きくなるほど、統合作業を時間内に終えるのが難しくなります。
  • プロジェクト全体のビルドとテストが難しくなり、チームのフィードバックサイクルが長くなり、開発品質も下がっていきます。

そのため、バージョン番号による管理を導入する必要があります。

バージョン番号による管理:コンポーネントの新バージョンがリリースされるたびに、それに依存する各チームは、すぐに採用するかどうかを自分たちで判断できます。

バージョン番号による管理では、コンポーネントの依存関係グラフに循環を許してはいけません。つまりADPに従う必要があります。そうしなければ、翌朝症候群は避けられません。なぜでしょうか。

循環依存があると、循環内のすべてのコンポーネントが一つの大きなコンポーネントのようになり、他のコンポーネントが正常に依存するためには、すべてが整合するバージョンを使う必要があるからです。循環依存があるシステムでは、テストも厄介になります。スタブやmockは広く使われていますが、循環内の各コンポーネントに繰り返しスタブを作るのは、洗練された方法とは言えません。

では、循環依存をどう解消すればよいのでしょうか。二つの方法があります:

  • 依存性の逆転を使い、インターフェースを作る。
  • 新しいコンポーネントを作る。

DIPによる循環依存の解消は理解しやすいので、ここでは仕事で出会った事例を使い、新しいコンポーネントを作って循環依存を解消する方法を説明します。

チームがドメイン駆動設計でシステムを設計している場面を考えます。EntityのDogは、Saveによる保存のためにDog Repoに依存します。つまりEntityモジュールがRepoモジュールに依存しています。ところが残念なことに、Entityモジュール内にはDog POまで定義されています。困った設計です。RepoのSaveも、オブジェクトの変換と保存にPOを使う必要があるので、EntityとRepoの間に循環依存が生まれます。幸い、Goではモジュール間の相互依存が許されず、チェック時に「循環インポート」エラーが出ます。どう対処すればよいでしょうか。

答えは、新しいPOモジュールを作り、EntityにあるPOを移すことです。Repo内でPOに依存する関数もPO側へ分ければ、依存の循環がなくなります。依存性注入を使う方法もあります。SprintBootなら@AutoWiredで解決できますが、その本質も依存関係を管理するプールという新しいコンポーネントを作り、循環を解消することです。

似た例は数多くあります。anacondaでPythonライブラリをダウンロードするときに起きた、ライブラリのバージョン競合を覚えているでしょうか……。

少し補足すると、私はコンポーネント構造を設計する際、自然にシステムの機能と対応させることを考えていました。コンポーネントとシステムの機能は一対一に対応し、依存関係図も機能モジュールの分割と対応するはずだと思ったのです。システム設計の最初にコンポーネントの依存関係図も出来上がるものだと、当然のように考えていました。

しかし著者は、トップダウンの設計は不可能だと明言しています。本書の説明を見てみましょう:

[!NOTE]

コンポーネントの構造図は、ソフトウェアシステムの変化に合わせて変化し、拡張される必要があり、構築の最初に完璧に設計することはできません。依存関係図は機能と一対一に対応するものではなく、むしろアプリケーションのビルドしやすさと保守しやすさの地図だからです。

設計、実装されたモジュールが増えるにつれて、コンポーネントの依存関係を管理する必要が出てきます。変更の影響範囲をできるだけ小さくしたいので、単一責任の原則SRPと共通閉鎖の原則CCPを適用し、同時に変更されることが多いクラスをまとめます。

コンポーネント構造図の重要な目標の一つは、頻繁な変更をどう隔離するかを示すことです。頻繁に変更されるコンポーネントが、本来安定しているべき他のコンポーネントへ影響することは避けたいのです。

アプリケーションの成長に伴い、再利用可能なコンポーネントを作る必要性も高まります。そこでCRPがコンポーネントの構成に影響し始めます。さらに循環依存が現れると、非循環依存の原則ADPを適用することで、コンポーネントの依存関係は組み替えられ、拡張されます。

安定依存の原則 SDP

SDPは、依存関係をより安定した方向へ向けるべきだと教えています。通常、ソフトウェアアーキテクチャの下層ほど安定させるようにします。

頻繁に変更されると予想するコンポーネントに、変更しにくいコンポーネントが依存してはいけません。そうすると、変化の多いコンポーネントまで変更しにくくなってしまいます。これがソフトウェア開発の難しいところです。変更しやすいように丁寧に設計したコンポーネントでも、誰かが一本の依存関係を追加しただけで、非常に変更しにくくなり得ます。SDPに従えば、この問題を避けられます。

コンポーネントの安定性は、指標を使って判断できます。著者は次の式で計算する指標を定義しています: $$ I=\frac{Fan-out}{Fan-in+Fan-out} $$ Fan-inはコンポーネントに入ってくる依存の数、Fan-outは外へ向かう依存の数です。外への依存が少ないほどコンポーネントは安定します。外への依存が0なら、コンポーネントを変更させる要因がないことを意味するからです。

安定依存の原則SDPは、各コンポーネントのIが、依存先のコンポーネントのIより大きいことを求めます。つまり、矢印の先のコンポーネントのほうが安定している必要があります。

SDPは、すべてのコンポーネントを安定させることを求めているわけではありません。コンポーネントの構造図を設計する目的は、どれを安定させ、どれを不安定にするかを決めることです。すべてのコンポーネントが安定しているアーキテクチャは柔軟ではなく、十分なアーキテクチャの価値を持ちません。

安定度・抽象度の原則 SAP

SAPによると、コンポーネントの抽象度は安定度に見合っているべきです。

高レベルの方針などは安定したコンポーネントに置くべきですが、そのままでは変更しにくくなります。幸い、オープン・クローズドの原則OCPは、安定したコンポーネントでも拡張しやすく設計できることを教えています。抽象クラスには安定した抽象化の能力があり、安定性を保ちつつ、拡張や変更がしやすい設計にできます。

抽象クラスは、インターフェースとクラスの間の緩衝地帯です。

安定度・抽象度の原則SAPは、コンポーネントの安定度と抽象度を結び付けます。安定したコンポーネントは抽象的でもあるべきであり、そうすれば安定性が拡張性を損ないません。一方、不安定なコンポーネントには具象実装を含め、その不安定さを具体的なコードの変更によって扱いやすくします。したがって、安定したコンポーネントにしたいなら、将来の拡張に備えてインターフェースと抽象クラスで構成するべきです。

安定度と同様に、コンポーネントの抽象度も指標で測れます: $$ A=\frac{N_c}{N_a} $$ Ncはコンポーネント内のクラスの数、Naは抽象クラスとインターフェースの数を指します。Aの範囲は0から1で、0は抽象クラスが一つもないこと、1は抽象クラスだけで構成されていることを意味します。

考えてみる🤔

SDPとSAPには、どのような関係があるのでしょうか。

まず、SDP、SAP、DIPの関係から考えます

実際には、SDP+SAP=コンポーネントレベルのDIPです。SDPは依存関係をより安定した方向に向けることを求め、SAPは安定性そのものが抽象化を必要とすることを教えます。つまり、依存関係はより抽象的な方向へ向かうべきです。

これはどう理解すればよいのでしょうか。

DIPがクラスに対して果たす役割は、柔軟性の確保です。SDP+SAPも、コンポーネントを安定させつつ十分な柔軟性を保つためのものです。どちらもアーキテクチャ上では、具象実装から抽象クラス/インターフェースへ依存を向けます。そのため、役割としてはSDP+SAP=コンポーネントレベルのDIPと考えられます。

ただし、DIPとSDP+SAPには違いもあります。クラスの設計には中間がなく、抽象クラスか、そうでないかのどちらかです。SDPとSAPはコンポーネントレベルに適用されるため、部分的に抽象的で、部分的に安定したコンポーネントも認める必要があります。

次に、SAPとSDPが目指すものから考えます

SDPは安定したものへ依存を向けることを求め、SAPは高レベルの方針を抽象的なコンポーネントに置くことを求めます。これによって、アーキテクチャ設計で方針と詳細を分けやすくなり、プラグイン方式の開発もしやすくなります。SDPとSAPの目的は一致しています。

ここでもまた、プラグイン方式の開発という言葉が出てきました。前回はどこで出てきたでしょうか。そう、依存性逆転の原則DIPです。このことも、SAP+SDPがコンポーネントレベルのDIPに似ていることを示しています。

主系列

コンポーネントの不安定度Iと抽象度Aを組み合わせて図にすると、a=-i+1という直線を主系列と呼びます。

image-20240706171725797

図全体は三つの領域に分けられます:

  • 苦痛の領域:(0,0)に近い領域です。ここにあるコンポーネントは非常に安定していますが、同時に非常に具象的なので、変更しにくくなります。データベースのテーブルが例です。ユーティリティライブラリもこの領域のコンポーネントです。Iが1で、多くのコンポーネントに依存するため不安定であっても、変更すると多くのコードに問題が出るので変更できません。
  • 無用の領域:(1,1)に近い領域です。ここのコンポーネントは通常きわめて抽象的ですが、他のコンポーネントから依存されておらず、利用できないことが多くあります。この領域のソースコードやクラスの設計上の問題は、削除し忘れた古いコードなど、歴史的な理由で生じることが一般的です。
  • 主系列の領域:主系列全体に沿った領域です。コンポーネントにとって最適なのは、線の両端である(0, 1)と(1, 0)です。優れたアーキテクトは、自分が設計するコンポーネントの大部分を、できるだけこの二点へ近づけるべきです。

コンポーネントを総合的に評価するにはどうすればよいでしょうか。答えは主系列からの距離を計算し、設計が主系列にどれほど適合しているかを数値化することです。この値をDと呼び、0なら主系列上、1なら主系列から最も遠い位置にあります。 $$ D=|A+I-1| $$ Dが0からどれだけ離れているかを、コンポーネントのリファクタリングや再設計の指針にできます。

Dにはほかにも用途があります。たとえば、設計内の全コンポーネントのDの平均と分散を計算し、統計的にシステム設計を分析できます:

  • 良いシステム設計では、Dの平均と分散はともに0に近いはずです。
  • 分散をコンポーネントの「合格基準」として利用し、設計の中で通常の範囲から外れたコンポーネントを見つけられます。
  • Dの分散を時系列で追跡すれば、ソフトウェアアーキテクチャの安定性と抽象性が時間とともにどう変化するかを観察できます。

(第四回・完)

comments powered by Disqus