MQTT 是物联网里最常用的发布 / 订阅协议:固定头只有两个字节,报文只有十几种,却有三档送达保证、保活、遗嘱和保留消息。这篇自己写了一个迷你的 MQTT 代理,手工发每个字节,把这些机制逐个做出来看。
CONNECT(25 字节):10 17 00 04 4d 51 54 54 04 02 00 3c 00 0b 77 31 30 34 2d 63 6c 69 65 6e 74
= 10(类型 1 = CONNECT,标志 0)| 17(剩余长度 23)| 00 04 "MQTT"(协议名)| 04(协议级别 4 = MQTT 3.1.1)| 02(连接标志:Clean Session)| 00 3c(保活 60 秒)| 00 0c "w104-client"(客户端标识)
CONNACK(4 字节):20 02 00 00 = 类型 2(CONNACK)| 剩余长度 2 | 00(会话存在标志 0)| 00(返回码 0 = 接受)
SUBSCRIBE(21 字节):82 13 00 01 00 0e 73 65 6e 73 6f 72 73 2f 2b 2f 74 65 6d 70 01:82 = 类型 8,标志必须是 0010;包标识 00 01;主题过滤器 "sensors/+/temp";请求的 QoS 01。SUBACK:90 03 00 01 01(类型 9,包标识 1,授予的 QoS 1)固定头 1 字节类型加标志 + 变长的剩余长度;CONNECT 25 字节,CONNACK 4 字节。
发布者发出(QoS 0,26 字节):30 18 00 12 73 65 6e 73 6f 72 73 2f 72 6f 6f 6d 31 2f 74 65 6d 70 32 31 2e 35:30 = PUBLISH,QoS 0 | 18 剩余长度 24 | 00 12 + 18 字节主题 "sensors/room1/temp" | 4 字节载荷 "21.5"
订阅者收到(26 字节):类型 PUBLISH,主题 'sensors/room1/temp',载荷 b'21.5';单条消息的总开销 = 固定头 2 + 主题长度字段 2 = 4 字节,再加主题名本身 18 字节(MQTT 没有“头部压缩”,主题名每次都要带)
载荷 100 字节:剩余长度 103 → 编码成 1 字节(67);整个包 105 字节
载荷 127 字节:剩余长度 130 → 编码成 2 字节(82 01);整个包 133 字节
载荷 128 字节:剩余长度 131 → 编码成 2 字节(83 01);整个包 134 字节
载荷 16383 字节:剩余长度 16386 → 编码成 3 字节(82 80 01);整个包 16390 字节
载荷 16384 字节:剩余长度 16387 → 编码成 3 字节(83 80 01);整个包 16391 字节固定开销 4 字节,但每次都带完整主题名(无压缩);剩余长度用变长编码。
第一次 PUBLISH:32 13 00 08 71 31 2f 74 6f 70 69 63 00 07 70 61 79 6c 6f 61 64(32 = QoS 1,DUP 0);代理故意不回;客户端1 秒内没有收到 PUBACK
重发 PUBLISH:3a 13 00 08 71 31 2f 74 6f 70 69 63 00 07 70 61 79 6c 6f 61 64(3A = QoS 1 + DUP 1,包标识仍是 7);代理回 PUBACK 40 02 00 07(类型 4,包标识 7)
代理看到的 QoS1 PUBLISH 事件:[('PUBLISH QoS1', 7, False), ('故意不回 PUBACK', 7), ('PUBLISH QoS1', 7, True)]——同一个包标识被收到了两次,所以 QoS 1 是“至少一次”,接收方可能看到重复至少一次:PUBACK 丢了就用 DUP 重发,接收方可能看到重复。
C→S PUBLISH QoS2 pid=9
S→C PUBREC pid=9
C→S PUBREL pid=9
S→C PUBCOMP pid=9
订阅者此时还没收到消息(代理在等 PUBREL);PUBREL 之后订阅者收到:主题 'q2/topic',载荷 b'exactly once'。QoS 2 用 4 个包(2 个往返)换来“恰好一次”,开销是 QoS 0 的 4 倍恰好一次:四个包,PUBREL 之后才投递一次。
不发任何包、保活 2 秒 → 订阅者在 3.1 秒后收到遗嘱:主题 'clients/status',载荷 b'w104-dead went offline';代理记录:[('超时断开', 3.11), ('遗嘱触发', 'clients/status')]
正常 DISCONNECT(e0 00,2 字节)的客户端:订阅者没有收到遗嘱——遗嘱只在“非正常断开”时发布
PINGREQ c0 00 → PINGRESP d0 00(各 2 字节,0.1 ms):客户端在空闲时每隔保活时间发一次它来证明自己还活着超过 1.5 倍保活时间没有包,代理断开并发布遗嘱;正常 DISCONNECT 不发布。
先发布 retain 消息 home/door = closed(31 = PUBLISH, RETAIN 1)。之后才来订阅的客户端,SUBACK 之后立刻收到:类型 PUBLISH,保留位 1,载荷 b'closed'(订阅完 0.1 ms 内);用途:设备的“最新状态”
代理存下最后一条,晚到的订阅者立刻收到。