Featured image of post コード探偵ロックの事件簿【Registry】三者面談〜DI信者が見落とした「正しい棚」の使い方〜

コード探偵ロックの事件簿【Registry】三者面談〜DI信者が見落とした「正しい棚」の使い方〜

Service Locator撤去後にDI過剰適用で行き詰まった問題を、Registry+DIハイブリッド構成で解決。SL・DI・Registryの三つ巴比較で使い分けの境界線を明確化する。

予約のない来客

火曜の午後、デスクでプラグインシステムの設計書を睨んでいた。

通知プラグインの追加申請が3件溜まっている。Slack連携、Webhook通知、SMS通知。どれも技術的には単純だ。だが今の設計では、1つプラグインを追加するたびにコンストラクタとテストを全面修正しなければならない。

2週間前に Service Locator を全面撤去した。ロックさん——あの探偵気取りのコード調査員——に教わった通り、すべての依存をコンストラクタインジェクションに移行した。CIの安定率は92%まで回復した。正しい判断だったと思っている。

思っている、のだが。

「経過は良好だな。ただし、新たな症状が出ている」

背後から声がした。

振り向くと、コートを着た男が私のデスクの横に立っていた。手にはホワイトボード用の付箋を3色——赤、青、緑——持っている。うちのオフィスの備品だ。

「ロックさん。……アポイントは取っていないはずですが」

「定期検診だよ、ワトソン君。前回の患者——失礼、依頼人の経過観察は、探偵の職務に含まれる」

含まれない。そもそもこの人は探偵ですらない。ただのコード調査員だ。

「ワトソン君は……もう慣れました。好きに呼んでください」

「つまらないな。抵抗がないと張り合いがない」

ロックさんはそう言いながら、私の許可なくホワイトボードの前に移動した。既に書いてあった設計メモを端に寄せ、付箋を3色に分けて貼り始めた。赤、青、緑。それぞれに何か書いている。

「何してるんですか」

「分類だよ。赤は取りに行く者。青は渡してもらう者。緑は置いておく者

意味がわからない。だが前回の経験上、この人の行動には——必ずとは言わないが——大抵何かの意図がある。

「CIの安定率は92%になりました」

「よくやった。ただし——」

ロックさんが私のデスクのモニターを覗き込んだ。

「その画面のコードに、新しい事件のにおいがするね」

DI 万能説の限界

「見ていいですか」とも訊かずに、ロックさんが椅子を引き寄せてモニターの前に座った。私は自分のデスクなのに立ったまま説明する側になっていた。

「前回教わった通り、Service Locator を全面撤去してコンストラクタインジェクションに移行しました。CIは安定しています。ですが——」

画面には通知配信モジュールが表示されている。

「——このプラグインシステムだけ、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
25
package NotificationDispatcher;
use Moo;
use v5.36;
use Types::Standard qw(InstanceOf);

# プラグインが増えるたびにここが膨らむ
has email_notifier => (
    is => 'ro', isa => InstanceOf['Notifier::Email'], required => 1,
);
has slack_notifier => (
    is => 'ro', isa => InstanceOf['Notifier::Slack'], required => 1,
);
has webhook_notifier => (
    is => 'ro', isa => InstanceOf['Notifier::Webhook'], required => 1,
);
# SMS を追加したい → has を追加 → テスト全修正

sub dispatch ($self, $event) {
    $self->email_notifier->notify($event);
    $self->slack_notifier->notify($event);
    $self->webhook_notifier->notify($event);
    # 新しいプラグインを追加するにはここも修正
}

1;

「プラグインが3つの段階ではまだ我慢できます。ですが、SMS通知を追加するには has sms_notifier を追加して、dispatch メソッドを修正して、既存テスト20件を全部直す必要がある。来月にはLINE通知とPush通知も予定されていて——」

「コンストラクタが肥大化する」

「はい。それだけではなく、顧客のプランによって有効なプラグインが異なります。エンタープライズプラン向けにはWebhookとSMSが必要ですが、フリープランにはメールだけでいい。でも全部 required なので、フリープランのテストでもWebhookやSMSのインスタンスを渡さないとオブジェクトが作れません」

ロックさんが腕を組んだ。

「前回のService Locatorは依存を隠した罪で有罪だった。だが今度は、正義感から全員を法廷に引きずり出しすぎている」

「正しいことをしたはずなのに、なぜ苦しいんですか」

「DI は万能ではないからだ。依存が固定的で少数のとき、DI は最良の選択になる。だが、動的に増減するもの、名前で区別するものには向かない。——ワトソン君、第三の容疑者を紹介しよう」

第三の容疑者

ロックさんがホワイトボードの付箋に向き直った。

赤い付箋には「SL: 取りに行く」、青い付箋には「DI: 渡してもらう」と書いてある。そして緑の付箋にはこう書かれていた。

「Registry: 棚に置いておく」

「棚?」

「名前で引ける棚だ。プラグインを棚に置く。使いたい者は棚から取る。棚自体は、誰が何を取るかに関心がない。——まず棚を作ろう」

ロックさんが私のエディタに新しいファイルを作り始めた。

 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
33
34
35
36
37
38
39
40
package PluginRegistry;
use Moo;
use v5.36;
use Carp qw(croak);
use namespace::clean;

has _plugins => (
    is       => 'ro',
    default  => sub { {} },
    init_arg => undef,
);

sub register ($self, $name, $plugin) {
    croak "Plugin name required"          unless defined $name;
    croak "Plugin object required"        unless defined $plugin;
    croak "Plugin '$name' already exists" if exists $self->_plugins->{$name};
    $self->_plugins->{$name} = $plugin;
    return $self;
}

sub get ($self, $name) {
    croak "Plugin '$name' not found"
        unless exists $self->_plugins->{$name};
    return $self->_plugins->{$name};
}

sub has_plugin ($self, $name) {
    return exists $self->_plugins->{$name};
}

sub all_plugins ($self) {
    return values %{$self->_plugins};
}

sub unregister ($self, $name) {
    delete $self->_plugins->{$name};
    return $self;
}

1;

シンプルだった。ハッシュに名前とオブジェクトのペアを入れて、名前で取り出す。それだけだ。

「待ってください。名前でオブジェクトを取得するという点では、前回のService Locatorと同じに見えるのですが」

ロックさんが——待っていたかのように——頷いた。

「いい警戒だ。前回の事件を覚えている者ならそう思うだろう。だが、棚番仲介人は違うのだよ」

「違いはどこですか。コードの見た目はほぼ同じです」

「責務の範囲だ。この PluginRegistry が持つメソッドを見たまえ。registergethas_pluginunregisterall_plugins。——これ以外に何がある?」

「何もないです」

「そうだ。何もない。この棚には、オブジェクトを生成する力がない。ライフサイクルを管理する力がない。依存を連鎖的に解決する力がない。受け取ったものを名前で保管し、求められたら返す。それが棚番の全仕事だ」

「前回の ServiceLocator も、やっていることは registerresolve だけでしたよね?」

「コードの見た目は似ている。だが問題の本質はスコープだった。前回の ServiceLocatormy %services ——パッケージレベルの静的変数で全サービスを管理していた。アプリケーション全体がひとつの帳簿を共有していた。今回の PluginRegistryhas _plugins ——インスタンス属性だ。テストのたびに新しい棚を作れる。棚同士は干渉しない」

前回の事件の核心は、テスト間のグローバル状態汚染だった。ServiceLocator->reset を忘れると、前のテストの証拠品が次のテストに紛れ込む問題。

「つまり……PluginRegistry はインスタンスだから、テストごとに独立した棚を用意できる。前回のようなグローバル汚染は構造的に起きない、ということですか」

「その通りだ」

棚を渡す

「では、NotificationDispatcher をどう変えますか」

「こうだ」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package NotificationDispatcher;
use Moo;
use v5.36;
use Types::Standard qw(InstanceOf);

has registry => (
    is       => 'ro',
    isa      => InstanceOf['PluginRegistry'],
    required => 1,
);

sub dispatch ($self, $event) {
    for my $plugin ($self->registry->all_plugins) {
        $plugin->notify($event);
    }
}

sub dispatch_by_name ($self, $name, $event) {
    my $plugin = $self->registry->get($name);
    $plugin->notify($event);
}

1;

コンストラクタの引数が、3つのプラグインから registry 1つに変わっている。

「Registry をコンストラクタインジェクションするんですか?」

「そうだ。棚を手渡すのだよ。棚の中身は知らなくてよい。だが、棚があることは宣言する。required => 1 だ」

「中身が増えても、コンストラクタは変わらない……」

「プラグインが3つでも30でも、NotificationDispatcher のコンストラクタは registry の1行だけだ。プラグインの追加は、棚に物を足すだけで済む。クラス定義の修正は不要。テストの修正も最小限で済む」

「でも、それだと NotificationDispatcher が何のプラグインに依存しているか、コンストラクタからは見えませんよね。前回の事件では『コンストラクタが依存を明示する』ことが重要だと言っていたのに、矛盾しませんか」

ロックさんが一瞬止まった。

「……鋭いな、ワトソン君。前回と同じ論理で、今回を切ろうとしている。嫌いではないよ」

「矛盾してますよね?」

「していない。依存の性質が違うからだ」

ロックさんがホワイトボードに戻った。

OrderProcessorvalidatorpricernotifier に依存している——これは固定的な構造依存だ。注文処理には必ずバリデーションと価格計算と通知が必要で、この関係は変わらない。こういう依存は、コンストラクタに1つずつ明示すべきだ」

「一方、NotificationDispatcher のプラグインは——」

動的なコレクションだ。メール、Slack、Webhook、SMS……何がいるかは実行環境や顧客のプランで変わる。数もコンパイル時には確定しない。こういうものを個別にコンストラクタに並べると、前に見たコードのように肥大化する」

「なるほど。固定的な少数の依存は DI で直接宣言し、動的に変わるコレクションは Registry に委ねて、Registry 自体を DI で渡す……」

「そういうことだ。DI と Registry は対立しない。併用するのだ」

三者の法廷

ロックさんがホワイトボードの3色の付箋を指差した。

「ここで三者を法廷に並べよう。赤い共犯者青い正義の味方、そして緑の棚番だ」

	graph LR
    subgraph "緑: Registry + DI"
        direction LR
        R_Assembler[アセンブラ] -->|"new(registry => $reg)"| R_Client[クライアント]
        R_Client -->|"get('email')"| R_Registry[Registry]
        R_Registry -.->|"名前付き管理"| R_Plugins[プラグイン群]
    end
    subgraph "青: Dependency Injection"
        direction LR
        DI_Assembler[アセンブラ] -->|"new(db => $db)"| DI_Client[クライアント]
        DI_Client -.->|"明示的な依存"| DI_Service[サービス]
    end
    subgraph "赤: Service Locator"
        direction LR
        SL_Client[クライアント] -->|"resolve('db')"| SL[Service Locator]
        SL -.->|"隠れた依存"| SL_Service[サービス]
    end

「構造を比較する」

観点Service LocatorDIRegistry + DI
依存の方向Client → Locator(Pull)Assembler → Client(Push)Client → Registry(Pull)、ただし Registry 自体は DI で Push
依存の可視性隠蔽される完全に明示的Registry の存在は明示的、中身は動的
テスト容易性低(グローバル状態汚染)高(各テストが独自の依存を保持)高(Registry インスタンスがテストごとに独立)
動的追加容易困難(コンストラクタ修正が必要)容易(Registry に register するだけ)
責務の範囲全サービスの依存解決なし(外部が配線)同一インターフェースの名前付き管理のみ

「前回の事件——Service Locator の罪状は3つあった。暗黙的依存、テスト汚染、沈黙する警報器」

「はい。全部覚えています」

「では、Registry はその3つの罪を犯すか?」

私は表を見直した。

「暗黙的依存……Registry の存在自体はコンストラクタに required で明示されている。中身のプラグインは動的だが、それはプラグインシステムの性質上やむを得ない。——有罪ではない」

「テスト汚染は?」

「Registry はインスタンスだから、テストごとに新しいインスタンスを作る。グローバルな共有状態がない。——前回の事件とは構造が違う。有罪ではない」

「沈黙する警報器は?」

「プラグインの追加は Registry への register だけで済む。コンストラクタは変わらない。……これは『依存追加が非破壊的』という、前回有罪にした性質と同じでは?」

ロックさんが笑った——この人にしては珍しく、素直な笑みだった。

「よく気づいた。そこが境界線だ。固定的な構造依存——DBコネクション、ロガー、バリデータ——を Registry に逃がせば、確かにそれは Service Locator の再犯になる。だがプラグインのように動的に変わるコレクションを Registry で管理するのは、正当な使い方だ」

「判断基準はありますか。どこまでが正当で、どこからが再犯なのか」

「一つだけだ。コンストラクタに書けるのに Registry に逃がすな。コンストラクタに書けるものを Registry に入れるのは、告白を握りつぶす行為だ。コンストラクタに書ききれないもの——動的なコレクション——だけを Registry に任せろ」

「もう一つ質問があります。この Registry に遅延生成やライフサイクル管理を入れたら便利そうですが」

ロックさんの表情が引き締まった。

「そこがだ。生成ロジックを入れた瞬間、棚番は仲介人に変わる。ライフサイクル管理を入れれば、コンシェルジュに変わる。責務が膨張し、気づいたときには前回の犯人と同じ顔をしている」

「つまり、registergethas_pluginunregister。これ以上のメソッドを生やさない」

「そうだ。棚番には棚番の仕事だけをさせる」

グリーンの確認

テストを書いた。

 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
# テスト用の Role と実装
package Role::Notifier;
use Moo::Role;
requires 'notify';

package Notifier::Email;
use Moo;
with 'Role::Notifier';
has sent => (is => 'ro', default => sub { [] });
sub notify ($self, $event) {
    push @{$self->sent}, { type => 'email', event => $event->{type} };
}

package Notifier::Slack;
use Moo;
with 'Role::Notifier';
has sent => (is => 'ro', default => sub { [] });
sub notify ($self, $event) {
    push @{$self->sent}, { type => 'slack', event => $event->{type} };
}

package Notifier::Webhook;
use Moo;
with 'Role::Notifier';
has sent => (is => 'ro', default => sub { [] });
sub notify ($self, $event) {
    push @{$self->sent}, { type => 'webhook', event => $event->{type} };
}

Registry の基本操作。

1
2
3
4
5
6
7
8
9
subtest 'Registry: 登録・取得・存在確認' => sub {
    my $registry = PluginRegistry->new;
    my $email = Notifier::Email->new;

    $registry->register('email', $email);
    ok $registry->has_plugin('email'), 'email が登録されている';
    is $registry->get('email'), $email, '同じインスタンスが返る';
    ok !$registry->has_plugin('slack'), '未登録は false';
};

NotificationDispatcher と組み合わせたテスト。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
subtest 'Dispatcher + Registry: 全プラグインに配信' => sub {
    my $email = Notifier::Email->new;
    my $slack = Notifier::Slack->new;

    my $registry = PluginRegistry->new;
    $registry->register('email', $email);
    $registry->register('slack', $slack);

    my $dispatcher = NotificationDispatcher->new(registry => $registry);
    $dispatcher->dispatch({ type => 'order_completed' });

    is scalar(@{$email->sent}), 1, 'メール通知1件';
    is scalar(@{$slack->sent}), 1, 'Slack通知1件';
};

名前指定の配信。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
subtest 'Dispatcher: 名前指定で特定プラグインのみ配信' => sub {
    my $email = Notifier::Email->new;
    my $slack = Notifier::Slack->new;

    my $registry = PluginRegistry->new;
    $registry->register('email', $email);
    $registry->register('slack', $slack);

    my $dispatcher = NotificationDispatcher->new(registry => $registry);
    $dispatcher->dispatch_by_name('email', { type => 'alert' });

    is scalar(@{$email->sent}), 1, 'メールのみ送信';
    is scalar(@{$slack->sent}), 0, 'Slackは送信されない';
};

そして、今回一番確認しておきたかったテスト。動的なプラグイン追加だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
subtest '動的追加: 実行時にプラグインを追加' => sub {
    my $email = Notifier::Email->new;
    my $registry = PluginRegistry->new;
    $registry->register('email', $email);

    my $dispatcher = NotificationDispatcher->new(registry => $registry);
    $dispatcher->dispatch({ type => 'signup' });
    is scalar(@{$email->sent}), 1, '初回: email のみ';

    # 実行時に Webhook を追加
    my $webhook = Notifier::Webhook->new;
    $registry->register('webhook', $webhook);

    $dispatcher->dispatch({ type => 'signup' });
    is scalar(@{$email->sent}), 2, '2回目: email も配信';
    is scalar(@{$webhook->sent}), 1, '2回目: 追加した webhook も配信';
};

Before の設計では、Webhook を追加するには has webhook_notifier を追加し、dispatch メソッドを修正し、全テストを直す必要があった。After では registry->register('webhook', $webhook) の1行で済む。クラス定義に触れていない。

そして、前回の事件の教訓——テスト間の状態汚染が起きないことの確認。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
subtest 'テスト間の状態独立: Registry はインスタンス' => sub {
    my $registry1 = PluginRegistry->new;
    $registry1->register('email', Notifier::Email->new);

    my $registry2 = PluginRegistry->new;
    $registry2->register('slack', Notifier::Slack->new);

    ok $registry1->has_plugin('email'),   'registry1 には email';
    ok !$registry1->has_plugin('slack'),  'registry1 には slack がない';
    ok !$registry2->has_plugin('email'),  'registry2 には email がない';
    ok $registry2->has_plugin('slack'),   'registry2 には slack';
};

2つの Registry インスタンスが互いに干渉しない。前回のパッケージ変数 my %services とは根本的に構造が異なる。

最後に、Registry が DI 経由で渡されること——つまり、Registry 自体が暗黙的な依存にならないことの確認。

1
2
3
4
subtest 'Registry は DI 経由で注入(required)' => sub {
    eval { NotificationDispatcher->new };  # registry を渡さない
    like $@, qr/required/, 'Registry なしで生成するとエラー';
};

テストを実行した。

1
2
3
4
5
6
ok 1 - Registry: 登録・取得・存在確認
ok 2 - Dispatcher + Registry: 全プラグインに配信
ok 3 - Dispatcher: 名前指定で特定プラグインのみ配信
ok 4 - 動的追加: 実行時にプラグインを追加
ok 5 - テスト間の状態独立: Registry はインスタンス
ok 6 - Registry は DI 経由で注入(required)

全件グリーン。

棚番の哲学

ロックさんが付箋を指差しながら、最後の整理をした。

「使い分けの基準を確認しよう」

「依存が固定的で少数なら DI」

「そうだ」

「同一インターフェースの複数実装を名前で管理する、あるいは動的に追加・削除されるなら Registry」

「そうだ。そしてRegistryをDI経由で渡す」

「Service Locator は?」

「フレームワークの制約でクラス内部から依存を解決するしかない場合に限り、Composition Root の近くで使う。それ以外は選ばない」

「前回の帳簿——Service Locator が、全面的に悪というわけではないんですか」

「万能の仲介人に頼った構造が問題なのだ。限定された用途で、限定されたスコープで使う分には、道具に善悪はない。——だが、そこまで自制できる開発チームは少ない。だから私は DI を推す。判断に迷ったら DI だ」

ロックさんがコートを羽織り、ホワイトボードの付箋を眺めた。赤、青、緑。3色が並んでいる。

「この3色は剥がさないでおくといい。誰かが『便利だから全部 Registry に入れよう』と言い出したとき、赤い付箋の意味を思い出す」

「仲介人に戻る瞬間を」

「そうだ」

ロックさんが鞄にホワイトボードマーカーを戻し——うちのオフィスの備品なので返してほしいのだが——出口に向かった。

「報酬の件ですが——前回はCIの安定率を報酬にしていましたね」

「92%という報告は受け取った。見事な成績だよ、ワトソン君」

「では今回は?」

「定期検診は無料だよ。だが——」

ロックさんがドアノブに手をかけたまま振り返った。

「——新しい処方箋は有料だ。次回からは見積もりを出すよ、ワトソン君」

ドアが閉まった。

プラグインシステムの設計書に視線を戻した。通知プラグインの追加申請が3件。Slack連携、Webhook通知、SMS通知。

PluginRegistry を作る。register で3つのプラグインを登録する。NotificationDispatcher のコンストラクタを registry 1つに書き換える。クラス定義の修正はそれだけで済む。

ホワイトボードの3色の付箋が、午後の光を受けて並んでいた。棚番か。悪くない名前だ。


探偵の調査報告書

容疑(問題)真実(パターン)証拠(効果)
DI 過剰適用(全プラグインをコンストラクタに列挙)Registry + DI ハイブリッドプラグイン追加時のクラス修正が不要
コンストラクタ肥大化(プラグイン数=引数数)PluginRegistry をDI で注入コンストラクタは registry の1引数のみ
動的追加の不可(has の追加が必要)registry->register による実行時追加クラス定義の修正なしにプラグインを追加可能
テスト全修正(プラグイン追加のたびに)テスト用 Registry インスタンス必要なプラグインだけ登録した軽量テスト

三つ巴の判断フロー

	graph TD
    Q1{依存は固定的で少数か?} -->|Yes| DI["DI(コンストラクタインジェクション)"]
    Q1 -->|No| Q2{同一IFの複数実装を<br/>名前で管理するか?}
    Q2 -->|Yes| REG["Registry + DI ハイブリッド"]
    Q2 -->|No| Q3{動的に追加・削除されるか?}
    Q3 -->|Yes| REG
    Q3 -->|No| DI
観点Service LocatorDIRegistry + DI
依存の方向Client → Locator(Pull)Assembler → Client(Push)Registry 自体は DI(Push)、中身は Pull
依存の可視性隠蔽される完全に明示的Registry の存在は明示的、中身は動的
テスト容易性
動的追加容易困難容易
推奨度限定的第一選択動的コレクション向け

推理のステップ

  1. DI 過剰適用の検知: 同一インターフェースの実装がコンストラクタに3つ以上並んでいたら、Registry の出番を疑う
  2. Registry クラスの作成: registergethas_pluginunregisterall_plugins のみ。Factory やライフサイクル管理を入れない
  3. コンストラクタの書き換え: 個別のプラグイン属性を削除し、registry 属性(required => 1)に置き換える
  4. dispatch の汎用化: 個別メソッド呼び出しを all_plugins or get によるループに変更
  5. Registry の組み立て: Composition Root でプラグインを登録し、Registry を DI で渡す
  6. 境界線の確認: 構造依存(DB、ロガー等)を Registry に逃がしていないか検証する

ロックより

棚番・仲介人・配達人。この3つの名前を覚えておくといい。

棚番は物を預かって返す。それだけだ。仲介人は何でも見つけてくれるが、誰に何を頼んだか外からは見えなくなる。配達人は必要なものを届けてくれるから、自分で取りに行く必要がない。

すべてを配達してもらう必要はない。動的に増えるものは、棚に任せればいい。ただし、棚にFactoryやライフサイクルの力を与えてはいけない。力を与えた瞬間、棚番は仲介人に変わる。そして仲介人は、前回我々が有罪にしたばかりの容疑者だ。

道具に善悪はない。使い方の境界線を引くのは、設計者の仕事だ。

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