照规范写一个解析器
目标
亲手实现 WHATWG HTML 标准的事件流解析规则,并在其上加上通过重连来续传的机制。
为什么重要
SSE 是“普通的 HTTP”,所以不用库也能使用。因此在实际工作中,一行 split("\n\n") 的解析器非常常见,而这种解析器遇到被拆成片段到达的输入时,马上就会崩溃。因为 TCP 并不保证按你发送的单位到达。
续传就更是如此。根据 id 缓冲区是否在每个事件之后被重置、是否派发被截断的最后一个事件,重连之后的界面会出现空缺或重叠。 规则在规范中都写得很清楚,本实验就是把这些规则转化为代码。
要创建的内容
在 /root/work/resume/client.py 中提供下面这些。
| 名称 | 契约 |
|---|---|
StreamParser |
feed(조각)(占位符为数据片段)→ 这次完成的事件列表。属性 last_event_id、reconnection_time |
replay(events, last_event_id) |
服务器一侧的回放窗口——返回该 id 之后的内容 |
resume_headers(parser) |
重连时要携带的请求头字典 |
一个事件是一个字典:{"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 无法使用。
- 两个常见的错误:把横跨片段边界的
\r\n算成两行,以及 Python 的str.isdigit()对全角数字也会返回真。
片段与行结束符
在 /root/work/resume/client.py 中创建 StreamParser。feed(조각)(占位符为数据片段)返回用这个片段完成了的事件列表。无论输入在哪里被截断后到达,结果都必须相同,行结束符必须同时接受 CRLF、LF、CR 三种。最前面的一个 BOM 要丢弃。
mkdir -p /root/work/resume。把尚未处理的尾部保留在实例的缓冲区中,只处理到能找到行结束符为止。有一个陷阱——如果缓冲区以 \r 结尾,它可能是 \r\n 的前半部分,所以必须等待下一个片段。这一步只处理 data: 也可以。
字段规则
加入规范的字段规则。以冒号开头的行忽略,没有冒号的行整行是字段名,值为空字符串,data 把值拼接起来并每次多加一个换行,event 修改事件类型(默认是 message)。未知的字段忽略。
值前面的空格只去掉一个。在派发时,要在去掉换行之前检查 data 缓冲区是否为空字符串,如果不为空,只去掉最后的一个换行。所以只有一行 data 的块,会作为 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() 这样同时检查两个条件。空字符串、+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。events——派发的事件数。last_event_id——此时最后的 id。retry_ms——此时的 reconnection_time。
那个文件的最后一个事件在没有空行的地方被截断了。规范规定不要派发这样的事件,所以三个数字全都取决于这条规则。评分器会用你的解析器重新灌入同一个文件,并与写下的值核对,所以不要凭眼睛估算。
是什么堵住了缺口
在 /root/work/resume/08-notes.md 中写三行以上。没有 Last-Event-ID 时,重连之后会看到什么;派发被截断的事件会出现什么错位;对超出回放窗口的 id 只发送剩余内容,会产生什么。
正文中必须包含 Last-Event-ID、재접속(韩文,意为“重连”)、잘린(韩文,意为“被截断的”)(或 불완전(韩文,意为“不完整”))。这三点就是把 SSE 投入生产时,真正会把人叫起来的三件事。