呼ばれた探偵
金曜の午後、会議室で一人、CIの失敗ログを眺めていた。
夜間バッチのテスト結果。昨夜は18件中6件が赤い。月曜は4件、水曜は7件、今日は6件。テストコード自体を触った覚えはない。コードレビューでも致命的な変更は見当たらない。それなのに、毎晩ランダムに落ちる。
リーダーに就任して2週間。前任の田中さんから引き継いだのは、社内SaaS基盤の共通サービス群と、「便利だからそのまま使ってね」という一言だった。
社外の技術コミュニティのSlackチャンネルで、ある投稿が目に留まったのは3日前のことだ。
レガシーコードの設計問題なら LCI が確実。ちょっと変わった人だけど。
「レガシー・コード・インベスティゲーション」。検索すると簡素なウェブサイトが出てきた。連絡先にメールを送ると、翌日「現場を拝見したい。お伺いします」と返信があった。
会議室のドアが開いた。
コートを着た男が入ってきた。鞄から虫眼鏡型のUSB顕微鏡を取り出し、会議室のプロジェクタの接続口を覗き込み始めた。
「ほう、VGAポートがまだ生きている。レガシーインターフェースというのは、こういう場所にも棲んでいるのだね」
変わった人だとは聞いていた。聞いていた通りだった。
「ロックさん。お時間をいただいてありがとうございます。早速ですが、本題に入らせてください」
「せっかちなワトソン君だ」
私は聞こえなかったことにして、モニターに映したCIのログを指し示した。
ロックさんが少し物足りなそうな表情をした。
「……反応しないのか。最近の依頼人は堪え性がないか、あるいは情報を持ちすぎている。どちらだね」
「後者です。Slackで評判は拝見しました。呼び方の件は……もういいです、ワトソン君で」
「話が早いのは美徳だ。では見せてもらおう」
共犯者の名簿
私はノートPCで共通サービス基盤のリポジトリを開いた。
「このシステムでは、全サービスが ServiceLocator というモジュール経由で依存を取得しています」
ロックさんがモニターに目を向けた。私が ServiceLocator.pm を開く。
| |
「register でサービスを登録して、resolve で取り出す。全マイクロサービスがこのモジュールを使っています。前任者が構築した共通基盤で、便利だからとそのまま広まりました」
ロックさんが画面を見たまま、静かに言った。
「犯人はここにいる。……いや、正確に言おう。犯人はこの帳簿に載っている全員だ」
「全員、ですか」
「このレジストリは便利な仲介人だ。誰でも預けられ、誰でも引き出せる。だが仲介人が便利すぎると、誰が何を預けたか分からなくなる。帳簿が共犯を隠蔽しているのだよ」
私は注文処理モジュールを開いた。
| |
ロックさんが一行ずつ指でなぞった。
「このクラスは、何に依存しているか分かるかね?」
「validator、pricer、notifierですね。コードを読めば分かります」
「コードを読めば。だが、コンストラクタは何も教えてくれない。OrderProcessor->new を見て、この3つが必要だと気づけるか?」
間を置いた。コンストラクタの定義を見返す。has 宣言はない。引数なしで new が呼べてしまう。
「……気づけません。new は引数なしで通ります」
「それがこの仲介人の罪だ。依存を隠す。事前条件を隠す。そして、実行してみるまで何が足りないか分からない」
ロックさんが会議室のホワイトボードに向かった。マーカーを取り、テストコードを書き始めた。
「CIが30%落ちる理由を見せよう。ワトソン君、この2つのテストを順に実行したと考えてくれたまえ」
| |
「テストAが notifier を登録し、reset を呼ばずに終わる。テストBは pricer だけ再登録するが、notifier はテストAのものが残っている。テストAの送信履歴がテストBに紛れ込む。送信件数のアサーションが期待1件、実際2件で失敗する」
私はCIのログを見直した。失敗しているテストの大半が、このパターンだった。テスト間で reset を呼ぶ規約はあるが、全員が守っているわけではない。
「テスト間で reset が必要……でも、reset を忘れたら?」
「前の事件の証拠品が次の事件に紛れ込む。証拠品保管庫が汚染されるのだよ。これがCIが30%落ちる理由だ。テストの実行順序が変われば、汚染のパターンも変わる。だからランダムに見える。だが原因は決定的だ——グローバルな状態の共有だ」
さらにもう一つ、ロックさんがホワイトボードに書き加えた。
| |
「new は何の不満もなく成功する。依存が3つ足りないのに。壊れるのは process を呼んだ瞬間だ。つまり本番のリクエストが来るまで気づかない」
依存の糸を可視化する
ロックさんがマーカーのキャップを閉じた。
「解法は単純だ。隠された依存を、光の下に引きずり出す」
ロックさんが私のエディタに手を伸ばし——私が操作するのを待たずに——OrderProcessor.pm を書き換え始めた。
| |
「required にするだけですか。それだけで、テストの不安定さが消えるんですか」
「ワトソン君、required は手段だ。本質はそこではない。依存をコンストラクタに宣言することで、2つの変化が起きる」
ロックさんが指を立てた。
「第一に、このクラスが何に依存しているかが、コードを読み込まなくても分かるようになる。コンストラクタの引数リストが、依頼人名簿だ」
もう一本、指を立てた。
「第二に、各テストが自分専用の依存を持つ。グローバルな共有状態がなくなる。テストAの証拠品がテストBに紛れ込むことは、構造的に起こりえなくなる」
ロックさんがテストコードを書き換えた。
| |
各 subtest が自分の new で依存を組み立てている。ServiceLocator は呼ばれていない。reset も不要になった。
「要するに、依存の受け渡し経路を、グローバルな帳簿からコンストラクタの引数に変えるということですか」
「正確だ。よくまとめたね、ワトソン君」
テストCに相当する確認もした。
| |
new が失敗する。process を呼ぶ前に、構築の時点で依存不足が判明する。
「Before では new が素通りして process で爆発していました。After では new の時点で止まる。検知のタイミングが実行時から構築時に前倒しされた」
「そうだ。Service Locator は、壊れるべきところで壊れないシステムだった。そして壊れるべきところで壊れないシステムを、人は安全だと錯覚する」
ここで一つ、引っかかることがあった。
「依存が5つ、10個と増えたら、コンストラクタが肥大化しませんか」
ロックさんが首を傾げた。
「そのときは別の事件だ。クラスの責務が多すぎるという。コンストラクタの肥大化は、責務過多の正直な告白だよ。Service Locator は、その告白を握りつぶしていたのだ」
「つまり、resolve なら何個追加してもインターフェースは変わらないから、責務の膨張に気づけない。コンストラクタなら、引数が増えるたびに『多すぎる』と目に見える形で教えてくれる、ということですか」
「その通り。仲介人が告白を握りつぶすか、コンストラクタが正直に告白するか。違いはそれだけだ」
「もう一つ質問があります。DIコンテナ——Bread::Board のようなものを使えば、配線の手間をもっとスマートに管理できるのでは?」
「コンテナ自体は無罪だ。ただし、使い方を誤れば有罪になる」
ロックさんがホワイトボードに図を描いた。
graph LR
subgraph "❌ Service Locator化"
A1[クラスA] -->|"container->get()"| C1[DIコンテナ]
B1[クラスB] -->|"container->get()"| C1
D1[クラスC] -->|"container->get()"| C1
end
subgraph "✅ Composition Root"
CR[エントリポイント] -->|"container->get()"| C2[DIコンテナ]
CR -->|完成品を渡す| A2[クラスA]
CR -->|完成品を渡す| B2[クラスB]
CR -->|完成品を渡す| D2[クラスC]
end
「コンテナの get() をアプリケーションコードの各所で呼べば、それは Service Locator の別名だ。コンテナを使うなら Composition Root ——アプリケーションの入口、1箇所だけ。そこで全ての配線を済ませ、あとは完成品を渡す」
| |
「この形なら、DIコンテナを使っても使わなくても同じ構造になる。依存は new に明示され、グローバル状態はない。テストでは各テストが自分の依存を組み立てる。それだけだ」
静かなグリーン
After のテストを実行した。
| |
全件パス。赤いログが一つもない。
「CIで100回実行しても同じ結果になる、ということですか」
「そうだ。各テストが自分の依存を保持している限り、実行順序は関係ない。決定的なテストだ」
新しい依存を追加した場合の影響を考えた。コンストラクタに引数を追加すれば、既存の呼び出し元で required のエラーが出る。変更の影響が、構築時に分かる。
「Service Locator だと、新しい依存を resolve() で追加しても呼び出し元は何も変わらないんですよね。便利に見えますが——」
「便利に見えて、変更の影響を隠している。壊れるべきところが壊れない。それは安全ではない。沈黙する警報器だよ」
「全サービスを一度にリファクタリングするのは現実的ではないのですが」
「一つずつだ。最も不安定なテストを持つクラスから始めろ。CIのログが優先順位を教えてくれる」
一人の会議室
ロックさんが帰り支度を始めた。コートを羽織り、鞄にUSB顕微鏡を戻す。会議室を出る前に、プロジェクタのVGAポートに名残惜しそうな視線を送っていた。
「報酬の件ですが——」
「金は要らない。CIの安定率を90%にしたら教えてくれ。それが報酬だ」
「……変わったことを言う人ですね」
「変わっているのは私ではない。便利だという理由で依存を隠すシステムの方が、よほど変わっている」
ロックさんが会議室を出て行った。ドアが閉まり、空調の音だけが残った。
私はCIのログを閉じた。エディタを開いた。
失敗率の高いテストファイルのリストは、さっき作ったばかりだ。一番上のファイルを開く。ServiceLocator->resolve が3箇所。まずここからだ。
has を3行書き、required => 1 を付ける。process メソッドの中の resolve を $self-> に置き換える。テストファイルを開き、ServiceLocator->register を消して、new の引数に差し替える。
小さな変更だ。ただし、変更の意味は小さくない。
依存は、見えているうちは管理できる。見えなくなった瞬間に、管理しているつもりの幻想が始まる。ちょうど、あのレジストリのように。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
| Service Locator(グローバルレジストリ) | コンストラクタインジェクション | 依存関係がコンストラクタで明示される |
暗黙的依存(resolve で動的取得) | has + required => 1 | 依存不足が構築時に検知される |
テスト間の状態汚染(共有 %services) | 各テストが独自の依存を保持 | テスト実行順序に依存しない決定的テスト |
| 沈黙する警報器(依存追加が非破壊的) | コンストラクタ引数の増加 | 変更の影響が構築時に明示される |
推理のステップ
- Service Locator の特定: グローバルな
my %servicesを持つモジュールが、全サービスの依存解決に使われていないか確認する - 暗黙的依存の洗い出し:
resolve()を呼んでいるクラスのメソッドを調べ、本当に必要な依存のリストを作る - コンストラクタへの移行: 各依存を
has属性として宣言し、required => 1を付ける。isaによる型制約も追加する resolveの置き換え: メソッド内のServiceLocator->resolve('xxx')を$self->xxxに置き換える- テストの書き換え:
ServiceLocator->registerとresetを削除し、各テストでnewに依存を直接渡す - Composition Root の設定: アプリケーションのエントリポイント1箇所で、全依存を組み立てて渡す
ロックより
便利な仲介人は、最初は歓迎される。誰でも預けられ、誰でも引き出せる。チーム全員がそれを「共通基盤」と呼び、設計の一部だと信じている。
だがその利便性の裏側で、依存関係は帳簿の奥に沈んでいく。テストは夜ごとに不安定になり、新しい機能を追加するたびに、どこかで見覚えのないエラーが顔を出す。
便利な仲介人を追放せよ。コンストラクタに真実を語らせるのだ。コードが正直であること——それが最も確実な安定性である。
