Featured image of post コード探偵ロックの事件簿【Dependency Injection】密室の new〜依存を手渡す者が扉を開く〜

コード探偵ロックの事件簿【Dependency Injection】密室の new〜依存を手渡す者が扉を開く〜

クラス内部でnewを直書きしたコードがCI環境でテスト不可能になる問題を、コンストラクタインジェクションとMoo::RoleのConsumerOfで解決するリファクタリング事例

カフェの隣席

土曜の午後、行きつけのカフェでノートPCを開いていた。

画面には GitHub Actions のワークフロー結果。赤い。全部赤い。先週CIを導入してから、テストが一つも通らない。ローカルでは全部グリーンなのに。

チームが5人に増えたタイミングで「そろそろCIを入れよう」と提案したのは私だ。創業期から一人でサーバーサイドを書いてきた。テストも書いている。コードには自信がある。だから余計に苛立つ。CIの設定が悪いのではないかと3日間調べたが、設定に問題はなかった。

「……失礼。その赤いログだが、問題はCIではないね」

隣の席から声がした。

振り向くと、コートを着た男が分厚い洋書を膝に置いたまま、私の画面を見ていた。表紙には「Programming Perl」の文字が見えた——それも第3版の、かなり年季の入ったやつだ。

「は? すみません、どなたですか」

男はこちらの困惑を無視して続けた。

「そのテストコード、new を何箇所で呼んでいる? いや、テストコードではない——テスト対象のクラスの中で」

初対面の人にコードを覗かれている。普通なら不快だ。だが指摘が具体的すぎて、反射的にエディタのタブを切り替えてしまった。

ReportGenerator.pm。メソッドの中に DataSource::CSV->newFormatter::HTML->newMailer::SMTP->new が並んでいる。

「3箇所ですけど……それが何か?」

男がコートの内ポケットから名刺を取り出した。

「レガシー・コード・インベスティゲーション」——探偵事務所。ロック、と名前が書いてある。

財布を開いた。半年前にフリーランスの先輩からもらった名刺が、レシートの間に挟まっていた。同じデザイン。同じ肩書き。あのとき先輩は私のコードをちらっと見て「おまえ、全部自分で new してるな。まあ Service Locator よりはマシかもしれないけど」と意味ありげに笑っていた。Service Locator が何なのかも訊かなかった。動いているコードを変える理由はないと思っていたから。

「……あ」

「おや、どこかで私の名刺を?」

「知り合いからもらったんです。忘れてました。……探偵ごっこですか?」

男——ロックさんが真顔で答えた。

「ごっこではないよ。ロックだ。そして君はいまから私のワトソン君だ」

先輩が「面白い人がいる」と言っていた意味がわかった。面白いというか、一方的だ。

new の指紋採取

ロックさんが自分の本を閉じ、断りもなく私の向かいの席に移動した。

「まず現場検証だ。問題のクラスを見せてもらおう」

私は ReportGenerator.pm を画面に出した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package ReportGenerator;
use Moo;
use v5.36;

sub generate ($self, $params) {
    my $source    = DataSource::CSV->new(path => $params->{file});
    my $formatter = Formatter::HTML->new;
    my $mailer    = Mailer::SMTP->new(host => 'smtp.example.com');

    my $data   = $source->fetch;
    my $report = $formatter->format($data);
    $mailer->send(to => $params->{to}, body => $report);

    return { status => 'sent', to => $params->{to} };
}

1;

ロックさんが画面を一瞥して言った。

「密室だね」

「密室?」

「このクラスは密室だよ、ワトソン君。鍵をかけた部屋の中で、自分だけで全てを完結しようとしている。CSVファイルがある、SMTPサーバーに繋がる——それは密室の中にいる者だけが知っている前提だ」

「でも、ReportGenerator->new は引数なしで作れますよ。シンプルじゃないですか」

「シンプルに見えるのは、複雑さを隠しているからだ」

ロックさんが画面を指差した。

「このコンストラクタは何も要求しない。何に依存しているか、嘘をついている。外から見れば、このクラスは何も必要としていないように見える。だが実際は、CSVファイルとHTMLフォーマッタとSMTPサーバーがなければ動かない」

「嘘って……動いてますよ。ローカルでは全テスト通ってます」

「ローカルには何がある?」

「開発用のCSVファイルと、テスト用のSMTPサーバーです」

「CIには?」

止まった。CIにはどちらもない。だから落ちる。

「……ああ」

「テスト対象のクラスの中で new を呼んでいる場所を、私は指紋と呼んでいる。犯人が現場に残す証拠だ。指紋が多いほど、そのクラスは密結合度が高い」

ロックさんが私のPCを操作して——やめてほしいのだが——テストコードを開いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# --- テストA: 正常系...のつもり ---
subtest 'テストA: レポート生成' => sub {
    my $gen = ReportGenerator->new;
    # ↑ 依存ゼロで生成できてしまう

    eval { $gen->generate($params) };
    # data/report.csv が存在しない → 死ぬ
    # smtp.example.com に接続できない → 死ぬ
    like $@, qr/Cannot open/, 'ファイルがないのでエラー';
};

new は成功する。依存が3つ隠されているのに。壊れるのは generate を呼んだ瞬間だ。しかも壊れ方はファイルの有無やネットワーク環境に依存する——環境依存のテストだ」

「でも、テストのために設計を変えるのは本末転倒じゃないですか? テストが環境に依存しているなら、環境を揃えればいい話で——」

ロックさんが首を振った。

「逆だ、ワトソン君。テストしやすい設計こそが、正しい設計の指標なんだよ。環境を揃えるのは対症療法だ。密室の中に酸素ボンベを持ち込んでも、密室であることは変わらない」

反論したかった。でも、CIの赤いログは私の味方をしてくれなかった。

密室を開く鍵

「密室を開くのは簡単だ。扉を作ればいい。——いや、正確には鍵穴を作る」

ロックさんが新しいファイルを書き始めた。

1
2
3
4
5
6
7
package Role::DataSource;
use Moo::Role;
use v5.36;

requires 'fetch';

1;

requires 'fetch'——これだけですか?」

「これが鍵穴の形を決めている。fetch というメソッドを持つ者だけが、この鍵穴に合う鍵になれる。CSV でも API でも、テスト用のモックでもいい。形が合えば開く

同様に、フォーマッタとメーラーの Role も書いた。

1
2
3
4
5
6
7
package Role::Formatter;
use Moo::Role;
use v5.36;

requires 'format';

1;
1
2
3
4
5
6
7
package Role::Mailer;
use Moo::Role;
use v5.36;

requires 'send';

1;

「次に、密室の壁を壊す」

ロックさんが ReportGenerator.pm を書き換えた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package ReportGenerator;
use Moo;
use v5.36;
use Types::Standard qw(ConsumerOf);

has source => (
    is       => 'ro',
    isa      => ConsumerOf['Role::DataSource'],
    required => 1,
);

has formatter => (
    is       => 'ro',
    isa      => ConsumerOf['Role::Formatter'],
    required => 1,
);

has mailer => (
    is       => 'ro',
    isa      => ConsumerOf['Role::Mailer'],
    required => 1,
);

sub generate ($self, $params) {
    my $data   = $self->source->fetch($params);
    my $report = $self->formatter->format($data);
    $self->mailer->send(to => $params->{to}, body => $report);

    return { status => 'sent', to => $params->{to} };
}

1;

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 を開いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package DataSource::CSV;
use Moo;
use v5.36;
with 'Role::DataSource';  # ← この1行を追加

has path => (is => 'ro', required => 1);

sub fetch ($self, $params = {}) {
    open my $fh, '<', $self->path
        or die "Cannot open @{[$self->path]}: $!";
    my @rows;
    while (my $line = <$fh>) {
        chomp $line;
        push @rows, [split /,/, $line];
    }
    close $fh;
    return \@rows;
}

1;

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 は、玄関で鍵を手渡してもらう。手渡してもらえば、どの鍵かは渡す側が決められる。テスト時にはテスト用の鍵、本番時には本物の鍵だ」

赤から緑へ

「では、密室を開いた結果を確認しよう」

ロックさんがテストコードを書き始めた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# --- テスト用モック実装 ---
package DataSource::Mock;
use Moo;
with 'Role::DataSource';
has data => (is => 'ro', default => sub { [['Alice', 100], ['Bob', 200]] });
sub fetch ($self, $params = {}) { return $self->data }

package Formatter::Mock;
use Moo;
with 'Role::Formatter';
has last_input => (is => 'rw');
sub format ($self, $data) {
    $self->last_input($data);
    return "formatted:" . scalar($data->@*) . " rows";
}

package Mailer::Mock;
use Moo;
with 'Role::Mailer';
has sent => (is => 'ro', default => sub { [] });
sub send ($self, %args) {
    push $self->sent->@*, { to => $args{to}, body => $args{body} };
    return 1;
}

「モッククラスは全て Role を消費している。with 'Role::DataSource' があるから ConsumerOf のチェックを通る。だが CSV ファイルも SMTP サーバーも使わない。テスト用の偽の鍵だ」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# --- テストA: モック注入でレポート生成 ---
subtest 'テストA: モック注入でレポート生成' => sub {
    my $mailer = Mailer::Mock->new;
    my $gen = ReportGenerator->new(
        source    => DataSource::Mock->new,
        formatter => Formatter::Mock->new,
        mailer    => $mailer,
    );

    my $result = $gen->generate({ to => 'test@example.com' });

    is $result->{status}, 'sent', 'レポートが送信された';
    is scalar($mailer->sent->@*), 1, 'メール1件送信';
    is $mailer->sent->[0]{to}, 'test@example.com', '宛先が正しい';
};

テストを実行した。緑。CSVファイルがなくてもSMTPサーバーがなくても、通る。

「……CIでも通りますよね、これ」

「当然だ。外部リソースに依存していない。どの環境でも同じ結果になる」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# --- テストB: フォーマッタの動作検証 ---
subtest 'テストB: フォーマッタへのデータ受け渡し' => sub {
    my $formatter = Formatter::Mock->new;
    my $gen = ReportGenerator->new(
        source    => DataSource::Mock->new(data => [['x', 1]]),
        formatter => $formatter,
        mailer    => Mailer::Mock->new,
    );

    $gen->generate({ to => 'test@example.com' });

    is scalar($formatter->last_input->@*), 1, 'フォーマッタに1行渡された';
};

「Before では Formatter::HTML がハードコードされていて差し替えられなかった。After では Formatter::Mock を渡してデータの受け渡しを検証できる。しかも各テストが独自のモックを持つから、テスト間の状態汚染もない」

もう一つ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# --- テストC: 依存不足はnew()で即検知 ---
subtest 'テストC: 依存不足の早期検出' => sub {
    eval {
        ReportGenerator->new(
            source => DataSource::Mock->new,
            # formatter と mailer を渡し忘れ
        );
    };
    like $@, qr/required/, 'new() の時点でエラー';
};

「Before では new が素通りして generate で爆発していた。After では new の時点で止まる」

「検知のタイミングが、実行時から構築時に前倒しされた……」

「その通り。密室は開かれた。鍵穴が見える。鍵を持っていない者は、部屋に入れない。それが正直な設計だ」

最後に、Composition Root の話が出た。

new を呼ぶのは本番コードでは1箇所だけにしたまえ。アプリケーションのエントリポイントだ。Composition Root と呼ばれる」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# main.pl(Composition Root)
use ReportGenerator;
use DataSource::CSV;
use Formatter::HTML;
use Mailer::SMTP;

my $generator = ReportGenerator->new(
    source    => DataSource::CSV->new(path => 'data/report.csv'),
    formatter => Formatter::HTML->new,
    mailer    => Mailer::SMTP->new(host => 'smtp.example.com'),
);

$generator->generate({ to => 'user@example.com' });

「ここだけが具象クラスを知っている。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箇所での組み立て)依存グラフが一箇所で見渡せる

推理のステップ

  1. クラス内の ->new を検索し、メソッドの中で生成している依存を洗い出す
  2. 依存ごとに Moo::Role でインターフェース(requires)を定義する
  3. 既存の具象クラスに with 'Role::...' を追加してロールを消費させる
  4. 対象クラスに has ... => (isa => ConsumerOf['Role::...'], required => 1) を追加する
  5. メソッド内の ClassName->new(...)$self->属性名 に置き換える
  6. メインスクリプト(Composition Root)で依存を組み立てて注入する
  7. テストではモック実装を注入し、外部リソースなしで動作確認する

ロックより

密室の犯人は、いつも内側から鍵をかけている。自分だけで完結しようとする。外の助けを必要としないふりをする。だがテストの赤いランプが、その嘘を暴く。

new を外に出したまえ。依存を手渡してもらうことは、弱さではない。正直さだ。コンストラクタが「私にはこれが必要だ」と宣言するとき、コードは初めて信頼に足る存在になる。

密室を開く鍵は、いつも外側にある。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。