0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

#208 コード上の依存関係を潰さず、推測を混ぜずに残す

0
Posted at

はじめに

前回は、コードから読み取れる依存関係を「正本」として切り出す考え方を整理しました。

  • 解析方法と表示方法の間に、依存関係そのものを置く
  • コードから読み取った事実と、その評価を分ける
  • 正本そのものと、そこから導出する情報を分ける

今回は、その正本の中身に入ります。
「事実として残す」と決めたとして、では「どの粒度で、どの種類の関係を、どこまで残すのか」。

なお、前回の最後で「実際のコードから依存関係を取り出す処理へ」と書きましたが、今回扱うのは抽出処理の実装ではありません。
抽出処理が最終的に何を出力するのか、つまり「抽出結果の形(契約)」の方を扱います。
抽出処理が何をすべきかは、この形が決まって初めて決まるためです。

例として、Todo アプリを解析した正本 JSON を使います。
(ID は読みやすさのため一部省略しています)

前回記事:コードの依存関係を「事実」として扱うための設計

依存関係の骨格

ノードとエッジ

正本の中心は、ノードとエッジです。

  • nodes:依存関係に登場するもの
  • edges:nodes 間の依存関係

ノードは、ファイルとメソッドの2つの粒度で持ちます。
たとえば src/usecase/CreateTodo.ts というファイルに、CreateTodo クラスの execute メソッドがあるとします。

src/usecase/CreateTodo.ts        ← ファイルノード
  └─ CreateTodo.execute          ← メソッドノード

このとき、正本には2つのノードが別々に入ります。
ファイルノードは、そのファイルのパスを持ちます。

{
  "id": "file:src/usecase/CreateTodo.ts",
  "kind": "file",
  "path": "src/usecase/CreateTodo.ts"
}

メソッドノードは、自分が属するファイル(parent)と、自分を持つクラス(owner)を持ちます。

{
  "id": "method:src/usecase/CreateTodo.ts#CreateTodo.execute",
  "kind": "method",
  "parent": "file:src/usecase/CreateTodo.ts",
  "name": "execute",
  "owner": "CreateTodo",
  "ownerKind": "class"
}

parent によって、メソッドノードからファイルノードを辿れます。
ファイルとメソッドは同じ nodes に並びますが、kind で区別しています。

ファイル粒度の依存関係からはメソッド粒度の関係を復元できず、
逆にメソッド粒度だけを持つと、import のようにファイル同士にしか存在しない関係を表現できません。
どちらかから他方を導出できないため、両方を正本に置いています。

関係の種類を kind で残す

前回、関係の種類を残す例として次のように書きました。

{ "from": "A", "to": "B", "type": "import" }

実際のスキーマでは、これは kind という名前にし、関係を4種類に分類しています。

kind 粒度 意味
import ファイル ファイルが別のファイルを読み込む
implements メソッド クラスのメソッドが interface を実装する
call メソッド メソッドが別のメソッドを呼ぶ
construct メソッド メソッドがクラスを生成する

kind によって、エッジが持つフィールドも変わります。
たとえば import は、次の情報を持ちます。

{
  "kind": "import",
  "from": "file:src/presentation/middleware/auth.ts",
  "to": "file:src/domain/User.ts",
  "importKind": "type",
  "specifier": "@/domain/User"
}

importKind は、その import が型としてだけ使われているのか(type)、値として使われているのか(value)を表します。
同じ「AがBを import している」でも、型だけの依存は実行時に何も起こさず、値の依存は起こします。
変更したときに影響が及ぶ範囲も変わります。

specifier には、@/domain/User のようなエイリアスを「解決する前の記述」のまま残しています。
to には解決後のファイルが入っているので、specifier は冗長に見えるかもしれません。
しかし、コード上に書かれていたのはあくまで @/domain/User であり、それが src/domain/User.ts に解決されたのは tsconfig の設定によるものです。
「何と書かれていたか」と「何に解決されたか」は、別の事実です。

たとえば、同じ src/domain/User.ts を、2つのファイルが別の書き方で import していたとします。

// src/presentation/UserController.ts
import { User } from '@/domain/User'
// src/usecase/RegisterUser.ts
import { User } from '../domain/User'

どちらも解決先は同じファイルなので、to は同じになります。

specifier: "@/domain/User"    → to: file:src/domain/User.ts
specifier: "../domain/User"   → to: file:src/domain/User.ts

to だけを残すと、この2つは区別できません。
エイリアスと相対パスが混在しているという状況は、コード上には確かに存在するのに、正本からは消えてしまいます。
逆に specifier だけを残すと、2つが同じファイルを指していることを知るには、tsconfig を読んで解決し直す必要があります。

論理依存と実依存を潰さない

interface を介した依存を正本でどう表すか

前回、interface を介した依存について、次の例を出しました。

UserService → UserRepository
                    │
                    └→ UserRepositoryImpl

コード上の依存先(interface)と、実際の解決先(実装)は、どちらかに潰さずに残す、という話でした。
正本では、これを次のように表現しています。

{
  "kind": "call",
  "from": "method:...CreateTodo.execute",
  "to": "method:...ITodoRepository.save",
  "resolution": "via-interface",
  "implementations": [
    "method:...InMemoryTodoRepository.save",
    "method:...TodoRepository.save"
  ]
}
  • to:コード上の呼び出し先。ここでは interface のメンバー
  • resolution:呼び出し先が interface 経由かどうか(static / via-interface)
  • implementations:その interface の実装として解析で見つかったもの

ここで、to を実装に書き換えることはしていません。
CreateTodo.execute のコードに書かれているのは ITodoRepository.save の呼び出しであり、それが「論理的な依存」です。
implementations は、その呼び出しが「実際に行き着きうる先」です。

なぜ片方に寄せないのか

この例では、実装が2つあります。

CreateTodo.execute → ITodoRepository.save
                          ├→ InMemoryTodoRepository.save
                          └→ TodoRepository.save

もし to を実装に寄せると、どちらを to にするかを決めなければなりません。
しかし、どちらが実行時に使われるかは DI の設定や実行環境によって決まり、コードのこの箇所からは決まりません。
片方を選んだ時点で、それは事実ではなく推測になります。

逆に to だけを残すと、「この呼び出しの先で実際に動くのはどこか」を知るには、再びコードを解析する必要があります。

そこで、どちらも残し、それぞれが何であるかをフィールドの名前で区別しています。
「interface 経由で読みたい」のか「実装まで辿りたい」のかは、正本を使う側が選べます。

前回、

後から失われると復元できない情報を、表示の都合で失わない

と書きましたが、論理依存と実依存はその典型です。
どちらの見方をしたいかは、表示する側の目的によって変わるためです。

事実の範囲を狭く言う

もう1つ、小さな例を挙げます。
call と construct のエッジは、sourceOrder という値を持っています。

{
  "kind": "call",
  "from": "method:...CompleteTodo.execute",
  "to": "method:...ITodoRepository.save",
  "sourceOrder": 3
}

これは、そのメソッドの中で呼び出しがソース上の何番目に出てくるかを表します。
たとえば、Todo を完了にする CompleteTodo.execute が次のようなコードだったとします。

class CompleteTodo {
  async execute(id: string) {
    const todo = await this.repository.findById(id) // sourceOrder: 0
    if (!this.policy.canComplete(todo)) {           // sourceOrder: 1
      throw new Error('cannot complete')
    }
    todo.complete()                                 // sourceOrder: 2
    await this.repository.save(todo)                // sourceOrder: 3
  }
}

このメソッドから出る4本の call エッジには、上から順に 0〜3 の sourceOrder が付きます。

ただし、これは「実行される順番」ではありません。
canComplete が false を返せば、complete と save は実行されません。
ループの中で同じメソッドを何度も呼ぶ場合も、sourceOrder は1つです。
条件分岐やループがあれば、ソース上の順番と実行順は一致しないのです。

executionOrder のような名前にすれば、使う側には便利に見えるかもしれません。
しかし静的解析で確定できるのは出現順までなので、名前もそこまでに留めています。
事実として残すときは、その事実が「何を意味していないか」まで含めて決める必要があります。

分からないものを、分からないまま残す

静的に追えない依存

静的解析では、すべての依存関係を確定できるわけではありません。
たとえば次のようなコードです。

container.resolve<CreateTodo>('CreateTodo')
await import(`../usecase/${name}`)
policy[ruleName](this)

どれもコード上では何かに依存していますが、その依存先は文字列やランタイムの値によって決まります。
コードを読むだけでは、どこに依存しているかを確定できません。

捨てずに unresolved に残す

こうしたものを、正本では edges とは別の unresolved に残しています。

{
  "reason": "dynamic-import",
  "from": "method:src/di/registry.ts#bootstrap",
  "expression": "await import(`../usecase/${name}`)",
  "candidates": [
    "method:...CreateTodo.execute",
    "method:...CompleteTodo.execute",
    "method:...DeleteTodo.execute",
    "method:...ListTodos.execute"
  ]
}
  • reason:なぜ追えなかったのか
  • expression:追えなかった箇所のコード
  • candidates:依存先の候補

追えなかったものを捨ててしまうと、正本はそれらが「存在しない」かのように振る舞います。
edges に現れない関係は、依存していないのか、それとも解析できなかったのか、区別できなくなります。

edges に無い
  ├─ 依存していない
  └─ 依存しているが、追えなかった   ← 捨てるとこちらが消える

正本は「コードに存在する関係」を残すものなので、
「関係はあるが、相手を確定できなかった」ということも、コードから読み取れる事実として残します。

推測を確定情報に混ぜない

ここで重要なのが candidates です。

candidates は、コードを解析して確かめた依存先ではありません。
名前の一致のような手がかりから、「おそらくここだろう」と見当をつけた候補です。
たとえば container.resolve('CreateTodo') であれば、'CreateTodo' という文字列と同じ名前のクラスがあることから、CreateTodo.execute が呼ばれるのだろうと見当をつけられます。
そして、実際その見当は当たっていることが多いはずです。

しかし、当たっていそうだからといって、edges に入れることはしません。
正本の中では、確定した依存と推測を、次のように別の場所に置いています。

{
  "edges": [
    // コードから確定できた依存
  ],
  "unresolved": [
    {
      // 依存はあるが、確定できなかったもの
      "candidates": [
        // その依存先の推測
      ]
    }
  ]
}

edges に入れた時点で、その推測は確定した依存関係と区別できなくなります。
後から「この線は推測だったのか、事実だったのか」を判別する手段がなくなります。

確定情報と推測情報は、置き場所の時点で分けておきます。
推測を利用するかどうか、どう見せるかは、正本を使う側が判断します。

空であることにも意味がある

candidates は空配列になることがあります。

{
  "reason": "dynamic-property",
  "expression": "policy[ruleName](this)",
  "candidates": []
}

これは「候補が見つからなかった」という意味であり、エラーではありません。
同様に、implementations が空であれば「interface の実装が見つからなかった」という意味になります。

一方で、循環を表す cycles のノードやエッジは、空を許していません。
空の循環は循環ではないためです。

同じ「空配列」でも、それが事実として意味を持つのか、データとして壊れているのかは、フィールドごとに異なります。
正本の形を決めるときには、こうした「無いことの意味」も合わせて決めておく必要があります。

解釈に近い事実の置き場所

最後に、前回の「事実と評価を分ける」に照らすと、正本に置いてよいのか迷う2つの情報を扱います。
循環と層です。
どちらも、評価に使われやすい情報です。

循環

循環依存は、正本の cycles に持たせています。

{
  "nodes": ["file:src/domain/Todo.ts", "file:src/domain/TodoList.ts"],
  "edges": ["e_0034", "e_0036"],
  "typeOnly": true
}

循環は edges から計算できる情報なので、前回の depth と同じく導出すべきもの、と考えることもできます。
しかし、depth が起点によって値が変わるのに対して、循環しているかどうかは、どこから見ても変わりません。
グラフそのものの性質です。

そこで、循環の検出は抽出側の責務とし、正本に載ったものを正とします。
表示する側は循環を再検出せず、正本にあるものをそのまま扱います。
表示する側ごとに検出方法が異なれば、同じ正本から異なる循環が見えてしまうためです。

cycles の edges は、edges に入ったエッジの ID を参照しています。
そのため、candidates のような推測を含む循環は、この形では表せません。
※ 推測を使う側での扱い方については、次回記事で整理する予定です。

typeOnly は、その循環が型の参照だけで構成されているかを表します。
型だけの循環は、実行時に値のやり取りを伴いません。
ただし、これも「型だけの循環なら問題ない」という評価ではありません。
循環の「性質」という事実です。

なお、typeOnly は import の importKind に由来するため、ファイル粒度の循環にしか現れません。
メソッドの呼び出しに「型だけ」というものは存在しないので、メソッド粒度の循環ではフィールドごと持たせていません。

ここでも、循環しているという事実は残しますが、「循環は悪い」とは書きません。

層

もう1つが、層です。

前回、

プロジェクト固有の層やルールは、その正本を利用する側で扱います。

と書きました。
一方で、実際の正本には層の定義が入っています。

"layers": [
  { "id": "presentation", "name": "Presentation", "match": ["src/presentation/**"] },
  { "id": "usecase",      "name": "UseCase",      "match": ["src/usecase/**"] },
  { "id": "domain",       "name": "Domain",       "match": ["src/domain/**"] },
  { "id": "infra",        "name": "Infrastructure", "match": ["src/infra/**"] }
]

そして、各ノードは抽出の時点でどの層に属するかが当てはめられています。

{ "id": "file:src/domain/User.ts", "kind": "file", "layer": "domain" }

これは前回の話と矛盾しているように見えます。
ここで線を引いているのは、次の2つの間です。

  • どこが Domain か:分類
  • Domain → Infrastructure は悪い:評価

src/domain/** を Domain と呼ぶ、という分類は、プロジェクトが決めたものではありますが、その決定自体は事実として記録できます。
そして、あるファイルがその分類に当てはまるかどうかは、パスから機械的に決まります。

一方で、「Domain から Infrastructure への依存は禁止」というのは、その分類を使った「評価」です。
これは正本に入れていません。
どの方向の依存を許すかといった情報も持たせていません。

層への当てはめは抽出時に済ませ、正本を正とします。
表示する側はパスから層を推測し直すことも、match を再照合することもしません。
循環と同じく、表示する側ごとに分類が変わってしまうことを避けるためです。

また、どの match にも当てはまらないノードには layer がありません。
これも「層を決められなかった」という事実として、そのまま残します。

おわりに

今回は、依存関係の正本に何を、どの形で残すかを整理しました。

  • ファイルとメソッドの2つの粒度を、どちらも残す
  • 関係の種類を kind で残し、種類ごとの情報も残す
  • 論理依存(to)と実依存(implementations)を、どちらかに潰さない
  • 静的に追えないものを捨てず、unresolved に残す
  • 推測(candidates)を、確定した依存関係(edges)に混ぜない
  • 循環と層は、グラフの性質や分類という事実として残し、評価は持たせない

これらに共通しているのは、

表示の都合で失うと、コードを再解析しないと取り戻せない情報か

という基準です。

そしてもう1つ、今回見えてきたのが、正本に何を置くかは「事実か評価か」だけでなく、
「その情報について、どこを正とするのか」の問題でもある、ということです。
循環や層を正本に持たせているのは、表示する側がそれぞれ独自に判断しないようにするためでした。

次回は、この正本の外側を扱います。
正本を受け取り、そこから表示のための情報を導出し、画面に見せ、ローカルで動くツールとして成立させる。
その各段階で、正本の事実を壊さないために何を分けているのかを整理します。

最後まで閲覧いただき、ありがとうございました。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?