WebSocketのコードを書かないリアルタイム — ホワイトボード、チャット、通知をMQTTで
アプリの背後に立つMosquittoのブローカー一つが、共同ホワイトボードとスタッフ⇄会員のチャットと通知ベルを運んでいます。そして一度だけ、そのブローカーが自分自身を締め出しました。
- 行
- 31
- サーバー側のパブリッシャー
- 行
- ~75
- ブラウザ側、再接続込みで
- 読み取りタイムアウト
- 24 h
- 中継される/mqttのWebSocketで
背景
MemberHubには、誰も操作していないのに画面が変わってほしい場所が三つあります。複数人が同時に描く共同ホワイトボード、スタッフと会員が双方ページを開いたまま話すチャット、そしてチケットが起票された瞬間や請求が支払われた瞬間に増えてほしい通知ベル。それ以外はすべてリクエストとレスポンスで、そのほうが幸せです。
制約は、バックエンドがすでに何であるかということでした。同期型のDjangoモノリスが一つあり、それを書いた本人が運用しています。定石の答えであるDjango Channelsを選べば、WSGIの隣にASGIのデプロイが増え、チャネルレイヤーとRedisと非同期ビューがついてきます。三つの機能のために払う代償としては大きすぎました。
そこでリアルタイムはアプリケーションの外に置きました。隣にMosquittoのブローカーを一つ立て、Djangoとは素のTCPで、ブラウザとはWebSocketで話します。同時に決めた規則には、以後すべてが従っています。真実の源はデータベース、バスは物事を即座にするだけ、そして事実がバスの上にしか存在しない状態は許さない。
作ったもの
サーバー側は31行のモジュールです。gunicornのワーカーごとに遅延接続のpahoクライアントを一つだけ持ち、切れていれば再接続し、外に出すのはpublish(topic, payload)という関数一つだけ。ペイロードをJSONにし、すべてのトピックに製品の名前空間を前置し、QoS 1で送ります。失敗する経路はどれもログを書いて戻るだけなので、ブローカーが落ちてもAPIリクエストが巻き添えになることはありません。
ブラウザは生のMQTTを話せないので、ブローカーはループバックのアドレスでWebSocketのリスナーも開き、前段のリバースプロキシが/mqttをそこへ通します。アップグレードヘッダーと24時間の読み取りタイムアウト付きで、同じオリジン、同じ証明書、外に開くポートは増えません。
ブラウザ側は約75行です。mqtt.jsのクライアントと5秒の再接続間隔、そしてトピックからコールバック集合へのMapを持ち、connectハンドラが記録済みのトピックをすべて張り直すので、再接続しても購読は生き残ります。その上にある一つの判断が、残りを安くしました。起動時にアプリがワイルドカードを一度だけ購読し、届いたメッセージをすべてwindow上のDOM CustomEventとして再放送するのです。したがって、ほとんどのコンポーネントはMQTTクライアントを読み込みもしません。マウント時にwindowのリスナーを足し、アンマウント時に外すだけです。自前の絞り込んだ購読を持つのは、チャットとホワイトボードのエディタだけです。
- memberhub/ — 全トピックが持つ名前空間
- 組織全体 — 平たいdomain/event名。 組織idで絞る
- チャット — chat/{org}/{thread}、 /typing
- ホワイトボード — whiteboard/{board}/shape、 /cursor、/laser
- memberhub/ — 組織全体
- memberhub/ — チャット
- memberhub/ — ホワイトボード
通知センターはモデル一つとエンドポイント五つ、書き込み経路はただ一つです。行を作り、それから配信する。全体をtry/exceptで包んで失敗時はNoneを返します。これを呼ぶ11のツールが、通知が書けなかったせいで自分の処理を失敗させてはならないからです。ヘッダーは読み込み時と組織の切り替えのたびに未読数を取り直すので、バッジは必ずデータベースと辻褄が合います。リアルタイムの経路は、それが早く増えるようにするだけです。
ホワイトボードは図形の変更をボードのJSONドキュメントに保存してから、署名付きの形に直して再配信します。購読している側は、埋め込み画像をもう一度取りに行かずにそのまま描けます。カーソルとレーザー、そしてチャットの入力中表示はブラウザからブローカーへ直に流れ、どこにも記録されません。参加者の一覧はそこから自然に出てきます。最後のカーソルから10秒で消えるだけです。
設計上の判断
Django ChannelsではなくMQTT。 Python側は非同期の事情を持たない素のWSGIアプリのままでいられます。代償は、自分で面倒を見るデーモンがもう一つ増えること、そして私のアプリケーションのユーザーという概念を持たない転送路を選んだこと。後者が、のちの事故の出どころそのものになります。
データベースが先、ブローカーが後。 もう一方の設計、つまり配信してクライアントにそれを記録として扱わせるやり方は、速いかわりに取りこぼします。この設計なら、タブが閉じていてもブローカーが一瞬詰まっても失うものはありません。行はすでに書かれていて、次のREST取得で辻褄が合います。
タブごとに接続は一つ、一時的な状態はサーバーに触れない。 すべてのメッセージがタブ内のすべてのリスナーに届きますが、このペイロードの大きさなら実質ただで、重い二つのコンポーネントだけがそこから抜けます。カーソル、レーザー、入力中表示には、ふさわしいだけの永続性を与えました。つまり、なし。
会員とゲストで書き込み経路を非対称に。 ログイン済みのエディタは図形をブローカーへ直接送り、それとは別に2秒のデバウンスで文書全体を保存します。公開ボードの匿名ゲストはどちらもできません。彼らの編集は、サーバーが図形の操作を一つだけ永続化してから再配信する狭いエンドポイントを通ります。匿名の入力に対して信頼できる書き手は、サーバーだけです。
うまくいかなかったこと
ブローカーがアプリを締め出した。 このブローカーは同じマシン上の別のアプリケーションと共有していて、そちらのセキュリティ強化が匿名接続を全体で無効にし、ブローカーをパスワードファイルに向けました。MemberHub側は何も変えていないし、何もデプロイしていません。それでもサーバー側のパブリッシャーもブラウザのタブも、接続の時点で「認可されていない」と断られるようになり、ブラウザは6秒ごとに、いつまでも再試行し続けました。
直し方は、匿名の客人ではなくブローカーの正式な住人になることでした。バックエンドのパブリッシャー用に識別子を一つ、ブラウザ用にもう一つ。そしてブラウザに資格情報を渡す小さなエンドポイントを三つ用意し、それぞれ、その画面をすでに認可しているものに門番をさせました。管理アプリはスタッフの認証、会員向けチャットのページは有効なスレッドのトークン、公開ホワイトボードのページは実際に公開されているボードであること。フロント側の三か所が、接続の前に資格情報を取りに行くようになりました。
正直に書いておくことが二つあります。ブローカーにはまだトピック単位のアクセス制御がなく、認証さえ通っていればどのトピックにも publish も subscribe もできます。それが次の強化として分かっていることであり、組織全体に流れるイベントに機微でないメタデータしか載せていない理由でもあります。もう一つは、ブローカーの障害でAPIリクエストを壊さないための設計、つまり失敗を記録して戻るという設計が、今回の障害をサーバー側で静かにしていたということ。堅牢さと沈黙は、同じ性質を二つの方向から見たものです。
データを人質に取ったService Worker。 手書きのService Workerは一度書き直されています。すべてをキャッシュ優先で返していた版が、デプロイをまたいで利用者を古いJavaScriptに閉じ込めたからです。そこから出た規則が、静的パス以下のJavaScriptはネットワーク優先、それ以外(コメント曰く「CSS、画像、フォント」)はキャッシュ優先、というものでした。
数か月後、翻訳カタログの修正が再訪問者に届かなくなりました。ディスク上のファイルは新しく、ハードリロードすれば新しい文字列が出るのに、一度でも訪れたことのあるブラウザは古いラベルを表示し続けます。カタログは実行時に取得されるJSON、つまりコードではなくデータで、鮮度の方針はファイルの拡張子で分岐しているので、画像のために引かれた線のキャッシュ優先側に落ちていたのです。壊れてはいませんでした。言われたとおりに動いていただけです。唯一の追い出し口はWorkerの先頭にあるキャッシュ世代の文字列なので、JavaScript以外の静的ファイルを変えたら必ず世代を上げる、というのが規則になりました。
結果
生きているように見える三つの機能を支えているのは、31行のサーバー側モジュールと75行のブラウザ側モジュール、そしてブローカー一つです。カーソルと在席表示のついた共同描画、会員がアカウントを作らなくても成立するスタッフ⇄会員のチャット、11のツールが一つの関数を通して流し込む通知ベル。チャットのスキーマは、二つ目のマイグレーションを必要としたことが一度もありません。
残っている宿題は隠さずに書いておきます。ブローカーのトピック単位のアクセス制御、通知のユーザーごとの既読状態、そしてキャッシュ世代の番号 — いまは50番台です — が保証ではなく人間の規律でしかない、という事実。