9.18. 逐字节解析 MQTT¶
到这一步,摄像头已经具备了与开放互联网上真实服务通信所需的一切:TCP socket、用来加密封装的 TLS、用来给对端命名的 DNS,以及让同一个脚本在连接打开期间还能处理其他工作的 asyncio。MQTT 是第一个把这些全部串联起来、真正被部署产品所采用的有线协议。
本页讲解协议本身——它的有线格式、各参与方扮演的角色以及设计上的权衡取舍——讲得足够透彻,使得内置的 mqtt 客户端看起来像是对已知内容的一种显而易见的封装,而不是一次盲目的跃进。
9.18.1. 发布/订阅 vs 请求/响应¶
HTTP——大多数摄像头项目最先想到的协议——是 请求/响应 式的。客户端向某个特定服务器请求某个特定资源;服务器作出应答。每一次交互都是一对一的,且两端都事先知道对方的地址。
MQTT 是 发布/订阅 式的。客户端连接到位于中间的第三方,称为 代理(broker)。发布者 向一个具名的 主题(topic) 发送消息,而无需知道也无需关心谁在收听。订阅者 告诉代理它想要哪些主题,此后便会收到发布到这些主题的每一条消息。代理就是那个扇出节点:在 yard-cam/motion 上发布一次,就会到达每一个订阅了 yard-cam/motion 的设备,无论它们是零个、一个还是五十个。
这种模型上的转变带来三个结果:
解耦。 发布者无需知道订阅者的存在。订阅者可以来去自如而不被发布者察觉。新增第二个仪表盘只需在新仪表盘上写一行代码;摄像头无需任何改动。
扇出。 代理负责处理每一份副本,因此无论有多少设备读取,摄像头都只发送一个数据包。这正是 MQTT 当初被设计来解决的使用场景。
不对称性。 代理如今成了必需的基础设施——没有它,协议就无法工作。对于家庭项目来说,这通常是一个免费的公共代理(
test.mosquitto.org、broker.hivemq.com),或是你自己运行的一个小型代理。
9.18.2. 主题¶
主题是以斜杠分隔的字符串。惯例是最通用的部分在左侧,最具体的部分在右侧:
yard-cam/motion
yard-cam/temperature
workshop-cam/motion
workshop-cam/temperature/sensor-3
两个通配符可用于 订阅(而不能用于发布):
+匹配单个层级。+/motion订阅每个摄像头的 motion 主题;yard-cam/+订阅 yard-cam 下的每个子主题。#匹配一个或多个尾部层级。yard-cam/#订阅yard-cam/motion、yard-cam/temperature、yard-cam/temperature/sensor-3以及yard-cam/下的任何其他内容。它必须出现在订阅字符串的末尾。
主题字符串区分大小写。根据规范,以 $ 开头标识代理内部主题($SYS/...),发布者不应向其写入。
9.18.3. 数据包格式¶
MQTT 运行在 TCP 之上。每个控制包都以一个单字节的 固定头部 开始,后跟一个可变长度的 剩余长度(Remaining Length) 字段,然后是特定于包类型的 可变头部,最后是 有效载荷。同样的外层格式涵盖了每一个命令——CONNECT、PUBLISH、SUBSCRIBE、PUBACK、DISCONNECT 等等——这也是为什么一个 MQTT 客户端可以用几百行代码写完。
固定头部为一个字节:
第 7..4 位是 控制包类型。
0x3表示 PUBLISH(所以第一个字节通常以0x3?开头)。0x1是 CONNECT,0x2是 CONNACK,0x8是 SUBSCRIBE,0xC是 PINGREQ,0xE是 DISCONNECT,等等。第 3..0 位是特定于包类型的 标志。对于 PUBLISH,这些标志编码了 DUP 重传标志、QoS 等级(2 位)和 RETAIN 标志。
剩余长度是一个 1 到 4 字节的可变长度整数,用于统计它自身之后的每一个字节。每个字节的最高位是延续标记——1 表示“后面还有一个长度字节”,0 表示“这是最后一个”。小于 128 的长度可以用一个字节表示;更大的有效载荷则使用更多字节。最大可编码长度为 256 MiB。
对于 PUBLISH 来说,可变头部是主题名称——一个 2 字节的长度,然后是 UTF-8 字节——后跟一个 2 字节的 包标识符,该标识符仅在 QoS 为 1 或 2 时才存在。其余字节即为有效载荷,协议将其视为不透明的字节。
一个将 ok 以 QoS-0 发布到 a/b 的最小 PUBLISH 包为:
30 07 00 03 'a' '/' 'b' 'o' 'k'
30—— PUBLISH,所有标志为零。07—— 后面跟着 7 个字节。00 03—— 主题长度为 3。'a' '/' 'b'—— 主题。'o' 'k'—— 有效载荷。
线路上九个字节,消息就送达代理上每一个订阅了 a/b 的订阅者。
9.18.4. QoS 等级¶
服务质量(Quality-of-Service)控制代理(以及客户端)为确保送达而付出多大努力。三个等级为:
QoS 0 —— 至多一次。 发完即忘。PUBLISH 包被发出后从不确认。如果 TCP 送达了,代理就转发。如果连接在发送途中断开,消息就丢失了。大多数传感器遥测在 QoS 0 下都没问题——在每 30 秒发送一次的数据流中漏掉一次温度读数无关紧要。
QoS 1 —— 至少一次。 发布者包含一个包标识符并等待 PUBACK。如果在超时之前没有收到 PUBACK,发布者就会设置 DUP 标志并重传。代理最终可能会向同一等级上的订阅者投递同一条消息两次;订阅者必须能够处理重复消息。
QoS 2 —— 恰好一次。 一个四步握手(PUBREC / PUBREL / PUBCOMP)确保消息恰好送达一次,即使跨越重连也是如此。这在往返次数和代理状态上代价高昂。很少有摄像头应用需要它。
内置的 mqtt 客户端实现了 QoS 0 和 QoS 1;如果你请求 QoS 2 则会抛出异常。对于一个上报传感器读数的摄像头来说,QoS 0 几乎总是正确的选择。
9.18.5. 保留消息与遗嘱¶
有两个特性值得了解,因为它们改变了代理对你的主题所记住的内容。
RETAIN。 如果一个 PUBLISH 设置了 RETAIN 标志,代理就会存储该消息,并在每个 未来的 订阅者订阅的那一刻将其转发给它们。MQTT 就是这样处理“当前值是多少?”的——传感器以保留方式发布它最新的读数,十分钟后才订阅的仪表盘仍然会收到最近的值,而不必等待下一次发布。用同一主题重新发布会覆盖保留值;发布一个空的有效载荷则会清除它。
遗嘱。 客户端连接时可以给代理留一份“遗嘱(last will and testament)”:一个主题、一个有效载荷、一个 QoS 和一个保留标志。如果该客户端 非正常地 断开连接——TCP RESET、断电、未发送 DISCONNECT 包的网络中断——代理就会代该客户端发布这份遗嘱。订阅者会把它看作摄像头已经离线的通知。摄像头自身从不发送遗嘱;由代理发送,因为到那时摄像头已经下线了。
9.18.6. 保活与重连¶
CONNECT 携带一个以秒为单位的 保活(keepalive) 间隔。如果客户端沉默了那么长时间,代理就会认为它已经失效。为防止这种情况,客户端会定期发送一个 PINGREQ(一个字节:0xC0)并收到一个 PINGRESP(0xD0)回应——这是该协议所能承载的最小、最廉价的心跳。大多数摄像头应用会把保活设为 30 或 60 秒。
如果 TCP 连接断开,双方都会察觉并从头重新连接。断开之前所做的订阅会丢失,除非客户端在连接时使用了 持久会话;对于简单的摄像头应用来说,重连即重新订阅的模式更简短,而且效果同样好。
这些内容已足以阅读 MQTT 规范,或在 socket.socket 之上手写一个客户端。mqtt 中内置的客户端正是这么做的,并额外为应用代码提供了一套合理的 API。