Featured image of post コード探偵ロックの事件簿【Law of Demeter】列車事故の共犯者たち〜メソッドの連鎖が暴く内部構造の地図〜

コード探偵ロックの事件簿【Law of Demeter】列車事故の共犯者たち〜メソッドの連鎖が暴く内部構造の地図〜

住所テーブルを変更しただけで配送料計算が全滅した——$order->customer->address->prefectureの4段連鎖が招くLaw of Demeter違反をMooのhandles委譲で断ち切るコード探偵ロックの推理。

深夜1時を回っていた。

僕は森川ユウジ、30歳。ECシステムのバックエンドとインフラを兼任して3年目になる。今夜は障害対応でオフィスに残っている。

症状は単純だ。配送料の計算が全滅している。注文は受け付けられるが、配送料が0円になる。正確に言えば、配送料の計算メソッドが例外を吐いて死んでいる。

原因には心当たりがあった。昨日の午後、住所テーブルのカラム名を変更した。prefectureregion に。都道府県だけでなく海外の地域コードも扱えるようにするための拡張だった。マイグレーションは通った。Address クラスのアクセサも書き換えた。単体テストも通した。

なのに配送料が死んだ。税計算も死んだ。請求書生成も死んだ。触っていないはずのクラスが、3つ同時に壊れた。

ターミナルのスタックトレースを見ても、壊れているのは ShippingCalculator の中だ。Address のテストは通っているのに、そこから3クラス先の ShippingCalculator が倒れている。

影響範囲が見えない。prefecture を使っている箇所を grep すれば出てくるが、直接使っているのか、何かを経由しているのか、依存の深さが分からない。

コーヒーを淹れに給湯室へ行ったとき、窓の外に目が止まった。同じビルの3階に灯りがついている。この時間に灯りがついているフロアは、僕が知る限り5階のうちのオフィスだけのはずだった。

3階のテナントは知らない。入居時に案内板を見た記憶では、IT系の会社名が並んでいたはずだ。この時間にまだ仕事をしているなら、同業の可能性が高い。

藁にもすがる気持ちで階段を降りた。ドアの前に立って、初めて看板を読んだ。

「レガシー・コード・インベスティゲーション」

聞いたことのない社名だった。ドアは半開きで、中から蛍光灯の光が漏れている。

ノックして開けると、壁一面に紙が貼ってあった。

クラス図だった。印刷されたクラス図がA4用紙で20枚以上、壁にテープで貼り付けてある。クラスとクラスの間を赤い糸で結んでいる。刑事ドラマの捜査ボードそのものだ。

部屋の中央のデスクに、男が一人座っていた。赤い糸の端をピンで留めながら、クラス図を睨んでいる。デスクの上にはエナジードリンクの空き缶が5本と、開きかけの宅配ピザの箱。

「あの、すみません。上の階でシステムの障害対応をしていて——」

「座りたまえ、ワトソン君」

振り向きもせずに言った。壁のクラス図から目を離さない。

「森川です。今それどころじゃないんです。配送料の計算が全滅していて——」

「知っている」

男がようやくこちらを向いた。

「知っている、とは」

「このビルの共用Wi-Fiのパケットが22時頃から荒れていてね。誰かが何度もデプロイとロールバックを繰り返しているのが分かった。3階でそんなことをするのは私ではないから、上の階だろうと推理していたところだ」

パケットの乱れでデプロイを推理する人間は初めて見た。

「見せたまえ、ワトソン君。コードをだ」

「森川です」

訂正が届いた様子はなかった。しかし選んでいる場合ではなかった。ノートPCを開いて、壊れている ShippingCalculator のコードをこの男——ロックと名乗った——に見せた。

列車事故の現場

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package ShippingCalculator;
use Moo;

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

sub calculate ($self) {
    my $pref = $self->order->customer->address->prefecture;
    my %zone = (
        '東京都'   => 'kanto',
        '神奈川県' => 'kanto',
        '千葉県'   => 'kanto',
        '大阪府'   => 'kansai',
        '京都府'   => 'kansai',
    );
    my $zone = $zone{$pref} // 'other';
    my %rate = (kanto => 500, kansai => 700, other => 1000);
    return $rate{$zone};
}

ロックさんは5秒ほど画面を見て、壁のクラス図の方を指さした。

「この行だ」

1
my $pref = $self->order->customer->address->prefecture;

「矢印が4本つながっている。$self から orderorder から customercustomer から addressaddress から prefecture。この連鎖は列車だ」

「列車?」

「客車が4両つながっている。先頭車両の $self から最後尾の prefecture に到達するには、途中の客車——ordercustomeraddress——をすべて通過しなければならない。途中の車両を1両でも入れ替えたら、後ろの車両ごと脱線する。Train Wreck——列車事故だ」

	graph LR
    SC["ShippingCalculator<br>#calculate"] -->|order| O[Order]
    O -->|customer| C[Customer]
    C -->|address| A[Address]
    A -->|prefecture| P["prefecture<br>(値)"]
    style SC fill:#fee,stroke:#c00
    style O fill:#ffd,stroke:#aa0
    style C fill:#ffd,stroke:#aa0
    style A fill:#ffd,stroke:#aa0

ShippingCalculator は、配送料を計算するだけのクラスだ。なのに Order の中に Customer がいて、Customer の中に Address があって、Addressprefecture を持っている——という構造を丸ごと知っている。知りすぎている」

「でも、都道府県がないと配送ゾーンは決められませんよね」

「都道府県が必要なのは正しい。だが都道府県を得るために、CustomerAddress の存在を知る必要があるかどうかは別の話だ」

ロックさんはエナジードリンクの缶を1本取り上げたが、空だった。舌打ちして缶を戻す。

「これは Law of Demeter——デメテルの法則 の違反だ。原則はシンプルでね。『直接の友人とだけ話せ。見知らぬ者とは話すな』」

「見知らぬ者」

ShippingCalculator にとって order は直接の友人だ。属性として保持している。だが customer は友人の友人であり、address は友人の友人の友人だ。見知らぬ者に道を尋ねて、さらにその先の見知らぬ者に案内を頼んでいる」

理屈は分かった。しかし実務的な疑問が先に出た。

「つまり何をすればいいんですか」

「列車を解体する」

推理披露:handles で列車を解体する

ロックさんは壁のクラス図に新しい紙を1枚追加した。

「3段階で進める。まず、配送ゾーンの判定を住所に返す」

Step 1:Address に配送ゾーン判定を移す

「都道府県から配送ゾーンを決めるロジックは、ShippingCalculator にある必要がない。このロジックが必要とするデータは prefecture だけであり、それは Address が持っている」

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

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

sub shipping_zone ($self) {
    my %zone = (
        '東京都'   => 'kanto',
        '神奈川県' => 'kanto',
        '千葉県'   => 'kanto',
        '大阪府'   => 'kansai',
        '京都府'   => 'kansai',
    );
    return $zone{$self->prefecture} // 'other';
}

shipping_zone$self->prefecture しか使っていない。自分のデータだけで完結している」

「計算をデータの持ち主に寄せるのは分かりました。でも ShippingCalculator から呼ぶときは、結局 $self->order->customer->address->shipping_zone って書くんですよね。連鎖が3段残ったままじゃないですか」

ロックさんはピザの箱を開けて1切れ取り、噛みながら答えた。

「そうだ。ロジックの置き場所を正しただけでは、列車事故は止まらない。ShippingCalculator はまだ OrderCustomerAddress という経路——内部構造の地図を丸暗記している。計算を移すだけじゃなくて、経路を短くしないと意味がない」

「どうやって短くするんですか」

「委譲だ。Moo の handles を使う」

Step 2:Customer と Order に handles を設定する

「次に、この shipping_zone を上位のクラスに委譲する。Moo の handles を使う」

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

has name    => (is => 'ro', required => 1);
has email   => (is => 'ro', required => 1);
has address => (
    is       => 'ro',
    required => 1,
    handles  => [qw(shipping_zone)],
);

Customerhandles => [qw(shipping_zone)] と書くことで、$customer->shipping_zone と呼べるようになる。裏では $customer->address->shipping_zone が呼ばれるが、呼び出し側はそれを知らない」

「1層挟んでいるだけじゃないですか。Address の構造が変わったら、結局 Customer も壊れるんじゃ」

ロックさんはピザを置いた。

「壊れる場所が違う。以前は ShippingCalculator のメソッド本体の中で壊れていた。今は Customerhas address の宣言——クラス定義の入り口で壊れる。メソッドの中身を掘り返す必要がない」

「でも、壊れること自体は変わらないですよね」

「壊れることは変わらない。変わるのは、壊れる場所の数だ」

ロックさんは壁のクラス図を指さした。

「今の設計では、$self->order->customer->address->prefecture を使っているクラスが3つある。ShippingCalculatorTaxCalculatorInvoiceGenerator。住所テーブルのカラム名を変えたら、この3つのメソッド本体がすべて壊れた。それが今夜の障害だ」

図星だった。まさにその3つが同時に死んでいた。

handles にすれば、壊れるのは Addressshipping_zone メソッドと、Customerhas addresshandles 宣言——この2箇所だけだ。ShippingCalculatorTaxCalculatorInvoiceGenerator も、Order から shipping_zone を受け取るだけだから、中間の構造がどう変わろうと影響を受けない」

同じことを Order にも適用する。

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

has item_name  => (is => 'ro', required => 1);
has quantity   => (is => 'ro', required => 1);
has unit_price => (is => 'ro', required => 1);
has customer   => (
    is       => 'ro',
    required => 1,
    handles  => [qw(shipping_zone)],
);

Step 3:ShippingCalculator も handles で受け取る

「最後に ShippingCalculator 自体も handlesshipping_zone を受け取る」

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

has order => (
    is       => 'ro',
    required => 1,
    handles  => [qw(shipping_zone)],
);

sub calculate ($self) {
    my %rate = (kanto => 500, kansai => 700, other => 1000);
    return $rate{$self->shipping_zone};
}

$self->order->customer->address->prefecture$self->shipping_zone になった。4両の列車が1両になっている」

「でも裏側では4段の委譲が連鎖しているわけですよね。ShippingCalculatorOrderCustomerAddress。結局、構造は同じじゃないですか」

ロックさんは空き缶をデスクの端に並べた。5本の缶を一列に。

「これが列車だ。先頭の缶を押すと、5本全部が倒れる」

1本目を指で弾いた。缶が倒れ、2本目にぶつかり、5本目まで連鎖して倒れた。

ロックさんは缶を立て直し、今度は1本ずつの間に手のひらを挟んだ。

「これが handles だ。1本目が倒れても、2本目には届かない。間に壁がある」

「壁?」

handles はインターフェースの宣言だ。Customer は『shipping_zone を提供する』と宣言している。その裏で Address に委譲しているのは実装の都合であって、Order はそれを知らない。AddressGeoAPI に変わろうと、Customershipping_zone を提供し続ける限り、Order は壊れない」

「委譲は地図を持つことではない——地図を持たなくて済むようにすることだ」と言いたいわけですか」

「私が言おうとしていたことを先に言うのはやめたまえ、ワトソン君」

「森川です」

	graph LR
    SC["ShippingCalculator<br>#calculate"] -->|"handles: shipping_zone"| O[Order]
    O -->|"handles: shipping_zone"| C[Customer]
    C -->|"handles: shipping_zone"| A[Address]
    style SC fill:#efe,stroke:#0a0
    style O fill:#efe,stroke:#0a0
    style C fill:#efe,stroke:#0a0
    style A fill:#efe,stroke:#0a0

列車が止まった夜

「テストだ」

ロックさんが顎をしゃくった。僕はターミナルに向き直った。

まず Address の単体テスト。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
subtest 'Address: shipping_zone — 東京都はkanto' => sub {
    my $addr = Address->new(prefecture => '東京都');
    is($addr->shipping_zone, 'kanto', '関東ゾーン');
};

subtest 'Address: shipping_zone — 大阪府はkansai' => sub {
    my $addr = Address->new(prefecture => '大阪府');
    is($addr->shipping_zone, 'kansai', '関西ゾーン');
};

subtest 'Address: shipping_zone — 北海道はother' => sub {
    my $addr = Address->new(prefecture => '北海道');
    is($addr->shipping_zone, 'other', 'otherゾーン');
};

パス。Address のテストは Address だけで閉じている。

次に Customer の委譲テスト。

1
2
3
4
5
6
7
8
subtest 'Customer: handles — shipping_zoneが委譲される' => sub {
    my $customer = Customer->new(
        name    => '森川ユウジ',
        email   => 'morikawa@example.com',
        address => Address->new(prefecture => '大阪府'),
    );
    is($customer->shipping_zone, 'kansai', 'Customer経由で関西ゾーン');
};

パス。Customer のテストは CustomerAddress で閉じている。OrderShippingCalculator は関係ない。

Order の委譲テスト。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
subtest 'Order: handles — shipping_zoneが委譲される' => sub {
    my $order = Order->new(
        item_name  => 'ウィジェットA',
        quantity   => 1,
        unit_price => 1000,
        customer   => Customer->new(
            name    => '森川ユウジ',
            email   => 'morikawa@example.com',
            address => Address->new(prefecture => '東京都'),
        ),
    );
    is($order->shipping_zone, 'kanto', 'Order経由で関東ゾーン');
};

パス。

最後に ShippingCalculator

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
subtest 'ShippingCalculator: 東京都 — 500円' => sub {
    my $order = Order->new(
        item_name  => 'ウィジェットA',
        quantity   => 1,
        unit_price => 1000,
        customer   => Customer->new(
            name    => '森川ユウジ',
            email   => 'morikawa@example.com',
            address => Address->new(prefecture => '東京都'),
        ),
    );
    my $calc = ShippingCalculator->new(order => $order);
    is($calc->calculate, 500, '関東ゾーン');
};

全テスト、パス。

僕はターミナルの ok の列を見ながら、今夜の障害を思い返していた。

prefectureregion に変更したとき、Address クラスのアクセサは書き換えた。Address の単体テストも通した。なのに3つのクラスが同時に死んだのは、3つのクラスがすべて $order->customer->address->prefecture という連鎖で Address の内部構造に直接到達していたからだ。

もし handles で委譲していれば、prefectureregion に変わっても、修正するのは Addressshipping_zone メソッドの中——$self->prefecture$self->region に変えるだけだ。CustomerOrderShippingCalculator も、shipping_zone というインターフェースを通じて結果を受け取っているだけだから、内部のカラム名など知る必要がない。

壊れなかったのではない。壊れる場所が、列車全体から1両目だけに絞られた。

「メソッドチェーンが何段まではOK、みたいな基準ってあるんですか」

ロックさんはピザの最後の1切れに手を伸ばした。

「段数の問題ではない。ドットの数を数えることに意味はない。Fluent Interface——メソッドが自分自身を返す連鎖は、何段続いても Law of Demeter には違反しない。問題は、連鎖の途中で異なるオブジェクトを渡り歩いているかどうかだ」

「Fluent Interface なら OK?」

$builder->set_name('X')->set_age(30)->build() は毎回 $builder 自身が返っている。相手は変わらない。だが $order->customer->address->prefecture は、Order から Customer へ、Customer から Address へと、相手が変わり続けている。話し相手が変わるたびに、知らなくてよかった構造を知ることになる」

答えに納得した。段数ではなく、型が変わるかどうか。

ロックさんが立ち上がった。ピザの箱を閉じて、壁のクラス図を1枚ずつ剥がし始めた。

「報酬は」

「え?」

「深夜のコンサルティング料だ」

この時間にピザを食べているのはこの人だ。僕が提供したものは何もない。

「……ピザの代金を払えということですか」

「ピザは私のだ。代金は受け取れない。だが君が2階のオフィスに戻る途中で、1階の自販機で缶コーヒーを1本買ってきてくれれば、それでいい」

「5階です。僕のオフィスは5階です」

「そうだったかね」

クラス図をすべて剥がし終えた壁は、テープの跡だけが残っていた。

壊れた列車を直すのに必要だったのは、新しい車両ではなかった。車両と車両の間に壁を立てて、それぞれが自分の車両の中だけで仕事をするようにすること。見知らぬ者に道を尋ねるのをやめて、隣の友人に聞くだけで済む設計にすること。

缶コーヒーを買いに階段を降りながら、明日のリファクタリング計画を頭の中で組み立てていた。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
Law of Demeter 違反(Train Wreck / Message Chains)Moo の handles(委譲)による構造の隠蔽メソッドチェーンが解消され、中間オブジェクトの変更が波及しなくなる

推理のステップ

  1. Train Wreck を見つける: メソッド内で $self->a->b->c->d のように異なるオブジェクトを渡り歩く連鎖を探す
  2. 末端のロジックを末端に移す: 連鎖の最後で取得しているデータを使った計算を、そのデータを持つクラスのメソッドにする(例: shipping_zoneAddress に移動)
  3. handles で1段ずつ委譲する: 各クラスが直接保持する属性に対して handles を宣言し、上位クラスに必要なメソッドだけを公開する
  4. 呼び出し側を $self->method に書き換える: メソッドチェーンを handles 経由の1段アクセスに置き換え、連鎖を解消する
  5. テストの独立性を確認する: 各クラスのテストが、他クラスの内部構造の変更に影響されないことを検証する

ロックより

列車事故の原因は、速度でも車両の数でもない。車両同士が直結していて、1両の脱線が全体を巻き込むことだ。handles は車両間の連結器に衝撃吸収材を入れるようなものだ。衝撃は伝わらなくなるが、列車は走り続ける。見知らぬ者に道を尋ねるのは、今夜で最後にしたまえ。

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