夕方の厨房は、昼間とは空気が違う。
野菜の下ごしらえが始まっていた。ごぼうの土を落とす音、鉄鍋がコンロに乗る音。シェフは今日のディナーの仕込みに入っていて、私はその隣で洗い上がった皿を拭いていた。来客があるとは思っていなかった。
暖簾越しに人影が見えた。引き戸が開く。「すみません」と若い男性の声がした。
私が「いらっしゃいます」と言うと、シェフがコンロから目を離さず「座れ」と言った。
ラップトップを抱えた27歳ほどの男性が入ってきた。疲れた顔だったが、取り乱してはいなかった。状況を整理して持ってきた人の顔だ、と私は思った。
「友人に紹介してもらいました。コードを——見ていただきたくて」
椅子に座りながら、ラップトップを開く前に話し始めた。
「先週、システムアラートが届かなくなって。インシデント自体はもう直したんですが、また同じことをやりそうで」
シェフが振り返った。「何か所ある?」
「三か所です。サービスクラスが——UserService と OrderService と MonitoringService」
シェフが「見せろ」と言った。
この記事で学ぶこと
この記事は、本番・staging・テスト環境でLoggerとMailerの生成コードが各サービスクラスに散在し、staging追加時にMailerの分岐を書き忘れてアラートが届かなくなった問題を、Abstract Factoryパターンで整理する話です。AppFactory(Moo::Role)が create_logger と create_mailer を requires で宣言し、各環境のファクトリが 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の基本(
has、new、with)がなんとなく分かる - 環境(本番・テスト・開発)の切り替えをコードで書いたことがある
- 「環境を増やすと何か所も直す必要がある」という経験がある
技術スタックはPerl / Mooです。コードはすべて手元で動かし、テストが通ることを確認しています。
六か所のうち、一か所が抜けた
シェフが三つのファイルを順に開いた。包丁を野菜に向けたまま、画面だけ目で追っている。
私はカウンター越しに見ていた。何を見ているのか、分からなかった。でも「三か所」という数に何か反応したのが気になった。
「LoggerとMailerを、別々に選んでいる」とシェフが言った。
「そうです。環境ごとに条件分岐で。productionのときはFile logger、SMTPメーラー——testのときはStderrとLogメーラー——と分けて」と依頼人が言った。
シェフ:「stagingを追加したとき、何か所を直した?」
「三つのサービスクラスで、LoggerとMailerの両方を——だから六か所です。そのうち一か所が抜けた」
MonitoringService のコードはこうなっていた。
| |
UserService.pm と OrderService.pm にも同様の条件分岐が散在している。こちらはstaging Mailerの分岐を正しく追加していた。MonitoringService だけが漏れた。
シェフが包丁を置いた。「LoggerとMailerは、セットで同じ環境でなければならない。でもこのコードは、それぞれを別々に選んでいる——組み合わせが崩れる構造になっている」
私はコードを見た。LoggerとMailerが同じクラスの中に並んでいるのに、選ぶ処理はそれぞれ独立した if/elsif/else ブロックだ。一方のブロックを修正しても、もう一方は修正されない。
「staging Logger と Console Mailer の組み合わせになった」と私は言った。「アラートはコンソールに出るだけで——誰も見ていない」
依頼人が「そうです。翌朝のチェックで初めて気づいて」と言った。「また同じことをやりそうで、こわい」
問題の核は「散らばり」ではなかった。LoggerとMailerを別々の if/elsif で選んでいるから、「組み合わせを保証するものが何もない」ことだ。環境を追加するたびに、人間が6か所を正確に修正しなければならない。1か所でも漏れれば今回が再現する。
「どうまとめるか、だ」とシェフが言ってコードを書き始めた。
業者を替えれば、食材が一式届く
シェフが新しいファイルを開いた。最初に書いたのは、LoggerでもMailerでもなかった。
| |
ロールから書き始めた。Moo::Role はインスタンスを直接作れない設計図で、with 'AppFactory' で別のクラスに組み込まれて動く。LoggerでもMailerでもなく、「何かを生成する台所の設計図」を先に置いている——シェフはいつも骨格から入る。
シェフがコードを書きながら話した。
「本番の仕入れ業者に頼めば、本番の食材が一式届く。staging の業者に替えれば、staging の食材が一式届く——業者をそっくり替えれば、食材の組み合わせは必ず揃う。この『業者』を、ファクトリと呼ぶ」
Abstract Factory(抽象ファクトリ)——関連するオブジェクト一式を生成するインタフェース(ここでは AppFactory ロール)を定義し、具体的なファクトリクラスが環境ごとに実装するパターンです。
続いて、環境ごとのファクトリを書いた。
| |
| |
| |
| |
MonitoringService はファクトリを受け取るだけになる。
| |
環境を知っているのはエントリポイントだけ。
| |
クラスの関係を整理すると、こうなる。
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 は「ProductionFactory が AppFactory ロールを 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_logger と create_mailer の2メソッドを書くだけだ。既存の ProductionFactory にも TestFactory にも触れない。
「これだと——StagingFactory を1つ書いて、エントリポイントを1か所直すだけですね」と依頼人が言った。
「そうだ」とシェフが言った。
短く、でも明確だった。私は思わず画面から目を離した。
シェフが認めた。
でも——今日、私は何も答えていない。依頼人が答えた。シェフはその答えを認めた。私は……ただ聞いていた。
「あと一つ。create_cache を追加したいとき——AppFactory に create_cache を足すと、全部のファクトリクラスを修正しますよね」と依頼人が言った。
「ある」とシェフが言った。
「それは……デメリットですか?」
「ある。環境を追加するコストは低い——ファクトリクラスを1つ書くだけ。部品の種類を追加するコストは高い——全ファクトリを修正しなければならない」
依頼人が少し考え込んだ顔で「なるほど」と言った。
テストを走らせた。
Before(environment-scattered-creation:環境ごとに生成コードが散在している状態):
| |
テスト5〜7番——staging環境でLoggerはStderrが返る(正しく追加した)のに、Mailerは else→Console に落ちる。LoggerとMailerの組み合わせが崩れている。これが今回のインシデントの実態だ。テスト8〜9番——テストコードでは send_alert 内の環境分岐を _make_logger / _make_mailer というプライベートメソッドとして抽出している(実務コードも同様の構造になることが多い)。そのメソッドが存在することを確認している。テスト10番——MonitoringService 自身が環境名を env として保持している——これが「散在」の起点だ。
After(Abstract Factoryパターン):
| |
全テスト通過、警告なし。
テスト15番——StagingFactory->create_logger も StagingFactory->create_mailer も、必ず StagingFactory から出てくる。LoggerとMailerの組み合わせを別々に選ぶ余地がない。テスト16番——MonitoringService は env 属性を持たない。環境を知っているのはエントリポイントだけだ。
Moo::Role の requires について——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_logger と create_mailer を requires で宣言。各環境のファクトリクラスが with 'AppFactory' で両メソッドを実装。呼び出し元はファクトリを受け取るだけ | LoggerとMailerは必ず同じファクトリから出てくる。新しい環境を追加するとき、新しいファクトリクラスを1つ書いてエントリポイントを1か所直すだけ。既存サービスクラスは変更しない |
工程
- 関連するオブジェクトをセットで生成するロール(
AppFactory)を作る。Moo::Roleでrequiresを使って生成メソッドを宣言する - 各環境のファクトリクラスを作る(
ProductionFactory、TestFactory、StagingFactory、DevFactory)。use Moo; with 'AppFactory';を書き、create_loggerとcreate_mailerを実装する - 呼び出し元(サービスクラス)を修正する。
has envを削除し、代わりにファクトリを引数で受け取るようにする。$factory->create_loggerと$factory->create_mailerを呼ぶ - エントリポイント(スクリプトの起動部分)でファクトリを選ぶ。
$ENV{APP_ENV}などで環境を判断し、対応するファクトリを1か所だけnewする - 新しい環境を追加するとき:新しいファクトリクラスを1つ書き、エントリポイントの選択肢に1行足すだけ。他のコードは変更しない
シェフより
「仕入れ業者を複数抱えて、食材ごとに別の業者から頼んでいたら——ある日、肉は staging 用の柔らかい食材、野菜は production 用の固いものが混ざって届いた。それが今日の話だ」
「業者を一社に絞れば、その日の注文は全部そこから揃う。本番用の業者に替えれば、全部が本番用になる。業者を替えるだけでいい——食材を一つひとつ確認しなくていい」
依頼人が帰り支度をしながら、笑いながら言った。
「……一つだけ聞いていいですか。うちみたいな、三人チームの、小さいWebアプリに——これ、やり過ぎじゃないですかね? ファクトリクラスが四つ、ロールが一つ——コードの量が増えた気がして」
シェフは答えなかった。包丁を取り上げて、また野菜に戻った。
私は口を開きかけた——「環境の追加が多いなら……」。でも依頼人はもう「いや、考えます」と笑って暖簾をくぐっていた。
暖簾が揺れた後、厨房にはシェフの包丁の音だけが戻った。
シェフは、さっき「環境を追加するコストは低い、部品のコストは高い」と言っていた。それが判断の基準なのだろう。依頼人はその基準を持って帰った。
じゃあ——三人チームの、小さいWebアプリに——それは当てはまるのか。私にはまだ分からない。環境の切り替えが今後も増えるのか。部品の種類がこれから多くなるのか。それは、このシステムを使っている人でなければ判断できない。
シェフは答えなかったのではなく——答える人が違う、ということなのかもしれない。
今日、私は何も答えていなかった。依頼人が答え、シェフがそれを認め、私はただ見ていた。
それなのに——問い始めたのは、私の方だった。
