Featured image of post コードシェフの仕込み帳【Abstract Factory】厨房セットをそっくり入れ替える〜本番・テスト・開発の部品生成が散らばるコードを、ファクトリ一つの差し替えで一式切り替える〜

コードシェフの仕込み帳【Abstract Factory】厨房セットをそっくり入れ替える〜本番・テスト・開発の部品生成が散らばるコードを、ファクトリ一つの差し替えで一式切り替える〜

staging追加時にMailer分岐を書き漏らしアラートが届かなかった問題。Abstract FactoryパターンをPerlとMooで実装し、AppFactoryロールが環境ごとのLoggerとMailerを一式提供する設計へ直す。

夕方の厨房は、昼間とは空気が違う。

野菜の下ごしらえが始まっていた。ごぼうの土を落とす音、鉄鍋がコンロに乗る音。シェフは今日のディナーの仕込みに入っていて、私はその隣で洗い上がった皿を拭いていた。来客があるとは思っていなかった。

暖簾越しに人影が見えた。引き戸が開く。「すみません」と若い男性の声がした。

私が「いらっしゃいます」と言うと、シェフがコンロから目を離さず「座れ」と言った。

ラップトップを抱えた27歳ほどの男性が入ってきた。疲れた顔だったが、取り乱してはいなかった。状況を整理して持ってきた人の顔だ、と私は思った。

「友人に紹介してもらいました。コードを——見ていただきたくて」

椅子に座りながら、ラップトップを開く前に話し始めた。

「先週、システムアラートが届かなくなって。インシデント自体はもう直したんですが、また同じことをやりそうで」

シェフが振り返った。「何か所ある?」

「三か所です。サービスクラスが——UserServiceOrderServiceMonitoringService

シェフが「見せろ」と言った。

この記事で学ぶこと

この記事は、本番・staging・テスト環境でLoggerとMailerの生成コードが各サービスクラスに散在し、staging追加時にMailerの分岐を書き忘れてアラートが届かなくなった問題を、Abstract Factoryパターンで整理する話です。AppFactory(Moo::Role)が create_loggercreate_mailerrequires で宣言し、各環境のファクトリが with で一式を実装する構造へ直します。

学ぶことひとことで言うと
Abstract Factory パターン関連するオブジェクト一式を生成する「ファクトリ」をロールとして定義し、環境ごとのファクトリが実装する。呼び出し元はファクトリを受け取るだけで、具体的なクラスを知らなくていい
ミスマッチセット問題LoggerとMailerを別々の条件分岐で生成すると「staging用Logger + else→Console Mailer」という不整合な組み合わせが構造的に防げない。今回のバグはまさにこれ
Moo::Role での実装requires で生成メソッドを宣言し、with 'AppFactory' で実装を強制する。実装漏れは起動時のエラーになる
Factory Method との違いFactory Methodは1種類のオブジェクト生成を1か所に委ねる。Abstract Factoryは複数の関連オブジェクトを一式でセットにし、組み合わせの整合性を保証する

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

  • PerlとMooの基本(hasnewwith)がなんとなく分かる
  • 環境(本番・テスト・開発)の切り替えをコードで書いたことがある
  • 「環境を増やすと何か所も直す必要がある」という経験がある

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

六か所のうち、一か所が抜けた

シェフが三つのファイルを順に開いた。包丁を野菜に向けたまま、画面だけ目で追っている。

私はカウンター越しに見ていた。何を見ているのか、分からなかった。でも「三か所」という数に何か反応したのが気になった。

「LoggerとMailerを、別々に選んでいる」とシェフが言った。

「そうです。環境ごとに条件分岐で。productionのときはFile logger、SMTPメーラー——testのときはStderrとLogメーラー——と分けて」と依頼人が言った。

シェフ:「stagingを追加したとき、何か所を直した?」

「三つのサービスクラスで、LoggerとMailerの両方を——だから六か所です。そのうち一か所が抜けた」

MonitoringService のコードはこうなっていた。

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

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

sub send_alert {
    my ($self, $message) = @_;

    # Logger を環境ごとに選ぶ(staging は正しく追加した)
    my $logger;
    if ($self->env eq 'production') {
        $logger = Logger::File->new(path => '/var/log/monitor.log');
    }
    elsif ($self->env eq 'staging') {
        $logger = Logger::Stderr->new;
    }
    else {
        $logger = Logger::Stderr->new;
    }

    # Mailer を環境ごとに選ぶ(staging の分岐を書き忘れた)
    my $mailer;
    if ($self->env eq 'production') {
        $mailer = Mailer::SMTP->new(host => 'smtp.example.com');
    }
    elsif ($self->env eq 'test') {
        $mailer = Mailer::Log->new;
    }
    else {
        # staging はこの else に入る → Mailer::Console(誰も見ていない)
        $mailer = Mailer::Console->new;
    }

    $logger->info('alert: ' . $message);
    $mailer->send('alert: ' . $message);
}

UserService.pmOrderService.pm にも同様の条件分岐が散在している。こちらはstaging Mailerの分岐を正しく追加していた。MonitoringService だけが漏れた。

シェフが包丁を置いた。「LoggerとMailerは、セットで同じ環境でなければならない。でもこのコードは、それぞれを別々に選んでいる——組み合わせが崩れる構造になっている」

私はコードを見た。LoggerとMailerが同じクラスの中に並んでいるのに、選ぶ処理はそれぞれ独立した if/elsif/else ブロックだ。一方のブロックを修正しても、もう一方は修正されない。

「staging Logger と Console Mailer の組み合わせになった」と私は言った。「アラートはコンソールに出るだけで——誰も見ていない」

依頼人が「そうです。翌朝のチェックで初めて気づいて」と言った。「また同じことをやりそうで、こわい」

問題の核は「散らばり」ではなかった。LoggerとMailerを別々の if/elsif で選んでいるから、「組み合わせを保証するものが何もない」ことだ。環境を追加するたびに、人間が6か所を正確に修正しなければならない。1か所でも漏れれば今回が再現する。

「どうまとめるか、だ」とシェフが言ってコードを書き始めた。

業者を替えれば、食材が一式届く

シェフが新しいファイルを開いた。最初に書いたのは、LoggerでもMailerでもなかった。

1
2
3
4
5
package AppFactory;
use Moo::Role;
use v5.36;

requires 'create_logger', 'create_mailer';

ロールから書き始めた。Moo::Role はインスタンスを直接作れない設計図で、with 'AppFactory' で別のクラスに組み込まれて動く。LoggerでもMailerでもなく、「何かを生成する台所の設計図」を先に置いている——シェフはいつも骨格から入る。

シェフがコードを書きながら話した。

「本番の仕入れ業者に頼めば、本番の食材が一式届く。staging の業者に替えれば、staging の食材が一式届く——業者をそっくり替えれば、食材の組み合わせは必ず揃う。この『業者』を、ファクトリと呼ぶ」

Abstract Factory(抽象ファクトリ)——関連するオブジェクト一式を生成するインタフェース(ここでは AppFactory ロール)を定義し、具体的なファクトリクラスが環境ごとに実装するパターンです。

続いて、環境ごとのファクトリを書いた。

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

sub create_logger {
    my ($self) = @_;
    Logger::File->new(path => '/var/log/app.log');
}

sub create_mailer {
    my ($self) = @_;
    Mailer::SMTP->new(host => 'smtp.example.com');
}
1
2
3
4
5
6
7
package TestFactory;
use Moo;
use v5.36;
with 'AppFactory';

sub create_logger { my ($self) = @_; Logger::Stderr->new }
sub create_mailer { my ($self) = @_; Mailer::Log->new }
1
2
3
4
5
6
7
8
# StagingFactory(新環境追加:ProductionFactory と TestFactory は変更しない)
package StagingFactory;
use Moo;
use v5.36;
with 'AppFactory';

sub create_logger { my ($self) = @_; Logger::Stderr->new }
sub create_mailer { my ($self) = @_; Mailer::Log->new }
1
2
3
4
5
6
7
package DevFactory;
use Moo;
use v5.36;
with 'AppFactory';

sub create_logger { my ($self) = @_; Logger::Stderr->new }
sub create_mailer { my ($self) = @_; Mailer::Console->new }

MonitoringService はファクトリを受け取るだけになる。

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

sub send_alert {
    my ($self, $factory, $message) = @_;
    my $logger = $factory->create_logger;
    my $mailer = $factory->create_mailer;
    $logger->info('alert: ' . $message);
    $mailer->send('alert: ' . $message);
}

環境を知っているのはエントリポイントだけ。

1
2
3
4
5
6
7
8
my $env     = $ENV{APP_ENV} // 'development';  # // は defined-or 演算子:未定義なら右辺を使う
my $factory = $env eq 'production' ? ProductionFactory->new
            : $env eq 'staging'    ? StagingFactory->new
            : $env eq 'test'       ? TestFactory->new
            :                        DevFactory->new;  # いずれでもなければ Dev(開発環境)

my $svc = MonitoringService->new;
$svc->send_alert($factory, 'disk full');

クラスの関係を整理すると、こうなる。

	classDiagram
    class AppFactory {
        <<Role>>
        +create_logger()
        +create_mailer()
    }
    class ProductionFactory {
        +create_logger() Logger::File
        +create_mailer() Mailer::SMTP
    }
    class StagingFactory {
        +create_logger() Logger::Stderr
        +create_mailer() Mailer::Log
    }
    class TestFactory {
        +create_logger() Logger::Stderr
        +create_mailer() Mailer::Log
    }
    class DevFactory {
        +create_logger() Logger::Stderr
        +create_mailer() Mailer::Console
    }
    class MonitoringService {
        +send_alert(factory, message)
    }
    AppFactory <|.. ProductionFactory : with
    AppFactory <|.. StagingFactory : with
    AppFactory <|.. TestFactory : with
    AppFactory <|.. DevFactory : with
    MonitoringService --> AppFactory : uses

(図の見方:<|.. は「左のインタフェースを右のクラスが実装する」関係。AppFactory <|.. ProductionFactory は「ProductionFactoryAppFactory ロールを with で実装している」という意味)

依頼人が呼び出し元のコードを見て、少し考えた。「一つ聞いていいですか」

create_logger($env)create_mailer($env) を——関数として別々に定義すれば良かったのでは? ファクトリクラスを作るほどのことかと」

私も同じことを考えていた。別々の関数を一か所に集めれば、六か所が二か所になる——それでは足りないのか。

シェフが依頼人に問い返した。「別々の関数にした場合——呼び出し元は何を渡す?」

「……環境名を。両方に」

「それとも、二つの関数を別々に呼ぶ。そのとき——staging の Logger と production の Mailer を組み合わせることを、何が防ぐ?」

依頼人が黙った。少し経って「……防がないですね。また同じミスができる」

シェフがAfterの呼び出し元コードを指した。「$factory->create_logger$factory->create_mailer は、同じ $factory から出てくる。StagingFactory なら——両方、staging用になる。Logger と Mailer の組み合わせを別々に選ぶ必要がない」

「……ファクトリを一つ渡すから、セットが保証される」と依頼人が言った。

私は業者の比喩を頭の中でなぞった。同じ業者から頼めば、食材は全部そこから届く——右手と左手を別々の業者に注文していたから、食材が混ざった。ファクトリは、LoggerとMailerの注文を一つの業者にまとめる仕組みだ。

Factory Method との違いについても触れておきます。Factory Method(1種類のオブジェクト生成を1か所の窓口に委ねる技法)と似ているように見えますが、問題の構造が違います。Factory Methodは「どのMailerクラスを作るか」という1種類の決定を委ねます。Abstract Factoryは「LoggerとMailerの両方を、どの環境向けのセットとして作るか」という複数の関連した決定をセットにします。

「窓口を一つ作る」がFactory Method。「その窓口が入っている台所ごと替える」がAbstract Factory、と私は理解した。

試食合格

シェフが StagingFactory のコードを見せた。短い——create_loggercreate_mailer の2メソッドを書くだけだ。既存の ProductionFactory にも TestFactory にも触れない。

「これだと——StagingFactory を1つ書いて、エントリポイントを1か所直すだけですね」と依頼人が言った。

「そうだ」とシェフが言った。

短く、でも明確だった。私は思わず画面から目を離した。

シェフが認めた。

でも——今日、私は何も答えていない。依頼人が答えた。シェフはその答えを認めた。私は……ただ聞いていた。

「あと一つ。create_cache を追加したいとき——AppFactorycreate_cache を足すと、全部のファクトリクラスを修正しますよね」と依頼人が言った。

ある」とシェフが言った。

「それは……デメリットですか?」

「ある。環境を追加するコストは低い——ファクトリクラスを1つ書くだけ。部品の種類を追加するコストは高い——全ファクトリを修正しなければならない」

依頼人が少し考え込んだ顔で「なるほど」と言った。

テストを走らせた。

Before(environment-scattered-creation:環境ごとに生成コードが散在している状態):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
ok 1 - 本番: Logger は File(ファイル書き込み)
ok 2 - 本番: Mailer は SMTP(実際の送信)
ok 3 - テスト: Logger は Stderr
ok 4 - テスト: Mailer は Log
ok 5 - staging: Logger は Stderr(staging 分岐あり・正しい)
ok 6 - staging: Mailer が Console に落ちた(staging 分岐なし・バグ)
ok 7 - ★ staging: Mailer が Log でない(staging 用に設定されていない)
ok 8 - MonitoringService->can('_make_logger')
ok 9 - MonitoringService->can('_make_mailer')
ok 10 - 環境名を MonitoringService 自身が保持している(散在の原因)
1..10

テスト5〜7番——staging環境でLoggerはStderrが返る(正しく追加した)のに、Mailerは else→Console に落ちる。LoggerとMailerの組み合わせが崩れている。これが今回のインシデントの実態だ。テスト8〜9番——テストコードでは send_alert 内の環境分岐を _make_logger / _make_mailer というプライベートメソッドとして抽出している(実務コードも同様の構造になることが多い)。そのメソッドが存在することを確認している。テスト10番——MonitoringService 自身が環境名を env として保持している——これが「散在」の起点だ。

After(Abstract Factoryパターン):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ok 1 - ProductionFactory は AppFactory ロールを実装している
ok 2 - TestFactory は AppFactory ロールを実装している
ok 3 - StagingFactory は AppFactory ロールを実装している
ok 4 - DevFactory は AppFactory ロールを実装している
ok 5 - 本番: Logger は File
ok 6 - 本番: Mailer は SMTP
ok 7 - テスト: Logger は Stderr
ok 8 - テスト: Mailer は Log
ok 9 - staging: Logger は Stderr
ok 10 - staging: Mailer は Log(Console に落ちない)
ok 11 - 開発: Logger は Stderr
ok 12 - 開発: Mailer は Console
ok 13 - StagingFactory: logger は Stderr
ok 14 - StagingFactory: mailer は Log
ok 15 - ★ StagingFactory は staging 用のセットを一式提供する(ミスマッチが構造的に起きない)
ok 16 - MonitoringService は env 属性を持たない(環境を直接知らない)
ok 17 - 本番ファクトリ → File ロガー
ok 18 - staging ファクトリ → Stderr ロガー
ok 19 - ProductionFactory->can('create_logger')
ok 20 - ProductionFactory->can('create_mailer')
ok 21 - StagingFactory->can('create_logger')
ok 22 - StagingFactory->can('create_mailer')
1..22

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

テスト15番——StagingFactory->create_loggerStagingFactory->create_mailer も、必ず StagingFactory から出てくる。LoggerとMailerの組み合わせを別々に選ぶ余地がない。テスト16番——MonitoringServiceenv 属性を持たない。環境を知っているのはエントリポイントだけだ。

Moo::Rolerequires について——requires 'create_logger', 'create_mailer' は、with 'AppFactory' したクラスが両メソッドを実装しているかを起動時に検証します。実装漏れがあれば、プログラムの起動時点でエラーになります(Perlは動的型付け言語のため「コンパイル時」ではなく「ロール適用時」のチェックです)。with する前にメソッドを定義していれば大丈夫です。

依頼人が「StagingFactory を1つ書くだけで、UserService にも OrderService にも触れなかった」と言った。「これで、staging を追加するたびに6か所を探して回らなくていい」


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
LoggerとMailerの生成を環境ごとに別々の条件分岐で書いている。staging追加時に1か所の分岐を書き漏らし、staging Logger + else→Console Mailerのミスマッチが発生。アラートが届かなかったAbstract Factoryパターン:AppFactory(Moo::Role)が create_loggercreate_mailerrequires で宣言。各環境のファクトリクラスが with 'AppFactory' で両メソッドを実装。呼び出し元はファクトリを受け取るだけLoggerとMailerは必ず同じファクトリから出てくる。新しい環境を追加するとき、新しいファクトリクラスを1つ書いてエントリポイントを1か所直すだけ。既存サービスクラスは変更しない

工程

  1. 関連するオブジェクトをセットで生成するロール(AppFactory)を作る。Moo::Rolerequires を使って生成メソッドを宣言する
  2. 各環境のファクトリクラスを作る(ProductionFactoryTestFactoryStagingFactoryDevFactory)。use Moo; with 'AppFactory'; を書き、create_loggercreate_mailer を実装する
  3. 呼び出し元(サービスクラス)を修正する。has env を削除し、代わりにファクトリを引数で受け取るようにする。$factory->create_logger$factory->create_mailer を呼ぶ
  4. エントリポイント(スクリプトの起動部分)でファクトリを選ぶ。$ENV{APP_ENV} などで環境を判断し、対応するファクトリを1か所だけ new する
  5. 新しい環境を追加するとき:新しいファクトリクラスを1つ書き、エントリポイントの選択肢に1行足すだけ。他のコードは変更しない

シェフより

「仕入れ業者を複数抱えて、食材ごとに別の業者から頼んでいたら——ある日、肉は staging 用の柔らかい食材、野菜は production 用の固いものが混ざって届いた。それが今日の話だ」

「業者を一社に絞れば、その日の注文は全部そこから揃う。本番用の業者に替えれば、全部が本番用になる。業者を替えるだけでいい——食材を一つひとつ確認しなくていい」


依頼人が帰り支度をしながら、笑いながら言った。

「……一つだけ聞いていいですか。うちみたいな、三人チームの、小さいWebアプリに——これ、やり過ぎじゃないですかね? ファクトリクラスが四つ、ロールが一つ——コードの量が増えた気がして」

シェフは答えなかった。包丁を取り上げて、また野菜に戻った。

私は口を開きかけた——「環境の追加が多いなら……」。でも依頼人はもう「いや、考えます」と笑って暖簾をくぐっていた。

暖簾が揺れた後、厨房にはシェフの包丁の音だけが戻った。

シェフは、さっき「環境を追加するコストは低い、部品のコストは高い」と言っていた。それが判断の基準なのだろう。依頼人はその基準を持って帰った。

じゃあ——三人チームの、小さいWebアプリに——それは当てはまるのか。私にはまだ分からない。環境の切り替えが今後も増えるのか。部品の種類がこれから多くなるのか。それは、このシステムを使っている人でなければ判断できない。

シェフは答えなかったのではなく——答える人が違う、ということなのかもしれない。

今日、私は何も答えていなかった。依頼人が答え、シェフがそれを認め、私はただ見ていた。

それなのに——問い始めたのは、私の方だった。

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