事件の発端 — 3本の鎖
カフェで待っていた。
オフィス街の、昼休みにはサラリーマンで埋まるチェーン店。午後2時を過ぎて人がまばらになった窓際の席で、アイスコーヒーのグラスに水滴がつくのを眺めながら、ノートを広げていた。
A5のノートの左ページに、手描きの図がある。OrderService から3本の矢印が InventoryService、ShippingService、BillingService に伸びている。各矢印の横に notify() と書き添えた。Observerパターンの構成図。半年前にモノリスから分割したときの設計図を、今朝の通勤電車で清書したものだ。
3ヶ月前までは、この図の通りに動いていた。OrderService が Subject、3つのサービスが Observer。注文が確定したら、順番に notify() を呼ぶ。シンプルだった。
——マイクロサービスに分割するまでは。
先月だけで3回、請求書サービスの障害で注文確定が止まった。先週は45分間。上長の「Observerの設計を見直せ」という声がまだ耳に残っている。見直すって、何を。Observer は間違っていないはず。リスナーを登録して、イベントが起きたら通知する。教科書通りだ。
教科書通りのはずなのに、壊れた。
技術カンファレンスの懇親会で、SREの佐藤さんが教えてくれた名前を思い出した。「変なコード探偵がいるらしいよ。レガシーコード専門で、キーボードで報酬を取るんだって」。半信半疑で検索したら、古くさいHTMLの1ページだけのウェブサイトが出てきた。レガシー・コード・インベスティゲーション。LCI。問い合わせフォームから連絡したら、翌日に返信が来た。「場所と日時を指定したまえ——L」。
だからカフェを指定した。事務所に行くのは——佐藤さんの説明だと「変な人」らしいので——初対面はカフェがいい。何かあればすぐ出られるし。
ドアが開いた。
ツイードのジャケットを着た男が入ってきた。年齢不詳。周囲を一瞥して、迷いなくこちらに向かってくる。手には何も持っていない。鞄もPCもない。ジャケットの内ポケットだけが微かに膨らんでいる。
立ち上がりかけて、声をかけた。
「あの、ロックさんですか? メールでやり取りさせていただいた——」
座りながら、わたしのノートに目を落とした。
「……ほう」
「え?」
「この矢印は何本ある?」
ノートの図を指差している。自己紹介もなく、挨拶もなく、わたしの名前も聞かず。
「3本ですけど——」
「3本。Subject から Observer への直接参照が3本。——3本の鎖だ、ワトソン君」
面食らった。
「……わたしの名前は——」
聞いていない。店員にエスプレッソを頼んでいる。
佐藤さんが言ってた「変な人」ってこういうことか。……まあいい。技術の話ができればそれでいい。
「3本の鎖、というのは?」
「Observer パターン。Subject が Observer のリストを保持し、状態が変わったら順番に notify() を呼ぶ。——君の図の通りだ。Subject は Observer を知っている。Observer のインターフェースへの参照を直接持っている。これが鎖だ」
「鎖というより、配線では。イベントの配線です」
ロックさんが少し目を細めた。
「配線でもいい。配線は、同じ部屋の中で壁のコンセントに挿すものだ。君のシステムは、かつては同じ部屋にあった。モノリスだった。コンセントも壁も、すべて同じプロセスの中にあった」
「はい。半年前までは1つのPerlアプリケーションでした」
「そして3ヶ月前にマイクロサービスに分割した。部屋を壁で仕切った。在庫サービスは隣の部屋、配送サービスはその隣、請求書サービスはさらにその向こう——」
「……はい」
「壁で仕切った後も、君は同じコンセントに配線を挿そうとしている。壁を突き破って」
ノートの図を見下ろした。3本の矢印。notify()。HTTP越しの同期呼び出し。
「……HTTPに変えました。Observer の notify() を HTTP の API 呼び出しに」
「HTTPに変えた。だが構造は変わっていない。OrderService は3つのサービスのエンドポイントを知っている。呼び出したらレスポンスが返るまで待つ。Observer の Subject が Observer を直接呼ぶ構造そのものだ。プロトコルが変わっただけで、鎖は切れていない」
先週の45分間の障害を思い出した。請求書サービスのDBメンテナンス中、OrderService が notify() のHTTPタイムアウトで止まった。3つのサービスのうち1つが落ちただけで、全体が止まった。
「……Observer を使えばいいと思っていたんです。リスナーを追加するだけで拡張できるし、疎結合のはず——」
「疎結合?」
ノートの図を指された。OrderService からの3本の矢印。
「Subject は Observer のインターフェースに依存している。インターフェースだから実装には依存していない——それが Observer の"疎結合"だ。しかし、参照そのものは持っている。呼び出しは同期的だ。Observer が応答しなければ Subject は待つ。Observer がクラッシュすれば Subject のスレッドが巻き込まれる。——これを疎結合と呼ぶなら、疎結合の定義を君と僕とで話し合う必要がある」
唇を引き結んだ。反論したかった。でも45分の障害が反論を封じている。
現場検証 — 壁に穴を開けて腕を突っ込む
ノートPCを開いて、コードを見せた。
「これが今のコードです」
| |
ロックさんはエスプレッソを受け取りながら、画面を覗き込んだ。
「$observer->notify($order)。このループが3回回る。3つのサービスを順番に、同期的に呼んでいる」
「はい。最初に在庫引当、次に配送手配、最後に請求書発行です」
「請求書発行が落ちたとき、何が起きる?」
「notify がHTTPタイムアウトで例外を投げます。confirm_order 全体が失敗します。在庫引当と配送手配は——成功したものもあれば、していないものもあります」
「成功したものは巻き戻せるかね?」
「……正直、そこまで考えていませんでした」
エスプレッソを一口。カップをソーサーに戻して。
「整理しよう。問題は3つある」
ロックさんがわたしのノートの空きスペースに、許可を取らずにペンを走らせ始めた。内ポケットから万年筆を出している。レトロな趣味だ。少し驚いたけれど、止めなかった。
| |
「3番目が本質だ。OrderService の仕事は注文を確定することであって、通知を届けることではない。しかし今の設計では、通知の失敗が注文の確定を巻き戻す。——配達員の風邪で手紙が書けなくなる。おかしいだろう?」
ノートの余白に書かれた3行を読んだ。問題3が刺さった。OrderService が通知の責任まで負っているから、通知の失敗で注文が死ぬ。
「……でも、Observer パターンは Subject と Observer を分離するためのパターンですよね? インターフェースで抽象化して——」
「Observer パターンは同じ部屋で正しく動くパターンだ。同一プロセス、同一メモリ空間。Subject が Observer のメソッドを直接呼べる距離にいる。——君のシステムは壁で仕切られた。別プロセス、別サーバー、別ネットワーク。Observer が設計された時代に、この壁は想定されていなかった」
「HTTP に変えたのは——」
「壁に穴を開けて腕を突っ込んだだけだ。穴の向こうで相手が腕を掴んでいる間、君は動けない。タイムアウトの45分間、OrderService は穴の向こうの請求書サービスが腕を離すのを待っていた。——Observer は壁を越えられない。Observer は壁がないことを前提としたパターンだ」
推理披露 — 掲示板に貼れ
ロックさんが万年筆を手に取り、わたしのノートの右ページに図を描き始めた。左ページにはわたしが描いたObserverの構成図。右ページは白紙だった。
「声が届かない相手に、どう伝える?」
「……メールとか?」
「メール——つまり、相手の宛先を知っていて、直接送る。それはObserverのHTTP版と何が違う?」
考え込んだ。直接送るのがダメなら——。
「掲示板に貼れ」
「掲示板?」
ノートの右ページに四角い枠を描いて、枠の上に「Topic」と書いた。
「通知を送る代わりに、掲示板に貼り紙を出す。“注文が確定した"という貼り紙を。誰が読むかは知らない。何人読むかも知らない。掲示板に貼るだけだ」
「誰が読むかわからないのに、それで大丈夫なんですか?」
「読む側が掲示板を見に来る。在庫引当は『注文確定の貼り紙が出たら在庫を引き当てる』というルールで掲示板を監視する。配送も、請求書も同じだ。——貼る人と読む人が、互いを知らない。これが Publish-Subscribe パターンだ」
ノートの右ページに図を描き足していく。
| |
左ページのObserver図と、右ページのPub/Sub図。並べて見ると、違いが一目でわかった。
「矢印の形が……変わりましたね。左のページではOrderServiceから3本の矢印が直接出ている。右のページではOrderServiceからTopicに1本、Topicから3つのサービスに3本」
「左のページでは、OrderServiceが3つのサービスの存在を知っている。右のページでは、OrderServiceはTopicの存在だけを知っている。3つのサービスはTopicの存在だけを知っている。OrderServiceと3つのサービスの間に、直接の参照がなくなった」
「Observerの Subject は Observer を知っていた。Publisher は Subscriber を知らない……」
「その通り。Observer は声で呼ぶ——相手の顔が見える距離で。Pub/Sub は掲示板に貼る——誰が読むかは掲示板が決める」
掲示板の管理人——MessageBroker
「掲示板の管理人は、誰がやるんですか?」
「メッセージブローカーだ。トピックを管理し、メッセージを受け取り、購読者に配信する。Observerの Subject が担っていた『通知の配信』という責務を、ブローカーが引き受ける」
ロックさんがわたしのPCに手を伸ばした。「借りるよ」と一言。エディタにコードを打ち始める。
| |
「eval で囲んでいる……ハンドラが失敗しても、publish は止まらない」
「Observer では notify() の例外が Subject に直撃した。Pub/Sub では、ブローカーがハンドラの障害を吸収する。請求書サービスが落ちても、在庫引当と配送手配には影響しない。そしてOrderServiceにも影響しない」
「——45分間の障害が、起きなくなる?」
「正確に言えば、起きてもOrderServiceが止まらなくなる。請求書サービスのDBメンテナンスはまだ起きる。だが、注文の確定は続く。請求書は、復旧後に追いつけばいい」
Publisher と Subscriber のロール
「Publisher と Subscriber をロールとして定義する」
| |
| |
「Role::Subscriber は handle を要求するだけ……」
「Subscriber が何をするかはブローカーの知るところではない。Subscriber は handle メソッドを実装し、ブローカーに登録するだけだ。ブローカーは Subscriber の実装を知らない。Subscriber はブローカーの実装を知らない。知っているのはトピック名だけだ」
OrderService を書き直す
「では、OrderService を書き直そう」
| |
左ページのBeforeコードを確認した。add_observer がなくなっている。observers のリストもない。ループもない。
「add_observer が消えた。observers のリストもない」
「OrderServiceはもうObserverを知らない。Observerという概念すら持っていない。やることは1つ——Topicに貼り紙を出す。以上だ」
「配線を挿す代わりに、掲示板に貼っただけ……」
「配線を引き抜いたのではない。配線そのものが不要になった。OrderServiceと3つのサービスの間に、直接の接続はない。ブローカーが間に立っている」
Subscriber の実装
「Subscriber側を1つ見せよう。在庫引当の例だ」
| |
「シンプルですね……Observerのときと違って、OrderServiceへの参照がない」
「Observerの Observer は、Subject を知っている——最低でも、Subject の型を知っている。Subscriber はPublisherを知らない。Topicを知っているだけだ」
「トピック名……order.confirmed。これだけがPublisherとSubscriberをつなげている」
「文字列1つだ。オブジェクトへの参照ではない。インターフェースへの依存でもない。文字列の一致——これがPub/Subの結合度だ」
全体の組み立て
「最後に、全体の配線をブローカーに委ねる」
| |
右ページに、自分の手でPublisher → Topic → Subscriberの構成を描き直していた。
「4つ目のサービスを追加したいとき、OrderServiceのコードを変更する必要があるかね?」
「……ない。ブローカーに新しいSubscriberを登録するだけ」
「Observerなら?」
「Observerを追加する。Subject の add_observer を呼ぶ。——あ、でもSubjectのコードは変わらない」
「Subject のコードは変わらない。しかしSubject が Observer を知っている事実は変わらない。add_observer を呼ぶ箇所はアプリケーションのどこかにある。そこでは Subject と Observer の両方の型が必要だ。Pub/Sub では、Subscriber の登録に Publisher の型は不要だ。PublisherとSubscriberが同じファイルに現れることすらない」
classDiagram
class MessageBroker {
-_subscriptions: HashRef
+subscribe(topic, handler)
+publish(topic, message)
}
class `Role::Publisher` {
+broker: MessageBroker
+publish_event(topic, message)
}
class `Role::Subscriber` {
+handle(message)*
}
class OrderService {
+confirm_order(order)
}
class InventorySubscriber {
+handle(message)
}
class ShippingSubscriber {
+handle(message)
}
class BillingSubscriber {
+handle(message)
}
OrderService ..|> `Role::Publisher` : with
InventorySubscriber ..|> `Role::Subscriber` : with
ShippingSubscriber ..|> `Role::Subscriber` : with
BillingSubscriber ..|> `Role::Subscriber` : with
`Role::Publisher` --> MessageBroker : broker
MessageBroker --> `Role::Subscriber` : dispatch
事件の解決 — 消失と重複
ロックさんがエスプレッソの2杯目を頼んだ。わたしはPCの画面にテストコードを表示した。
「テストを書いてみたんですけど……」
「テストは君が書くべきだ、ワトソン君。自分のシステムを理解するのは君だ」
少し驚いた。でも、その通りだ。
核心のテストは1つ。請求書サービスが障害でも注文が確定するかどうか。
| |
「請求書サービスが die しても、注文は confirmed になって、在庫引当は実行される」
「Observerなら、請求書サービスの die が confirm_order を巻き戻していた。Pub/Subでは、ブローカーが例外を吸収する。Publisherは Subscriber の成否を知らない。知る必要がない」
「でも——請求書が発行されないままになりませんか?」
「なる。Subscriberが復旧するまで、請求書は未発行だ。——だが、注文は確定している。在庫は引き当てられている。配送は手配されている。ビジネスは止まっていない。請求書は復旧後にリトライすればいい」
「Observerでは全部止まっていた」
「Observerでは、1つのサービスの障害が全体を人質に取った。Pub/Subでは、障害は局所に閉じ込められる」
ふと、疑問がわいた。
「Subscriberがダウンしていたら、メッセージは消えるのですか? Observerでは notify の瞬間に相手がいなかったら——声は消えましたよね」
「Observerではそうだ。notify の瞬間に Observer が不在なら、通知は空気に溶ける。——Pub/Subでは、掲示板に貼り紙が残る。Subscriberが復旧したとき、貼り紙を読めばいい。メッセージブローカーは、未処理のメッセージを保持する」
「最低1回は届く……at-least-once delivery」
「知っているのか」
「言葉だけは。実装では使ったことがなくて」
「at-least-once。最低1回は届く。しかし、ちょうど1回とは限らない。ブローカーの確認応答が遅れれば、同じメッセージが再送される」
「2回届く可能性がある?」
「ある」
眉をひそめた。
「思い出したまえ。Observerでは届かなかった。Pub/Subでは重複する可能性がある。届かないメッセージは復元できない。重複したメッセージは、冪等な処理で1つを捨てればいい。——消失と重複、どちらが対処しやすい?」
しばらく黙った。左ページのObserver図を見ていた。3本の矢印。45分間、声が届かなかった3本の鎖。
「……重複のほうが、ずっとマシです」
「問題が消えたのではない。問題の性質が変わった。対処不可能な問題が、対処可能な問題に変わった」
「メッセージIDで重複を弾けばいい、ということですね」
「その通りだ」
通知の歴史 — 声が届く範囲を広げる
ロックさんが立ち上がる気配がした。でも、1つだけ聞きたいことがあった。
「ロックさん——1つ聞いてもいいですか」
「何かね」
「このPub/Subは、メッセージの配信を信頼性のある形で実現するんですよね。……通知の信頼性って、他にもパターンがあるんですか?」
ロックさんが座り直した。少し目が光った気がする。
テーブルの紙ナプキンを手に取り、万年筆で図を描き始めた。
| |
「Observer は声が届く範囲での通知だ。同じ部屋にいる限り、確実に届く。しかし部屋が壁で仕切られたら、声は届かない」
「Domain Event は何が起きたかを名前のあるオブジェクトとして記録する。“注文が確定した"という事実を、コールバックの引数ではなく、OrderConfirmed という独立した記録にする。——記録は残る。しかし、記録を壁の向こうに伝える仕組みは、Domain Event 単体では持っていない」
「Pub/Sub は壁の向こうに掲示板で伝える。Publisher は掲示板に貼る。Subscriber は掲示板を見に来る。壁を越えても通知が届く。——しかし、掲示板に貼り紙を出す瞬間にアプリケーションが落ちたら? ビジネスデータは更新されたが、貼り紙は出なかった?」
「それは——メッセージが消える問題……」
「Outbox パターンは、貼り紙をビジネスデータと同じトランザクションで保存する。貼り紙が確実に作られたことを、DBの原子性で保証する」
ナプキンの図を見つめた。4つの段階。Observer → Domain Event → Pub/Sub → Outbox。
「——通知の歴史とは、声が届く範囲を広げながら、信頼性を積み上げてきた歴史だ」
その一言が、カフェの空気を変えた気がした。
「……わたしのシステムは、最初の段階にいたんですね。声が届く範囲でしか通知できないObserverを、壁の向こうに無理やり使っていた」
「Observerが悪いのではない。Observerは同じ部屋のために作られた。壁ができたなら、壁のための仕組みに切り替える。それだけのことだ」
ロックさんが立ち上がった。
「あの——報酬は」
「報酬?」
「佐藤さんが、キーボードで報酬を取るって聞いたので——」
少し笑った。この人が笑うのは珍しい気がする。初対面だからわからないけれど。
「キーボードは余っている。——次に壁にぶつかったとき、掲示板を思い出せ。それが報酬だ」
ドアベルが鳴って、ロックさんがカフェを出ていった。
しばらくその場に残った。テーブルに残ったナプキンの図と、自分のノートの見開き。
左ページにはカフェに来る前に描いたObserverの図。OrderService から3本の矢印が3つのサービスに直接伸びている。名前を知って、顔を見て、返事を待っていた。
右ページには今日描いたPub/Subの図。OrderService からTopicに1本の矢印。Topicから3つのサービスに3本の矢印。誰が読むかは掲示板が決める。
同じ通知なのに、矢印の意味が全部変わった。左のページでは、わたしが3人に声をかけていた。右のページでは、わたしは掲示板に1枚の紙を貼っただけだ。
壁の向こうに声は届かない。でも掲示板なら、見に来ればいい。
ノートを閉じた。PCを開いた。エディタに向かう。
| |
まず、掲示板から。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
| 分散環境でのObserver直接呼び出し — SubjectがObserverのHTTPエンドポイントへの直接参照を保持し、同期的にnotifyを呼ぶ。1サービスの停止で全体が停止する | Publish-Subscribe — メッセージブローカー(Topic)を仲介者とし、PublisherとSubscriberが互いを知らない疎結合な非同期通知 | Subscriberの障害がPublisherに波及しない。サービス追加にPublisherのコード変更が不要。障害が局所に閉じ込められる |
推理のステップ
- Observerの直接参照を特定する: SubjectがObserverのリスト(エンドポイント)を保持し、同期的に呼び出していないか確認する
- メッセージブローカーを導入する: Topicを管理し、publishとsubscribeを仲介するMessageBrokerを作る
- Publisherロールを定義する: SubjectをPublisherに変える。ブローカーへの
publish_eventだけを持ち、Observerへの参照を捨てる - Subscriberロールを定義する:
handleメソッドをrequiresとするロールを作り、各サービスが実装する - ブートストラップで接続する: アプリケーション起動時にSubscriberをトピックに登録する。PublisherとSubscriberはこの接続を知らない
- 冪等性を考慮する: at-least-once deliveryに対応するため、Subscriberのハンドラをメッセージ ID で冪等にする
ロックより
Observerは声が届く距離のためのパターンだ。壁ができたなら、壁のための仕組みに切り替えたまえ。掲示板に貼り紙を出す——それだけで、声は壁を越える。通知の歴史とは、声が届く範囲を広げながら信頼性を積み上げてきた歴史だ。君のシステムにも、その歴史を刻むがいい。
