LoginSignup
139
130

More than 3 years have passed since last update.

知識の露出・共有を適切にしてクリーンな設計をしよう

Last updated at Posted at 2019-09-05

皆さんは技術的負債や○○コードとか、○○な設計に苦しめられていませんか?

※○○には食事時などに見るべきでは無い、あまり綺麗ではないお言葉が入ります

技術的負債を生み出す背景には組織・人・金、など、技術では対処しきれない問題が多く含まれますが、技術的要因ももちろん無視できません。

今回は「知識の露出・共有」について説明します。ここで覚えて欲しいことは、知識とその露出共有についてです。

技術書典7では東京ラビットハウスというサークルで「TypeScriptとクリーンアーキテクチャで、最高の開発者体験をしよう!」という本を出す予定です。ご興味有ればサークルチェックお願いします。

分かりにくい、ここ間違ってないか?など何かしらツッコミや感想がありましたら是非お気軽コメントなりいただければ幸いです。

対象読者

技術的負債に苦しみたくない人。密結合が大体良くないこと位は知っていても、なぜ密結合が良くないのかを知りたい人

※概念説明のためにTypeScriptでサンプルコードを記述していますが、説明用なので動作するとは限りません。

技術的負債

技術的負債については、過去に記事を書きましたが、ここでは簡単に「保守や機能追加が高コストになったシステム」を技術的負債が貯まっていると定義しておきます。

技術的負債が貯まる理由には、大体パターンがあります。密結合と疎結合、凝集度、コンウェイの法則などがキーワードとなります。

大体そういったマズいシステムになるのにはパターンがあります。それらを回避するために、適切な単位で分割し、知識の露出やそれへの依存を最小限にするために、SOLID原則と呼ばれる人類の知恵があります。

SOLID原則の詳細は別途書いた記事をそれぞれご覧ください。

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

疎結合・密結合と知識

技術的負債の貯まったシステムには、不適切に高まった結合度、つまり密結合がつきものです。大抵は結合度を下げることによって、疎結合にすることでメンテナンス性が向上します。

※この記事では、結合度の高いものを密結合、低いものを疎結合とします。

英語版Wikipediaの loose coupling (疎結合)の説明を見ると

In computing and systems design a loosely coupled system is one in which each of its components has, or makes use of, little or no knowledge of the definitions of other separate components.

と書かれています。Google翻訳で日本語にすると

コンピューティングおよびシステム設計において、疎結合システムとは、各コンポーネントが他の個別のコンポーネントの定義に関する知識をほとんどまたはまったく使用しないシステムです。

となります。「疎結合とは他のコンポーネントの定義に関する知識をほとんどまたはまったく使用しない」と書かれています。

ここでいう知識とは何でしょうか?

クラス・インターフェース・関数・変数など様々なモノの名前や実装、マジックナンバーなどのソースコードに書かれてある全てが知識です。他にもRDBや実ファイルなどのデータ構造も知識ですし、ファイル名やディレクトリ構造なども知識に該当します。

密結合は、その逆で知識を使いまくってる状況ということです。基本的には知識を多く共有する関係が密結合です。この密結合の範囲が広くなるとメンテナンスや機能追加が高コストになりますが、適切な広さであればメリットも多く存在します。

知識を共有する関係なので、知識を得るコストは存在しないかほぼありません。疎結合は縛りプレイのようなものなので、小さなモジュールを疎結合で作るようなことは困難です。

ゆえに、疎結合と密結合をうまく使い分ける必要があります。

  • 疎結合は、知識を使う方向(=依存関係)が片方向であり、使用する相手の知識が最小限であるべき
  • 密結合は、範囲が限定された、知識を共有するグループであるべき

これら2つを使い分けると、凝集度と結合度のバランスがとれたシステムになります。

逆にこうすると技術的負債になるというのも少し説明すると

  • 密結合の範囲が広いこと
  • 正しい疎結合をするための仕組みが乏しいこと

というふうになります。

さて、概念ばかりを説明しても何のことかわからないと思うので、解説用のサンプルコードを交えて、より具体的に解説していきます。

保守や機能追加が高コストになる原因を実コードと知識という考え方で説明する

技術的負債が貯まって保守や機能追加が高コストになっているときに何が起こっているでしょうか?

ある機能について調べるときに、調べる対象範囲が広く、把握しづらい状態になっているはずです。

ある関数のたった10行程度を書き換えるとして、そのコード変更が、他にも影響を及ぼしそれら全部について把握している必要がある、つまり確認コストが高い状態に身に覚えがありませんか?

その場合、その関数を参照する回数が多い、共有しているデータを読み書きしているなどといった原因があることが多いでしょう。

以下は、部屋を予約するという関数の例です。

function reserve(id, start, end, isCheckOnly) {
  const room = global.room[id]
  for (let hour = start; hour < end; hour++) {
    if (room[hour]) {
      if (isCheckOnly) {
        return true
      } else {
        throw new Error('room is reserved')
      }
    }
  }
  if (isCheckOnly) {
    return false
  } else {
    for (let hour = start; hour < end; hour++) {
      global.room[id][hour] = true
    }
  }
}

reserve関数を書き換えるとして考えなければいけない事はいくつもあります。

まずglobal.roomというグローバル変数の存在です。roomがどういうデータ構造になっているか知っている必要があります。

このコードを読むと、おそらくIDと時間ごとの二次元配列になっていて、予約された状態がtrueになるというものだろうなという予測はできるでしょう。

しかし、このroomの使い方を変えたくなったとしたらどうでしょうか?たとえば予約しているユーザーの情報も付与したい場合はどうでしょうか?

export function reserve(id, start, end, isCheckOnly, user) {
  const room = global.room[id]
  for (let hour = start; hour < end; hour++) {
    if (room[hour]) {
      if (isCheckOnly) {
        return room[hour]
      } else {
        throw new Error('room is reserved')
      }
    }
  }
  if (isCheckOnly) {
    return null
  } else {
    for (let hour = start; hour < end; hour++) {
      global.room[id][hour] = user
    }
  }
}

たとえばこのように改修するとします。

  • reserveの呼び出し元全てにuserを追加する
  • isCheckOnlyを使っている場合
    • 戻り値の処理を変更するか
    • falseもnullも同じように判定されるだろうから無視するという決断をする
  • global.room 変数を別の場所からアクセスしているかどうかを確認する

こういった調査・改修や、それに対するテストが必要となります。これは決して数行を書き換えるだけの簡単なお仕事ではありません。

データを直接読み書きする

まず1つめの大きな問題点として、roomのデータを直接読み書きしていることが挙げられます。

reserve関数を作る為には、global.roomにアクセスする必要がありますが、そもそもそれはglobal.room知識が露出されているからです。

  • roomという名前
  • 二次元配列であること
  • 最初の状態ではbooleanが入り、改修後はuserが入る
  • 自由に読み書きしても良い

ちなみに今回の事例はグローバル変数だからダメというわけではありません。roomを取得・更新するメソッドがあったとしても状況はほぼ改善しません。

  const room = getRoom(id)
...
...
      room[hour] = user
      setRoom(id, room)

データ構造に直接アクセスしていること自体はかわらないので、改修の大変さはほとんど変わりません。

これを改善しようとするなら、roomをただの変数ではなく、例えばRoomクラスのオブジェクトにするといいでしょう。Roomクラスに、reserveメソッドやcheckメソッドを用意すればいいだけです。

グローバル変数やシングルトンなどは、わかりやすく使ってはいけないものとして認識されやすいですが、意外に使ってしまうのがRDBへのアクセスです。

reserve関数に直接、RDBアクセスのコードを書いたとしたらどうでしょうか?実のところ、RDBへのアクセスは変数を直接読み書きするのと、粒度としてはかわりません。グローバル変数から、RDBに、知識の所在が移動したにすぎないからです。

改修する場合、RDBをアクセスする全てのコードをチェックしてroomテーブルを触っているコードを探し出す必要がでてくるでしょう。

O/Rマッパーがある場合どうでしょうか?RDB直接よりはマシですが、それでもやはりデータ構造に直接アクセスするという点は変わり有りません。

責務が複数存在している

ツッコミを入れたくてうずうずしていた方もいるでしょう。isCheckOnlyってなんやねん!reserve関数に予約する以外の機能を詰め込むなよ!というツッコミはとてもごもっともです。

ただ、データ構造に直接アクセスするタイプのコードを書いてしまうような開発チームでは、わりとこういうような地雷をたやすく踏み抜いてしまいがちです。

reservecheckReserved関数を分離したとしても、データ構造のアクセスの仕方、というおなじようなコードが散逸し重複することでしょう。

今回の事例だけでいえば、おそらくreservecheckReservedを呼び出すという形で分離は容易なはずですが、もっと複雑にからみあった、現実的な技術的負債の場合は、分離が困難な事例も多いでしょう。

先ほど説明したように、データに直接アクセスするのではなく、クラスを用意してそのメソッドを叩くように変更すれば、分離はたやすくなるはずです。

もう1つ、複数の責務があるパターンを紹介します。

// 色んな所から呼ばれるログ出力処理
function log(kind: number, code: number, message: string) {
  switch (kind) {
    case FUNCTION_A_KIND:
      // 機能Aのログ出力処理
      if (code === Env.CODE_1 || code === Env.CODE_2) {
        // 機能A、コード1か2のログ出力処理
      } else {
        // 機能A、コード1か2以外のログ出力処理
      }
      // こんなコードが沢山・・・
      break;
    case FUNCTION_B_KIND:
      // 機能Bのログ出力処理
      break;
      // こんな分岐が沢山・・・
    default:
      // 共通のログ出力処理
      break;
  }
}

こういったコードに遭遇したことはないでしょうか?

これはFORTEさんが、

僕の経験だと、金額や日付のフォーマット処理とか、ログ出力処理とか、DBやファイル周りのバグを直したりリファクタしようとすると、そのシステムの全仕様を把握してないと怖くて直せないという事がよくありました。

ということで書いてくれたサンプルをTypeScriptに書き直したものです。

さて、複数の責務があるというのを言い換えると、関数 reservelog が追うべき責任が複数あり、どのような引数に対してどの処理がなされるべきかという、複雑な知識が露出しているといえます。

関数に書かれたコードが知識として露出される

reserveは、id start endといった引数を受けとる関数であるという知識が存在します。

logでは、引数のkindで機能IDを指定し、codeでさらに細かいログ種別を指定し、messageという文字列を出力するという仕様が、はやり知識として存在します。

どこかからかアクセスされる関数なので、関数名や引数、戻り値の仕様が明らかになることは当然です。

ただし、他にもいくつも知識が記述されています。

たとえば reserveであれば、global.roomをどう処理すれば、予約できるのか?という実装の実例という知識も含まれているのです。

何を当たり前のことをと思われるかもしれませんが、少なくともこのコードをコピペすれば予約処理や予約確認処理ができてしまいます。技術的負債の貯まるチームでは、こういった知識というのはたやすく悪用されがちです。

露出する知識(外部から使ってもいい知識)と、そうではない知識を明確に分離することが大切です。

分離する方法の1つはインターフェースの活用です。

export interface Reserver {
  reserve(id: number, start: Date, end: Date, user: User): void
  checkReserved(id: number, start: Date, end: Date): User | null
}

export createReserver from './reserver-factory'
// export なんとか from ソースで、他のソースで定義されたものを
// 左から右に受け流すように、exportする構文です
// reserver-factory には、export default () => Reserver が定義されているとします。

たとえばこのように、Reserver インターフェースがあり、reserve関数とcheckReserved関数の仕様だけが定義されていて、Reserverの実装には直接アクセスせずに、createReserverというファクトリ経由で、Reserverを取得できるようにします。

これはSOLID原則のうち、Dependency Inverse Principle/DIP(依存性逆転の原則)と呼ばれるものです。詳しくはよくわかるSOLID原則5: D(依存性逆転の原則)の記事をご覧ください。

なんとなくこれだけで、知識の露出としてはすっきりしたのではないでしょうか?このコードでは単に露出していい知識と、そうではないものを分離しただけです。やろうと思えば、reserver-factoryのソースを読めば、中身について知ることもできるはずですが、チームの規約にでも「ここの実装を読んだ上で知識を悪用しちゃだめよ!」ということにしておけば良いのです。

これはSOLID原則のうち、Liskov's Substitution Princple/LSP(リスコフの置換原則)と呼ばれるものです。インターフェースの先にあるはずの実体の知識を利用してはいけません。詳しくはよくわかるSOLID原則3: L(リスコフの置換原則)の記事をご覧ください。

また、言語が異なれば、もっと明確にインターフェース部分と実装を分離できるものもあります。C言語はヘッダファイルがインターフェースであり、実装とヘッダファイルは完全に役目が分離されています。

知識を利用する方向性(=依存関係の方向性)

log関数の場合は、どういう機能があるのか?それぞれのコードには何があるのか?という知識を使っています。

これは、本来log関数という汎用的そう(=抽象度が高い)関数が、それぞれの機能やその中で使われるコードという、詳細(=抽象度が低い)知識を使っています。これを抽象度が高い側から低い側に依存しているといいます。(さらにlog関数は双方向依存性を持っているかも知れません)

抽象度が高い側から、抽象度が低い側への依存はあまり良いことではありません。本来、抽象度が高い関数というのは、詳細から開放され汎化されたものですが、詳細へ依存すると、抽象度という価値が損なわれてしまいます。

Reserverインターフェースとその実装の関係と同様に、DIP(依存性逆転の原則)により、抽象度の高い関数から詳細を抜き出して分離すべきです。

これをもしリファクタリングしようとするなら、まずはレガシーコード改善ガイドでいうところの、テストハーネスを書いたうえで、log関数を分割していくことになるでしょうか。

少なくとも「機能」というより具象的な存在が自分自身でログについての知識を持っているべきです。たとえば

export interface KindLogger {
  log(code: number, message: string): void
}

export interface Logger {
  // KindLoggerを登録する
  addKindLogger(kind: number, kindLogger: KindLogger): void

  // 登録されたKindLoggerに、codeとmessageを受け流すだけの関数
  log(kind: number, code: number, message: string): void
}

KindLoggerインターフェースを定義し、「機能」側が、このインターフェースを満たすものを実装し、グローバルロガーに、ロガーを登録します。

こうすれば、Loggerは、個々の機能についての知識を持つ必要がなくなります。

ここからさらに踏み込むと、機能の持つ知識のうち、露出してもいい知識と、そうじゃない知識の分離を行うことになるでしょう。ログを吐き出すというのが前者に該当するかどうかは設計次第で変わってくるかもしれません。

綺麗な分離を心がけると、おそらく機能の外からある機能に関するログを吐き出すというケース自体がなくなるはずです。ログを吐き出すという工程も、本来ならその機能自体が受け持つべき責務でしょう。

まとめ

コードに書かれている情報や、ファイル名・ディレクトリ構造・データ形式などは全て知識です。

これら知識が不必要に露出されると技術的負債が蓄積されます。必要最小限の露出にとどめることで疎結合にしましょう。このとき露出する知識として、インターフェース(言語によっては違う機能かもれない)などを活用しましょう。

むしろ知識の提供が足りないと、露出されてない知識を調べられることにより、紳士協定によって保たれるはずの知識の露出とそうじゃない知識の境界線が曖昧になり、割れ窓理論などを経て治安が一気に悪化します。

適切なインターフェース定義やJavaDoc JSDocやJsonSchemeのようなスキーマ定義を提供することが大切です。

もちろん知識を共有すべき場合は、最小限の範囲で知識を共有しましょう。疎結合だけではシステムは作れません。密結合も必要です。

露出していい知識とそうじゃない知識を分離しましょう。

分かりにくい、ここ間違ってないか?など何かしらツッコミや感想がありましたら是非お気軽コメントなりいただければ幸いです。

Special Thanks

  • サンプルコードを提供していただいたFORTEさん
  • キムラコウジさん
  • あとレビューしてくれた皆様

ありがとうございます

クリーンアーキテクチャ本出します

技術書典7では東京ラビットハウスというサークルで「TypeScriptとクリーンアーキテクチャで、最高の開発者体験をしよう!」という本を出す予定です。ご興味有ればサークルチェックお願いします。

139
130
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
139
130