Featured image of post コード探偵ロックの事件簿【Command】逆戻りする時間の怪 〜実行履歴という名のタイムマシン〜

コード探偵ロックの事件簿【Command】逆戻りする時間の怪 〜実行履歴という名のタイムマシン〜

複雑な業務ロジックと外部API通信が絡み合うFat Controller。取り消し不可能な処理をオブジェクトに封じ込める、Commandパターンによる実行履歴の魔法。

I. 依頼(事務所への来客)

深夜の「レガシー・コード・インベスティゲーション(LCI)」。 雑居ビルの2階にある探偵事務所のドアを、私はふらつく足取りで押し開けた。

「助けてください。もう3日も家に帰れていません……!」

私の名前はハヤト。外資系決済サービスのAPI連携を担当している入社2年目の若手エンジニアだ。徹夜明けでボロボロの私を出迎えたのは、季節外れのヨレヨレのトレンチコートを羽織った男——自称コード探偵ロックだった。

彼はモニターから視線を外さず、手元の異様にヌルい特大エナジードリンクをストローで啜った。ズズズ、という下品な音が響く。

「ほう。随分と酷い顔をしているね、ワトソン君。目の下のクマが深すぎて、まるで3重にネストされた for ループのようだ」 「ハヤトです。あの、システムで奇妙な不具合が起きていて……」 「聞かせてみたまえ。君を路頭に迷わせている『におい』の正体を」

私は重いノートPCをデスクに置き、震える手でコードを映し出した。

「注文処理でエラーが発生したとき、DBはロールバックできるんですが、外部の『決済API』への課金リクエストが取り消せないんです。『お代は頂いたのに注文はキャンセルされる』という最悪の不整合が起きていて……もう経理からのクレーム電話に出るのが怖いんです!」

II. 現場検証(時間の不可逆性)

外部APIへの決済リクエストという不可逆な処理を、トランザクションだけで解決しようとする危うい現場の概念図

ロックは私の書いた決済処理 OrderProcessor クラスを覗き込んだ。

 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 OrderProcessor {
    use Moo;
    has api => (is => 'ro', default => sub { External::PaymentAPI->new });
    has db  => (is => 'ro', default => sub { Database->new });

    sub process_order ($self, $order) {
        $self->db->begin_transaction();

        # 1. 外部APIで決済(ここで副作用が発生するがロールバック対象に入らない)
        eval {
            $self->api->charge($order->{amount});
        };
        if ($@) {
            $self->db->rollback();
            die "Payment failed: $@";
        }

        # 2. データベースに注文を保存
        eval {
            $self->db->save_order($order);
        };
        if ($@) {
            # 💥 悲劇の発生ポイント
            # DBの保存に失敗した場合、DBはロールバックされるが、
            # 直前に叩いた APIの「課金(charge)」はそのまま残ってしまう!
            $self->db->rollback();
            die "Order save failed: $@";
        }

        $self->db->commit();
        return 1;
    }
}

「ご覧の通りです。DBへの保存が失敗したら $self->db->rollback() で戻しています。でも、その直前に走っている charge メソッドは外部APIへのHTTPリクエストなので、DBのトランザクションでは戻らないんです」

私が絶望まじりに説明すると、ロックは深くため息をついた。

「初歩的なことだよ、ワトソン君。君のコードは、コントローラーの中に直接『副作用(API通信)』をベタ書きしている。これは Fat Controller / Hardcoded Actions と呼ばれるにおいだ」

「じゃあ、失敗したときに手動で refund(返金API)を呼ぶコードを足せば……」 「それこそが泥沼への入り口だ! 『もしAが失敗したらBの返金を呼び、もしBが失敗したらAとBの返金を呼ぶ……』そんなエラーハンドリングのバケツリレーを書き始めたら、最後には君の正気ごとシステムがクラッシュする」

ロックは鋭い視線を画面に向けた。

「トランザクションという『魔法のキャンセルボタン』に甘えるのはやめたまえ。起きてしまった過去(外部通信)は、魔法では取り消せない。時間を巻き戻すには、自分が『何をしたか』を正確に記録したタイムマシンを作る必要があるのだよ」

III. 推理披露(実行履歴という名のタイムマシン)

ロックの指がキーボードの上で凄まじいスピードで踊り始めた。

「まず、君が無造作に呼び出していた『決済処理』や『DB保存』というただの「命令」を、独立した「オブジェクト」として切り離す。そして彼らに、実行(execute)と取り消し(unexecute)という2つの振る舞いを義務付けるんだ」

Command Role と具体的な役者の定義

 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
# -----------------------------------
# 1. Command Role (約束事)
# -----------------------------------
package Command::Role {
    use Moo::Role;
    requires 'execute';
    requires 'unexecute'; # 取り消し用・これがタイムマシンの鍵
}

# -----------------------------------
# 2. 具体的なCommandたち
# -----------------------------------
package Command::Payment {
    use Moo;
    with 'Command::Role';
    has api    => (is => 'ro', required => 1);
    has amount => (is => 'ro', required => 1);

    sub execute ($self) {
        $self->api->charge($self->amount);
    }
    sub unexecute ($self) {
        # 過去に行った処理を逆にたどる(返金)
        print "[UNDO] Reversing Payment...\n";
        $self->api->refund($self->amount);
    }
}

「……命令を、わざわざクラスにするんですか?」 「そうだ。こうすることで、『金額1000円の決済を行った』という事実が、物理的なオブジェクトとして手元に残る」

履歴管理者(Invoker)の召喚

ロックはさらに OrderInvoker というクラスを書き上げた。

「そして、これらのコマンドを順番に見届ける『歴史の記録者(Invoker)』を用意する。彼はコマンドが実行されるたびに、それをスタック(配列)に積み上げていくんだ」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# -----------------------------------
# 3. Invoker (履歴管理者)
# -----------------------------------
package OrderInvoker {
    use Moo;
    has history => (is => 'rw', default => sub { [] });

    sub execute_command ($self, $command) {
        $command->execute();              # 実行し...
        push @{$self->history}, $command; # 履歴に記録する
    }

    sub undo_all ($self) {
        # 履歴を新しい順(最後に実行したものから)ポップして取り消す
        while (my $cmd = pop @{$self->history}) {
            $cmd->unexecute();
        }
    }
}

Context の解放

Commandパターンの概念図。Invokerがコマンドをスタックに積み上げ、問題が起きれば逆順にUndoを実行していく、フラットでモダンなTechデザイン。

「さあ、ワトソン君。これですべての準備は整った。君のFat Controllerを安全な世界へ導こう」

 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
# -----------------------------------
# 4. Context (利用側・メインロジック)
# -----------------------------------
package OrderProcessorSmart {
    use Moo;
    has api => (is => 'ro', default => sub { External::PaymentAPI->new });
    has db  => (is => 'ro', default => sub { Database->new });

    sub process_order ($self, $order) {
        my $invoker = OrderInvoker->new;
        $self->db->begin_transaction();

        eval {
            # 決済コマンドの発行(Invoker経由)
            $invoker->execute_command(
                Command::Payment->new(api => $self->api, amount => $order->{amount})
            );
            
            # 保存コマンドの発行
            $invoker->execute_command(
                Command::SaveOrder->new(db => $self->db, order => $order)
            );
        };
        if ($@) {
            print "[SYSTEM] Error occurred. Starting compensation process (Undo)...\n";
            $self->db->rollback();
            
            # ✨ 履歴を逆再生して、APIなどの副作用を安全にロールバック!
            $invoker->undo_all(); 
            die "Order processing failed: $@";
        }

        $self->db->commit();
        return 1;
    }
}

私は画面を見て息を呑んだ。 あの煩わしかったエラーハンドリングのパズルが、たった1行の $invoker->undo_all() に置き換わっている。

「もし決済(API)だけ成功して、DB保存でエラーになったら……?」 「Invokerの履歴(history)には、Command::Payment が1つだけ積まれている状態だ。undo_all が呼ばれると、積まれた最新の履歴から unexecute(取り消し)が呼ばれる。つまり、確実に返金APIが叩かれるというわけさ」

IV. 解決(事件の終わり)

テスト用の注文データを流し込むと、コンソールに見事な緑色の文字が並んだ。 DBエラーが発生した瞬間、トランザクションのロールバックと同時に、APIへの返金処理が確実に行われたのだ。

「完璧です……! これなら、後から『ポイント付与』や『在庫引当』のAPI呼び出しが増えても、新しく Command::AddPointsCommand::ReserveStock を作って execute_command するだけでいいんですね」

「ご名答。もう手動で複雑なエラーハンドリング関数を継ぎ接ぎする必要はない。各々のコマンドが、自分自身の『行い』と『その償い方(取り消し方)』を知っているのだからね」

ロックは満足げに、空になったエナジードリンクの缶をゴミ箱へ放り投げた。

「コマンドがオブジェクトとして実体化するということは、単に取り消し(Undo)ができるというだけではない。ログとしてDBにシリアライズして保存すれば、もしサーバーがクラッシュしても、再起動後に再実行(Redo)することすら可能なのだよ」

「履歴という名のタイムマシン……。すごい、すごすぎます、ロックさん!」 「フッ。真名で呼んでくれたのは君が初めてかもしれないな。だが忘れないでくれたまえ。過去を正しくやり直すためには、現在の行いを正確に記録する知性が必要なのだ」

私は深々と頭を下げた。これでようやく、自宅のベッドで眠ることができる。経理からの恐怖の電話におびえることもない。

「あの、ロックさん。調査費用はいかほどで……?」 「さてね。君のシステムが失わずに済んだ決済手数料と同じだけの、とびきり美味いピザを頼むとしようか。もちろん、注文を『取り消す』権利は放棄してもらうがね」

私は笑いながら、深夜のピザ屋のメニューを開いた。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
Fat Controller / Hardcoded Actions。コントローラーの中に外部API呼び出し等の「副作用」が直書きされ、エラー発生時の取り消し(ロールバック)や再実行が不可能・複雑化している状態。Command パターン。「〜をする」という命令(振る舞い)そのものを1つのオブジェクト(クラス)としてカプセル化し、実行(execute)と取り消し(unexecute)を対にして定義する。コマンドをキューやスタックに積むことで、複数ステップの処理に対する安全な Undo (取り消し) や Redo (再実行)、非同期実行などが可能になる。

推理のステップ

  1. Command Roleの定義: 『実行する(execute)』と『取り消す(unexecute)』のインターフェースを持つRoleを作成する。
  2. 命令のオブジェクト化: 外部API通信やDB保存など、副作用の伴う具体的な処理をそれぞれ独立したCommandクラスとして切り出す。
  3. Invokerによる履歴管理: コマンドを直接実行するのではなく、管理役(Invoker)を通じて実行し、実行したコマンドをスタックに保存する。
  4. 安全なロールバック: エラー発生時は、Invokerのスタックからコマンドを取り出し、逆の順番で unexecute を呼び出して状態を復元(補償トランザクション)する。

ロックより

ワトソン君。君が頼っていたデータベースのトランザクションは確かに強力だが、それはシステムという閉じた箱の中でのみ通用する魔法に過ぎない。 一歩でも外(APIやメールなどの外部システム)へ足を踏み出せば、覆水は盆に返らないのだ。

システム設計において『取り消し』を想定することは敗北ではない。それは、世界が不確実であることを受け入れる勇気と知性の証だ。 Commandオブジェクトとして封じ込められた君の「行い」は、きっと未来のトラブルから君自身を救ってくれることだろう。

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