カフェの隣席
土曜の午後、行きつけのカフェでノートPCを開いていた。
画面には GitHub Actions のワークフロー結果。赤い。全部赤い。先週CIを導入してから、テストが一つも通らない。ローカルでは全部グリーンなのに。
チームが5人に増えたタイミングで「そろそろCIを入れよう」と提案したのは私だ。創業期から一人でサーバーサイドを書いてきた。テストも書いている。コードには自信がある。だから余計に苛立つ。CIの設定が悪いのではないかと3日間調べたが、設定に問題はなかった。
「……失礼。その赤いログだが、問題はCIではないね」
隣の席から声がした。
振り向くと、コートを着た男が分厚い洋書を膝に置いたまま、私の画面を見ていた。表紙には「Programming Perl」の文字が見えた——それも第3版の、かなり年季の入ったやつだ。
「は? すみません、どなたですか」
男はこちらの困惑を無視して続けた。
「そのテストコード、new を何箇所で呼んでいる? いや、テストコードではない——テスト対象のクラスの中で」
初対面の人にコードを覗かれている。普通なら不快だ。だが指摘が具体的すぎて、反射的にエディタのタブを切り替えてしまった。
ReportGenerator.pm。メソッドの中に DataSource::CSV->new、Formatter::HTML->new、Mailer::SMTP->new が並んでいる。
「3箇所ですけど……それが何か?」
男がコートの内ポケットから名刺を取り出した。
「レガシー・コード・インベスティゲーション」——探偵事務所。ロック、と名前が書いてある。
財布を開いた。半年前にフリーランスの先輩からもらった名刺が、レシートの間に挟まっていた。同じデザイン。同じ肩書き。あのとき先輩は私のコードをちらっと見て「おまえ、全部自分で new してるな。まあ Service Locator よりはマシかもしれないけど」と意味ありげに笑っていた。Service Locator が何なのかも訊かなかった。動いているコードを変える理由はないと思っていたから。
「……あ」
「おや、どこかで私の名刺を?」
「知り合いからもらったんです。忘れてました。……探偵ごっこですか?」
男——ロックさんが真顔で答えた。
「ごっこではないよ。ロックだ。そして君はいまから私のワトソン君だ」
先輩が「面白い人がいる」と言っていた意味がわかった。面白いというか、一方的だ。
new の指紋採取
ロックさんが自分の本を閉じ、断りもなく私の向かいの席に移動した。
「まず現場検証だ。問題のクラスを見せてもらおう」
私は ReportGenerator.pm を画面に出した。
| |
ロックさんが画面を一瞥して言った。
「密室だね」
「密室?」
「このクラスは密室だよ、ワトソン君。鍵をかけた部屋の中で、自分だけで全てを完結しようとしている。CSVファイルがある、SMTPサーバーに繋がる——それは密室の中にいる者だけが知っている前提だ」
「でも、ReportGenerator->new は引数なしで作れますよ。シンプルじゃないですか」
「シンプルに見えるのは、複雑さを隠しているからだ」
ロックさんが画面を指差した。
「このコンストラクタは何も要求しない。何に依存しているか、嘘をついている。外から見れば、このクラスは何も必要としていないように見える。だが実際は、CSVファイルとHTMLフォーマッタとSMTPサーバーがなければ動かない」
「嘘って……動いてますよ。ローカルでは全テスト通ってます」
「ローカルには何がある?」
「開発用のCSVファイルと、テスト用のSMTPサーバーです」
「CIには?」
止まった。CIにはどちらもない。だから落ちる。
「……ああ」
「テスト対象のクラスの中で new を呼んでいる場所を、私は指紋と呼んでいる。犯人が現場に残す証拠だ。指紋が多いほど、そのクラスは密結合度が高い」
ロックさんが私のPCを操作して——やめてほしいのだが——テストコードを開いた。
| |
「new は成功する。依存が3つ隠されているのに。壊れるのは generate を呼んだ瞬間だ。しかも壊れ方はファイルの有無やネットワーク環境に依存する——環境依存のテストだ」
「でも、テストのために設計を変えるのは本末転倒じゃないですか? テストが環境に依存しているなら、環境を揃えればいい話で——」
ロックさんが首を振った。
「逆だ、ワトソン君。テストしやすい設計こそが、正しい設計の指標なんだよ。環境を揃えるのは対症療法だ。密室の中に酸素ボンベを持ち込んでも、密室であることは変わらない」
反論したかった。でも、CIの赤いログは私の味方をしてくれなかった。
密室を開く鍵
「密室を開くのは簡単だ。扉を作ればいい。——いや、正確には鍵穴を作る」
ロックさんが新しいファイルを書き始めた。
| |
「requires 'fetch'——これだけですか?」
「これが鍵穴の形を決めている。fetch というメソッドを持つ者だけが、この鍵穴に合う鍵になれる。CSV でも API でも、テスト用のモックでもいい。形が合えば開く」
同様に、フォーマッタとメーラーの Role も書いた。
| |
| |
「次に、密室の壁を壊す」
ロックさんが ReportGenerator.pm を書き換えた。
| |
「required にしたら、new のときに必ず渡さないといけないですよね。引数が3つ増える。面倒じゃないですか」
「面倒に感じるなら、それはクラスの責務が多すぎるという別の事件だよ。コンストラクタの引数の数は、依存の数だ。依存の数は、責務の数だ。5つも10個もあるなら、クラスを分割すべきだ。コンストラクタは正直に告白しているのだよ」
引数が3つ——依存が3つ。言われれば当然だ。今まで new の引数がゼロ=シンプルだと思い込んでいたが、あれは正直だったのではなく、黙秘していただけだった。
「……わかりました。でも、ConsumerOf というのが気になります。InstanceOf['DataSource::CSV'] のほうが素直じゃないですか?」
「具象に依存する錠前は、特定の鍵でしか開かない。DataSource::CSV に依存すれば、テスト時にモックを渡せない——isa のチェックで弾かれる。ConsumerOf で Role に依存すれば、その Role を with しているクラスなら何でも受け入れる」
InstanceOf は身分証の確認だ。「お前は CSV か?」と訊く。ConsumerOf は能力の確認だ。「お前は fetch できるか?」と訊く。身分ではなく能力——それなら、テスト用のモックだって能力さえ持っていれば通れる。
「……なるほど。InstanceOf だとモックが弾かれるのか。それは困る」
「そういうことだ。ConsumerOf['Role::DataSource'] は、Role::DataSource を consume——消費しているオブジェクトを要求する。with で Role を取り込んでいれば、CSV でも API でもモックでも通る。ConsumerOf の名前の通りだよ」
腑に落ちた。依存先を具象クラスではなく Role にする。テスト時はモック、本番時は実装——鍵穴の形さえ合えばいい。
既存の具象クラスにも変更が必要だ。ロックさんが DataSource::CSV を開いた。
| |
「with 'Role::DataSource' を足すだけ。既存のメソッドが requires を満たしていれば、合成は成功する。もし fetch を実装していなければ、この時点でエラーになる——実行前にだ」
new の直書きがダメ。外から渡す——。
ふと、先輩の言葉が頭に浮かんだ。半年前、名刺を渡されたときに聞いた言葉。「全部自分で new してるな。まあ Service Locator よりはマシかもしれないけど」。あのときは意味がわからなかった。でも今なら、少しだけ輪郭が見える。
「ロックさん、一つ訊いていいですか。Service Locator っていうのは、new の直書きとは違うんですか? 以前、知り合いにそういう言葉を言われたことがあって」
ロックさんの目が光った——ように見えた。
「良い質問だ、ワトソン君。先日、ある現場で Service Locator という仲介人を相手にした。あれはグローバルなレジストリから依存を取りに行く手法だった。ServiceLocator->resolve('validator') のように。今回の new の直書きは、自分で依存を作っている。DataSource::CSV->new のように」
「取りに行くのと、自分で作るのは違いますよね」
「方向は違うが、問題は同じだ。どちらも依存が外から見えない。Service Locator はグローバルな帳簿に依存を隠す。new の直書きはメソッドの中に依存を閉じ込める。依存が外から見えないバリエーションが2つあるだけだ」
ロックさんがテーブルの紙ナプキンに図を描いた。
graph TB
subgraph "3つの依存獲得パターン"
direction TB
A["① new の直書き<br/>自分で作る"] -->|"密結合"| X["依存が外から見えない"]
B["② Service Locator<br/>レジストリに取りに行く"] -->|"暗黙的依存"| X
C["③ Constructor DI<br/>外から渡してもらう"] -->|"明示的依存"| Y["依存がコンストラクタで宣言される"]
end
「Dependency Injection だけが Push だ。依存を渡してもらう。new の引数として、外から手渡す。方向が根本的に違う」
Pull か Push か。先輩の言葉がようやく腑に落ちた。先輩はあのとき、私のコードが全部 Pull——自分で作る、自分で取りに行く——だと見抜いていたのだ。「Service Locator よりはマシ」という皮肉も、いま思えば「同じ Pull 族だけどな」という意味だったのだろう。悔しいが、半年越しで負けを認めるしかない。
「……続けてください」
「new の直書きは、密室に閉じこもって自分で鍵を作る。Service Locator は、共有のロッカーから鍵を取りに行く。DI は、玄関で鍵を手渡してもらう。手渡してもらえば、どの鍵かは渡す側が決められる。テスト時にはテスト用の鍵、本番時には本物の鍵だ」
赤から緑へ
「では、密室を開いた結果を確認しよう」
ロックさんがテストコードを書き始めた。
| |
「モッククラスは全て Role を消費している。with 'Role::DataSource' があるから ConsumerOf のチェックを通る。だが CSV ファイルも SMTP サーバーも使わない。テスト用の偽の鍵だ」
| |
テストを実行した。緑。CSVファイルがなくてもSMTPサーバーがなくても、通る。
「……CIでも通りますよね、これ」
「当然だ。外部リソースに依存していない。どの環境でも同じ結果になる」
| |
「Before では Formatter::HTML がハードコードされていて差し替えられなかった。After では Formatter::Mock を渡してデータの受け渡しを検証できる。しかも各テストが独自のモックを持つから、テスト間の状態汚染もない」
もう一つ。
| |
「Before では new が素通りして generate で爆発していた。After では new の時点で止まる」
「検知のタイミングが、実行時から構築時に前倒しされた……」
「その通り。密室は開かれた。鍵穴が見える。鍵を持っていない者は、部屋に入れない。それが正直な設計だ」
最後に、Composition Root の話が出た。
「new を呼ぶのは本番コードでは1箇所だけにしたまえ。アプリケーションのエントリポイントだ。Composition Root と呼ばれる」
| |
「ここだけが具象クラスを知っている。ReportGenerator 自身は Role::DataSource としか話さない。CSV か API かは、この1箇所が決める。テストコードも同じ構造で、ここだけがモッククラスを知っている」
47件の new
ロックさんが席を立ち、洋書を小脇に抱えた。
「さて、私はこの本の続きを読まなければならない。第8章のタイイングが佳境でね」
「あの——ちゃんとお願いしたいことがあるんですけど。うちのコード、他にも new がたくさんあって——」
「必要なら事務所に来たまえ。名刺は持っているだろう?」
ロックさんがコートを翻してカフェの出口に向かった。ドアベルが鳴った。
一人残された。コーヒーはとっくに冷めていた。
エディタで ->new を検索してみた。
47件。
ReportGenerator だけの話ではなかった。バッチ処理のクラス、通知サービス、ログ出力——あちこちで、クラスが自分の内側で依存を作っていた。密室がたくさんあった。
47個の密室。一つずつ、鍵穴を作っていくしかないんだろうな。
テーブルに財布の中の名刺を置いた。今度は忘れないように、PCの横に立てかけた。
「レガシー・コード・インベスティゲーション」。月曜日に連絡しよう。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
| new の直書き(依存の内部生成) | コンストラクタ DI(依存の外部注入) | 依存がコンストラクタで明示され、テスト時にモック差し替え可能 |
| 具象クラスへの直接依存 | Moo::Role + ConsumerOf によるインターフェース分離 | 実装の差し替えが自由、Open/Closed 原則に準拠 |
| 依存の組み立てが散在 | Composition Root(1箇所での組み立て) | 依存グラフが一箇所で見渡せる |
推理のステップ
- クラス内の
->newを検索し、メソッドの中で生成している依存を洗い出す - 依存ごとに
Moo::Roleでインターフェース(requires)を定義する - 既存の具象クラスに
with 'Role::...'を追加してロールを消費させる - 対象クラスに
has ... => (isa => ConsumerOf['Role::...'], required => 1)を追加する - メソッド内の
ClassName->new(...)を$self->属性名に置き換える - メインスクリプト(Composition Root)で依存を組み立てて注入する
- テストではモック実装を注入し、外部リソースなしで動作確認する
ロックより
密室の犯人は、いつも内側から鍵をかけている。自分だけで完結しようとする。外の助けを必要としないふりをする。だがテストの赤いランプが、その嘘を暴く。
new を外に出したまえ。依存を手渡してもらうことは、弱さではない。正直さだ。コンストラクタが「私にはこれが必要だ」と宣言するとき、コードは初めて信頼に足る存在になる。
密室を開く鍵は、いつも外側にある。
