MQTT3.1.1协议解析

MQTT3.1.1协议解析

协议产生背景

MQTT (Message Queuing Telemetry Transport,消息队列遥测传输协议),是一种基于TCP/IP网络协议栈的应用层协议。MQTT最开始是1999年IBM公司用于通过卫星通信连接石油管道监测系统而创造的协议。
这种场景的特点是:

  • 资源受限低带宽:卫星资源宝贵且有限
  • 适合发布订阅Publish/Subscribe(Pub/Sub):卫星下发命令给系统、系统上报各点监测数据
  • 适合机器对机器通信Machine to Machine(M2M):比如子管道系统通报给主管道系统

HTTP虽然在网页浏览等场景很好用,但对于这种遥测场景太耗费资源了,MQTT恰逢其时,不过在当时还是小众协议。随着互联网和通信技术发展,生活用品接入网络越来越容易,物联网loT (Internet of Things)应运而生,智能家居设备也非常符合这些场景:

  • 资源受限低带宽:门窗传感器、电动牙刷等,都是电池,资源有限
  • 适合发布订阅Publish/Subscribe(Pub/Sub):一个命令,多个设备响应
  • 适合机器对机器通信Machine to Machine(M2M):无法接入互联网的设备与网关设备交互

你当然也可以使用HTTP,甚至自己构造的应用层协议来和设备通信,但耗电量可能更大、协议可能更复杂、重复造轮子只适用于自己公司的设备不够通用。MQTT已成为物联网的事实协议,很多芯片出广都自带MQTT协议解析,这些硬件条件已成为MQTT协议的壁垒。

MQTT协议版本

MQTT协议有很多版本:

  • MQTT v1.0/v2.0:IBM协议,私有性强
  • MQTT v3.0、v3.1.0:OASIS联盟国际组织发布,尝试标准化协议,但有缺陷
  • MQTT v3.1.1:2014年OASIS发布的第一个MQTT标准协议。修复了3.1.0的缺陷,被广泛运用,是很多硬件芯片内置的基础协议
  • 没有MQTT v4:MQTT报文采用1个字节表示版本,v3.1.0用了3,v3.1.1用了4,OASIS组织希望后面的报文都和版本保持一致,因此考虑跳过v4版本,直接使用v5版本。MQTTv4说明
  • MQTT v5:2018年发布,最新协议,支持更多高级功能,但物理硬件支持还比较少
  • MQTT-SN:2013年发布了MQTT-SN(MQTT for Sensor Networks),设计目的是能用于传感器网络,这些网络的传输层协议通常是UDP、Zigbee等。但这个协议并没有成为主流协
    议,协议也很多年未变了。MQTT-SN介绍

全系列版本官方协议文档:https://mqtt.org/mqtt-specification/
3.1.1中文版翻译:https://github.com/mcxiaoke/mqtt

我们以工程实现的视角,分析MQTT3.1.1协议,会在功能描述后标记对应的文档来源。

协议层和端口

protocol_stack.png

1、MQTT基于TCP/IP,所以网络层是IP协议,传输层是TCP协议

4.2 网络连接:MQTT 3.1使用的传输层协议是[RFC793]定义的TCP/IP协议。

2、SSL/TLS支持
SSL/TLS工作在传输层和应用层之间,MQTT推荐使用它来进行数据加密。

4.2 网络连接:下面的协议也支持:TLS协议[RFC5246]
5.1 概述:MQTT 仅关注消息传输,提供合适的安全功能是实现者的责任。使用TLS[RFC5246]是比较普遍的选择。

3、应用层MQTT协议、WebSocket协议
协议没有明确提及但很明显,MQTT是类似HTTP一样工作在应用层的协议。同时MQTT为了便于和H5交互,支持在WebSocket上传输,这种交互方式通常用于手机APP。注意,MQTT和WebSocket实际上都工作在应用层,不过MQTT把WebSocket当作一种传输协议在使用。
为了区别,通常这样描述二者:MQTT over TCPMQTT over WebSocket

4.2 网络连接:下面的协议也支持:WebSocket 协议[RFC6455]
6 使用WebSocket用于网络传输

4、端口

  • MQTT over TCP:1883端口,已在IANA注册
  • MQTT over TCP with TLS:8883端口,已在IANA注册
  • MQTT over WebSocket:协议没有规定,通常使用80、1884、或8083
  • MQTT over WebSocket with TLS:协议没有规定,通常使用443、8884、或8084

4.2 网络连接:TCP端口8883和1883已在IANA注册,分别用于MQTT的TLS和非TLS通信。
5.1 概述:强烈推荐提供TLS的服务端实现应该使用TCP端口8883(IANA 服务名:secure-mqtt)

说明一下WebSocket为什么使用这些端口:

  • 80、443:WebSocket和HTTP联系紧密,直接使用了HTTP和HTTPs(over SSL/TLS)的端口
  • 1884、8884:跟随标准端口1883、8883
  • 8083、8084:HTTP API通常使用8080端口提供服务,因此这里结合了HTTP API的8080,并加上了MQTT 83后缀的这个特征

在工程实现上,尽量使用上述标准或惯例端口,因为中间的网络节点为了屏蔽攻击通常会屏蔽很多不常见端口,这会导致路由中断连接不稳定。

连接、会话和安全

TLS

1、使用TLS 1.2/1.3+

协议全文引用的TLS都是[RFC5246]: Dierks, T. and E. Rescorla, “The Transport Layer Security (TLS) Protocol Version 1.2”, RFC 5246,August 2008. http://www.ietf.org/rfc/rfc5246.txt
很明显,要求使用更安全的TLS 1.2以上的版本,而不是使用SSLv3.0、TLS v1等有攻击漏洞的低版本协议。

2、很少见的场景:多个域名解析到同一IP的服务器,可以考虑使用TLS的SNI (Server Name Indication Extention)功能,让服务端返回正确的证书。
tls_sni.png

5.4.3 服务端身份验证:MQTT协议不是双向信任的,它没有提供客户端验证服务端身份的机制。但是使用TLS[RFC5246]时,客户端可以使用服务端发送的SSL证书验证服务端的身份。从单IP多域名提供MQTT服务的实现应该考虑RFC6066[RFC6066]第3节定义的TLS的SNI扩展。SNI允许客户端告诉服务端它要连接的服务端主机名。

3、TLS证书可能遭遇:被吊销、服务器改名、证书过期、私钥泄漏、安全策略定期轮换等,都会导致客户端的TLS证书失效。但已存在的链接仍然基于这个旧TLS证书协商的参数在通信,此时服务端应该主动断开连接迫使客户端重新协商,或者使用TLS会话恢复/重连的方式更新连接。

5.4.9 其它的安全注意事项
如果客户端或服务端的SSL证书丢失,或者我们考虑证书被盗用或者被吊销(利用CRLs[RFC5280]和OSCP[RFC6960])的情况。服务端可以断开客户端连接,并要求他们使用新的凭证重新验证身份。受限网络上的受限设备和客户端可以使用TLS会话恢复[RFC5077]降低TLS会话重连[RFC5246]。

4、通常我们只使用单向认证,如果使用双向认证,还需要考虑如何远程更新客户端内置证书,毕竟设备在用户手里没法拿回来。

WebSocket

1、客户端必须将字符串mqtt包含在它提供的WebSocket子协议列表里,服务端选择和返回的WebSocket子协议名必须是mqtt。

6 使用WebSocket作为网络层:

  • 客户端必须将字符串mqtt包含在它提供的WebSocket子协议列表里[MQTT-6.0.0-3]
  • 服务端选择和返回的WebSocket子协议名必须是mqtt[MQTT-6.0.0-4]

2、如果在WebSocket端口收到非WebSocket的报文怎么办?断开连接。

6 使用WebSocket作为网络层:
MQTT控制报文必须使用WebSocket二进制数据帧发送。如果收到任何其它类型的数据帧,接收者必须关闭网络连接[MQTT-6.0.0-1]

MQTT报文固定习惯

在解析MQTT报文的TCP数据包时,按照如下的方式解析:
1、一个字节比特位会按照7到0顺序排序,7是最高有效位,0是最低有效位
bit_order.jpg

2、对于可变长度的数据负载,通常会采用1个16比特的Integer表示紧跟后面的数据字节数。由于是16比特,会分成2个字节 顺序默认按照大端序:最高有效字节(Most Sianificant Byte,MSB)在最低地址(在前),最低有效字节(Least Significant Byte,LSB)在最高地址(在后)。2个字节不能超过\(2^{16}\),因此数据负载字节数取值范围是[0, 65535]。

大多数网络协议都是大端字节序,比如TCP/IP、HTTP,因为这符合人类的阅读习惯:高位在前,低位在后。

3、对于FixedHeader报文中的剩余长度,是变长编码,可以有1字节~4字节,最大256MB(268,435,455)报文大小。
最高位是标志位,表示是否有更多字节,因此:

  • 1个字节时,从0(0x00)到127(0x7f)
  • 2个字节时,从128(0x80, 0x01)到16383(0Xff, 0x7f)
  • 3个字节时,从16384(0x80, 0x80, 0x01)到2097151(0xFF, 0xFF, 0x7F)
  • 4个字节时,从2097152(0x80, 0x80, 0x80, 0x01)到268435455(0xFF, 0xFF, 0xFF, 0x7F)

2.2.3 剩余长度

4、数据负载使用UTF-8的格式编码,除了字节数长度0~65535的要求外,要求保留通常的UTF-8使用习惯:

  • 不能包含U+D800~U+DFFF之间的字节码,这些字符有特殊功能。如果包含,则必须断开连接。
  • 不能包含空字符U+0000.如果包含,则必须断开连接。
  • 不应该包含Unicode码点,如果包含,则可以断开连接。
    • U+0001~U+001F的控制字符
    • U+007F~U+009F的控制字符
    • Unicode规范定义的非字符码点(例如U+OFFFF)
    • Unicode规范定义的保留字符(例如U+OFFFF)
  • OxEF OxBB 0xBF是UTF-8的BOM(Byte Order Mark)标记,但UTF-8不需要标记大小端字节序,Unicode不推荐使用,因此MQTT规定如果发现这样的字符,直接认为是真实数据,而不是把它当作标记跳过。

1.5数据表示

4、QoS消息服务质量
MQTT所有关键消息都会有消息服务质量,区分消息重要性:

  • QoS0:低,最多一次,发送后就不管了
  • QoS1:中,至少一次,发送后至少要收到一次回包,确认送达。消息可能重复。
  • QoS2:高,仅一次,仅发一次且仅收到一次回包,消息不会重复。

连接过程

connect_flow.jpg

CONNECT-CONNACK 报文分析

connect.jpg
connack.jpg

CONNECT固定报头

connect_fixed_header.jpg

  • 报文类型CONNECT=1
  • 保留标志位是不填写的,都为0
  • 剩余长度=可变报头10字节+数据负载字节数=0x62=\((98)_{10}\)

1、连接建立后,客户端发送的第1个报文必须是CONNECT,如果不是则断开连接。

3.1 CONNECT-连接服务端:客户端到服务端的网络连接建立后,客户端发送给服务端的第一个报文必须是CONNECT报文[MQTT3.1.0-1]
4.8错误处理:除非另有说明,如果服务端或客户端遇到了协议违规的行为,它必须关闭传输这个协议违规控制报文的网络连接[MQTT-4.8.0-1]

2、客户端多次发送CONNECT报文?断开连接。

3.1 CONNECT-连接服务端:在一个网络连接上,客户端只能发送一次CONNECT报文。服务端必须将客户端发送的第二个CONNECT报文当作协议违规处理并断开客户端的连接[MQTT-3.1.0-2]。有关错误处理的信息请查看4.8节。
4.8 错误处理:除非另有说明,如果服务端或客户端遇到了协议违规的行为,它必须关闭传输这个协议违规控制报文的网络连接[MQTT-4.8.0-1]

3、客户端做一些恶心的行为,比如Reserved给你填值,或者剩余长度算得不对?断开连接。

4.8 错误处理:除非另有说明,如果服务端或客户端遇到了协议违规的行为,它必须关闭传输这个协议违规控制报文的网络连接[MQTT-4.8.0-1]

CONNECT可变报头

connect_variable_header.jpg

  • 协议名:固定写4字节的MQTT
  • 协议版本:3.1对应Level3、3.1.1对应Level4,5.x对应level5
  • 保持连接心跳时间:保活心跳周期
  • 连接标志位:
    connect_flag.jpg
    • Username Flag (UF):1bit, 后面的负载是否有用户名
    • Password Flag (PF):1bit, 后面的负载是否有密码
    • Will Retain(WR):1bit, 遗嘱消息是否作为保留消息发布
    • Will QoS (WQ):2bit, 遗嘱消息发布的消息质量等级
    • Will Flag (WF):1bit, 是否有遗嘱消息
    • Clean Session (CS):1bit, 是否清理会话
    • Reserved (R):1bit, 保留字段

1、4字节的MQTT,是UTF-8编码,只是因为UTF-8兼容ASCII,所以字母编码一致。识别到其他协议比如“MQTS”,除非真的支持MQTS协议,可以按MQTS协议处理;否则认为它就是有问题的MQTT报文,断开连接。

3.1.2.1协议名
协议名是表示协议名 MQTT的UTF-8编码的字符串。MQTT 规范的后续版本不会改变这个字符串的偏移和长度。
如果协议名不正确服务端可以断开客户端的连接,也可以按照某些其它规范继续处理CONNECT报文。对于后一种情况,按照本规范,服务端不能继续处理CONNECT报文[MQTT-3.1.2-1]

2、如果发现服务器不支持协议版本,应该返回0x01的CONNACK报文,然后断开连接。

3.1.2.2协议级别
如果发现不支持的协议级别,服务端必须给发送一个返回码为0x01(不支持的协议级别)的CONNACK报文响应CONNECT 报文,然后断开客户端的连接[MQTT-3.1.2-2]

3、Reserved保留标志位,如果填了值,则断开连接。

3.1.2.3 连接标志 服务端必须验证CONNECT控制报文的保留标志位(第 0 位)是否为0,如果不为0必须断开客户端连接[MQTT-3.1.2-3]。

4、CleanSession 清理会话

CleanSession=1,清理会话;CleanSession=0,使用上次连接的会话。
客户端发起CleanSession=0,表示它断线重连后:

  • 继续处理:已发送给服务端,但没有收到服务端回包的QoS1、QoS2消息
  • 继续处理:从服务端接收,但还没有回复确认的QoS2消息
  • 业务设计上,客户端应该总是一直设置0或者1;对于特殊情况,比如保持0后数据发生异常需要清理,则先设置1连一次后断开,然后设置0重连。

服务端收到CleanSession=0,如果会话存在,需要:

  • 恢复上次订阅的主题
  • 继续处理:已发送给客户端,但没有收到客户端回包的QoS1、QoS2消息
  • 继续处理:从客户端接收,但还没有回复确认的QoS2消息
  • 继续处理:原计划发给客户端,但它掉线了没能发出去,掉线期间缓存的QoS1、QoS2消息(QoS0可以选择发送或者丢弃)

3.1.2.4 清理会话

5、WillFlag 遗嘱标志

如果遗嘱标志设置为1,则异常断开时发送遗嘱消息,如果收到DISCONNECT报文正常断开则删除遗嘱消息。
异常情况例如:

  • 服务端故障
  • 客户端保持心跳超时
  • 客户端没有发送DISCONNECT就直接断开
  • 客户端发送了异常格式报文,导致服务端需要主动断开连接

如果WillFlag设置1,则CONNECT的数据负载必须包含遗嘱消息内容;如果WillFlag设置0,则WillQoS和WillRetain都必须为0,且数据负载不能有遗嘱内容。

  • 遗嘱和清理会话没有联系,即使CleanSession=1要求清理,异常断开时也要发送遗嘱消息。
  • 遗嘱每次重连都需要覆盖式地重新设置一次。如果上一次设置了遗嘱,本次没有,那就是没有遗嘱。
  • 遗嘱消息发布的方式:服务端发布PUBLISH报文。

3.1.2.5 遗嘱标志

6、WillQoS 遗嘱消息质量等级

注意有2bit,官方要求是:

  • 遗嘱标志为00,则QoS必须为0
  • 遗嘱标志为01,则QoS可以为0、1、2的任何值
  • 遗嘱标志的值不能是3(也就是二进制11)

实际上,大部分客户端的实现都是:

  • 遗嘱标志为00,则QoS=0
  • 遗嘱标志为01,则QoS=1
  • 遗嘱标志为10,则QOS=2

7、WillRetain 遗嘱保留消息

WillRetain=1,则遗嘱会作为保留消息发布。保留消息独立于连接和会话,连接和会话终止也不能删除保留消息,后面PUBLISH报文再详述。实现时按照服务端收到客户端PUBLISH的保留消息去实现即可。

8、Username Flag、Password Flag

  • 虽然在业务上需要用户名密码验证身份,但作为MQTT协议本身是可以不使用的。
  • 标志位必须和负载一致,标志位=1,则负载必须有数据,标志位=0,则负载必须没有数据,否则断开连接。
  • 如果Username Flag=0,则Password Flag也必须为0,否则断开连接。

    3.1.2.8用户名标志
    3.1.2.9 密码标志

9、保持连接

  • 单位是秒
  • 空闲时间的计算方式是“两个报文之间的时间”,工程实现上一定不要用单纯的“定时器”,而是需要每次报文处理后重置计时。
  • 客户端必须在这个时间内至少发送1个报文,如果没有其他控制报文可以发,则发送一个PINGREQ报文,并预期收到一个PINGRESP报文,如果没有在合理的时间收到PINGRESP报文,说明服务器有问题,官方认为客户端“应该”关闭连接。工程实现上通常是:客户端5秒内未收到回复,优先怀疑网络有问题,再发起一次PINGREQ,如果5秒内仍未收到,才断开。
  • 如果心跳时间不是0,则服务端必须预期在1.5倍心跳时间内收到客户端的1个报文,否则必须断开连接。
  • 时间越短,客户端发送报文频率越高,耗电快,但是好处是检测掉线也很快;时间越长,检测掉线会很慢,表现就是app上点了“开灯”,结果没有任何反应,体验差。需要做权衡trade-off。
  • 16bit长度,意味着取值[0, 65535]
    • 取0,表示不需要保持心跳。不过,服务端可以在任何认为客户端断线的情况下,主动断开客户端连接。
    • 工程实现上,[60, 235]区间是一个合理的值。
    • 取65535最大值,是18小时12分15秒。

3.1.2.10 保持连接

CONNECT有效载荷

connect_payload.jpg

  • 可变的报文,每个小块都是“MSB/LSB长度+数据”形式组成,数据都是UTF-8编码字符串,长度[0, 65535]
  • 数据必须和标志位的取值一致,如果有数据,必须按顺序出现:
    • 【必须】客户端标志符 ClientId
    • 遗嘱主题 Will Topic
    • 遗嘱消息 Will Message
    • 用户名 Username
    • 密码 Password

3.1.3 有效载荷

1、客户端标志符 ClientId

  • 客户端的唯一标志
  • 任何异常格式,都返回0x02(标志符不合格)CONNACK,并断开连接
  • 长度可以为0:
    • 服务端必须通过其他形式来标记自己的连接,比如Username
    • CleanSession必须为1,如果客户端传的0,视为异常格式
  • 官方推荐长度是1~23个字节,并使用“大写字母、小写字母、数字”,没有强制。工程实现上,可以超过23个字节,也可以用其他UTF-8的字符,$@_这几个字符是比较常用的
  • 如果多个重复的ClientId连接,则保留最后一个,踢掉前面的TCP连接。工程上称为“换腿”。由于需要处理并发,“换腿问题”是处理连接报文最大的难点。

3.1.3.1 客户端标识符
3.1.4 响应

2、遗嘱消息

  • 遗嘱消息通常会使用JSON格式,换行和空格这些特殊符号也会被传递

CONNACK响应

1、响应时机

  • 如果连接建立后,没有在合理的时间收到CONNECT,则服务端应该直接断开连接。工程上这个时间为60秒。
  • CONNECT报文格式不正确,服务端必须直接断开连接
  • 报文正确,但是协商不上,需要用CONNACK设置响应码(ReturnCode)给客户端提示,如果非0,还需要关闭连接:
    • 0x00:连接已接受
    • 0x01:连接已拒绝,不支持协议版本。比如服务器只支持3.1.1,但客户端传的5。
    • 0x02:连接已拒绝,不合格的客户端标识符。比如服务器只允许字母,但客户端传了数字。
    • 0x03:连接已拒绝,服务端不可用。比如服务端承载能力达到上限,不希望再有新的连接了。
    • 0x04:连接已拒绝,无效的用户名或密码。Username、Password鉴权失败。
    • 0x05:连接已拒绝,客户端未授权。某个客户端被拉黑等情况

3.1.4 响应
3.2.2.3 连接返回码

2、CONNACK报文
connack.jpg

  • 连接标志有7位是保留位,只有1位是会话存在标志(SP,Session Present):
    • 如果CleanSession=1,要求清理会话,则返回SP=0,表示是新会话
    • 如果CleanSession=0,且连接正常。如果复用了会话,则返回SP=1,表示复用会话;如果服务端没有历史会话,则启用新会话,SP=0
    • 如果CleanSession=0,且连接异常返回码非0,则直接设置SP=0,没有含义。

3.2.2.1 连接确认标志
3.2.2.2 当前会话

订阅

sub_unsub_flow.jpg

订阅时机

通常,在收到CONNACK后,客户端应该发起订阅以接受MQTT消息。不过,你也可以做一些奇葩的事情,不违反规定,只是工程上不推荐:

1、在CONNECT发出后,CONNACK收到前,发送SUBSCRIBE消息
预期的情况是,服务端正确处理CONNECT,处理完成后再继续处理SUBSCRIBE。但对服务器要求高,需要暂存消息,对客户端收益也小,不是一个合理的选择。

3.1.4 响应
允许客户端在发送CONNECT报文之后立即发送其它的控制报文;客户端不需要等待服务端的CONNACK报文。如果服务端拒绝了 CONNECT,它不能处理客户端在CONNECT报文之后发送的任何数据[MQTT3.1.4-5]。客户端通常会等待一个CONNACK报文。然而客户端有权在收到CONNACK之前发送控制报文,由于不需要维持连接状态,这可以简化客户端的实现。

2、连接后过一段时间订阅
通常来说,订阅属于初始化操作,应该越早完成越好,这样可以避免遗漏很多消息。对于中途订阅,我认为只有1种情况比较适合,就是有固件变化或者拓扑变化。比如“网关”新连入了一个“门窗感应器”,那么”网关“可以在中途发起一次订阅,订阅”门窗感应器“需要接受消息的主题。

SUBSCRIBE 报文分析

wireshark_subscribe.jpg
wireshark_suback.jpg

SUBSCRIBE 报文

subscribe.jpg

  • 2字节固定报头,SUBSCRIBE类型为8。注意这里保留标志位是0010;其中1字节~4字节的长度信息,最大256MB,足够使用了。
  • 可变报头占用了2字节,表明报文包ID,工程上通常称之为PacketId,0~65535循环,通过这个ID可以把请求和响应对应起来,在网络用塞有重复报文时能分辨是正常回包还是重复包。
  • 有效载荷,由2字节长度、任意字节UTF-8主题过滤器(Topic Filter)、1字节质量等级组成。质量等级,QoS 0、1、2。有效载荷的这种结构可以重复,也就是可以一次性订阅多个主题过滤器。
  • SUBSCRIBE至少需要1个订阅过滤器

主题过滤器TopicFilter和主题Topic

通常订阅-发布模型,都会有一个主题Topic。MQTT增加了“主题过滤器”的概念,通过通配符“#”、“+”,可以少量订阅,通配大量主题,很适合有拓扑结构设备网络的消息监听。

  • PUBLISH发布的是“主题”,SUBSCRIBE订阅的是“主题过滤器”。主题不能使用通配符,主题过滤器可以使用通配符。主题和主题过滤器在不使用通配符时可以是相同的。
  • 主题和主题过滤器,都使用斜杠(‘/’ U+002F)来分割层级(Topic Level),比如testtopic/aaatesttopic/#testtopic/+/abc。斜杠可以出现在任意位置,都是合法的,比如:
    • 合法,开头:”/testtopic”
    • 合法,多个相邻:”//testtopic”
    • 合法,末尾:”testtopic/“
    • 合法,只有层级:”///“
    • 合法,大量的层级:”////////////////“
  • 数字标志(‘#’ U+0023)表示匹配任意个末尾层级,它必须单独占用一个层级,只能出现1次,且必须是最后一个字符:
    • 合法:”testtopic/#” -> 匹配 “/testtopic/a/b/c/d”
    • 合法:”#” -> 匹配所有主题
    • 非法,因为需要单独一层:”testtopic/abc#”
    • 非法,因为必须在末尾:”testtopic/#/abc”
  • 加号 (‘+’ U+002B) 表示匹配单个层级,它必须单独占用一个层级,可以出现多次,可以匹配空层级,可以和#混合使用:
    • 合法,出现在末尾:”testtopic/+” -> 匹配 “testtopic/abc”,不能匹配”testtopic/abc/d”
    • 合法,出现在开头:”+/testtopic” -> 匹配 “abc/testtopic”,不能匹配”abc/d/testtopic”
    • 合法,出现多个:”+/+/+” -> 匹配”a/bc/d”,匹配空层级也可以的”//abc/d”
    • 合法,混合使用:”testtopic/+/b/#”,匹配”testtopic/a/b/d/e”
    • 合法,单独使用:”+” -> 匹配单层主题
    • 非法,因为需要单独一层:”testtopic/abc+”
  • 美元符号(‘$’)表示特殊用途,例如在MQTT5中的新特性”共享订阅”通常就会使用”$Share”作为主题开头,这种特殊用途的主题不能被通配符匹配
    • “#” 无法匹配 “$Share/abc”
    • “+/abc” 无法匹配 “$Share/abc”
    • “$Share/#” 可以匹配 “$Share/abc”
  • 主题和主题过滤器,是大小写敏感的,且可以包含空格,受限于报文长度,不能超过65535个字符
    • “testtopic”和”TestTopic”是不同的
    • “test topic”是合法的,有1个层级
  • 如果主题不合法,直接断开连接

从工程的角度:

  • 只使用大写字母、小写字母、层级符号/、特殊符号$、通配符+#,不要使用其他奇怪的字符
  • 不要有空层级,没有意义
  • 不以斜杠开头
  • 主题在指定位置,携带GroupId、ClientId等唯一标识,方便控制订阅权限:”home/zhangsanGroup/device123/switch/on”。这个很重要,因为你不想让别人订阅到你的摄像头视频数据,你也不希望别人能给你的门锁下发打开命令。
  • 主题需要详细规划,根据使用场景和产品,提前规划好。比如先出了一款插座ON/OFF,后面又出了一款插座上面有独立小开关,在设计主题之初就需要考虑好
  • 主题不要过大,不然就退化成了TCP透传,比如不要有/all/passthrough这种东西

SUBACK报文

suback.jpg
服务端会回复SUBACK作为回应。其中,PacketId是和SUBACK一致的,SUBACK响应码可以取值:

  • 0x00 - 订阅成功 - 最大 QoS 0

  • 0x01 - 订阅成功 – 最大 QoS 1

  • 0x02 - 订阅成功 – 最大 QoS 2

  • 0x80 - Failure 失败

  • 如果多次订阅相同的主题过滤器,以最后一次为准,且视为新订阅,会触发保留消息接收(保留消息是指,发布时保留在主题上的一条消息,任何新订阅都会触发消息下发)

  • 如果SUBSCRIBE有多个需要订阅的主题过滤器,SUBACK必须有对应数量、对应顺序的响应码

  • SUBSCRIBE表示的是客户端希望被授予的订阅消息质量等级,SUBACK的响应码是服务端授予的“最高”质量等级,所以:

    • 服务端可以授予相同、或比希望的QoS低的质量等级,在SUBACK里返回。
    • 如果发布的质量等级高于订阅质量等级,则降级为订阅质量等级进行下发;如果发布的质量等级低于订阅质量等级,则按发布质量等级下发。简单来说,就是取Min(发布QoS,订阅QoS)
  • 如果订阅失败,通常客户端需要自己根据业务情况处理:

    • 继续重试单个主题
    • 全部重试,视为网络失败导致
    • 认为客户端异常,断开连接,等待服务器修复 —— 多次重试都失败的策略

3.8 SUBSCRIBE - 订阅主题

取消订阅

取消订阅时机

在工程上,通常不需要取消订阅,因为客户端通常会在掉线后根据cleanSession=0或1来决定清除订阅或保留订阅。同样也只有1种情况适合——固件变化或拓扑变化,需要清理订阅后重新订阅的场景。

UNSUBSCRIBE 取消订阅报文分析

wireshark_unsubscribe.jpg
wireshark_unsuback.jpg

UNSUBSCRIBE 报文

unsubscribe.jpg
UNSUBSCRIBE和SUBSCRIBE基本类似,只是不需要QoS这个参数了。值得注意的是:

  • 只会取消订阅UNSUBSCRIBE指定的订阅过滤器
  • 如果已经有正在发送中的QoS1/QoS2消息,必须继续发送完
  • 如果有即将发送的消息,可以继续发送。工程上来说,一般不处理就行,这样可能会继续发也可能不会。这样做不需要同步,会很方便。

UNSUBACK 报文

unsuback.jpg

  • 如果取消订阅失败,也必须发送UNSUBACK报文
  • UNSUBACK没有有效载荷,只是表示服务端尝试处理过了,客户端不知道到底是否取消订阅成功。

3.11 UNSUBACK – 取消订阅确认

发布

发布-QoS0

wireshark_publish_qos0.jpg

PUBLISH 报文分析

publish_qos0.jpg

  • DUP是QoS1/2使用的,当需要重发报文时,标记为1
  • 发布的QoS等级是00、01、和10,不允许出现11,出现就断开链接
  • 发布的是主题,主题不能有通配符
  • QoS0是没有报文ID的,因为不需要回包,发送了就认为成功了。QoS1/2会有报文ID,便于回包、重试的确认。
  • 有效载荷没有字节长度,是因为字节长度可以计算出来:20字节长度 = 31字节剩余长度-2字节长度-9字节主题动态长度-0字节报文ID
  • 有效载荷还是UTF-8字符串,空格、换行符等不可见字符都会变为UTF-8字符数据,如本例中的0x0A、0x20
  • 有效载荷可以是0字节。空报文通常用于提醒,没有需要传递的业务字段。
  • PUBLISH存在于3种场景:客户端A发给服务端A,服务端A发给服务端B、服务端B发给客户端B,这几种场景,发送者和接收者是不同的,服务端有时是发送者,主动发起PUBLISH;有时是接收者,被动响应PUBLISH。不过我们通常的讨论,都是基于“客户端作为发送者,服务端作为接收者”,其他情况可以类推。

保留消息 Retain Message

PUBLISH有个特殊的保留消息标记。

  • 如果PUBLISH了一条保留消息,服务器需要保存这条保留消息内容和QoS,当有新的订阅者订阅匹配时就下发这条保留消息。注意只需要匹配即可,不需要完全相同,订阅者可以使用通配符。这也意味着如果匹配到了多条保留消息存在的主题,会收到多条保留消息。
  • 如果是因为订阅触发保留消息下发,服务端发给客户端的这条消息,也必须携带RETAIN=1的保留消息标记,这样客户端知道是保留消息。
  • 如果客户端多次发送保留消息,服务端保留最新的一条。
  • 如果保留消息的有效载荷是0字节,表示移除这个主题的保留消息。

保留消息通常用于控制平台上线后需要立刻感知其他设备数据的场景。比如app上线,可以不用主动查询智能家居的状态,而是被动接收设备最后一次更新的状态保留消息。

4.3.1 QoS0: 最多分发一次
3.3 PUBLISH – 发布消息

发布-QoS1

publish_qos1_flow.jpg

PUBACK 报文分析

wireshark_puback.jpg
puback.jpg

发出QoS1的PUBLISH之后,会收到PUBACK作为响应:

  • 如果在业务规定的时间内没有收到响应,可以视为丢包,客户端可以将PUBLISH的DUP设置为1,重传包
  • QoS1会有一个报文标志符,便于关联PUBLISH-PUBACK。在客户端发起PUBLISH之后,收到PUBACK之前,这个占用的报文标志符不可以重用,在收到PUBACK之后就可以重用了。
    QoS1是业务经常使用的质量等级,既不过于严苛,又保证了一定的可靠性。

4.3.2 QoS1: 至少分发一次
3.4 PUBACK –发布确认

发布-QoS2

publish_qos2_flow.jpg

QoS2为了保证只投递1次,除了客户端确认服务端收到消息了,服务端还需要确保客户端知道消息已经投递了不会再重试了。

  • 客户端存储消息,发送PUBLISH,QoS=2,DUP=0。客户端开始计时等待。客户端开始计时等待,随时准备重试。
  • 服务器存储报文ID并分发消息,回复PUBREC,携带报文ID,通知客户端已经投递了。消息只会分发1次,后面如果有重试,只会回包,不会再分发消息了。服务端开始计时等待,随时准备重试。
  • 客户端丢弃消息,存储PUBREC的报文ID,返回PUBREL,表示自己收到了。客户端开始计时等待,随时准备重试。
  • 服务端收到PUBREL,丢弃报文ID,返回PUBCOMP,流程结束。
  • 客户端收到PUBCOMP,丢弃报文ID,流程结束。

最后一步的PUBCOMP可能会丢失,不用担心,这种情况客户端会重试PUBREL,当服务器发现没有存储报文ID时,仍然会回包PUBCOMP。

4.3.3 QoS2: 仅分发一次

PUBREC、PUBREL、PUBCOMP 报文分析

pubrec.jpg
pubrel.jpg
pubcomp.jpg

三个报文格式很类似,主要是报文ID。

消息排序

发送消息可能重复,但必须是有序的,因此通常服务器会给每个连接设置一个“传输窗口”(In-Flight Window)。当发送者发送1、2、3、4消息时:

  • 传输窗口=1,接受消息顺序可能为1、2、3、3、4,不可能为1、2、3、2、3、4
  • 传输窗口=2,接受消息顺序可能为1、2、3、2、3、4,不可能为1、2、3、3、2、4

4.6 消息排序
3.5 PUBREC – 发布收到(QoS 2,第一步)
3.6 PUBREL – 发布释放(QoS 2,第二步)
3.7 PUBCOMP – 发布完成(QoS 2,第三步)

心跳请求 PINGREQ/PINGRESP

ping.jpg
心跳请求非常简单,只有报文头以节省带宽。

3.12 PINGREQ – 心跳请求
3.13 PINGRESP – 心跳响应

断开连接 DISCONNECT

disconnect.jpg

一般会在固件升级等场景,客户端会主动要求断开连接。当服务器收到断开连接请求时,需要清理会话中的遗嘱消息并断开链接。注意:遗嘱消息是在异常断开情况下才会分发的,在这种正常断开的情况下不会分发。

3.14 DISCONNECT –断开连接

附录

作者

浅雾

发布于

2023-10-29

更新于

2023-11-05

许可协议

评论