Featured image of post コードシェフの仕込み帳【Chain of Responsibility】誰が受け取るか〜承認フローを焼き込んだコードを、連鎖するハンドラオブジェクトで整理する〜

コードシェフの仕込み帳【Chain of Responsibility】誰が受け取るか〜承認フローを焼き込んだコードを、連鎖するハンドラオブジェクトで整理する〜

承認フローをstore別に関数コピーして閾値変更が反映されずバグ。Chain of ResponsibilityパターンをPerlとMooで実装し、ハンドラが限度額を属性として持ち実行時にチェーンを組む設計で、コピーなしに店舗別ポリシーを実現する。

朝の食堂は、人がいない分だけ静かだ。

7時半を少し過ぎたころ、まだシェフは来ていなかった。私は一人でホールの椅子を床に降ろして、カウンターを拭いていた。表の通りには早出の人間が歩いていた。

引き戸が開いた。

入ってきたのは35歳前後の男性だった。スラックスとシャツ、疲れたような目をしていた。食堂の中を一度確認してから「知人から聞きました。コードの相談をさせてもらえますか」と言った。紹介で来た人間の確かめ方だった。

「シェフはもうすぐ来ます。少し待てますか」

「はい。お時間いただいて」

彼はカウンターの端に座って、PCを開いた。私は椅子を並べながら、なんとなく画面に目をやった。

2つの関数が横に並んでいる。approve_discount_store_aapprove_discount_store_b。中身を見ると、if / elsif の構造がほぼ同じで、数字だけが違う。

——あ。

「あの」と私は言った。「それ、条件をリストにして——{can => ..., do => ...} みたいなハッシュの配列にして、ループで回せばいいんじゃないですか」

男性がこちらを見た。

「それも考えました。ただ、store によって閾値が違うので——1000030000 に変えたいとき、そのリストのエントリが——」

引き戸が開いた。シェフが来た。

この記事で学ぶこと

この記事は、「フランチャイズ加盟店の割引承認フローを store ごとに関数コピーしたら、閾値変更が一部のコピーにしか反映されずバグが出た」という問題を、Chain of Responsibilityパターンで整理する話です。各ハンドラが閾値(上限金額・割引率)を属性として持ち、実行時に store ごとのチェーンを組む設計で、コードをコピーせずに複数のポリシーを表現できるようになります。

学ぶことひとことで言うと
Chain of Responsibility パターン複数のハンドラを連鎖させ、各ハンドラが自分の条件で処理するか次に渡すかを判断する振る舞いパターン
policy-baked-in承認条件と閾値をソースに直接書いており、ポリシーが異なる場合にコードをコピーするしかない状態
Moo の extends基底クラスを継承して共通の振る舞いを受け取り、SUPER::handle で委譲先に処理を渡す
実行時チェーン構築同じハンドラクラスのインスタンスを属性違いで作り、store ごとに異なるチェーンを new で組む
単独テスト可能性各ハンドラを独立してテストできる。チェーン全体のテストとは別に、1つのクラスだけを確認できる

対象読者は、次のような人を想定しています。

  • PerlとMooの基本(hasnewextends)がなんとなく分かる
  • 「似た構造のコードを複数コピーして、片方を直したとき別の片方を直し忘れた」という経験がある

技術スタックはPerl / Mooです。コードはすべて手元で動かし、テストが通ることを確認しています。

閾値がソースに焼き込まれていた

シェフがコートを脱ぎながら画面を見た。私が椅子を持ったまま「説明しかけていたところです」と言うと、シェフが「続けろ」と促した。

男性が話した。フランチャイズ向けの注文管理SaaSを開発していて、割引承認フローを approve_discount 一関数に書いていた。本部標準ポリシーで作ったもので、「割引なし・店長承認・本社承認」の3段階を if/elsif で判定する。最近、フランチャイズ加盟店から「店舗によって承認上限が違う」という要件が出てきた。店舗Aは標準(店長権限1万円・本社権限5万円)、店舗Bは信頼度が高く店長権限3万円・本社権限10万円、他にも店舗Cと店舗Dが別の設定。関数をコピーして数字だけ変えて5つ作った。先週、コンプライアンス対応で本社上限を変えたとき、store_bのコピーへの反映を忘れてバグが出た。

元のコードは、こうだった。

 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 OrderRequest;
use Moo;
use v5.36;
has amount        => (is => 'ro', required => 1);
has discount_rate => (is => 'ro', required => 1);

# store A: 本部標準ポリシー
sub approve_discount_store_a {
    my ($request) = @_;
    return "承認不要"   if $request->discount_rate == 0;
    return "店長承認"   if $request->amount <= 10000 && $request->discount_rate <= 0.1;
    return "本社承認"   if $request->amount <= 50000 && $request->discount_rate <= 0.2;
    die "承認不可\n";
}

# store B: 店長権限が大きい(コピーして数字だけ変えた)
sub approve_discount_store_b {
    my ($request) = @_;
    return "承認不要"   if $request->discount_rate == 0;
    return "店長承認"   if $request->amount <= 30000 && $request->discount_rate <= 0.1;  # ← 違う
    return "本社承認"   if $request->amount <= 100000 && $request->discount_rate <= 0.2; # ← 違う
    die "承認不可\n";
}
# store_c, store_d, store_e ... さらに続く

本社上限を50000から40000に変えた。store_a は直した。store_b の反映を忘れた。

1
2
# store_a: 4.5万円15% → 承認不可(上限4万円)
# store_b: 4.5万円15% → 本社承認(まだ旧ポリシーのまま)

「コピーしたのが間違いだったと分かっています」と男性は言った。「でもどう直せばいいかが分からなかった」

シェフが私に向いた。「見習いが先に何か言っていたな」

「リストにして回せばいい、と言いました。でも store ごとに閾値が違うというところで——」

「そこから続けよう」とシェフは言った。

「方針を焼き込んでいた」

シェフが approve_discount_store_a を一行ずつ見た。

policy-baked-in——承認条件と閾値をソースに直接書いている状態だ。1000050000 という数字が、関数の中に固定されている。別の方針が必要な store ができたとき、この関数をコピーして数字を変えるしかない。コピーが増えるほど、変更が全コピーに反映される保証がなくなる」

「関数ではなく、設定ファイルに閾値を出せば——」と男性が言いかけた。

「それで改善する。だが、今日は別の問題も見る」とシェフは言った。「各ケースの判断が、全部一か所に集まっていることだ。割引なしのケース、店長のケース、本社のケース——これを一つの関数が全部知っている。新しい承認レベルが必要になったとき、この関数を開いて書き加える」

「地区マネージャーを追加するときも——この関数を直しますね」と私は言った。

「そうだ。毎回、誰かがこの関数を開いて、既存のコードの間に新しいケースを差し込む。既存のケースを壊すリスクが毎回発生する」

ハンドラが判断する

シェフが厨房のホワイトボードを持ってきた。図を描き始める——窓口が並んでいる絵。

「各窓口が、自分で判断する。自分の仕事なら処理する。違うなら次の窓口に渡す」

それがChain of Responsibilityパターンだ。複数のハンドラを連鎖させ、各ハンドラが自分の条件で処理するか次に渡すかを判断する振る舞いパターン。

まず基底ハンドラを Moo で作る。「次のハンドラに渡す」という共通の振る舞いを持たせる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package ApprovalHandler;
use Moo;
use v5.36;

has next_handler => (is => 'rw');

sub handle {
    my ($self, $request) = @_;
    return $self->next_handler->handle($request) if $self->next_handler;
    my ($a, $d) = ($request->amount, $request->discount_rate);
    die "承認不可: ${a}円 (${\($d*100)}%)\n";
}

next_handler 属性に次のハンドラを持つ。handle のデフォルト実装は「次がいれば渡す、なければ承認不可」。

次に、各ケースを担うハンドラを extends で継承して作る。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package NoDiscountHandler;
use Moo;
use v5.36;
extends 'ApprovalHandler';

sub handle {
    my ($self, $request) = @_;
    return "承認不要" if $request->discount_rate == 0;
    return $self->SUPER::handle($request);
}

割引なしのケースを担う。discount_rate == 0 なら処理して返す。そうでなければ $self->SUPER::handle($request)——基底クラスの handle を呼ぶ。これが「次のハンドラに渡す」になる。

店長承認のハンドラには、limitmax_discount を属性として持たせる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package ManagerHandler;
use Moo;
use v5.36;
extends 'ApprovalHandler';

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

sub handle {
    my ($self, $request) = @_;
    if ($request->amount <= $self->limit && $request->discount_rate <= $self->max_discount) {
        return "店長承認";
    }
    return $self->SUPER::handle($request);
}

10000 という数字はソースに書かない。$self->limit として属性から取り出す。本社承認の HQHandler も同じ構造で、limitmax_discount を持つ。

そして、チェーンを実行時に組む。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# store A: 標準ポリシー(店長1万円・本社5万円)
my $chain_a = NoDiscountHandler->new(
    next_handler => ManagerHandler->new(
        limit        => 10000,
        max_discount => 0.1,
        next_handler => HQHandler->new(
            limit        => 50000,
            max_discount => 0.2,
        ),
    ),
);

# store B: 店長権限が大きい(店長3万円・本社10万円)— 同じクラス、違う属性
my $chain_b = NoDiscountHandler->new(
    next_handler => ManagerHandler->new(
        limit        => 30000,
        max_discount => 0.1,
        next_handler => HQHandler->new(
            limit        => 100000,
            max_discount => 0.2,
        ),
    ),
);

ManagerHandler クラスは1つ。limit => 10000 のインスタンスと limit => 30000 のインスタンスが、store ごとに異なるだけだ。コードをコピーしない。

本社上限を4万円に変えたいなら、store_a のチェーン構築コードの limit => 50000limit => 40000 に変えるだけ。store_b のチェーンには触れない——別のインスタンスだから。

男性が少し考えてから言った。

「さっき見習いが言っていたリスト——{can => ..., do => ...} のハッシュ配列でも、これと同じことができるのでは? クロージャで $limit を捕捉すれば、store ごとに閾値を変えられる。Handler クラスを作る必要はありますか?」

シェフがマーカーを持ったまま、少し止まった。

「テーブルでも動く。同じ設計だ」

男性が少し驚いた顔をした。私もそれを聞いてやや意外だった。

「クロージャで $limit を捕捉して、関数のリストを作る——それで設定値ごとのチェーンと同等のものが書ける。動作は変わらない」

「では——」

「クラスにする理由は別だ」とシェフは続けた。「一つ一つに名前が付く。ManagerHandler と grep できる。単独でテストできる——チェーン全体を組まなくても ManagerHandler->new(limit => 10000) だけを渡してテストが書ける。そして『次に渡す』をどこに書くか——リストを回すループに書くか、基底クラスの handle に一か所書くか。ハンドラが増えて、条件の判定だけじゃなくなったとき、その差が効いてくる」

私はその答えを聞きながら、少し頭の中を整理した。テーブルでも動く——合っていたんだ。でも「なぜクラスにするか」の答えが、テーブルではなくクラスを選ぶ理由だった。同じ設計を、名前が付いてテストができる形に書いたものがHandler だ。

「同じ設計を、もう少し素直に書いたものがHandler だ」とシェフが言った。

Facade・Builder・Iterator との違い

前々前回のFacadeは「複数のサブシステムへの呼び出しを一本化する」パターンだった。前々回のBuilderは「一つのオブジェクトの部品をどう積み上げるか」を引き取るパターンだった。前回のIteratorは「コレクションの内部表現を隠して、一件ずつ渡す走査インターフェースを提供する」パターンだった。Chain of Responsibilityは「各ハンドラが自分の条件で処理するか次に渡すかを判断する連鎖」——向きが違う。Facadeは段取りを隠し、Builderは組み立てを引き取り、Iteratorは走査インターフェースを固定し、Chain of Responsibilityは判断を各ハンドラに委ねる。

試食合格

テストを走らせた。まず Before(policy-baked-in)で、ドリフトバグの現場を確認する。

1
2
3
4
5
6
7
8
ok 1 - store_a: 割引なしは承認不要
ok 2 - store_a: 1万円10%は店長承認
ok 3 - store_a: 2万円10%は本社承認
ok 4 - store_b: 2万円10%は店長承認(上限が違う)
ok 5 - 同じリクエストでも store によって結果が違う(閾値が別々に焼き込まれている)
ok 6 - store_a_v2: 4.5万円・15%は承認不可(上限4万円に変更済み)
ok 7 - store_b: 4.5万円・15%がまだ本社承認になる(反映忘れのバグ)
1..7

テスト7番——store_a の上限を変えたのに、store_b のコピーはまだ旧ポリシーで動いてしまう。バグが ok になっている。本来は承認不可になるべき注文が、通過している。

次に After(Chain of Responsibility)で、ドリフトが起きないことを確認する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
ok 1  - NoDiscountHandler: 割引なし → 承認不要
ok 2  - NoDiscountHandler: next_handler なし + 割引あり → die
ok 3  - ManagerHandler 単体: 上限内 → 店長承認
ok 4  - ManagerHandler 単体: 上限超え + next_handler なし → die
ok 5  - chain_a: 割引なし → 承認不要
ok 6  - chain_a: 1万円10% → 店長承認
ok 7  - chain_a: 2万円10% → 本社承認
ok 8  - chain_a: 4.5万円15% → 本社承認
ok 9  - chain_a: 6万円25% → 承認不可
ok 10 - chain_b: 2万円10% → 店長承認(上限3万円)
ok 11 - store_a と store_b で 2万円10%の結果が違う(同じクラス・異なるインスタンス設定)
ok 12 - chain_a_v2: 4.5万円 → 承認不可(HQ limit を4万円に変更)
ok 13 - chain_b: 4.5万円15% → まだ本社承認(store_b は別インスタンス・影響なし)
1..13

全テスト通過、警告なし。

テスト12番と13番——store_a の HQHandlerlimit を4万円に変えても、store_b のチェーンには影響しない。別のインスタンスだから。コードをコピーしていないから、変更漏れは起きない。

男性がAfterのコードを見た。approve_discount_store_b という関数名が、どこにも出てこない。

「これで、store ごとのポリシーを、コードをコピーせずに表現できる」と彼は言った。

「チェーン構築コードだけが store ごとに違う」とシェフが言った。「ManagerHandler クラスは変えない」

「地区マネージャーを追加するときは——」

RegionalManagerHandler を1クラス書く。extends 'ApprovalHandler' して、limitmax_discount を持たせる。既存の NoDiscountHandlerManagerHandlerHQHandler も触らない。チェーン構築コードで順番を決める」

男性は少し間を置いてから言った。「チームメンバーに説明してみます」


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
承認条件と閾値をソースに焼き込んでおり(policy-baked-in)、store ごとに関数をコピーして管理コストが増大。閾値変更の反映漏れでバグが出たChain of Responsibilityパターン:ハンドラが limit などの設定値を属性として持ち、実行時に store ごとのチェーンを組むstore ごとに異なるチェーンを同じクラスから組める。コピーが不要。新しい承認レベルは新クラスを1つ書いてチェーンに挿すだけ

工程

  1. 「同じ構造で数字だけが違う関数が複数ある」状態を探す——コピーが policy-baked-in の典型
  2. 数字(閾値)をハンドラの has 属性に移す。ソースではなく new で渡す
  3. 基底クラス(ApprovalHandler)を Moo で作り、next_handler 属性と「次に渡す」デフォルト handle を実装する
  4. 各ケースを担う具体ハンドラを extends で継承し、自分の条件を判定して処理できなければ SUPER::handle で次へ渡す
  5. store ごとのチェーンを new のネストで組む——同じクラスのインスタンスを属性違いで作る
  6. 各ハンドラ単体と、チェーン全体のテストを両方書く
  7. 「store の上限変更」テストで、別 store のチェーンに影響しないことを確認する

シェフより

「数字を変えただけのコードを5つ作るな。数字をオブジェクトに持たせろ。同じクラスを使って、new で閾値を変える。入ってきた注文は最初の窓口に渡すだけ。窓口が自分で判断する。窓口を増やすなら新しいクラスを一つ書いてチェーンに挿せ。既存の窓口は触らなくていい」


男性が立ち上がって「ありがとうございました」と言った。荷物を持ちながら、「チームメンバーに説明してみます」ともう一度言った。

ドアが閉まった。シェフはコーヒーを持って厨房に戻った。

私はホールを片付けながら、その言葉をもう一度頭の中で繰り返していた。チームに説明する。

今日、私は最初に「リストにして回せばいい」と言った。方向は合っていた。シェフもそれを「動く、同じ設計だ」と言った。でも「なぜクラスにするのか」の続きが、自分では言えなかった。シェフが「テーブルでも動く。クラスにする理由は別だ」と言った瞬間、私は止まった。

理由は——名前が付くこと。単独でテストできること。「次に渡す」を一か所に置けること。

シェフの説明を聞いてから、分かった。では、次に誰かに聞かれたとき、自分の言葉で言えるか。

男性は「チームに説明できる」と言った。私は今日、シェフの言葉でそれを理解した。でも——自分の言葉で誰かに伝えられるかは、今夜試してみないと分からない。

見えることと、伝えることは、また別のことだと思った。

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