Featured image of post コードシェフの仕込み帳【Builder】弁当の詰め方〜ルーティングループが2か所に重複する注文処理を、詰め人クラスひとつで整理する〜

コードシェフの仕込み帳【Builder】弁当の詰め方〜ルーティングループが2か所に重複する注文処理を、詰め人クラスひとつで整理する〜

弁当注文システムのルーティングループが2か所に重複し、検証の書き忘れでバグが出た。BuilderパターンをPerlとMooで実装し、add_itemが振り分けを一手に引き取る仕組みを丁寧に解説します。

午後2時を少し回ると、食堂の中はしんとする。

ランチのお客さんが帰って、片付けが終わったころの静けさだ。私はカウンターの食器をまとめながら、午後のメニューのことを考えていた。シェフは奥の冷蔵庫の前にいて、メモを取りながら在庫を確認している。換気扇の低い音だけが厨房から聞こえていた。

引き戸が開いた。

入ってきたのは30代前半に見える男性だった。ラフな服装だが、目が落ち着いていた。急いでいる様子はない。

「失礼します。知人からここを紹介してもらったんですが」

「コードの相談ですか?」と私が聞くと、「そうです」と彼はうなずいた。「ランチのあとに来てしまって、準備中でしたか」

「大丈夫です。シェフを呼んできますね」

そう言いかけると、「準備の途中でしたら、少し後でも」と彼は言った。気を遣える人だと思った。食器を片付けながら「いえ、少し待ってもらえれば」と私は答えた。

彼はカウンターにノートPCを置いた。スクリーンを開いて、横並びに2つのファイルを表示する。片方には process_form_order、もう片方には process_recurring_order という関数がある。

私は食器を持ったまま、その画面をなんとなく眺めた。コードの意味は細かくはわからない。でも、2つの関数の形が、なんとなく違う。

片方には die "デザートにはドリンクが必要\n" という行がある。もう片方には、ない。

「あの」と私は言った。食器を持ったまま、少し自信なさそうに。「こっちには書いてあって、あっちには書いてないですね」

彼がこちらを見た。「そこです。そこが問題で」

シェフが奥から出てきたのは、そのやりとりの直後だった。

この記事で学ぶこと

この記事は、「フォーム注文と定期注文でルーティングループが重複して書かれており、片方の検証を書き忘れてバグが出た」という問題を、Builderパターンで整理する話です。add_item というメソッドがアイテムの振り分けを引き取ることで、なぜ呼び出し側のコードが同一の形に収束するのかを、仕組みから解説します。

学ぶことひとことで言うと
Builder パターン複雑なオブジェクトの組み立て手順を Builder クラスが担い、build() で検証済みの完成品を返す生成パターン
assembly-logic-in-caller呼び出し側のコードに、オブジェクトを組み立てるための振り分け・積み上げ・検証が重複して書かれている状態
Builder と BUILD の違いMoo の BUILD は「全引数を渡した後」に検証する。Builder の add_item は「渡すたびに」振り分けと検証を行う
中間状態の所在BentoOrderBuilder のインスタンスが「詰め最中の弁当箱」を抱える。関数には持ち場がない

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

  • PerlとMooの基本(hasnewBUILD)がなんとなく分かる
  • 「同じような処理が2か所に書かれていて、片方を変えたときに片方を忘れた」経験がある

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

ルーティングループが2か所にあった

シェフがカウンターのそばに来て、画面をのぞき込んだ。

彼が説明した。自分は社内のカフェテリア向け弁当注文システムを担当している。注文はPOSレジからもフォームからも届き、どちらも {type => '...', name => '...'} 形式のアイテムリストとして来る——メイン、副菜、ドリンク、デザートのどれかを示す type と、品名の name を持つハッシュの配列だ。

フォーム注文を処理する関数と、毎週固定の定期注文を処理する関数が、それぞれあった。

 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
package BentoOrder;
use Moo;
use v5.36;

has main    => (is => 'ro', required => 1);
has sides   => (is => 'ro', default => sub { [] });
has drink   => (is => 'ro');
has dessert => (is => 'ro');

# フォーム注文: ルーティングして BentoOrder->new(...) を呼ぶ
sub process_form_order {
    my (@items) = @_;
    my ($main, @sides, $drink, $dessert);
    for my $item (@items) {
        if    ($item->{type} eq 'main')    { $main    = $item->{name} }
        elsif ($item->{type} eq 'side')    { push @sides, $item->{name} }
        elsif ($item->{type} eq 'drink')   { $drink   = $item->{name} }
        elsif ($item->{type} eq 'dessert') { $dessert = $item->{name} }
    }
    die "副菜は3品まで\n"             if @sides > 3;
    die "デザートにはドリンクが必要\n" if $dessert && !$drink;
    die "メインは必須\n"               unless $main;
    BentoOrder->new(main => $main, sides => \@sides, drink => $drink, dessert => $dessert);
}

# 定期注文: 同じルーティングを別に書いている
sub process_recurring_order {
    my (@items) = @_;
    my ($main, @sides, $drink, $dessert);
    for my $item (@items) {
        if    ($item->{type} eq 'main')    { $main    = $item->{name} }
        elsif ($item->{type} eq 'side')    { push @sides, $item->{name} }
        elsif ($item->{type} eq 'drink')   { $drink   = $item->{name} }
        elsif ($item->{type} eq 'dessert') { $dessert = $item->{name} }
    }
    die "副菜は3品まで\n" if @sides > 3;
    # ← デザートの検証が抜けている
    die "メインは必須\n" unless $main;
    BentoOrder->new(main => $main, sides => \@sides, drink => $drink, dessert => $dessert);
}

BentoOrder クラス自体はシンプルなデータクラスだ。is => 'ro'(読み取り専用)で属性が固定されていて、一度作ったら変えられない。問題は BentoOrder の外にある——両方の処理関数が、同じルーティングループを別々に書いていた。

先週、弁当にデザートを追加できるオプションが増えた。彼は「デザートにはドリンクが必要」というルールを知って、フォーム側の関数に書いた。でも定期注文の関数のほうへ書くのを忘れた。本番で die が飛んだのは、デザートありドリンクなしの定期注文が届いたときだった。

「原因はすぐわかりました」と彼は言った。「でも、直し方がよくわからなくて。BentoOrderBUILD を書いたら一か所にまとめられると思って、試したんですが」

シェフが「どこまで試したか」と聞いた。

「デザートとドリンクのチェックを BentoOrderBUILD に移したら、そこは解消しました」と彼はつづけた。「BUILD は Moo でコンストラクタのあとに呼ばれる処理で——全引数を渡した後、作ったオブジェクトを検証できる」

1
2
3
4
5
sub BUILD {
    my ($self) = @_;
    die "副菜は3品まで\n"             if @{$self->sides} > 3;
    die "デザートにはドリンクが必要\n" if $self->dessert && !$self->drink;
}

「デザートのバグは消えました。でも——」と彼は画面を指さした。「type を見て変数に振り分けるループが、まだ両方の関数に残っていて。side@sides に押し込む、main$main に入れる、という判断を2か所で書き続けているのが、なんかすっきりしなくて」

シェフがコードを見たまま、うなずいた。

仕込みのムラを見つける

シェフがいった。「2冊の手順書がある。弁当の詰め方が書いてある。片方は今週更新した。もう片方は古いまま残った——今回はデザートの話だが、次は別の何かが残る」

私は食器の片付けを止めて、少し考えた。2つの関数に同じ処理が書いてある、ということは分かった。でも、なぜそれがまずいのかを、うまく言葉にできなかった。2冊の手順書、というイメージのほうが、なんとなくわかりやすかった。

BUILD で検証を一か所にするのはできた」とシェフが言った。「でもルーティングは動かない。誰がタイプを見て振り分けるかは、変わっていない」

これが、assembly-logic-in-caller と呼ばれる状態だとシェフは後で言った。オブジェクトを組み立てるための振り分け・積み上げ・検証が、呼び出し側のコードに重複して書かれている状態——BentoOrder を作るための「詰め手順」が、2か所のコードにそれぞれ存在している。手順が変わるたびに両方を直さないといけない。

「一か所にしたい」と彼は言った。「詰め方が変わっても、どちらかを忘れないようにしたい」

「弁当箱を持つ人間が必要だ」とシェフは言った。奥からホワイトボードを持ってきて、仕切りの入った弁当箱の絵を描いた。「メインを入れる。副菜を入れる。ドリンクを決める。デザートを追加する。そして蓋をする」

弁当箱は Builder の手の中に

シェフが BentoOrderBuilder を書いた。

 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
package BentoOrderBuilder;
use Moo;
use v5.36;

has _main    => (is => 'rw');
has _sides   => (is => 'rw', default => sub { [] });
has _drink   => (is => 'rw');
has _dessert => (is => 'rw');

sub add_item {
    my ($self, $item) = @_;
    my ($type, $name) = @{$item}{qw(type name)};
    if    ($type eq 'main')    { $self->_main($name) }
    elsif ($type eq 'side')    {
        die "副菜は3品まで\n" if @{$self->_sides} >= 3;
        push @{$self->_sides}, $name;
    }
    elsif ($type eq 'drink')   { $self->_drink($name) }
    elsif ($type eq 'dessert') { $self->_dessert($name) }
    $self;
}

sub build {
    my ($self) = @_;
    die "メインは必須\n"               unless $self->_main;
    die "デザートにはドリンクが必要\n" if $self->_dessert && !$self->_drink;
    BentoOrder->new(
        main    => $self->_main,
        sides   => $self->_sides,
        drink   => $self->_drink,
        dessert => $self->_dessert,
    );
}

BentoOrderis => 'ro' の不変なデータクラスのまま変えない。BentoOrderBuilderis => 'rw' の属性を持つ——詰め最中の弁当箱だから、中身を変えられる必要がある。

add_item がアイテムを一つ受け取るたびに、type を見て振り分ける。side なら上限を確認してから _sides に追加する。main なら _main に入れる。呼び出し側はアイテムを渡すだけで、どこに入るかを知らなくていい。

build を呼ぶと、最後の検証を済ませて BentoOrder->new(...) を呼び、完成品を返す。蓋が閉まったら中は変えられない——is => 'ro' の完成品が出てくる。

「呼び出し側はこうなる」とシェフが書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
sub process_form_order {
    my (@items) = @_;
    my $b = BentoOrderBuilder->new;
    $b->add_item($_) for @items;
    $b->build;
}

sub process_recurring_order {
    my (@items) = @_;
    my $b = BentoOrderBuilder->new;
    $b->add_item($_) for @items;
    $b->build;
}

彼は2つの関数を見比べた。

「同じになった」と彼は言った。「両方が同じ形になった」

「そうだ」とシェフは答えた。「ルーティングループは add_item の中に移った。どちらの関数も、アイテムを渡して build を呼ぶだけだ」

それから彼は少し考えて、こう言った。

add_item の中で type を判定して振り分けているだけですよね。それを普通の関数として切り出せばよかったのでは——なぜ Builder クラスである必要があるんですか?」

シェフが書く手を止めた。一拍、間を置いた。

「中間状態を誰が持つか、だ」

シェフがホワイトボードに、add_item が呼ばれるたびに Builder の中身が育っていく図を描いた。「add_item を1回呼ぶたびに、_main に何が入ったか、_sides に何品積まれたか、が変わる。この途中の弁当箱——どこまで詰まったか——を、BentoOrderBuilder のインスタンスが持っている」

「関数には持ち場がない」とシェフは続けた。「関数は呼ばれて、終わる。途中の弁当箱を、次の関数呼び出しまで預かる場所がない。Builder クラスのインスタンスが、build が呼ばれるまで弁当箱を手の中に持ち続ける」

彼は「@sides の積み上げも、Builder の中に入っている」と言った。

「それが引き取るということだ。蓋を閉める(build)まで、弁当箱は Builder の手の中にある」

私は聞きながら、仕切り弁当を想像した。詰めている最中の弁当箱がある。それを誰かが持っていないといけない。関数は呼ばれて終わるから、弁当箱を最後まで持ち続けることができない。BentoOrderBuilder のインスタンスが、それを持つ——そういうことだと思った。完全には言葉にできなかったけれど、絵としては浮かんだ。

「将来、topping という新しいタイプが増えても」とシェフが言った。「add_item に1行追加するだけだ。process_form_orderprocess_recurring_order も変えなくていい」

これが Builder パターン——複雑なオブジェクトの組み立て手順を Builder クラスが担い、build() で検証済みの完成品を返す生成パターン。Moo では、Builder が rw 属性で可変な中間状態を保ち、Product が ro 属性で不変に仕上がる。

前日の Facade が「複数のサブシステムへの呼び出しを一本化する」パターンだとすれば、Builder は「一つのオブジェクトの部品をどう積み上げるか」を引き取るパターンだ。Facade は段取りの向き先を隠す。Builder は弁当の詰め方そのものを引き取る。

Mermaid 図: Before と After の構造

Before(ルーティングループが2か所に重複):

	classDiagram
    class BentoOrder {
        +main: ro
        +sides: ro
        +drink: ro
        +dessert: ro
    }
    class process_form_order {
        ルーティングループ
        デザート検証あり
    }
    class process_recurring_order {
        ルーティングループ
        デザート検証なし
    }
    process_form_order --> BentoOrder
    process_recurring_order --> BentoOrder

After(ルーティングが add_item に集約):

	classDiagram
    class BentoOrderBuilder {
        +_main: rw
        +_sides: rw
        +_drink: rw
        +_dessert: rw
        +add_item(item)
        +build()
    }
    class BentoOrder {
        +main: ro
        +sides: ro
        +drink: ro
        +dessert: ro
    }
    class process_form_order {
        add_item for items
        build
    }
    class process_recurring_order {
        add_item for items
        build
    }
    process_form_order --> BentoOrderBuilder
    process_recurring_order --> BentoOrderBuilder
    BentoOrderBuilder --> BentoOrder

試食合格

テストを走らせた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
ok 1 - フォーム注文: メインが正しい
ok 2 - フォーム注文: 副菜2品
ok 3 - フォーム注文: ドリンクが正しい
ok 4 - フォーム注文: デザートが正しい
ok 5 - 定期注文: メインが正しい
ok 6 - 定期注文: 副菜2品
ok 7 - 定期注文: ドリンクが正しい
ok 8 - 定期注文: デザートが正しい
ok 9 - フォーム注文: デザート+ドリンクなし → die
ok 10 - 定期注文: デザート+ドリンクなし → die(バグ修正済み。Builder が検証する)
ok 11 - add_item: 副菜4品目 → die(add_item の中で即時検証)
ok 12 - build: メインなし → die
ok 13 - BentoOrder: main は ro(不変)
ok 14 - topping 追加: 呼び出し側は変えずに Builder だけ差し替えで動く
1..14

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

彼は After のコードをもう一度見た。process_form_orderprocess_recurring_order、両方が $b->add_item($_) for @items; $b->build; の同じ形になっている。

「これで、Builder が検証してくれる」と彼は言った。「どちらの関数もルールを知らなくていい」

「直すのは Builder だけだ」とシェフは言った。「次に新しいタイプが増えても、呼び出し側は変えなくていい」

「来てよかった」と彼は言った。芝居がかったところはなく、ただ率直だった。


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
アイテムリストのルーティングループ(type を見て変数に振り分ける処理)と検証ルールが、複数の呼び出し関数にそれぞれ重複して書かれていた(assembly-logic-in-caller)Builderパターン:add_item が振り分けと積み上げを担い、build() で検証済みの不変 Product を返すルーティングループが add_item の一か所に集まり、両方の呼び出し関数が同一の形になる。ルールを追加・変更しても Builder だけを直せばよい

工程

  1. BentoOrder->new(...) を呼ぶ前に、type を見て変数に振り分けるループが呼び出し側に書かれていないか確認する
  2. 同じルーティングループが複数の場所で繰り返されていないか確認する
  3. Builder クラス(BentoOrderBuilder)を作り、rw 属性で中間状態を持たせ、add_item の中でルーティングを行う
  4. 積み上げ中に確認できる制約(副菜の上限など)は add_item の中で、完成時の制約(デザート+ドリンクなど)は build() の中で検証する
  5. build() の中でのみ BentoOrder->new(...) を呼ぶ。Builder の属性は rw、Product の属性は ro
  6. 呼び出し側を $b->add_item($_) for @items; $b->build; の形に変更する
  7. テストを実行し、両方の呼び出し関数が同じ検証を受けることを確認する

シェフより

「弁当の詰め方を2冊の手順書に書くな。詰め方が変わるたびに2冊を書き直すのは、手順書の作り方がおかしい。詰め場所を一つにして、そこだけが詰め方を知っていればいい。蓋を閉めたら完成品だ——中を変える必要はない」


依頼人が帰って引き戸が閉まると、シェフは厨房に戻った。私はカウンターを拭きながら、今日のことを整理しようとした。

2つの関数。片方には書いてあって、片方にはなかった。それを見たとき、私は「あれ、同じことをやっているはずなのに形が違う」と思った。なぜ違ってはいけないのかは、今もうまく言えない。でも「ここが違いますね」とは言えた。

ep10 の朝は「おかしい気がします」と言えた。今日は「ここが違いますね」と言えた。どこが違うのかを、少し指差せた。

ふと、自分の担当している在庫管理のコードを思い出した。入荷処理と返品処理、両方を書いたとき、同じバリデーションを2か所に書いた覚えがある。あれも、同じ話だろうか。まだ確認していないけど。

今は直さなくてもいい。でも次に触るとき、そこを見るものが変わった気がした。

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