Featured image of post コード探偵ロックの事件簿【Plugin】開かずの金庫〜コアを壊さず拡張する鍵の在処〜

コード探偵ロックの事件簿【Plugin】開かずの金庫〜コアを壊さず拡張する鍵の在処〜

新機能追加のたびにコアのif/elsifチェーンを変更しリグレッションが頻発する問題を、Moo::Roleによるプラグインインターフェースと動的登録で「コアを閉じたまま拡張する」Plugin Patternで解決するリファクタリング事例

I. ロビーの邂逅

スマホの画面に「LCI レガシー・コード」と打ち込んだところで、検索結果を開く前に手が止まった。

知り合いのテックリード——別の会社で基盤チームを率いている男だ——が先月の飲み会で言っていた。「コードの診断サービスをやってる変な人がいる。探偵ごっこみたいなことをするんだけど、腕は確かだ」。酔った勢いの与太話だと思って聞き流していたが、先週の深夜対応のあと、その言葉が妙にひっかかっている。

自社ビルの1階ロビー、昼休みのソファ。弁当を食べ終えて、ぼんやりとスマホをいじっていたところだった。

「その消火器の配置は6メートル間隔だね。悪くないが、エレベーターホール側の7番目が欠けている」

独り言が聞こえた。ロビーの壁際に立つ男が、消火器を凝視している。コートを着ている。夏前のオフィスビルで、コートを。

男が振り向いた。スマホの検索結果に表示された写真の顔と一致した。

「あの——LCIの方ですか」

「ロック。コードの探偵だ」

男はコートのポケットから缶のエナジードリンクを取り出し、プルタブを開けた。ロビーで。

「別のクライアントとの打ち合わせ帰りでね。このビルの防火設備の配置が気になって——」

「すみません」俺はスマホを見せた。「ちょうど、あなたに連絡しようとしていたところなんです」

ロックさんは俺のスマホ画面を一瞥し、それからゆっくりと俺の顔を見た。鼻がかすかに動いた。

elsif のにおいがする。5段……いや、6段に近いな」

「は?」

「テックリードは金庫番に似ている」ロックさんはソファの反対側に腰を下ろした。「金庫を開け閉めする回数が多いほど、手に油のにおいが染みつく。君の場合は、鍵穴の数が多すぎるにおいだ」

「……鍵穴の数。うん、まあ、当たってるかもしれない」

「ワトソン君、まず金庫の中身を見せてくれたまえ」

ワトソン。いきなり来た。知り合いのテックリードが言っていた、「探偵ごっこ」とはこういうことか。

「ワトソン……いいよ、好きに呼んでくれ。名前より中身のほうが大事だろ」

俺はノートPCをカバンから取り出し、ロビーのソファでコードベースを開いた。

II. 金庫の中の散弾痕

通知システム。俺のチームが内製開発した、社内向けのイベント通知基盤だ。

「最初はメールとSlackだけだった。半年前の話だ」

俺は NotificationFormatter クラスを画面に映した。format_message メソッド。当初はシンプルな if/else だった。

「ところが、四半期ごとに新しいチャネルが増えていく。Webhook、SMS、Teams。そのたびに elsif を足した。そして先週——」

「先週?」

「Webhook対応のリリースで、既存のSlack通知のフォーマットが壊れた。深夜1時に障害報告が来て、3時間かけてホットフィックスを当てた」

ロックさんはエナジードリンクを一口飲み、画面を見た。

 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
sub format_message ($self, $channel, $event) {
    my $title   = $event->{title}   // croak "title is required";
    my $body    = $event->{body}    // croak "body is required";
    my $urgency = $event->{urgency} // 'normal';

    if ($channel eq 'email') {
        return {
            channel => 'email',
            from    => $self->default_sender,
            subject => "[$urgency] $title",
            body    => $body,
            format  => 'text/html',
        };
    }
    elsif ($channel eq 'slack') {
        my $emoji = $urgency eq 'high' ? '🚨' : 'ℹ️';
        return {
            channel => 'slack',
            text    => "$emoji *$title*\n$body",
            format  => 'mrkdwn',
        };
    }
    elsif ($channel eq 'webhook') {
        # ... 新しく追加した分岐
    }
    elsif ($channel eq 'sms') {
        # ... さらに追加
    }
    elsif ($channel eq 'teams') {
        # ... さらに追加
    }
    else {
        croak "Unknown channel: $channel";
    }
}

「一斉送信の broadcast メソッドにもチャネル名をハードコードしてある」

1
2
3
4
sub broadcast ($self, $event) {
    my @channels = qw(email slack webhook sms teams);
    # ...
}

ロックさんはしばらく黙っていた。それからエナジードリンクの缶を膝の上に置いて、低い声で言った。

「金庫の蓋を開けるたびに、中のものを全部取り出して並べ直しているのかね」

「そうなんだよ。新しいものを一つ入れたいだけなのに、既存の中身まで触らないといけない」

「犯人は明白だ」ロックさんは画面を指さした。「オープン・クローズド原則違反。拡張に開かれ、変更に閉じているべきコアが、機能を追加するたびに腹を切り開かれている。散弾銃手術——shotgun surgery だよ、ワトソン君。一発撃つと、弾が5つの分岐すべてに当たる」

「OCP か。知識としては知ってるんだが、具体的にどう『閉じる』のかがわからなかった。閉じたら拡張できないだろ?」

「金庫を閉じたまま、鍵穴だけを標準化するのだよ。プラグインという名の鍵を、外から差し込めるようにする」

III. 鍵穴の設計図

ロックさんは俺のノートPCを借り受けると、エディタに新しいファイルを開いた。

「解法は3つの部品で構成される。鍵穴の仕様、鍵そのもの、そして鍵師だ」

部品1: プラグインインターフェース(鍵穴の仕様)

「まず、鍵穴——すなわちプラグインのインターフェースを定義する」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package NotificationPlugin {
    use Moo::Role;

    requires 'channel_name';    # プラグインが対応するチャネル名
    requires 'format';          # フォーマット処理

    sub handles_channel ($self, $channel) {
        return $self->channel_name eq $channel;
    }
}

Moo::Role は Perl の世界でインターフェースと実装を兼ねる仕組みだ。requires で契約を定義する。この鍵穴に合う鍵は、channel_nameformat の2つのメソッドを必ず持っていなければならない」

「インターフェースか。Java でいう interface に近い?」

「近いが、もう少し筋肉質だ。Role は beforeafteraround といったメソッド修飾子を使って、合成先のクラスの振る舞いを拡張できる。インターフェースに筋肉がついたもの、と思ってくれればいい」

「で、requires を満たさなかったらどうなるんだ?」

「鍵穴と鍵の形が合わなければ、ドアは開かない。Role をクラスに合成する時点で即座にエラーだ。テスト以前の問題だよ。つまり、壊れたプラグインは存在すら許されない」

部品2: 具象プラグイン(鍵)

「次に、各チャネルをプラグインとして独立させる。今まで elsif の中にあったロジックを、それぞれ別の鍵にする」

 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
# --- Email プラグイン ---
package Plugin::Email {
    use Moo;
    with 'NotificationPlugin';

    sub channel_name ($self) { 'email' }

    sub format ($self, $event, $core) {
        my $urgency = $event->{urgency} // 'normal';
        return {
            channel => 'email',
            from    => $core->default_sender,
            subject => "[$urgency] $event->{title}",
            body    => $event->{body},
            format  => 'text/html',
        };
    }
}

# --- Slack プラグイン ---
package Plugin::Slack {
    use Moo;
    with 'NotificationPlugin';

    sub channel_name ($self) { 'slack' }

    sub format ($self, $event, $core) {
        my $urgency = $event->{urgency} // 'normal';
        my $emoji = $urgency eq 'high' ? '🚨' : 'ℹ️';
        return {
            channel => 'slack',
            text    => "$emoji *$event->{title}*\n$event->{body}",
            format  => 'mrkdwn',
        };
    }
}

「なるほど。今まで elsif の中にあった処理が、そっくりプラグインのクラスに引っ越しただけか。ロジック自体は変わっていない」

「そう。引っ越し先が変わっただけだ。だが、その引っ越しが決定的に重要なのだよ。なぜだかわかるかね」

「……それぞれ独立したファイルだから、Slackのコードを触ってもEmailに影響しない?」

「正解だ。分離されたことで、影響範囲が閉じた。先週の事故——Webhook追加でSlackが壊れた——は、もう起きようがない」

部品3: コア(金庫本体)

「そして最後に、金庫本体だ。if/elsif を全て取り除いた、清浄なコア」

 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package NotificationCore {
    use Moo;
    use Types::Standard qw(Str ArrayRef);
    use Carp qw(croak);

    has default_sender => (
        is => 'ro', isa => Str,
        default => sub { 'system@example.com' },
    );

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

    # プラグインの登録
    sub register_plugin ($self, $plugin) {
        croak "Plugin must consume NotificationPlugin role"
            unless $plugin->can('does')
                && $plugin->does('NotificationPlugin');
        push $self->_plugins->@*, $plugin;
        return $self;
    }

    # チャネルに対応するプラグインを検索
    sub find_plugin ($self, $channel) {
        for my $plugin ($self->_plugins->@*) {
            return $plugin if $plugin->handles_channel($channel);
        }
        return undef;
    }

    # フォーマット(プラグインに委譲)
    sub format_message ($self, $channel, $event) {
        my $plugin = $self->find_plugin($channel)
            // croak "No plugin registered for channel: $channel";

        my $result = eval { $plugin->format($event, $self) };
        if ($@) {
            croak "Plugin error [$channel]: $@";
        }
        return $result;
    }

    # 送信
    sub send_notification ($self, $channel, $event) {
        my $formatted = $self->format_message($channel, $event);
        return {
            status    => 'sent',
            channel   => $channel,
            formatted => $formatted,
        };
    }

    # 全登録チャネルへの一斉送信
    sub broadcast ($self, $event) {
        my @results;
        for my $plugin ($self->_plugins->@*) {
            my $result = eval {
                $self->send_notification(
                    $plugin->channel_name, $event
                );
            };
            if ($@) { next }  # 障害プラグインをスキップ
            push @results, $result;
        }
        return \@results;
    }

    # 登録済みチャネル一覧
    sub channels ($self) {
        return [ map { $_->channel_name } $self->_plugins->@* ];
    }
}

「ちょっと待ってくれ」俺はコードを上から読み直した。「format_messageif が一つもないぞ」

「そう。find_plugin がチャネル名に対応するプラグインを探し、そのプラグインの format を呼ぶ。チャネルが何であるかはコアの関心事ではなくなった」

「じゃあ、6つ目のチャネル——たとえばDiscord——を追加するときは?」

Plugin::Discord クラスを書いて、register_plugin で登録するだけだ。コアのコードは一行たりとも触らない」

「一行も?」

「一行もだ。broadcast もチャネル名をハードコードしていないだろう? 登録済みプラグインを順に回すだけだ。金庫を閉じたまま、鍵を足す。これがPlugin Pattern の核心だよ」

aroundbefore/after の使い分け

「ところで、さっき Role の話で beforeafteraround が出てきたが、全部 around でよくないか?」

around は金庫室の鍵そのものを預かる行為だ」ロックさんはエナジードリンクの缶を揺らした。「元のメソッドを呼ぶか呼ばないかまで制御できる。入退室の記録を取りたいだけなら、受付に before/after を置けばいい。権限の最小化は防犯の基本だよ、ワトソン君」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# before: 元メソッドの前に実行。戻り値は無視
before process => sub ($self, @args) {
    warn "[LOG] process called\n";
};

# after: 元メソッドの後に実行。戻り値は無視
after process => sub ($self, @args) {
    warn "[LOG] process done\n";
};

# around: 元メソッドを包む。$orig を呼ぶかどうかを制御
around process => sub ($orig, $self, @args) {
    # 前処理
    my $result = $self->$orig(@args);  # 元メソッド呼び出し
    # 後処理
    return $result;
};

「なるほどな。ログを取りたいだけなら before/after で十分で、入出力を加工したいときだけ around を使うと」

「そういうことだ。不必要に強い権限を渡さないこと。プラグインの設計も同じだ」

プラグインの障害隔離

「もう一つ聞いていいか。新人が書いたプラグインにバグがあったとして、コア全体が止まったりしないか?」

eval で隔壁を建てる。一つの金庫室で爆発が起きても、隣の部屋には延焼しない設計だ」

1
2
3
4
5
6
7
8
# broadcast 内の障害隔離
for my $plugin ($self->_plugins->@*) {
    my $result = eval {
        $self->send_notification($plugin->channel_name, $event);
    };
    if ($@) { next }  # 障害プラグインをスキップ
    push @results, $result;
}

「Slack のプラグインが壊れても、Email と Webhook は送信される。障害が局所化されているから、影響範囲を見積もれる——テックリードとして、これが一番ありがたいんじゃないか」

「ありがたい。深夜1時の障害報告で『全チャネル停止』と『Slack だけ停止』は天と地の差だ」

条件のオブジェクト化との接続

「……そういえば」俺はふと思い出した。「知り合いが、『条件をオブジェクトにしたら楽になった』と言っていた。Specification とか何とか」

ロックさんの口元がかすかに持ち上がった。

「条件の拡張と機能の拡張は、同じ思想の表裏だ。条件を if に書く代わりにオブジェクトにする。機能を elsif に書く代わりにプラグインにする。どちらも、コアを開かずに外から足す。鍵穴を増やすか、部屋を増やすかの違いだね」

IV. 金庫の検証

俺はプラグインのテストを書いた。まず各プラグインの単体テスト。それからコアとの統合テスト。

 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 Plugin::Discord;
    use Moo;
    with 'NotificationPlugin';

    sub channel_name ($self) { 'discord' }

    sub format ($self, $event, $core) {
        return {
            channel => 'discord',
            content => "**$event->{title}**\n$event->{body}",
            format  => 'markdown',
        };
    }
}

my $core = NotificationCore->new;
$core->register_plugin(Plugin::Email->new);
$core->register_plugin(Plugin::Slack->new);
$core->register_plugin(Plugin::Discord->new);  # 追加

# コアを一切変更せずに3チャネルで broadcast
my $results = $core->broadcast($event);
# => 3件すべて成功

テストが走った。全件グリーン。

「……新チャネル追加で、コアのテストを一切触っていないな」

「プラグインのテストだけを書けばいい。金庫の構造試験と、鍵の動作試験は別物だ」

俺は画面を見つめた。先週の深夜対応が頭をよぎる。あのとき壊したのはSlackのフォーマット処理で、原因はWebhookの分岐を追加した際の隣接行の編集ミスだった。プラグインなら、Webhookのファイルを触ってもSlackのファイルには手が届かない。物理的に壊しようがない。

「上出来だ」俺は言った。「明日のスプリントレビューで提案できる」

ロックさんはノートPCを俺に返し、ソファから立ち上がった。

「ワトソン君、一つだけ忘れるな」

「なんだ?」

ロックさんはコートの襟を正した。

「迷宮は、出口から抜けるものじゃない。入口を設計し直すんだ」

自動ドアが開き、ロックさんの背中がビルの外に消えた。エナジードリンクの空き缶だけが、ソファの肘掛けに残されていた。

「……入口の設計し直しか」

俺はノートPCを閉じ、スマホのカレンダーを開いた。明日のチームミーティングのアジェンダに一行書き足す。

「リファクタリング提案: プラグインアーキテクチャ」


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
OCP違反(機能追加のたびにコアの if/elsif を変更)Plugin Pattern(Moo::Role によるプラグインインターフェース + 動的登録)コアを変更せずに新チャネル追加が可能。影響範囲がプラグイン単位に局所化
散弾銃手術(1つの変更が複数分岐に波及)プラグインの分離(各チャネルが独立したクラス)チャネル間の依存がゼロ。一方の変更が他方に影響しない
ハードコードされたチャネルリスト登録ベースの動的チャネル管理broadcast がプラグイン一覧から自動生成。リスト管理の手動更新が不要

推理のステップ

  1. プラグインインターフェースを定義する: Moo::Rolerequires 'channel_name', requires 'format' を宣言。全プラグインが満たすべき契約を定める
  2. 既存の分岐をプラグインに分離する: elsif ごとの処理を独立したクラスに移動。with 'NotificationPlugin' でインターフェースを消費
  3. コアからif/elsifを除去する: find_plugin でチャネル名からプラグインを検索し、format に委譲。コアはチャネルの種類を知らない
  4. 障害隔離を組み込む: eval でプラグイン呼び出しを包み、一つの障害が全体に波及しないようにする
  5. テストを分離する: コアのテストとプラグインのテストを独立させ、プラグイン追加時にコアのテスト変更を不要にする

ロックより

ワトソン君。金庫を頑丈にすることと、金庫を開けやすくすることは矛盾しない。標準化された鍵穴さえあれば、金庫を閉じたまま中身を増やせる。それが Plugin Pattern の本質だ。

君のチームが来週新しいチャネルを追加するとき、触るのはプラグインのファイルだけだ。コアには一切の指紋を残さない。深夜の障害対応が減ることを祈っているよ。

金庫を閉じたまま鍵を増やせるようになった君なら、もう迷宮に惑わされることはないだろう。

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