Featured image of post コードシェフの仕込み帳【Mediator】ホール担当〜3つのステーションが互いを直接参照するコードを、全員が仲介役だけを知るスター型構造へ直す〜

コードシェフの仕込み帳【Mediator】ホール担当〜3つのステーションが互いを直接参照するコードを、全員が仲介役だけを知るスター型構造へ直す〜

キッチン管理システムで GrillStation・FryStation・DrinkCounter が互いを直接保持し、ステーション追加のたびに全クラスを修正する問題。MediatorパターンをPerlとMooで実装し、全員が KitchenCoordinator だけを知るスター型構造へ直す。

昼前の厨房は静かだった。

シェフが明日の仕込みをしていた。私はそのそばで、野菜の下ごしらえを手伝いながら、包丁の音を聞いていた。

引き戸が開いた。

38歳ほどの男性が入ってきた。スーツではなく、きれいなシャツ姿。「紹介いただいた者です」と言った。落ち着いた話し方だった。「キッチン管理のシステムを書いているんですが、設計の問題がありまして——」

シェフが包丁を置かずに「見せろ」と言った。

男性がPCをカウンターに置いて開いた。GrillStationFryStationDrinkCounter——3つのクラスが並んでいる。

GrillStationhas を見た。fryerdrink_counterFryStationhas を見た。grilldrink_counterDrinkCounterhas を見た。grillfryer

「——全員が全員を知っている」と私は言った。

この記事で学ぶこと

この記事は、「キッチン管理システムの各ステーションが互いのオブジェクトを直接保持し、ステーションを1つ追加・変更するたびに全クラスを修正しなければならない」という問題を、Mediatorパターンで整理する話です。全員が KitchenCoordinator(仲介役)だけを知るスター型構造へ直します。

学ぶことひとことで言うと
Mediator パターン複数のコンポーネントが互いを直接知っている代わりに、全員が1つの仲介者(Mediator)だけを知る。仲介者がすべての通信を制御する
n-to-n-couplingコンポーネント同士が相互参照している状態。コンポーネント数がNのとき、参照の数は最大N×(N-1)。1つを変えると全員に波及する
Moo での実装MediatorクラスがすべてのコンポーネントをMooの has で保持し、notify メソッドでイベントを受け取って転送する。各コンポーネントは has coordinator だけを持つ
複雑さの集中変更の波及をN対NからN対1に変える。複雑さを消すのではなく、1か所に集める

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

  • PerlとMooの基本(hasnew)がなんとなく分かる
  • クラスを追加したとき、既存のクラスをいくつも修正した経験がある

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

ステーションを1つ触ると全員に波及する

シェフが「どうなっている?」と男性に聞いた。

「最初は3つのステーションで動いていました」と男性は言った。「GrillStation(焼き場)と FryStation(揚げ場)と DrinkCounter(ドリンクカウンター)です。今年の春、SaladStation(サラダ場)を追加しようとして——既存の3クラスすべてを修正する羽目になりました。それ自体は仕方ないと思っていたんですが、先月 DrinkCounter の動作を少し変えたとき、GrillStationFryStation の両方に影響が出て。今はステーションを1つ触るたびに、全員を確認しないといけない状態で」

コードを見た。Before の構造はこうなっていた。

 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
package GrillStation;
use Moo;
use v5.36;
has fryer         => (is => 'rw');   # FryStation を直接参照
has drink_counter => (is => 'rw');   # DrinkCounter を直接参照

sub order_ready {
    my ($self, $order_id) = @_;
    $self->drink_counter->prepare($order_id);
    $self->fryer->sync_timing($order_id);
}

package FryStation;
use Moo;
use v5.36;
has grill         => (is => 'rw');   # GrillStation を直接参照
has drink_counter => (is => 'rw');   # DrinkCounter を直接参照

sub order_ready {
    my ($self, $order_id) = @_;
    $self->drink_counter->prepare($order_id);
    $self->grill->reduce_heat;
}

package DrinkCounter;
use Moo;
use v5.36;
has grill => (is => 'rw');   # GrillStation を直接参照
has fryer => (is => 'rw');   # FryStation を直接参照

sub prepare {
    my ($self, $order_id) = @_;
    $self->grill->check_capacity;
    # ドリンク準備
}

シェフが「n-to-n-coupling だ」と言った。「コンポーネントの数がNのとき、参照の数は最大N×(N-1)になる。1つを変えると、それを知っているすべてを確認する必要がある」

男性が「そうです——まさにそれです」と言った。問題に名前がついた、というような顔をした。

セットアップのコードも問題を示していた。GrillStation を1つ動かすだけでも、FryStationDrinkCounter のインスタンスを用意しなければならない。テストすら全員が必要だった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
my $grill = GrillStation->new;
my $fryer = FryStation->new;
my $drink = DrinkCounter->new;

# 全員が全員を知る——相互参照をすべて設定する
$grill->fryer($fryer);
$grill->drink_counter($drink);
$fryer->grill($grill);
$fryer->drink_counter($drink);
$drink->grill($grill);
$drink->fryer($fryer);

SaladStation を追加するとどうなるか。GrillStationhas saladstation を追加して order_ready で呼び出す。FryStationhas saladstation を追加する。DrinkCounterhas saladstation を追加する。3クラスすべてを修正する。それが n-to-n-coupling の実態だった。

ホール担当を立てる

シェフがホワイトボードに向かった。

「全員がコーディネーター——ホール担当だけを知る。ホール担当がすべての連絡を受けて、必要なところへ伝える」

KitchenCoordinator クラスを書いた。全ステーションを has で保持し、notify でイベントを受け取る。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package KitchenCoordinator;   # Mediator(ホール担当)
use Moo;
use v5.36;
has grill         => (is => 'rw');
has fryer         => (is => 'rw');
has drink_counter => (is => 'rw');

sub notify {
    my ($self, $from, $event, $order_id) = @_;
    if ($event eq 'order_ready') {
        if ($from eq 'grill') {
            $self->drink_counter->prepare($order_id);
            $self->fryer->sync_timing($order_id);
        }
        elsif ($from eq 'fry') {
            $self->drink_counter->prepare($order_id);
        }
    }
    elsif ($event eq 'check_capacity') {
        return $self->grill->current_capacity;
    }
}

各ステーションから他ステーションへの参照が消えた。代わりに has coordinator だけが残る。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package GrillStation;
use Moo;
use v5.36;
has coordinator => (is => 'ro', required => 1);   # coordinator だけを知る

sub order_ready {
    my ($self, $order_id) = @_;
    $self->coordinator->notify('grill', 'order_ready', $order_id);
}

# FryStation・DrinkCounter も同じ構造
# has coordinator => (is => 'ro', required => 1)
# sub order_ready(または prepare)で $self->coordinator->notify を呼ぶ

構築の順序は coordinator を先に作り、ステーションを coordinator を渡して作り、最後に coordinator にステーションを設定する。

1
2
3
4
5
6
7
my $coord = KitchenCoordinator->new;
my $grill = GrillStation->new(coordinator => $coord);
my $fryer = FryStation->new(coordinator  => $coord);
my $drink = DrinkCounter->new(coordinator => $coord);
$coord->grill($grill);
$coord->fryer($fryer);
$coord->drink_counter($drink);

「これで、SaladStation を追加するときは KitchenCoordinatornotify だけを修正すればいい」と私は言った。「既存の3クラスには手を触れない」

男性が「でも——」と言った。

SaladStation を追加したら、結局 KitchenCoordinator を修正する必要がある。それはN対N依存と同じではないですか?」

私は一拍置いた。

「違います——ステーションはお互いをもう知らない。焼き場を変えても、揚げ場とドリンクカウンターには影響しない。SaladStation を追加しても、既存のステーションは $self->coordinator->notify(...) を呼ぶだけで——誰が増えたかを知らなくていい。KitchenCoordinator が"誰と誰が話すか"の情報を全部引き受けています。変更は1か所だけです」

男性が「ああ——なるほど」と言った。既存のステーションに触れなくていいことが腑に落ちた顔をした。

シェフが「Mediatorパターン」と言った。

「複数のコンポーネントが互いを直接知っている代わりに、全員が1つの仲介者(Mediator)だけを知る。仲介者がすべての通信を制御する。コンポーネント間の依存がN対NからN対1のスター型に変わる。MooではMediatorクラスが各コンポーネントを has で保持し、notify メソッドがイベントを受け取って転送する」

男性が「なるほど。では——」と続けた。

KitchenCoordinator にロジックが集中しませんか?ステーションが増えるたびに KitchenCoordinator が大きくなっていく——今度は KitchenCoordinator が神オブジェクトになるのでは?」

私は少し考えた。

「そうです——複雑さを消しているのではなく、1か所に集めています。N対N直接参照では、変更が全コンポーネントに波及する。Mediatorにすると、変更は KitchenCoordinator だけを変えればいい。それはコントロールしやすい」

「ただ——どこまで大きくなったら、KitchenCoordinator を分割するべきか——それは、まだ言えません」

シェフが何も言わなかった。

男性が「分かりました。確かに、波及を抑えるのが先決ですね。試してみます」と言った。少し考える顔をした。

FacadeやObserverとの違い

同じく「一か所に集める」構造のパターンと混同しやすい。

パターン目的依存の向き
Facade複数サブシステムの窓口を一本化(呼び出し元を簡略化)外側から内側へ(一方向)
Observerイベント発行・購読(Pub/Sub)。発行者は購読者の種類を知らない発行者→購読者(1対N通知)
Chain of Responsibility複数ハンドラが連鎖して「誰が処理するか」を決める順送り
Mediatorコンポーネント間の通信を集中制御。N対N依存をスター型に変える全員→仲介者→転送先(スター型)

Facadeは「呼び出す側」を簡略化する。Mediatorは「コンポーネント同士の相互参照」をなくす。目的が異なる。Observerは発行者が購読者の数・種類を知らないが、Mediatorでは仲介者が全コンポーネントを知っている(それが集中のトレードオフでもある)。

試食合格

テストを走らせた。

Before(n-to-n-coupling):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
ok 1 - GrillStation は FryStation を直接保持している
ok 2 - GrillStation は DrinkCounter を直接保持している
ok 3 - FryStation は GrillStation を直接保持している
ok 4 - FryStation は DrinkCounter を直接保持している
ok 5 - DrinkCounter は GrillStation を直接保持している
ok 6 - DrinkCounter は FryStation を直接保持している
ok 7 - GrillStation::order_ready → DrinkCounter::prepare を直接呼ぶ
ok 8 - GrillStation::order_ready → FryStation::sync_timing を直接呼ぶ
ok 9 - GrillStation に saladstation 属性がない(追加するには修正必要)
ok 10 - FryStation に saladstation 属性がない(追加するには修正必要)
ok 11 - DrinkCounter に saladstation 属性がない(追加するには修正必要)
1..11

テスト9〜11番——3クラスすべてに手を入れなければ SaladStation は追加できない。これが n-to-n-coupling の具体的なコストだ。

After(Mediatorパターン):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
ok 1 - GrillStation に fryer 属性がない
ok 2 - GrillStation に drink_counter 属性がない
ok 3 - GrillStation は coordinator を持つ
ok 4 - FryStation に grill 属性がない
ok 5 - FryStation に drink_counter 属性がない
ok 6 - FryStation は coordinator を持つ
ok 7 - DrinkCounter に grill 属性がない
ok 8 - DrinkCounter に fryer 属性がない
ok 9 - DrinkCounter は coordinator を持つ
ok 10 - grill.order_ready → coordinator 経由で DrinkCounter::prepare が呼ばれた
ok 11 - coordinator が check_capacity を grill に転送する
ok 12 - SaladStation 追加後も grill.order_ready が DrinkCounter に届く
ok 13 - SaladStation 追加後 grill.order_ready が SaladStation にも届く
ok 14 - GrillStation に salad_station 属性なし(変更不要)
ok 15 - FryStation に salad_station 属性なし(変更不要)
ok 16 - DrinkCounter に salad_station 属性なし(変更不要)
...
1..20

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

テスト1〜9番——GrillStationFryStationDrinkCountercoordinator だけを持ち、互いへの直接参照がない。テスト12〜16番——SaladStation を追加した後も既存ステーションは変わらない。GrillStationsalad_station 属性はない。FryStation にも DrinkCounter にもない。KitchenCoordinator だけを変えた。

男性が「既存のステーションを変えなくていいんですね」と言った。少し表情が明るくなった。

「そうです。変更が1か所に集まる」と私が言った。


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
コンポーネント同士が互いを直接 has で保持・呼び出し(n-to-n-coupling)。ステーション追加・変更のたびに全コンポーネントを修正する必要があるMediatorパターン:KitchenCoordinator が全ステーションを保持し、notify でイベントを仲介する各ステーションは coordinator だけを知る。変更の影響が KitchenCoordinator に集中し、既存ステーションへの波及がなくなる

工程

  1. コンポーネント間の参照関係を洗い出す。何が何を has で知っているかをリストアップする
  2. KitchenCoordinator(Mediator)クラスを作る(use Moo
  3. has grillhas fryerhas drink_counter など、全コンポーネントをMediatorの属性として保持する(is => 'rw'
  4. notify($from, $event, $order_id) メソッドに、イベントに応じた転送ロジックを集める
  5. 各コンポーネントから他コンポーネントへの直接参照(has fryerhas grill など)を削除する
  6. 代わりに has coordinator => (is => 'ro', required => 1) を追加する
  7. コンポーネントが他コンポーネントを呼ぶ代わりに $self->coordinator->notify(...) を呼ぶ
  8. 構築の順序: coordinatorを先に作り(ステーション未設定)、ステーションを coordinator を渡して作り(ro/required)、最後にcoordinatorにステーションを設定(rw

シェフより

「ステーションは仕事をするだけでいい。誰に声をかけるかは——コーディネーターが決める。全員が全員を知っている必要はない。声をかける相手が1人なら、追加も変更も、その1人を直せばいい」


男性が帰った後、シェフが厨房の作業に戻った。

私は——答えた。「ステーションはお互いをもう知らない。変更は1か所に集まる」。男性は納得してくれた。

でも、シェフは何も言わなかった。

ep15のとき——「お前が答えろ」と言われて、最後まで言えた。シェフが頷いた。今日は——頷かなかった。

答えを言えることと、シェフが頷くことは、違うことなのかもしれない。

「どこまで大きくなったら、KitchenCoordinator を分割するべきか」が言えなかった。そこが——足りなかった部分だろうか。それとも——別のどこかが?

分からなかった。

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