仕様どおりにパーサーを作る
目標
WHATWG HTML標準のイベントストリームのパースルールを自分で実装し、その上に、 再接続で続きを受け取る仕組みを載せます。
なぜ重要なのか
SSEは「ただのHTTP」なので、ライブラリがなくても使えます。そのため実務では、
split("\n\n")1行のパーサーがとてもよく見られますが、そのパーサーは、断片に分かれて
届く入力であっけなく壊れます。TCPは、皆さんが送った単位で届くことを
保証しないからです。
続きの受け取りは、さらに厄介です。idバッファがイベントごとに初期化されるかどうか、途切れた最後の
イベントを出力するかどうかによって、再接続後の画面が抜けたり重複したりします。規則は
仕様にすべて書かれており、このラボは、その規則をコードに移す作業です。
作るもの
/root/work/resume/client.pyで、次のものを提供します。
| 名前 | 契約 |
|---|---|
StreamParser |
feed(조각)は、今回完成したイベントのリストを返します(プレースホルダーは断片です)。last_event_id・reconnection_time属性を持ちます |
replay(events, last_event_id) |
サーバー側の送り直しウィンドウです。そのidの後ろを返します |
resume_headers(parser) |
再接続のときに載せるヘッダーの辞書です |
イベント1つは辞書です: {"event": ..., "data": ..., "id": ...}。
ステップ
- 断片と行末:
feedがどこで途切れても同じ結果 - フィールドのルール: コメント・コロンのない行・dataの連結
idバッファは初期化されないretryはASCII数字のときだけ- 送り直しウィンドウ:
replay - 再接続ヘッダー:
resume_headers - 自分で測って書く:
/root/work/resume/07-gap.txt - まとめ:
/root/work/resume/08-notes.md
参考
- ステップ7が読むストリームの断片は、イメージに入っています:
/opt/fixtures/sse-resume/cut.txt。最後のイベントが空行なしで途切れています。 - 標準ライブラリだけで十分です。pip installはネットワークがないため使えません。
- よくある間違いが2つあります。断片の境界にまたがった
\r\nを2行として数えること、そして Pythonのstr.isdigit()が全角数字にも真を返すことです。
断片と行末
/root/work/resume/client.pyにStreamParserを作ってください。feed(조각)は、今回の断片で完成したイベントのリストを返します(プレースホルダーは断片です)。入力がどこで途切れて届いても結果が同じで、行末はCRLF・LF・CRの3つすべてを受け付ける必要があります。先頭のBOM1つは捨てます。
mkdir -p /root/work/resumeを実行します。まだ処理できていない末尾をインスタンスのバッファに残しておき、行末が見つかるところまでだけ処理してください。落とし穴が1つあります。バッファが\rで終わっている場合、それが\r\nの前半かもしれないので、次の断片を待つ必要があります。このステップではdata:だけを扱えば足ります。
フィールドのルール
仕様のフィールドルールを組み込んでください。コロンで始まる行は無視し、コロンのない行は行全体がフィールド名で、値は空文字列とし、dataは値を連結して改行を1つずつ追加し、eventはイベントタイプを変更します(デフォルトはmessage)。未知のフィールドは無視します。
値の前の空白は1つだけ取り除きます。出力するときは、dataバッファが空文字列かどうかを改行を取り除く前に確認し、空でなければ最後の改行1つだけを取り除きます。そのため、dataの1行だけのブロックは、dataが空文字列のイベントとして出力されます。
idバッファと最後のid文字列
idフィールドを処理してください。値にU+0000 NULLが含まれていればそのフィールドを無視し、そうでなければ内部のidバッファをその値に置き換えます。再接続に使うlast_event_idは、イベントを出力する箇所でそのバッファの値に更新され、出力したあとも初期化されません。出力するイベントのidには、その値を入れます。
仕様は、出力のステップで、①最後のid文字列をバッファの値に置き換え、②dataバッファが空ならそこで戻る、と書いています。順序がそのとおりなので、id:だけがありdataがないブロックでも再接続の位置は進みますが、空行が来ずに出力自体が行われなければ進みません。そしてidは文字列です。整数に変換しないでください。
retryはASCII数字のときだけ
retryフィールドを処理してください。値がASCII数字だけで構成されているときに限ってreconnection_timeをその整数に変更し、そうでなければ無視します。初期値はNoneです。retry:だけがあってdataがないブロックは、イベントではありません。
Pythonのstr.isdigit()は、全角数字にも真を返します。value.isascii() and value.isdigit()のように、2つの条件を一緒に確認してください。空文字列も、+100も、3sも、3.5も、無視する対象です。
サーバー側の送り直しウィンドウ
replay(events, last_event_id)を追加してください。eventsはidを持つ辞書のリストです。last_event_idが空かNoneならすべてを、ウィンドウの中にあるidならその後ろからを、ウィンドウにないidならNoneを返します。
Noneは、「続きを受け取れないので、最初から取り直してください」という合図です。残っているものだけを送ると途中が抜けますが、そのことを誰も知りません。idの比較は文字列のまま行ってください。b7-1042のようなidが実際に使われます。
再接続ヘッダー
resume_headers(parser)を追加してください。パーサーのlast_event_idが空文字列でないときだけ{"Last-Event-ID": 값}を返し、そうでなければ空の辞書を返します(プレースホルダーは値です)。
仕様は、最後のid文字列が空文字列でないときだけヘッダーを載せます。空の値を載せて送ると、サーバーは「0のあとから」なのか「最初から」なのか区別できません。ヘッダー名の大文字小文字とハイフンは、そのまま使ってください。
途切れたイベントを自分で測ってみる
/opt/fixtures/sse-resume/cut.txtを丸ごと皆さんのパーサーに流し込み、出た値を/root/work/resume/07-gap.txtに이름=값の形式で3行書いてください(プレースホルダーは名前と値です)。eventsは、出力したイベント数です。last_event_idは、そのときの最後のidです。retry_msは、そのときのreconnection_timeです。
そのファイルは、最後のイベントが空行なしで途切れています。仕様はそのようなイベントを出力しないと書いているので、3つの数値はすべてそのルールにかかっています。採点ツールが同じファイルを皆さんのパーサーで再度流し込んで、書かれた値と照合するので、目分量で見積もらないでください。
何が空白を防いだのか
/root/work/resume/08-notes.mdに3行以上書いてください。Last-Event-IDがないと、再接続のあとに何が見えるのか、途切れたイベントを出力すると何がずれるのか、送り直しウィンドウから外れたidに対して残っているものだけを送ると何が生じるのかを書きます。
本文にLast-Event-ID・재접속・잘린(または불완전)を含めてください(最初の語以外は、順に、韓国語で「再接続」「途切れた」「不完全」を意味する語です)。この3つが、SSEを本番に載せたときに、実際に人を呼び出す3つの問題です。