Featured image of post コードシェフの仕込み帳【Observer】走って伝える注文変更〜通知先を登録すれば、足で回らなくてよい〜

コードシェフの仕込み帳【Observer】走って伝える注文変更〜通知先を登録すれば、足で回らなくてよい〜

注文ステータスが変わるたびに全ステーションへ手動で通知するコードを、Observerパターンで整理します。PerlとMooで、Subject が通知先を知らなくてよい設計へ。なぜ追加するとき Subject を触らなくて済むのかを仕組みから丁寧に解説します。

「コードが、時限爆弾なんですよね、たぶん」

その言葉を聞いたとき、私はカウンター席でノートを読み返していました。私はこの「コード食堂」で見習いとして働く、駆け出しのエンジニアです。前の2件の仕込み直しで書き留めたメモを眺めていたのですが、「attach、detach、notify」という言葉をどこかで聞いた気がして探していたところでした——そんな言葉はどこにもなかったのですが。

この記事で学ぶこと

この記事は、注文ステータスが変わるたびに焼き場・揚げ場・ドリンクバーへ手動で逐次通知するコードを、Observerパターンで整理する話です。Perlのコードを少しずつ仕立て直していきます。

学ぶことひとことで言うと
Observerパターン変化を見張る側が、Subject(被観察者)の状態変化を自動で受け取る仕組み
密結合の手動通知Subject が通知先全員の名前を知っており、追加のたびに Subject を修正する問題
Moo::Role の requires「このメソッドを持つことを約束しろ」という型紙を定義する方法
attach / detachObserver が自ら登録・解除することで、Subject は誰が聞いているかを知らなくてよくなる仕組み
開放閉鎖原則(OCP)既存をいじらず、追加だけで機能を増やせる状態

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

  • PerlとMooの基本(hasnewwith)がなんとなく分かる
  • 通知先が増えるたびに同じ箇所を直し続けるコードに、なんとなく不安を感じたことがある
  • 第1作のStrategyパターン・第2作のStateパターンを読んで、「次は何が来るのか」と気になっている

技術スタックは Perl / Moo です。コードはすべて手元で動かし、テストが通ることを確認しています。なお本文中のモジュールは要点を抜き出して示しているため、実際にファイルへ保存するときは Perl の作法として末尾に 1; を加えてください。

今日の持ち込み素材

開店前の静かな店内に、入ってきたのは同年代に見える女性でした。スーツではなく、シャツと細身のパンツという実務的な格好。視線が合うと軽く会釈して「——予約はしていないんですが」と言いました。「どうぞ」と伝えてシェフを呼びに行こうとすると、シェフはすでに厨房から出てきていました。音で気づいていたようでした。

お客さんが椅子に座り、ノートPCを開いてから、最初に言った言葉が冒頭のものです。「コードが、時限爆弾なんですよね、たぶん」。

シェフが一瞬だけ手を止めました。「——まだ爆発していないのに来た?」とシェフが言うと、お客さんが「はい」と短く答えました。シェフは短い間を置いてから「珍しいな」と独り言のように言い、「見せてもらえますか」と続けました。

お客さんがスクリーンを向けました。スタートアップで飲食店向けのオーダー管理システムを開発しているのだそうです。注文のステータスが変わるたびに、厨房の各ステーション——焼き場、揚げ場、ドリンクバー——へ通知するコードを書いた。動いている。でも先週、バースタンドを追加したとき、通知の呼び出しを3か所すべてに書き忘れそうになった。テストで気づいたから事なきを得たけれど。「次のステーションを足すたびにこれをやるのかと思ったら、怖くなって」。

私は「壊れた後で来る人」しか見ていなかった。「壊れる前に来る人がいるんだ」と思ったが、うまく言葉にはできなかった。

コードはこういうものでした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package OrderManager;
use Moo;

has grill_station => (is => 'rw');  # 焼き場
has fryer_station => (is => 'rw');  # 揚げ場
has drink_bar     => (is => 'rw');  # ドリンクバー
# ↑ 通知先をすべて has で持つ。追加のたびにここを変更する

sub update_status {
    my ($self, $order_id, $status) = @_;
    $self->grill_station->on_order_update($order_id, $status);
    $self->fryer_station->on_order_update($order_id, $status);
    $self->drink_bar->on_order_update($order_id, $status);
    # ↑ 全員の名前を直書き。1か所書き忘れると通知されない
}

「動いています、今は」とお客さんは言い足しました。

素材を見る目

シェフがコードをしばらく見ていました。無言でした。それから、カウンターに置いていたペンを持ち、コードのある1か所を指しました。

「ここが、全員の名前を持ってますね」

お客さんが「そうです。今は3つです。来月、配膳モニターも繋ぐ予定で——増えます」と答えました。

「足すたびに、ここを直すことになる」とシェフが言いました。お客さんが「直し忘れたら、通知が届かない」と引き取ると、シェフはただ「そう」と言いました。

一瞬、シェフがなぜか私のほうを見ました。「料理で言ったら、どういうことだと思う?」

思いがけず問われて、私は少し考えました。前の2件の仕込み直しで、なんとなく比喩の当て方がわかってきた気がしていたので。「——走って伝えに行く、みたいな感じですか?注文が変わるたびに、焼き場に走って、揚げ場に走って」。

シェフは何も答えませんでした。ただ、返事の代わりにコンロに火をつけました。正解だったのか外れだったのか、わかりませんでした。でも、シェフが続きを始めたのでそれでよかったのだと思いました。

なぜ、これは壊れやすいのか

このコードのアンチパターンは、密結合の手動通知チェーンです——Subject(状態変化を起こす側)が、通知先全員の名前を直接知っている状態のことです。

具体的に言うと、OrderManager はいま grill_stationfryer_stationdrink_bar の3つを属性として持ち、update_status の中でそれぞれを手で呼んでいます。

新しいステーション(たとえばバースタンド)を追加するとき、必ず2か所を変更しなければなりません。

  1. has bar_station => ... という属性を OrderManager に追加する
  2. update_status の中に $self->bar_station->on_order_update(...) を書き加える

どちらか1か所でも忘れれば、バースタンドには通知が届きません。しかもこの「通知漏れ」は実行時になるまで発覚しません。

問題の本質は「変更箇所が2つに分散していること」ではなく、「変更の必要が OrderManager に生まれること」そのものです。新しいステーションを追加するという作業が、通知先とは関係のない OrderManager の改修を引き起こす。これが密結合の症状です。

仕込み直し

シェフがカウンターの隅のメモ帳を引き寄せて、書き始めました。お客さんが前のめりで見ています。私もつい後ろから覗き込みました。

最初に書いたのはこれでした。

1
2
3
package OrderObserver;
use Moo::Role;
requires 'update';

お客さんが「requires 'update' というのは——?」と聞きました。

シェフは書く手を止めないまま、「これを持っていることを約束しろ、という印。持っていなければ、with 'OrderObserver' した時点で怒る」と言いました。

資格証みたいなもの——と思った。料理で言うと、食品衛生責任者の資格がなければ厨房に立てない、みたいな。正しいかどうかは確認できなかったが、なんとなく腑に落ちた。

次にシェフが書いたのが、新しい OrderManager でした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package OrderManager;
use Moo;

has _observers => (
    is      => 'ro',
    default => sub { [] },
);

sub attach {
    my ($self, $observer) = @_;
    push @{$self->_observers}, $observer;
}

sub detach {
    my ($self, $observer) = @_;
    @{$self->_observers} = grep { $_ != $observer } @{$self->_observers};
}

sub update_status {
    my ($self, $order_id, $status) = @_;
    $_->update($order_id, $status) for @{$self->_observers};
}

Before の OrderManager と比べると、has grill_stationhas fryer_stationhas drink_bar がすべて消え、代わりに _observers というリストだけが残っています。update_status の中身も、「全員の名前を呼ぶ」ではなく「リストにある誰かの update を呼ぶ」に変わっています。

そして各ステーションは、こうなります。

 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
package GrillStation;
use Moo;
with 'OrderObserver';

sub update {
    my ($self, $order_id, $status) = @_;
    # 焼き場の処理
}

package FryerStation;
use Moo;
with 'OrderObserver';

sub update {
    my ($self, $order_id, $status) = @_;
    # 揚げ場の処理
}

package DrinkBar;
use Moo;
with 'OrderObserver';

sub update {
    my ($self, $order_id, $status) = @_;
    # ドリンクバーの処理
}

使う側では、こうなります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
my $mgr   = OrderManager->new;
my $grill = GrillStation->new;
my $fryer = FryerStation->new;
my $drink = DrinkBar->new;

$mgr->attach($grill);
$mgr->attach($fryer);
$mgr->attach($drink);

$mgr->update_status('A001', '調理開始');
# GrillStation、FryerStation、DrinkBar それぞれの update が呼ばれる

核心の問い

シェフがペンを置いたとき、お客さんが少し前のめりのまま聞きました。

「でも——attach を呼ぶのは誰かがやらなきゃいけない。それって、結局どこかが全員を知っている、という状態じゃないですか?」

シェフが向き直りました。感情を出さない顔でしたが、一拍置いてから答えました。

attach を呼ぶのは——各ステーションの方です。OrderManagerGrillStation の名前を知るんじゃなく、GrillStation が自分から attach を呼ぶ。新しいステーションを追加するときは、新しいクラスを書いて、その中で attach を1行書く。OrderManager のコードは、触らなくていい」

お客さんがしばらく止まりました。それからゆっくりと「——変更の場所が変わる、ということですか」と言いました。

シェフが「そう」と言いました。

なぜそれで問題が消えるのか

ここがこのパターンの核心です。

Before のコードでは、「バースタンドを追加する」という変更が OrderManager の中に入り込んでいました。OrderManager はバースタンドのことを知っていなければならなかったからです。

After のコードでは、OrderManager は誰が _observers のリストに入っているかを知りません。知っているのは「update メソッドを持つ誰か」だということだけです。バースタンドを追加するとき、OrderManager のコードは1行も変わりません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# バースタンドを追加するとき:OrderManager に手を入れない
package BarStation;
use Moo;
with 'OrderObserver';

sub update {
    my ($self, $order_id, $status) = @_;
    # バースタンドの処理
}

# 利用側で attach するだけ
$mgr->attach(BarStation->new);

「変更の場所が変わる」というお客さんの言葉は正確でした。変更は「新しいステーションを書く場所」だけに限定される。これが、密結合が解消されたということです。

また、with 'OrderObserver' を書いて update を実装し忘れた場合は、クラスを定義した時点でエラーになります。通知漏れのリスクが「実行時の沈黙」から「定義時のエラー」に変わるのも、Moo::Rolerequires の効果です。

構造の変化を図で見る

Before: 密結合OrderManager が全員を知っている)

	classDiagram
    class OrderManager {
        +grill_station
        +fryer_station
        +drink_bar
        +update_status(order_id, status)
    }
    class GrillStation {
        +on_order_update(order_id, status)
    }
    class FryerStation {
        +on_order_update(order_id, status)
    }
    class DrinkBar {
        +on_order_update(order_id, status)
    }
    OrderManager --> GrillStation : 直接呼ぶ
    OrderManager --> FryerStation : 直接呼ぶ
    OrderManager --> DrinkBar : 直接呼ぶ

After: Observer パターンOrderManager は Observer を知らない)

	classDiagram
    class OrderObserver {
        <<Moo::Role>>
        +update(order_id, status)*
    }
    class OrderManager {
        -_observers
        +attach(observer)
        +detach(observer)
        +update_status(order_id, status)
    }
    class GrillStation {
        +update(order_id, status)
    }
    class FryerStation {
        +update(order_id, status)
    }
    class DrinkBar {
        +update(order_id, status)
    }
    OrderObserver <|.. GrillStation : with
    OrderObserver <|.. FryerStation : with
    OrderObserver <|.. DrinkBar : with
    OrderManager o--> OrderObserver : notify
    GrillStation ..> OrderManager : attach
    FryerStation ..> OrderManager : attach
    DrinkBar ..> OrderManager : attach

依存の矢印の向きが変わっていることに注目してください。Before では OrderManager が各ステーションへ向かって矢印が伸びていました。After では、各ステーションが OrderManager に向かって「自分を登録する」矢印になっています。これが「変更の場所が変わる」の構造的な意味です。

試食合格

テストを実行しました。全項目グリーンでした。お客さんが画面を見て、静かに「これで、新しいステーションを追加しても OrderManager は変えなくていい」と、確認するように言いました。

シェフが「BarStation を作って、attach する。それだけ」と言いました。

お客さんが「このパターンに名前はありますか」と聞きました。

「Observerパターン——観察するものの設計パターン、とでも言うか。変化を見張る側が、Subject(被観察者)の状態変化を自動で受け取る」とシェフが答えました。

お客さんが礼儀正しく礼を言い、PCを閉じました。立ち上がりかけて、一瞬手を止めて「——最初からこう書けばよかった」と言いました。

「最初からは難しい。大事なのは、次から書けることだ」とシェフが言いました。

お客さんは小さくうなずいて出ていきました。ドアが閉まった後、しばらく店内が静かでした。

「最初からは難しい」——シェフは、お客さんのことを言った。でも、なぜか私のことを言われた気がした。私は今日まで、ここで見たことをメモするだけだった。今日も、メモするだけだった。——でも、シェフがさっき私に問いを向けたとき、自分なりの比喩が出てきた。「走って伝えに行く」。合っていたのかはわからない。でも、何か聞けばよかった気がした。なんと聞けばよかったのかは、まだわからないのだけれど。


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
Subject が通知先全員の名前を直書きしているObserverパターン(requires 'update' ロール)Subject は通知先を知らない。notify するだけでよい
通知先を追加するたびに Subject を2か所改修するObserver が attach で自ら登録する変更箇所が新しいクラスのみに限定される
通知漏れが実行時まで発覚しないMoo::Role の requiresupdate の実装を強制ロール使用時点(クラス構築時)にエラーになる

工程

  1. Observer のインターフェースを Moo::Role で定義する。requires 'update' を書く
  2. Subject(通知元)から通知先の属性(has grill_station 等)をすべて取り除く。代わりに _observers リストだけを持つ
  3. Subject に attach / detach / update_status(各 update を呼ぶ)を実装する
  4. 各 ConcreteObserver に with 'OrderObserver' を追加し、update を実装する
  5. 利用側で Observer を生成し、attach で Subject に登録する
  6. 新しい Observer を追加するときは、新しいクラスを書いて attach を呼ぶだけ。Subject のコードには触らない

シェフより

厨房に注文票を貼るピンチがある。注文が変わったら、票を差し替えるだけだ。焼き場も揚げ場も、自分で票を確認しに来る——誰かが走って伝えに行く必要はない。Observerパターンは、そういう仕込みだ。Subject は「変わった」と告げるだけでいい。誰が聞いているかは、聞く側が決める。

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