Skip to content

Latest commit

 

History

History
633 lines (345 loc) · 54 KB

chap-software-design.md

File metadata and controls

633 lines (345 loc) · 54 KB

ソフトウェア設計

ソフトウェア開発において、設計はコアとなるものです。本章では、そういったプログラミングの本質的な作業である設計について記します。

ウォーターフォールのように上流工程・下流工程のように捉える考え方があります。その分類では設計とは上流工程であり、下流工程には設計の余地がないと思われることもありますが、実際にはプログラミングにおいては、要件定義から詳細設計、コーディングまで、ほとんどの工程に設計行為を含みます。

たとえば誰もがやっている分かりやすい設計行為はネーミングです。

ユーザーからヒアリングをして、ビジネスロジックを考えるときにも、概念や機能などに名前を付ける必要があります。

詳細設計をするときにはモジュールの名前を付ける必要があります。

コーディングをするときも、ファイル名、クラス、関数、変数、あらゆるものには名前を付ける必要があります。

名前の大切さ

あらゆる名前は、ソースコードの読みやすさや治安1に直結します。ある関数の中身はその関数の名前に左右されます。

名前の付け方は、中で行うことの性質、何を行っているか What について、英単語でネーミングすることが推奨されます。

関数やメソッドの名前は、動詞か、動詞+名詞が使われます。それ以外では名詞が使われます。

英語がすべての基本

なぜ英語にしなければいけないのか?既存のライブラリ、フレームワーク、OSのAPIその他はほぼ全てが英語です。中国で生まれたOSSも、ドイツで生まれたOSSも、フランスで生まれたOSSも英語です。

コンピュータ関連の情報は主に英語で記述されています。残念ながら日本初の技術はもう殆どなく、大半はアメリカや中国から生まれています。また日本で生まれて世界で通用する技術は例外なく英語が重要視されています。

もちろん、日本には日本語の技術記事もありますし、海外の情報が翻訳されたものもあります。同様に韓国語で書かれた記事や中国語で書かれた記事もあります。

しかし、どの国に住んでいても英語を読まずに済ませられる機会はまずありません。

開発陣に日本人しかいないなら日本語でもいいかもしれません。ローマ字や、マルチバイト対応言語ならいっそ日本語を変数名や関数名にするという考え方もあるでしょう。一切新しい人材を採用しない現場ならローマ字でもカタカナでも漢字でも任意の文字列を付ければいいと思います。

ローマ字を使ったりした場合に問題になる一番の典型例は、採用できる人材が狭まることです。日本人以外を取ることができなくなるか、できたとしても現場に混乱をもたらすでしょう。日本人だとしても、英語のドキュメントに慣れた人材はローマ字で書かれた変数名を嫌がります。

ただし、英語化されている日本語はもちろんそのままでかまいません。TsunamiKaraokeKaroushiなんかは有名どころですね。他にも日本固有だと言い切れるもの、たとえば相撲の言葉なんかは日本語を使うべきです。

名実を一致させる

たとえば readHoge という名前の関数が読む read という名前に反して、書き込み write を行っていたとすれば、その関数は混乱しか生み出しません。この関数は名前を適切なものに変えるべきですが、そもそもなぜそういった状況になってしまったのか?を洗い出す必要性があるかもしれません。

よくあるパターンとしては、本来は読む動作を期待して名前付けを行っていたが、開発中の何かしらの事情で中身がどんどん変わっていき、最終的に名前と中身が乖離していたというものです。

名前と中身、つまり名実が一致してないと何が起こるのでしょうか?

  • 認知不協和を発生させ、ソースコードを管理するコストが増大する
  • 名前に事実上の意味がなくなり、誰も名前に関心を払わなくなる

そう、プロジェクトの治安の悪化です。場合によっては学習的無気力感により、メンバーのメンタルが壊され、人員が不足するかもしれません。

最悪の未来を回避するためにも名前と実態を一致させる必要があります。

  • 名前を実態に合わせる
  • 実態を名前に合わせる

名前を優先するか実態を優先するか?

ただし、前述のような read と名前のついた関数なのに、実は write を含んでいるような事例では、実態を変えるべきでしょう。これはいわゆるリファクタリングというもので、リファクタリングとは処理の内容・機能を変更せずに、コードの綺麗さを整えることです。

readHoge の中の処理がどれくらい絡み合っているか次第ですが、もし単純に分割可能であれば分割すべきです。readHogewriteFuga の2種類に分割すれば、名前と実態が一致するならそれが楽でしょう。

実際にはリファクタリングをするためにはもう少し面倒なことが待っているかもしれませんが、ここではいったんリファクタリングについて詳細には触れません。

[column] 問題のある匂い

問題の再発を心配しなくて済むなら、名前を変えればおしまいですが、開発体制に何かしらの問題があり、再発しうるのであれば、名前を変えるというのはただの対症療法にしかならないでしょう。

名前はある種の匂いです。

現実世界では、生物は危険な匂いのするものをまず食べません。腐った匂いのものを食べると、著しく健康を害するためです。匂いはセンサーだといえます。

ソースコードでも同様です。実態と一致しない名前を持った関数や変数、そういったものを見かけたら、それは危険サインです。

プログラマが経験を積むと、こういった匂いに敏感になります。

匂いがあるコードは、必ずしも改善する必要があるとはかぎりませんが、リファクタリングをするときには、匂いは重要なヒントになります。

[/column]

ソースコードのWhat/Why/How

ソフトウェア開発の界隈で有名なt_wadaさんのツイートに

コードには How テストコードには What コミットログには Why コードコメントには Why not

を書こうという話をした

引用元: https://twitter.com/t_wada/status/904916106153828352

というものがあります。

これでいうと名前は基本的にはWhatです。

名前に紛れ込むHow

ある関数の中身であるコードはHowですが、その関数の呼び出し元からするとHowはどうでもいい情報です。むしろHowに踏み込むと結合度が上がります。Howを共有するのが密結合なのです。

関数の名前は前述のとおりWhatであり、極力Howに踏み込んだネーミングはしてはいけませんが、その関数の仕様が詳細に踏み込んでいる場合、名前にその詳細が浸食してくることになります。

たとえば readPriceFromDB は値段情報をDB(データベース)から読み込むという名前です。本来 FromDB というのはHowです。値段情報がDBだろうが他の何に保存されていようが、本来は構わないはずですが、データベースから値段を読み出す関数が readPrice だったとすれば、それは名実が一致していない状態です。

ただしHowを共有するのは密結合への道です。ビジネスロジックはHowには一切触れるべきではありません。ビジネスロジックというのは、あなたが作ろうとしているアプリのコアです。たとえばホテルの予約アプリであれば、予約に関するロジックがビジネスロジックです。予約に関するロジックが、Howに踏み込む必要性はありませんよね?

ビジネスロジックはHowに触れると密結合と暗い未来しか待ち構えていないため、ビジネスロジックからアクセスするものをすべて readPrice のような、Howから切り離したものに限定すればいいでしょう。

このとき、readPriceはただのインターフェースで実態を持っていないかもしれません。readPriceFromDBの引数が、readPriceで提供されるべき引数と同じであれば、readPriceというインターフェースで、その実装がreadPriceFromDBであるというふうにできます。

もし追加の引数が必要であれば、何かしらのサービスやファクトリ、依存性注入などの考え方を駆使することになるでしょう。これは後ほどまた詳しく説明します。

  • DBから値段を読み込む関数にreadPriceという名前を付けると嘘になる
  • ビジネスロジックにとってはreadPriceFromDBというHowに踏み込んだものを呼び出すべきではない
  • readPriceFromDBとビジネスロジックをつなぎこむためのreadPriceが必要になる

この流れを忘れないようにしましょう。ソースコード、プロジェクトの治安を守るための第一歩です。

技術的負債

技術的負債とは、技術開発において「汚いけど早く開発できる方法」を採用することを、将来に対して技術面で負債を抱えているという比喩で説明するものです。 本章で度々でてきた 治安 という言葉も技術的負債と同じような意味合いです。

ここではより明確に、「機能修正や機能追加が高コストになってしまった状況」を技術的負債が貯まっていると表現します。

読みやすくメンテナンスしやすいコードを書こうとすると時間がかかるということがあります。環境構築をサボって取り急ぎ作業をすることもあるでしょう。そういった行為によって、初期の開発速度は上がるものの、後のメンテナンスコストが増大するという現象は色々な現場で見受けられるものです。

踏み倒すという選択肢もあるため、必ずしも負債を返済する必要はありません。

循環的複雑度

循環的複雑度は、関数やメソッドのもつ複雑さを数値化したものです。計算方法はif/forなどの制御構造による分岐数を数えるだけです。言い換えると循環的複雑度はその関数やメソッドを実行した時の通り道の組み合わせの数です。

循環的複雑度が大きくなればなるほど、バグの生じる可能性は増えるとされています。ユニットテストのテストケース数も増大します。 もしユニットテストでカバレッジ100%を目指そうとすれば、循環的複雑度の数だけテストケースが増えます。

一般的に循環的複雑度が10以下なら、十分シンプルという範疇であり、20以下なら複雑だけどまだ手に負える、50以下だとかなり苦しく、50を超えると保守は無理といわれています。

1つの関数やメソッドは、循環的複雑度をできる限り10以下、最悪でも20以下に抑えるべきです。

モジュール安定度

ここでいう安定度は必ずしも、バグが無い、変更が加わらないという意味ではありません。そのモジュールへ依存するモジュールが多ければ多いほどそのモジュールは安定しているといいます。

因果関係の逆転現象とでもいうべきでしょう。依存されていると、自身を変更したときに影響範囲は依存元に及びます。その数が多ければ多いモジュールは、変更したときの影響範囲が大きいモジュールであり、そういったモジュールは必然的にバグが無く、なるべく変更が無い、特に仕様の互換性が必要になるという意味を持ちます。

依存するなら、そういった安定度の高いモジュールに依存すべきです。

モジュール安定度自体は技術的負債とは関係しませんが、安定度の低いモジュールへの依存など、誤った依存関係は技術的負債につながります。

結合度(疎結合と密結合)

結合度は、言い換えればどれくらい知識を共有しているか?です。たとえば、インターフェースを利用して関数・メソッドをアクセスするだけなら、おそらく疎結合です。ところがその関数やメソッドの中の知識、Howに踏み込めば踏み込むほど結合度は上がります。無制限に内部情報にアクセスしていい状態ならば、結合度は最も高いといえます。

関数やメソッドの改修を考えたとき、アクセス元が利用している知識量に応じて、影響範囲が広がります。インターフェースを正しく使っていれば、内部実装が変わったとしても、インターフェースの仕様が変わらない限りは、アクセス元には影響はないはずです。たとえばリファクタリングをしても、あくまで影響範囲は自分のみです。ところが、内部知識を利用してアクセスされていれば、内部変更時に、呼び出し元に影響がある可能性が生じます。

これがメンテナンスしづらさを増大させる主原因ともいえるものです。ソースコードのメンテナンスのし辛さはソースコード変更時の影響範囲の広さや、影響範囲の把握が難しいことに、大きく由来します。そのため、密結合である範囲が広いことが問題なのです。

少なくとも全てのコードを疎結合にすることは現実的はありません。次に紹介する凝集性の問題もあるからです。

凝集度(高凝集性と低凝集性)

凝集性は、同じことをするものがどれくらい同じ場所にあるか?です。

同じことをするものがバラバラに存在している状態は低凝集状態であり望ましくありません。ある改修をするときに、複数の似たようなコードを書き換えないといけない、つまりこれも影響範囲、改修範囲が広くなるからです。凝集性を高める簡単な方法は同じクラス、モジュール、ディレクトリそういった単位で集めて、結合度を高めることです。

理想状態は凝集性を下げずに密結合を極力なくすことです。

責務・責任

ソフトウェア開発の世界にはよく責任や責務2という言葉が登場します。クラスやモジュールなどあらゆる単位で、責務という考え方は重要です。

たとえば関数でいえば、ある入力に対してどのような出力を返すべきなのか?というのを責任を持って請け負うことを指します。5W1H的にはWhoという存在について考えることができます。関数には必ず、関数の呼び出し元がいます。関数は呼び出し元に対して責務を負っているといえます。

あるモジュールにどれくらいの責務を持たせるか?責務が多くなると、高凝集・疎結合が実現しづらくなります。

読みやすさ・読みづらさ

ここまで取り上げてきた事例は技術的負債の中でも、数値的指標を求めやすいものでしたが、読みやすさ・読みづらさは数値化しづらいものです。しかしある程度考え方の原則のようなものはあります。

人間が考えるときにつかう脳内モデルに反するようなもの、直感的ではないものは読みづらい傾向にあります。人によっては、美しさで例える人がいますが、驚き最小の原則という言い方もあります。

ユーザインタフェースやプログラミング言語の設計および人間工学において、インタフェースの2つの要素が互いに矛盾あるいは不明瞭だったときに、その動作としては人間のユーザやプログラマが最も自然に思える(驚きが少ない)ものを選択すべきだとする考え方である。3

前述の名前付けについて、名実が一致していないのは論外ですが、紛らわしいネーミングをする、その言語ではあまり見かけないコードの書き方をする、同じプロジェクトの中で同じことを別の書き方で書くなどは、読みづらさに直結するものでしょう。

認知的不協和という指摘もできるでしょう。何かしらの矛盾を抱えた存在は心にストレスを与えます。

メンタルモデルという考え方もあります。脳内で理解している事柄と現実のズレは、読みづらさになります。

ただし、読みやすさ・読みづらさは人によって異なる部分もあり、自転車置き場議論 4になりがちなことに注意しましょう。

自転車置き場議論にならない1つの方法論は、ソースコードフォーマッタやLinterと呼ばれるものを使うことです。現代のチーム開発では必ず導入すべきものです。

人類の知恵

技術的負債に対抗するため、人類は50年以上設計論を改善し続けてきました。

プログラミングパラダイム

最初期のコンピュータプログラミングは、物理配線の付け替えで行われていました。原型となった階差機関や解析機関とよばれるものは歯車やギアを蒸気で駆動するものでした。

そういったものがLSIとなり、CPUがこの世に登場し、CPUが解釈できる数字列がマシン語と呼ばれるものになりました。マシン語はさらにアセンブリ言語という、少しだけ人間に優しいものから生成できるようになったり、アセンブリ言語やマシン語に変換できる高級言語というものが生み出されました。

構造化プログラミングはGOTO文を(極力)不要のものとし、オブジェクト指向はポインタ操作を不要のものとし、関数型言語は代入を不要のものとしてきました。

秩序には、意味のある制限・制約が必要です。無限条件でなんでもありの状態では、ソースコードの治安は守れません。

型とは、ある変数にどういった種類のデータが入るか?ということを意味します5。この型が自由自在に動作時に決まる言語が動的型付け言語で、コンパイル時にあらかじめ型を定義や判定できる言語が静的型付け言語です。

const hoge: string = 1
// hogeはstring型なので数値である1で初期化しようとしてエラーが出ます

function fuga(s: string) {
  return `fuga: ${s}`
}

fuga(1)
// fuga関数の第一引数はstring型なので数値である1を入れようとしてエラーが出ます

const piyo: number = fuga('ふが')
// fuga関数の戻り値はstring型だと推論されているため数値型のpiyoに代入できない

TypeScriptのような静的型付け言語はこのように、型を指定することで、間違った型のデータが入らないようにできます。

SOLID原則

SOLID原則は、先ほどの技術的負債で、密結合や低凝集状態を防ぐための知恵を集めたものです。SOLID原則の個々の指摘は古くからあったようですが、SOLID原則という名前が与えられて有名になったのは2000年代初め頃とされています。

  1. Single Responsibility Principle:単一責任の原則
  2. Open/closed principle:オープン/クロースドの原則
  3. Liskov substitution principle:リスコフの置換原則
  4. Interface segregation principle:インターフェース分離の原則
  5. Dependency inversion principle:依存性逆転の原則

Single Responsibility Principle:単一責任の原則

単一責任の原則を堅い言葉でいうと、あるモジュールやクラスや関数などを改修する理由はたった1つになるようにしましょうというものです。

実際には、単一責任という言葉では少し足りなくてある1つのアクターに対しての単一責任と言うべきですし、説明についてもあるモジュールやクラスや関数などを、ある1つのアクターに対する責任だけで済むように適切に分割しましょうと言い換える方がわかりやすいでしょう。

アクターとは大抵の場合は、人間を指します。たとえばウェブサービスにアクセスするユーザーや、そのサービスを運用するためのオペレータや、オペレータよりも上位の権限を持つ管理者などです。アクターには自動処理、概念、機械などといった人間以外も含まれます。

大雑把にいうと、あるクラスがユーザーとオペレータと管理者向けの機能を全部持っていると複雑になりすぎるからやっちゃダメだから、単一のアクターに対して機能提供できるように分割しましょうということです。

たとえば、あるクラスが、ユーザー・オペレータ・管理者向けの機能を持っていたとして、ユーザー向けにコードを改修したいとします。

コードを改修するときには影響範囲の調査は必須ですし、改修したらテストも必須です。今回の事例だと、そのクラスはオペレータや管理者向けの機能も持っているため、オペレータや管理者にも影響が無いかどうか?という調査やテストが必須ということです。

これを怠ると、改修したら別のバグを生み出した!というよくある現象に遭遇することになり、貴方は上司に怒られることでしょう。もしかしたらサービスを止めて胃が痛くなるような障害対策をするハメになるかもしれません。

これを避けるためにはあるクラスから、ユーザー向けの機能、オペレータ向けの機能、管理者向けの機能を分離するとよいでしょう。

分離がどうしてもうまくいかない場合は、さまざまなテクニックを駆使して、改修の影響が単一アクターにのみ限定されるようにすることは可能です。SOLID原則のDにあたるDependency Inversion Principle(依存関係逆転の原則)といったものです。

ユーザー・オペレータ・管理者にそれぞれ共通のコードももちろん存在するはずです。この場合は当然改修をする時には、ユーザー・オペレータ・管理者に影響が無いか調査・テストが必須ですが仕方ありません。

できることは、共通のコードと、ユーザー向けののみのコードを分離して、胃の痛くなるような共通のコードを最小限にすることです。

  • 単一責任の原則は、クラスや関数などといったものは単一のアクターにのみ機能を提供するように、適切に分割すべき
  • 複数のアクターにまたがるコードは最小限にして、単一のアクターにのみ提供するコードと分離すべき

[column] コンウェイの法則

メルヴィン・コンウェイという人が提唱した法則があります。

システムを設計する組織は、その構造をそっくりまねた構造の設計を生み出してしまう

単一責任の原則が重要になってくる理由は、このコンウェイの法則にもあります。

縦割り型の組織は、縦割り型のシステムを生み出します。当然でしょう。縦割りで横の連携がとれないのに、組織の壁を越えたシステムを生み出すのは困難です。

たとえば縦割り型の組織で、動画配信サイトの組織と、ストリーミング配信の組織が分かれていたらどうでしょうか?

このとき、動画配信に必要な機能とストリーミング配信に必要な機能が、同じクラスに実装されていると、とても大変なことになります。そのクラスの改修には両方の組織で判子承認リレーが度々生じることでしょう。

それならそのクラスは、動画配信に必要な機能だけ、ストリーミング配信に必要な機能だけをもつべきです。少なくともあるクラスを改修する時に2つの組織の事情を調整する必要はありません。

これが単一責任の原則が重要になってくる理由です。

コードベースが異なっていれば、単一責任の原則の範囲では、開発速度や開発難度・バグの生じやすさに問題は生じません。

それらのその組織がリリースするアプリは、動画配信アプリとストリーミング配信アプリが異なるものになっていて、AppleやGoogleのアプリ承認がそれぞれ発生するという問題はあるかもしれませんがそれは別の話です。

組織構造は往々にして、このようにアプリやサービスの境界面となるのです。

※ただの例であり、実在の組織やサービスとは関係ありません。

SRPは大体コンウェイの法則から、成り立っていると思ってかまいません。

[/column]

Open/closed principle:オープン/クローズドの原則

オープンとクローズドという文字列を見ても、何を指してるかはわかりにくいと思います。 何に対してオープンで、何に対してクローズドなのかの情報が抜け落ちているためです。

オープンにするのは、拡張に対してです。 クローズドにするのは、修正に対してです。

さて、なぜ修正に対してクローズドであるべきなのかについて、そもそも修正とは何かを考えるところから始めましょう。

修正とは、修正元のコードがあるからこその修正です。そして多くの場合、修正というのはすでに動いているコードのことです。

すでにあるコードを修正するというのはそれなりにコストのかかる行為です。

  • そのコードを修正することでどこに影響があるか調査しなければいけない
  • 修正の影響で、動作に支障が出ないか確認しなければいけない

SRPのときと同様です。

ただし、何かしらバグがあるのでそれを修正するのは仕方ありません。修正に対してクローズドにする、というのは別に修正してはいけないという意味ではありません。

ある関数・メソッド・クラスには、責任があります。それはドキュメントに書かれていたり、インターフェースとして定義されていたりする、自身は○○という機能を提供すると表明されているもののことです。

つまりクローズドの原則とは、機能追加の為に責任を修正すべきではない。ということです。

では、機能追加はどうすればいいのでしょうか?

まず、拡張は修正とは異なります。何かしらのフックポイントを用意した上で、そのフックに新機能を追加するような仕組みです。

インターフェースを用いたやり方、イベント駆動やオブザーバーパターンを用いたやり方や、プラグイン機構などがあります。

あるいは委譲を用いて、あるクラスのさらに包み込むようなクラスを作成して、そのクラスは元のクラスよりも多くの機能を提供するようにするというようなやり方でしょうか。

このようにする理由は簡単です。

・ すでにあるものに悪影響を与えにくい ・ 新機能はガシガシ追加していきたいが拡張なら遠慮する必要はない

もちろん無計画な新機能の拡張は混乱を招く可能性があるので、SOLID原則の他の原則や、あるいはさらに別の人類の知恵を駆使して、あるべき設計をしなければいけません。

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

リスコフの置換原則は、難しくいうなら

S が T の派生型であれば、プログラム内で T 型のオブジェクトが使われている箇所は全て S 型のオブジェクトで置換可能でなければいけない

という原則です。このときTはオブジェクトやクラスなどとは限らず、現代であればインターフェースなどで表現されることが多いでしょう。Sは継承やダックタイピングやインターフェースの実装など、あらゆる手段で、Tを拡張した何かです。

  • Tは仕様書
  • Sはそれの実装

と読み替えてもいいでしょう。Tの仕様を満たすSなら、それは他のものと置換してもいいということです。たとえば、S1・S2・S3・S4など、いくつかの実装があったとして、Tという文脈であれば、S1でもS2でもS3でもS4でも、全て問題なく動作しなければいけません。

もう少し踏み込むと、インターフェースTの実装であるS1、S2などは、呼び出し方はTの呼び出し方そのものでなければいけないこということです。 S1固有の呼び出し方、S2固有の呼び出し方などは許されません。もしどうしてもそれがしたければ、Tでそれを何かしら定義する必要があります。

また、戻り値や副作用などはTで決まってる範囲は必ずS1、S2、S3などどれもが実装していなければいけません。本来文字列が帰ってくるはずなのに、S1の場合だけnumberを返したり、nullを返したりしてはいけません。

これを知識6という観点で説明すると、リスコフの置換原則はTを経由してS1、S2、S3にアクセスするなら、S1、S2、S3の知識を使ってはいけないということです。S1は実際にどういう実装をしているか?に併せてエラー処理を追加することは許されません。必ず、T自体がそれを規定していなければいけないのです。

逆の言い方をすると、Tさえ見ればS1、S2、S3を見なくても、Tを使ったプログラミングができるということです。S1やS2やS3固有の情報なんて見たくもありません。

これはまさに疎結合にするための仕組みです。 次に紹介するISP(インターフェース分離の原則)でも、知識を遮断するという考え方がとても重要になります。

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

ここでいうインターフェースとは、抽象クラス、基底クラス、ダックタイピング的なものを全て含めたインターフェースとしての働きをもつもの全てです。

この原則は、インターフェースの利用者にとって不要なメソッドに依存させてはいけないというものです。

こういうとややこしく聞こえますが、簡単にいうとインターフェースの利用者の立場に立って、インターフェース自体、利用用途に応じた最小限の規則だけを決めておくか、それぞれのメソッドが独立して使えるようにすべきということです。

あと、逆の視点では、インターフェースを、実装する側の都合も考えてましょう。不要なメソッドがあると、それは大体技術的負債になります。

簡単にいうと、インターフェースを複雑にしてはいけないので、分離できるものは分離しましょうという原則です。

単一責任の原則(SRP)やオープン・クローズドの原則(OCP)、リスコフの置換原則(LSP)と大体関連が強い原則です。

まず、SRPに抵触するようなインターフェースはインターフェース分離の原則にも抵触しているでしょう。

複数の異なるアクター、たとえばECサイトの利用者と出展者と、システム管理者がいるとします。

ECサイトに出展される商品についてのインターフェースを作るとします。そのインターフェースは、商品情報を取得するメソッドがあるとします。ところが、ECサイトの利用者と出展者と、システム管理者では、取得したい情報に違いが生じます。

※もちろんこれはそもそもSRP違反です

  • createUserContext
  • createOwnerContext
  • createAdminContext
  • getItem

という4つのメソッドがあり、商品情報を取得するためには、最初の3つのメソッドを呼び出し、それぞれのアクターに応じたコンテキスト(という名のオブジェクト)を作り、getItemの引数にコンテキストを渡す必要があるとします。

これがインターフェース分離の原則に反する設計です。getItemにアクセスするために、create*Context を呼び出さなければいけない。というのは複雑さを増やしています。

アクターとしての利用者の為のコードを書くときに、createUserContextgetItemの2つのメソッドを知っておき、getItem より以前に createUserContext を呼び出しておくという知識が必要になりますし、たとえば、ユーザー情報が何かしらの状況によって更新されてしまった場合には createUserContext でコンテキストを作り直す必要があるのでしょうか?ないのでしょうか?

エラー処理なども含めるとこういった制約は、複雑さをもたらすものです。

また、このインターフェース自体に、createOwnerContextcreateAdminContext という別のアクターが存在するという知識が含まれています。それはもちろんそれぞれのメソッドの引数に何が指定されるか?という知識も知ることになります。

そういった知識を知らないものとしてスルーするのが大人の態度かもしれませんが、その知識はそもそも不要です。

その getItem があるインターフェースがどうあるべきか次第ですが、

  • コンテキストの作成を分離する
  • アクターごとにインターフェースを分離する

という二種類の分離方法があるでしょう。

場合によってはアクターごとのインターフェースは、実はさらに別の、コンテキスト作成インターフェースと、商品情報にアクセスするためのインターフェースをアクセスするための、アダプタかもしれません。

わざわざインターフェース分離の原則という、SRPとは異なる原則がある以上当然ですが、同一のアクターだとしても分離したいケースもあります。

ECサイトの利用者という単一のアクターのみが利用したい場合もあるでしょう。その場合でも何かしら事前準備が必要になるケースもあります。たとえば、通販においては届け先の住所によって送料が異なります。

届け先に応じて、アクターを別途用意するのはさすがにやりすぎでしょう。そんなコードをメンテナンスしたい人はそうそういるはずがありません。

  • 届け先など商品情報に影響のある情報をコンテキストとして作成するメソッド
  • 実際に商品を取得するメソッド

他にも、複雑なインターフェースにしようと思えばいくらでも複雑にできるはずです。カートに追加されている商品や、持っているクーポン、あるいは他社と連携しているサービスによる何か、そういった商品情報に影響を与えるものは何かしらあるはずです。

これらを統合したインターフェースは複雑なインターフェースです。

インターフェースは、必ずしも1つの実装と対応しているわけではありません。インターフェースを、実際に実装するケースもあるでしょう。たとえば、データのリポジトリのインターフェースを設計するとします。

このときインターフェースにあまりも多くのメソッドを用意してしまうととても面倒なことになります。

リポジトリのインターフェースというと一般的には

  • find
  • findById
  • findByName
  • save (あるいは store
  • delete

などがあるでしょう。ここに rollback というメソッドがあったら、結構めんどくさいなーという気持ちになりませんか?

リポジトリパターンでは、実際のデータ保存先や保存方法については一切情報を出しません。リスコフの置換原則を思い出しましょう。リポジトリというインターフェースの先にある実装の知識を知っていてはいけないのです。

rollbackができることを前提として設計されたインターフェースならば、rollback機能を実装しないわけにはいきません。システムの動作そのものに多大な影響を与えてしまうからです。

逆に、rollbackが失敗してもいいとうインターフェース設計だった場合には、利用者の方が、rollbackを使えるか使えないか調べた上でrollback機能を使うことになります。リスコフの置換原則に反せずにこのインターフェースを設計するなら、おそらくは、canRollback のような rollback が可能か問い合わせるメソッドが必要になるでしょう。

利用者は、rollbackが利用可能か問い合わせて、それに併せてロジックを分岐させる必要がでてくるでしょう。

ここまでして rollback 機能を使いたいと思いますか?

repository には rollback のようなメソッドは存在すべきではありません。

RollbacklableRepository とでも名付けた別のインターフェースを用意するか、TransactionalRepository として、最初からトランザクションを考慮したインターフェースを設計することでしょう。

身も蓋もないこというと、ある1つのインターフェースに何もかもぶち込むのはアンチパターンなので、1つのインターフェースには最小限のものだけを定義しておき、別の役割をもったインターフェースを作りましょう。

Dependency inversion principle:依存性逆転の原則

依存性逆転の原則とは、設計上望ましい依存の方向性と、素直に実装しようとしたときの方向性は矛盾しちゃうので、そこをテクニックでカバーして逆転させると、じつはスッキリと望ましい設計どおりに実装できますよ!という人類の知恵です。原則というよりはテクニックです。

依存関係というものがあります。プログラミングの世界では、たとえば、AというモジュールがBというモジュールを読み込む場合、AはBに依存しているといいます。

このとき、Bを変更するとAにも影響が生じます。このときの当然のことながらBだけの問題ではなく、Aについても影響範囲の調査や改修が必要になります。

特に破壊的変更を行うとその影響は甚大です。OCP(オープン・クローズドの原則)を思い出してください。

依存しているというのは、依存対象の知識を持っているということでもあります。AはBについての知識を持っているからこそ、Bのモジュールを読み込んで、その機能を利用できるのです。

依存対象の知識ということでLSP(リスコフの置換原則)を思い出してみましょう。Aが依存しているというBは、果たしてどういうものなのでしょうか?

インターフェースか抽象クラスかあるいは他の何かはさておいて、LSPではBというモジュールが実際にどういう実装をしているかという知識を持つべきではありません。

このように依存対象について必要以上の知識を持たなければ、それは疎結合だといえます。逆に密結合とは互いに知識を多く共有しているということです。

また、SOLID原則では取り扱っていませんが、依存関係は有向非循環グラフ、まぁ簡単にいうと双方向依存禁止、循環禁止、というのが設計の重要な原則です。

望ましい依存の仕方は

  • 双方向依存しない
  • 依存関係が循環しない
  • 必要以上の情報を受け取らず疎結合にする

そしてもう1つあります。

抽象と詳細は、クリーンアーキテクチャ本で登場する呼び方です。抽象・具象という言い方もあります。抽象は共通点を束ねた物ではありますがそれだけではありません。詳細からエッセンスだけ抜き出したものともいえます。

リポジトリパターンのようなものは、抽象(インターフェース)に対して詳細(実装)は複数あり得るので、共通点を束ねる、抽象化したものですが、それだけではなく、詳細(実装)の中から外に漏れても仕方のない情報をそげ落として、エッセンスを抜き出したものも抽象(インターフェース)です。

SOLID原則を突き詰めると、抽象化されたものに依存すべきであり、詳細に依存してはいけないというのが導き出されます。

LSPでは、たとえばリポジトリパターンがあったとして、そのリポジトリのインターフェースの知識を持っている(つまり依存する)ことは許されますが、実装についての知識をもつこと(依存すること)は許されません。

  • 詳細に対して依存していはいけない

ただ、詳細に対して依存してはいけないといっても、モジュール読み込みしたいのはインターフェースそのものではないはずです。実際の実装を読み込まなければ動作しないソフトウェアになってしまいます。

たとえばあるコードがリポジトリパターンを使ってデータを読み書きしたいとします。ところがリポジトリパターンを実装したコードの知識にアクセスせずに読み込むにはどうすればいいのでしょうか?

答えの1つは、DI(依存性の注入・Dependency Injection)です。言語やライブラリによってなさまざまなDIの方法があるでしょう。これはインターフェースのみに依存しつつも、同時に詳細実装を読み込んでアクセスできるようにしてくれます。

他にはファクトリーメソッドパターンを使うなどもあるでしょう。

どういう方法を使うにしても、詳細の実態となるオブジェクトを、依存元に渡す方法が何かしらあれば、それで実現が可能です。

デザインパターン

  • GoF
  • PoEAA

デザインパターンで有名なものに、GoF と PoEAA などがあります。他にも大小さまざまなデザインパターンがあります。

デザインパターンは、建築の世界のパターンランゲージというものを真似てソフトウェア設計のパターン化・カタログ化を行うものです。

大体どんなプログラマも、プログラミングをしていると似たような問題にぶち当たります。

  • どういう問題事例があるのか?
  • どういう解決方法を考えられるのか?
  • それにどう名前を付けるのか?

デザインパターンはこれらを取り扱うものであり、目的はコミュニケーションと問題意識の共有にあります。

たとえば、あるオブジェクトの状態変更を常に監視するパターンにObserverパターンというものがあります。このパターンでは監視される対象のオブジェクトが何かしらの方法で、監視者に対して教えてくれる方法を提供します。古今東西、さまざまなところでObserverパターン、別の名前としてはイベントドリブンの仕組みが使われています。GUIには大体欠かせないものですし、リアクティブプログラミングと呼ばれるものもこのパターンです。

デザインパターンを共有しているプログラマ同士であれば「Hogeは、オブザーバーパターンで設計するよ」というような短い会話で、お互いの意図が伝わるはずです。

ただし、デザインパターンはなんでもできる魔法の道具ではありません。デザインパターンでもっとも有名なGoFも、当時は有用だったけど、今の時代ではすでに無効になっているもの、あるいは有害になってるものすらあります。

[column] パターンを暗記しないようにしよう

じつのところデザインパターンのコードは暗記するような性質のものではありません。

問題意識の共有やコミュニケーションが主体です。デザインパターンの本やブログで、○○パターンならこういう実装をしろ、みたいなものを見かけることがありますが、あれは単なるその当時のその言語によるサンプルに過ぎません。数年経てば意味が変わってくることもざらです。

[/column]

境界線を引く

ここまでで説明したように、技術的負債を防ぐためには、高凝集性と疎結合を保つために、適切な境界線で知識の分離をしなければいけません。

モジュール

言語によってモジュールというものが指すものは異なります。JVMやネイティブのバイナリを生成するタイプのコンパイラを使っているなら、おそらくコンパイルされた一塊がモジュールと呼ばれることでしょう。このような事例ではモジュールの切り分けはコンパイルの手間やデプロイの単位を基準にすることになるでしょう。こういったシステムでは、リポジトリ=モジュールという事例もあるでしょう。

Rubyならば、Module という特定の名前空間を指すでしょう。どういう名前空間で切り分けるかになります。このような事例では、そのモジュールがどういう意味合い、責務をもつか?で考えるべきでしょう。

JavaScript/TypeScript ならば、ファイル1つがモジュールだと考えられます。ECMAScriptのモジュール仕様にせよ、Node.jsなどのcommonjsの仕様にせよ、ファイル単位でimport/exportするからです。この場合、次で説明する、ディレクトリとファイルという切り分けと同一になります。

ディレクトリとファイル

言語仕様によってはディレクトリ構造やファイルの置き方に制約があるものもあります。それがない場合は、大体はその言語やフレームワークの文化に沿ったものになるでしょう。たとえば最近のJavaScriptではsrcというディレクトリ以下に、さらにディレクトリを切るスタイルが標準的です。next.jsやnuxt.jsを使っている場合、src/pages以下にウェブページコンポーネントを置いて、自動ルーティングをするでしょう。Reactでよく見かけるスタイルはsrc/components以下にReactコンポーネントを置きます。

Reactコンポーネントの場合、色々なスタイルがあり得ます。最近よく使われるAtomic Designでは

  • atoms/
  • molecules/
  • organisms/

というコンポーネントを分割した単位ごとに作ります。

atomsはそれ以上分解のしようがないGUIコンポーネント、たとえばボタンなどを配置します。

moleculesは、たとえばボタンと文字入力エリアとアイコンを組み合わせて、検索ボックスのようなコンポーネントを作り上げます。

organismsは、moleculesや場合によってatomsを組み合わせて、より意味のある単位を作り上げます。たとえば、検索ボックスとリスト表示を組み合わせた、インクリメンタルサーチやソート機能を持った検索結果表示コンポーネントです。

クリーンアーキテクチャやDDDのようなレイヤードアーキテクチャを採用してるなら、ディレクトリは、1つのレイヤーを指すことでしょう。

コンポーネント

コンポーネントは言語やライブラリによって異なります。Reactでいえばコンポーネントとはある1つのGUI要素です。Editorというコンポーネントを定義していれば、<Editor value={value} />のようなJSXの記述でウェブページが構成されます。

OOPではクラス・オブジェクトを1つのコンポーネントと見なすこともあるでしょう。

関数型言語ではもう少し違う単位になっているかもしれません(言語によってはコンポーネントという考え方はないかもしれません)

どの環境でもコンポーネントはモジュールよりは小さい単位です。

サービス

サービスは多くの場合、単一か複数のモジュールから成り立つもので、たとえば1台のマシンや、それらの集合体、Dockerコンテナやk8sのpodなどから成り立つでしょう。サービスはHTTP/HTTPSや他の何かしらのプロトコルによるポートを持ち、ポート経由でやりとりをします。

サービスがデータストアだけであることもあれば、データストアとプロキシとWebサーバーのセットになったものであることもあります。

多くの場合、サービスはデプロイや組織の都合を反映するものになります。

たとえば最近流行のマイクロサービスは、小さいサービスを多く設置することで大きなサービス(オンラインフリーマーケットサービスなど)を提供します。

マイクロサービスの問題点は、難易度が上がることです。細かくサービスが分かれているため、それらの間で通信しなければいけませんし、モニタリングも必要です。

サービスは大体、リポジトリと同じ単位になるでしょう。

ただし、1つのリポジトリに、依存関係のあるサービスがひとまとまりとなったモノレポ mono repo というケースもあります。

サービスは、大体組織の都合に左右されるものです。コンウェイの法則がもっとも現れることになるでしょう。

設計論は大体プログラミング言語に左右されてしまう話

手続き型

OOP

関数型

ダックタイピング

動的型付け言語にも型はある

型の魔術

型は安心感

抽象と具象

具象(詳細)の悪夢、昨日の常識は明日の非常識になる

詳細決定は、出来うる限り後に回せ

抽象化は、必ずしも共通項の括りだしとは限らない

[column] 式年遷宮

スケールアウトとスケールアップ

Footnotes

  1. 皆さんも見に覚えがありませんか?すでに前例があるからと言って、読みづらいけどさくっと書けるコードを追加していく行為。読みやすいコードを書く気がなくなるプロジェクト。あたかもスラム街の如く治安の悪いプロダクトを…。ここでいう治安は、ソースコードに秩序がなくなり、力がすべてを支配する世界となってしまった状態を指します。

  2. 責務は「義務を果たすべき責任」という意味です。

  3. 日本語のこの文章は https://ja.wikipedia.org/wiki/驚き最小の原則 から引用しました。

  4. 自転車置き場の屋根を何色に塗るかを延々議論することを、自転車置き場議論などと呼びます。人によって好みの分かれるような、ある意味どうでもいいことを指します。ソースコードの読みやすさに関しても、好みの問題は多く含まれているため、注意しなければいけません。

  5. ここでは言語として、変数に型はあるけど、その型を強制しないJavaScriptのような言語はいったん無視します。

  6. 知識というのは、たとえばソースコードの名前や、メソッドの実装、マジックナンバーなどあらゆる情報を知識と呼びます。