はじめに
前回は、コードから読み取れる依存関係を「正本」として切り出す考え方を整理しました。
- 解析方法と表示方法の間に、依存関係そのものを置く
- コードから読み取った事実と、その評価を分ける
- 正本そのものと、そこから導出する情報を分ける
今回は、その正本の中身に入ります。
「事実として残す」と決めたとして、では「どの粒度で、どの種類の関係を、どこまで残すのか」。
なお、前回の最後で「実際のコードから依存関係を取り出す処理へ」と書きましたが、今回扱うのは抽出処理の実装ではありません。
抽出処理が最終的に何を出力するのか、つまり「抽出結果の形(契約)」の方を扱います。
抽出処理が何をすべきかは、この形が決まって初めて決まるためです。
例として、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つ、今回見えてきたのが、正本に何を置くかは「事実か評価か」だけでなく、
「その情報について、どこを正とするのか」の問題でもある、ということです。
循環や層を正本に持たせているのは、表示する側がそれぞれ独自に判断しないようにするためでした。
次回は、この正本の外側を扱います。
正本を受け取り、そこから表示のための情報を導出し、画面に見せ、ローカルで動くツールとして成立させる。
その各段階で、正本の事実を壊さないために何を分けているのかを整理します。
最後まで閲覧いただき、ありがとうございました。