自己动手实现MQTT协议

写在前面

前段时间弄IoT相关的东西,系统学习了一下 MQTT 协议,在此分享出来。

本文先是对 MQTT 协议做了简单的介绍;接着是对 MQTT协议的内容做了较为全面的解读;最后使用 Python 语言去实现一个简单的 MQTT 客户端和服务器

简介

MQTT 全称是 Message Queue Telemetry Transport,翻译成中文意思是“遥测传输协议”。它最先是由IBM提出,是一种基于 TCP 协议,具有简单、轻量等优点,特别适合于受限环境(带宽低、网络延迟高、网络通信不稳定)的消息分发。MQTT 协议有 3.x, 5.x 等多个版本,目前最常用的版本是 v3.1.1 ,本文也是对此版本的协议进行的解读。MQTT 协议已纳入ISO标准 (ISO/IEC PRF 20922),现今主流的 IoT 平台都支持该协议。

PS: 更详细的信息可参考 WikipediaMQTT 官网

快速开始

MQTT 是一种发布-订阅协议,这意味着:

  • 客户端(Client)可以向服务端(Broker) 订阅(Subscribe)自己感兴趣的主题(Topic)
  • 客户端还可以向服务端发布(Publish)关于某个主题的信息(主题不需要提前创建,发布消息时指定即可);
  • 服务端在收到客户端发布的消息后,会将该消息转发给订阅了该主题的其他客户端。

我们可以在自己的电脑上运行一个 MQTT 的服务端,和多个 MQTT 的客户端来体验这一过程。

MQTT 服务端有很多可以选择。这里我们使用 Mosquitto,按照其官方文档的说明安装即可,这里不多做介绍。

Mac 用户可以用以下命令安装并启动 Mosquitto:

brew install mosquitto
brew services start mosquitto

Mosquitto 提供了命令行工具 mosquitto_submosquitto_pub ,它们可用来向服务端订阅主题 和发布消息。

在一个命令行窗口中,执行以下命令去订阅名为 “foo” 的主题:

mosquitto_sub -h 127.0.0.1 -p 1883 -t foo -q 2

在另一个命令行窗口中,执行以下命令发布消息 “Hello, MQTT” 到 “foo” 主题:

mosquitto_pub -h 127.0.0.1 -p 1883 -t foo -q 2 -m 'Hello, MQTT'

最终我们将看到,在第一个命令行窗口中,打印出了消息 “Hello, MQTT”。这意味着,第一个客户端在主题 “foo” 上,收到了第二个客户端发布的消息。

协议详解

数据包整体格式

从整体上看,数据包分为3个部分:一个是固定头部,它是一定存在的;另一个是可变头部,它不一定存在;剩下一个是载荷,它也不一定存在。数据采用大端方式存储。

+----------------------------+
|                            |
|      固 定 头 部 (必 需 )    |
|                            |
+----------------------------+
|                            |
|     可 变 头 部 (非 必 需)   |
|                            |
+----------------------------+
|                            |
|        载 荷 (非 必 需 )     |
|                            |
+----------------------------+

固定头部(Fixed header)

固定头部格式如下:

+---------------------------------------------------------+
|   bit   |  7  |  6  | 5   |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |      Packet type      |         Flags         |
+---------------------------------------------------------+
| byte2...|              Remaining Length                 |
+---------------------------------------------------------+
包类型(Packet type)
Name Value Direction of flow Description
Reserved 0 Forbidden Reserved
CONNECT 1 Client to Server Client request to connect to Server
CONNACK 2 Server to Client Connect acknowledgment
PUBLISH 3 Client to Server or Server to Client Publish message
PUBACK 4 Client to Server or Server to Client Publish acknowledgment
PUBREC 5 Client to Server or Server to Client Publish received (assured delivery part 1)
PUBREL 6 Client to Server or Server to Client Publish release (assured delivery part 2)
PUBCOMP 7 Client to Server or Server to Client Publish complete (assured delivery part 3)
SUBSCRIBE 8 Client to Server Client subscribe request
SUBACK 9 Server to Client Subscribe acknowledgment
UNSUBSCRIBE 10 Client to Server Unsubscribe request
UNSUBACK 11 Server to Client Unsubscribe acknowledgment
PINGREQ 12 Client to Server PING request
PINGRESP 13 Server to Client PING response
DISCONNECT 14 Client to Server Client is disconnecting
Reserved 15 Forbidden Reserved
标记(Flags)

不同包类型标记位含义不尽相同,具体情况如下表所示:

Control Packet Fixed header flags Bit 3 Bit 2 Bit 1 Bit 0
CONNECT Reserved 0 0 0 0
CONNACK Reserved 0 0 0 0
PUBLISH Used in MQTT 3.1.1 DUP1 QoS2 QoS2 RETAIN3
PUBACK Reserved 0 0 0 0
PUBREC Reserved 0 0 0 0
PUBREL Reserved 0 0 1 0
PUBCOMP Reserved 0 0 0 0
SUBSCRIBE Reserved 0 0 1 0
SUBACK Reserved 0 0 0 0
UNSUBSCRIBE Reserved 0 0 1 0
UNSUBACK Reserved 0 0 0 0
PINGREQ Reserved 0 0 0 0
PINGRESP Reserved 0 0 0 0
DISCONNECT Reserved 0 0 0 0
剩余长度(Remaining Length)

Remaining Length 表示的是本数据包剩余部分的字节数,即可变头部和载荷的字节数之和。为了节省传输时的字节数,Remaining Length 采用的是一种变长编码方式。这就是说 Remaining Length 字段的字节数不是固定的,它可能使用1~4个字节。既然 Remaining Length 的字节数是可变的,那么问题来了,我们在解码包数据的时候,怎么知道 Remaining Length 究竟是使用几个字节编码的呢?解决这个问题的办法是,将每个字节的最高位(MSB)作为标志位。若该位的值是1,则意味着下一个字节属于参与 Remaining Length 编码的字节;若该位的值是0,则意味着本字节已经是最后一个参与 Remaining Length 编码的字节了。

举几个🌰, 当开始解码 Remaining Length 时,

  • 若当前读到的字节是: 0x50(0101 0000),则说明 Remaining Length 字段只用1个字节编码;
  • 若连续读到的字节是:0x80(1000 0000), 0x80(1000 0000), 0x01(0000 0001)则说明 Remaining Length 字段占3个字节。

交代清楚 Remaining Length 的长度编码规则后,再说一下它的实际值是怎么计算出来。

假设 \(B_0\), \(B1\), ... \(B_n\) 依次是编码 Remaining Length 的 \(n\) 个字节;函数 \(V(B)\) 表示的是字节 \(B\) 除去最高位后(低7位)转化成十进制的值。那么:

RemainingLength = \(\sum\limits_{i=0}^{3} V(B_i) * 128 ^ i (0\le n \le 3, n \in Z)\)

解码 Remaining Length 的方法可有如下伪代码描述:

multiplier = 1
value = 0

do
    encodedByte = 'next byte from stream'
    value += (encodedByte & 127) * multiplier
    
    if (multiplier > 128*128*128)
      throw Error(Malformed Remaining Length)
      
    multiplier *= 128
while ((encodedByte & 128) != 0)

再举几个🌰,当开始解码 Remaining Length 时,

  • 若我们读到的第一个字节为0x40(0100 0000),它的最高位是0,这说明编码 Remaining Length 的字节到此就结束了,最终 Remaining Length 的值为 64。
  • 若编码 Remaining Length 的 2 个字节 分别是 0x82 (1000 0010), 0x41(0100 0001),, ,则解码出的 Remaining Length 的值为 8322(\(2 * 128^0 + 65 * 128 ^ 1\))

编码 Remaining Length (设为X) 的方法其实是解码方法的逆过程,这里就不多做解释,直接给出伪代码:

do
    encodedByte = X MOD 128
    X = X DIV 128

    if ( X > 0 )
        encodedByte = encodedByte OR 128
    endif
    
    'output' encodedByte

while ( X > 0 )

Remaining Length 的数值范围与对应的字节数可由下表查出:

Digits From To
1 0 (0x00) 127 (0x7F)
2 128 (0x80, 0x01) 16 383 (0xFF, 0x7F)
3 16 384 (0x80, 0x80, 0x01) 2 097 151 (0xFF, 0xFF, 0x7F)
4 2 097 152 (0x80, 0x80, 0x80, 0x01) 268 435 455 (0xFF, 0xFF, 0xFF, 0x7F)

从以上可以看出,变长编码缩小了给定字节数表示的数值的范围,例如,若不采用变长编码,4字节最大表示的数值是 4 294 967 296,而使用变长编码,4字节最大表示的数值是 268 435 455。这是可以接受的。268 435 455 字节约为 256 兆字节,能满足绝大多数数据传输场景。倘若真的需要传输超过256M的数据,可以将数据拆分为多个包传输。虽然这在一定程度上增加了编程的复杂性,但优点是,当我们需要传输的数据很少时,Remaining Length 使用的字节数更少;并且拆分为多个包传输可能增加容错性,当某个包传输失败时,只需要重传这个包即可,而不必整个包都重新传输。

可变头部(Variable header)

可变头部正如它的名字一样,是不定的,不同的包类型具有不同的可变头部。但许多包都有包 ID(Packet Identifier)字段。下表是包 ID 字段在各类型包中的存在情况:

Control Packet Packet Identifier field
CONNECT NO
CONNACK NO
PUBLISH YES (If QoS > 0)
PUBACK YES
PUBREC YES
PUBREL YES
PUBCOMP YES
SUBSCRIBE YES
SUBACK YES
UNSUBSCRIBE YES
UNSUBACK YES
PINGREQ NO
PINGRESP NO
DISCONNECT NO
包 ID(Packet Identifier)

包 ID 占 2 个字节,如下图所示:

+--------------------------------------------+
|    Bit     | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
+--------------------------------------------+
|   byte 1   |    Packet Identifier MSB      |
+--------------------------------------------+
|   byte 2   |    Packet Identifier LSB      |
+--------------------------------------------+

需要说明的是,服务端发送到客户端的包 和 客户端发送到服务端的包中 携带的 包ID 彼此之间是相互独立,毫不相干的。这就是说,即使 服务端发送到客户端的包 和 客户端发送到服务端的包 中的 包ID 相同,也没关系。

可变头部的详细信息见各数据包的详细说明

载荷(Payload)

载荷是数据包的第三部分,也是最后一部分。它也是有的包携带,有的包不携带,这和包的类型有关。下面是各类型的包是否携带载荷的明细表:

Control Packet Payload
CONNECT Required
CONNACK None
PUBLISH Optional
PUBACK None
PUBREC None
PUBREL None
PUBCOMP None
SUBSCRIBE Required
SUBACK Required
UNSUBSCRIBE Required
UNSUBACK None
PINGREQ None
PINGRESP None
DISCONNECT None

各数据包详细说明

下面对 14 种数据包类型做详细的介绍。

CONNECT Packet

方向

客户端 -> 服务端

说明

此包是客户端与服务端建立连接后,发送的第一个包,且第一个包必须是此包。在一个连接中,该包只能发送一次。若发送了多次,当服务器第二次收到该包时,应该作为违法处理,立即断开连接。

固定头部

包类型为1;标记字段保留,值为0。结构如下图所示:

+---------------------------------------------------------+
|   bit   |  7  |  6  | 5   |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  0  |  0  | 0   |  1  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
| byte2...|              Remaining Length                 |
+---------------------------------------------------------+
可变头部

可变头部结构如下:

+--------+----------------+---+---+---+---+---+---+---+---+
|  Bit   |   Description  | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
+---------------------------------------------------------+
| byte 1 |  Length MSB (0)| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
+---------------------------------------------------------+
| byte 2 |  Length LSB (4)| 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
+---------------------------------------------------------+
| byte 3 |       'M'      | 0 | 1 | 0 | 0 | 1 | 1 | 0 | 1 |
+---------------------------------------------------------+
| byte 4 |       'Q'      | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 1 |
+---------------------------------------------------------+
| byte 5 |       'T'      | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 0 |
+---------------------------------------------------------+
| byte 6 |       'T'      | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 0 |
+---------------------------------------------------------+
| byte 7 |     Level(4)   | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
+---------------------------------------------------------+
| byte 8 |        x       | x | x | x | x | x | x | x | 0 |
+--------+----------------+---+---+---+---+---+---+---+---+
| byte 9 |                 Keep Alive MSB                 |
+---------------------------------------------------------+
| byte 10|                 Keep Alive LSB                 |
+--------+----------------+---+---+---+---+---+---+---+---+

其中,

  • 如前面介绍,前 2 个字节是包 ID。

  • 3 ~ 6 字节是协议名称,字符使用 UTF-8 编码;

  • 第 7 个字节是协议等级(协议版本。3.1.1 版本对应的协议等级是 4 );

  • 第8个字节包含一些连接标记位。如下图所示:

    Bit Description
    7 User Name Flag
    6 Password Flag
    5 Will Retain
    4 ~ 3 Will QoS
    2 Will Flag
    1 Clean Session
    0 Reserved. 目前值为 0

    注意:

    • 若服务端不支持客户端协议版本,需要响应一个 CONNACK 包,指定 code 为 0x01,然后断开连接;

    • 服务端需校验 Reserved 位。若值不为 0,需断开连接。

    • Clean Session:用来指定对 Session 的处理方式。若值为0,在服务端和客户端断开连接后,它们都要保存 Session 信息,以便再次连上时恢复之前 Session 中的信息。除此之外,服务端还需要在断开连接后保存 QoS 1 和 QoS 2 消息和客户端的订阅内容;若值为1,当客户端和服务端连接上时,必须丢弃之前的 Session 状态信息再创建一个新的 Session 和订阅内容。

      • 在客户端,Session 状态信息包括:
        • 已被发送到服务端,但还没有被确认的 QoS 1 和 QoS 2 消息;
        • 已被服务器接收,但还没有被确认的 QoS 2 消息。
      • 在服务端,Session 状态信息包括:
        • 客户端的订阅内容;
        • 已被发送到服务端,但还没有被确认的 QoS 1 和 QoS 2 消息;
        • 待发送到客户端的QoS 1 和 QoS 2 消息;
        • 已被客户端接收,但还没有被确认的QoS 2 消息;
        • (可选)待发送到客户端的QoS 0 消息;
    • Will Flag,Will QoS,Will Retain:这三个字段是用来预立“遗嘱”的。预立“遗嘱”的意思是:客户端在连接服务端时,可将预先定义好的主题和对应消息发送给服务端。当它和服务端连接断开时,服务端将及时地发布这段消息到预定的主题。

      其中,“连接断开”的情形包括但不限于:

      • 服务端检测到 I/O 错误或网络失败;
      • 客户端在 Keep Alive 时间内没有发送任何消息;
      • 客户端在没有发送 DISCONNECT 包的情况下断开了连接;
      • 服务端因为协议错误断开连接。

      若Will Flag 被设置为 0 ,这表示不预立“遗嘱”,此时 Will QoS 和 Will Retain 字段也必须被设置为 0,并且载荷(payload)中不能存在 Will Topic 和 Will Message;Will Flag 被设置为 1 的情况与此相反。

      服务端一旦发布了“遗嘱”信息或收到了 DISCONNECT 包,“遗嘱”信息应当立即被服务端从保存的 Session 状态信息中移除。

      Will QoS 表示的是遗嘱消息的服务质量(参见以下对消息服务质量的解释),取值0,1,2,占两个字节。

      Will Retain 表示当“遗嘱”消息被发布后,它是否还被保留。

    • User Name Flag 和 Password Flag 分别表示载荷(payload)中是否存在用户名和密码。

  • 第 9 ~ 10 个字节是 Keep Alive 时间,单位是秒,取值范围是 0 ~ 65535。通常,客户端需要每隔小于 Keep Alive 的时间发送一次 PINGREQ 消息,服务端会响应 PINGRESP 包示意网络正常和自己都工作正常。若服务端在 1.5 倍 Keep Alive 时间内没有接收到客户端的任何消息,服务端必须断开连接;若客户端在发送 PINGREQ 消息后,在一段时间(自己定义)内没有收到来自服务端的 PINGRESP 消息,客户端也应该断开连接。

    Keep Alive 的值是客户端指定的,通常会设置为几分钟,最大是 18 小时 12 分钟 15 秒。特别地,若值为0,表示不启用 Keep Alive 机制。

载荷

CONNECT 包的载荷一定包含 Client Identifier 字段,可能包含 Will Topic, Will Message, User Name, Password 字段(由可变头部中的各标记位决定)。这些字段若存在,一定要按照以上顺序排列。

  • Client Identifier:由客户端自己指定的 ID,服务端据此来标识客户端(以此关联Session)。因此不同客户端之间的 ID 不能重复(重复将视为同一客户端)。它使用 UTF-8编码,长度通常在1 ~ 23个字节之间,通常包含 [0-9a-zA-Z] 中的字符(允许例外,由服务端的实现决定)。若客户端 ID 不存在,服务端需要为其指定一个独一无二的 ID。在这种情况下,客户端必须设置 CleanSession 为 1;若不设为 1,服务端需要 响应 CONNACK 包,其中 返回 code 0x02(Identifier rejected), 随后断开连接。

  • Will Topic,Will Message:当 Will Flag 值为 1 时存在,均采用 UTF-8 编码。

  • User Name:采用 UTF-8 编码,用来做身份认证。

  • Password:长度不固定,头两个字节用来指明密码的字节数,之后是密码的字节,结构如下:

    +-----------+---+---+---+---+---+---+---+---+
    |    Bit    | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
    +---------------+---+---+---+---+---+---+---+
    |  byte 1   |        Data length MSB        |
    +-------------------------------------------+
    |  byte 2   |        Data length LSB        |
    +-------------------------------------------+
    |  byte 3...|      Data, if length > 0.     |
    +-------------------------------------------+
    

一些情况的处理方式:

  • 在一个客户端在线的情况下,同一客户端(相同 Client ID)再次连接服务端,服务端需断开之前的连接;
  • 客户端在发送 CONNECT 包后,可以再立即发送其他的包,而无需等待 CONNACK 包的响应。但服务端收到 CONNECT 包后,若拒绝连接,一定不能处理客户端在 CONNECT 包后发送的包。

关于 UTF-8 编码:

在 MQTT 协议中,字符串均采用的 UTF-8 编码。字节结构如下:

+------------------------------------------------------------+
|   Bit   |  7  |  6  | 5   |  4  |  3  |  2  |  1  |    0   |
+------------------------------------------------------------+
|  byte1  |                 String length MSB                |
+------------------------------------------------------------+
|  byte2  |                 String length LSB                |
+------------------------------------------------------------+
| byte3...|    UTF-8 Encoded Character Data, if length > 0   |
+------------------------------------------------------------+

其中,前两个字节用来指定字符串的字节数,后面则是字符串各个字节(UTF-8编码)。

CONNACK Packet

方向

服务端 -> 客户端

说明

CONNACK 包是 CONNECT 包的响应,从服务端发送到客户端的第一个包必须是此包。

固定头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  | 5   |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  0  |  0  | 1   |  0  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
|  byte2  |  0  |  0  | 1   |  0  |  0  |  0  |  1  |  0  |
+---------------------------------------------------------+
可变头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  | 5   |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  0  |  0  |  0  |  0  |  0  |  0  |  0  |  X  |
+---------------------------------------------------------+
|  byte2  |  X  |  X  |  X  |  X  |  X  |  X  |  X  |  X  |
+---------------------------------------------------------+
  • 第 1 个字节用来作连接确认标记,其中第 1 ~ 7 位被保留,值均为 0。第 0 位(SP字段) 是 Session 是否存在标记(SP, Session Present Flag)。若 CleanSession 为(参见 CONNECT Packet 中相关说明)1,SP 字段必须为 0,且连接返回码也必须为 0;若 CleanSession 为 0,SP 字段的值取决于服务端是否为此客户端存储了 Session 状态信息:存储了,SP 取值为 1,否则取值为0。同样,连接返回码也取0。

  • 第 2 个字节是连接返回码(Connect Return code),只有当连接返回码值为 0 时,才表示服务端接受连接。服务端若返回了除 0 之外的其他值,紧接着必须断开连接。下表是各返回码代表的含义:

    Value Description
    0 Connection accepted
    1 拒绝连接:协议版本不支持
    2 拒绝连接:客户端 ID 被拒绝
    3 拒绝连接:服务不可用
    4 拒绝连接:用户名或密码错误
    5 拒绝连接:没有认证
    6-255 Reserved for future use
载荷

PUBLISH Packet

方向

客户端 <-> 服务端

说明

此包是用来发送消息

固定头部
+------------------------------------------------------------+
|   bit   |  7  |  6  | 5   |  4  |  3  |  2  |  1  |    0   |
+------------------------------------------------------------+
|  byte1  |  0  |  0  | 1   |  1  | DUP |    QoS    | RETAIN |
+------------------------------------------------------------+
|  byte2  |                Remaining Length                  |
+------------------------------------------------------------+

正如前面介绍过的,第 1 个字节前 高 4 位是包类型,低 4 位是标记位。其中,

  • 第 3 位是 DUP 标记,它用来指示该包是否是重复的投递。这就是说,如果该位的取值是0,这意味着这个包是第 1 次发送的;否则表明此包不是第 1 次发送,是对之前已经发送的包的再次重发。

    若 QoS 值为 0,DUP 位必须为 0

  • 第 1 ~ 2 位是 QoS 字段,取值及其含义如下表所示:

    QoS value Bit 2 bit 1 Description
    0 0 0 包至多被传送一次
    1 0 1 包至少被传送一次
    2 1 0 包被传送,且仅被传送一次
    - 1 1 保留,不能被使用(发现使用,必须断开连接)
  • 第 0 位是是否保留标记位。下面对 2 种场景下该字段取值的含义作出说明:

    • 在客户端发往服务端的包中,若 RETAIN 值被客户端设为1,服务端必须保存应用消息,和它对应的 QoS,以便服务端将该消息发送给之后接入的订阅了该主题的客户端。
    • 对于服务端发往客户端的包,若此包是一个新的订阅的响应消息,RETAIN 值必须被设为 1;若是一个已经被创建的订阅的响应消息,RETAIN 值必须被设为 0。
可变头部

可变头部包含两个字段:Topic NamePacket Identifier(仅当 QoS 级别为 1 或 2 时存在)。Topic Name 采用 UTF-8 编码,其中一定不能包含通配符;Packet Identifier 之前作过说明。

载荷

载荷是由用户(客户端或服务端)在使用中自己指定的,它的长度可由固定头部中的 Remaining Length 减去 可变头部的长度算出。

补充说明:

PUBLISH 包期望的响应包见下表:

QoS 等级 期望的响应包
QoS 0
QoS 1 PUBACK 包
QoS 2 PUBREC 包

PUBACK Packet

方向

客户端 <-> 服务端

说明

PUBACK 包是对 QoS 为 1 的 PUBLISH 包的确认响应。

固定头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  0  |  1  |  0  |  0  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
|  byte2  |  0  |  0  |  0  |  0  |  0  |  0  |  1  |  0  |
+---------------------------------------------------------+
可变头部

可变头部中的内容只有 包 ID,占 2 个字节,这表示此响应是对包 ID 为该值的包的确认。

+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |             Packet Identifier MSB             |
+---------------------------------------------------------+
|  byte2  |             Packet Identifier LSB             |
+---------------------------------------------------------+
载荷

PUBREC Packet

方向

客户端 <-> 服务端

说明

PUBREC (Publish Received)包是对 QoS 为 2 的 PUBLISH 包的确认响应。它是 QoS 2 协议的第 2 个包(第 1 个是 PUBLISH 包)。

固定头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  0  |  1  |  0  |  1  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
|  byte2  |  0  |  0  |  0  |  0  |  0  |  0  |  1  |  0  |
+---------------------------------------------------------+
可变头部

可变头部中的内容只有 包 ID,占 2 个字节,这表示此响应是对 包 ID 为该值的包的确认。

+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |             Packet Identifier MSB             |
+---------------------------------------------------------+
|  byte2  |             Packet Identifier LSB             |
+---------------------------------------------------------+
载荷

PUBREL Packet

方向

客户端 <-> 服务端

说明

PUBREL (Publish Release) 包是对 PUBREC 包的响应。它是 QoS 2 协议的第 3 个包。

固定头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  0  |  1  |  1  |  0  |  0  |  0  |  1  |  0  |
+---------------------------------------------------------+
|  byte2  |  0  |  0  |  0  |  0  |  0  |  0  |  1  |  0  |
+---------------------------------------------------------+

注意:

第 1 个字节的 0 ~ 3 位是被保留的,但各个位上必须是上图中的取值。若发现其他值,必须断开连接。

可变头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |             Packet Identifier MSB             |
+---------------------------------------------------------+
|  byte2  |             Packet Identifier LSB             |
+---------------------------------------------------------+
载荷

PUBCOMP Packet

方向

客户端 <-> 服务端

说明

PUBCOMP (Publish Complete) 包是对 PUBREL 包的响应。它是 QoS 2 协议的第 4 个包,也是最后一个。

固定头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  0  |  1  |  1  |  1  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
|  byte2  |  0  |  0  |  0  |  0  |  0  |  0  |  1  |  0  |
+---------------------------------------------------------+
可变头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |             Packet Identifier MSB             |
+---------------------------------------------------------+
|  byte2  |             Packet Identifier LSB             |
+---------------------------------------------------------+
载荷

SUBSCRIBE Packet

方向

客户端 -> 服务端

说明

SUBSCRIBE 包是客户端用来订阅主题(Topic)的,一次可定义一个或多个主题。服务端会将发送到该主题的消息转发到各个订阅了该主题的客户端。除此之外,SUBSCRIBE 包中还声明了自己可以接受的从服务端发来的消息的 QoS 等级的最大值(maximum QoS)。

固定头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  1  |  0  |  0  |  0  |  0  |  0  |  1  |  0  |
+---------------------------------------------------------+
|  byte2  |              Remaining Length                 |
+---------------------------------------------------------+

同样,第 1 个字节的 0 ~ 3 位是保留位,取值也必须是 0 0 1 0,不然必须断开连接。

可变头部

可变头中仅包含包 ID。

+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |             Packet Identifier MSB             |
+---------------------------------------------------------+
|  byte2  |             Packet Identifier LSB             |
+---------------------------------------------------------+
载荷

上面提到过,一次是可以订阅多个主题的,因此载荷在逻辑上是一个列表(List)的结构。列表中的每个元素描述一个订阅的主题。每个元素包含 3 部分:主题字节数、主题、 QoS 最大值,结构如下:

+-------------------------------------------------------------+
|     Bit     |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+-------------------------------------------------------------+
|    byte1    |                 Length MSB                    |
+-------------------------------------------------------------+
|    byte2    |                 Length LSB                    |
+-------------------------------------------------------------+
| byte 3..N+2 |             Topic (UTF-8 encoded)             |
+-------------------------------------------------------------+
|   byte N+3  |  0  |  0  |  0  |  0  |  0  |  0  |  X  |  X  |
+-------------------------------------------------------------+

第 1 ~2 字节是指明 Topic 按照 UTF-8 编码后有多少字节;假设有 N 个,那么接下来的 N 个字节,即第 3 ~ N+2 个字节是 Topic 编码后的字节;第 N+3 个字节是指明客户端接受的,来自此主题的消息的 QoS 最大值,其中 2 ~ 7 位保留,0 ~ 1 位是接受的 QoS 最大值。

举个完整的载荷的🌰:

假设客户端要订阅 a/b 和 c/d 两个主题,并指定接受的它们的最大 QoS 值分别是 1 和 2,完整结构如下表:

Bit Description 7 6 5 4 3 2 1 0
Topic Filter
byte 1 Length MSB (0) 0 0 0 0 0 0 0 0
byte 2 Length LSB (3) 0 0 0 0 0 0 1 1
byte 3 ‘a’ (0x61) 0 1 1 0 0 0 0 1
byte 4 ‘/’ (0x2F) 0 0 1 0 1 1 1 1
byte 5 ‘b’ (0x62) 0 1 1 0 0 0 1 0
Requested QoS
byte 6 Requested QoS(1) 0 0 0 0 0 0 0 1
Topic Filter
byte 7 Length MSB (0) 0 0 0 0 0 0 0 0
byte 8 Length LSB (3) 0 0 0 0 0 0 1 1
byte 9 ‘c’ (0x63) 0 1 1 0 0 0 1 1
byte 10 ‘/’ (0x2F) 0 0 1 0 1 1 1 1
byte 11 ‘d’ (0x64) 0 1 1 0 0 1 0 0
Requested QoS
byte 12 Requested QoS(2) 0 0 0 0 0 0 1 0

SUBACK Packet

方向

服务端 -> 客户端

说明

SUBACK 包是服务端对客户端发送的 SUBSCRIBE 包的响应。服务端在收到 SUBSCRIBE 包后,必须响应此包,且可变头中指定的包 ID 必须与对应的 SUBSCRIBE 包中的包 ID 相同。

固定头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  1  |  0  |  0  |  1  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
|  byte2  |                Remaining Length               |
+---------------------------------------------------------+
可变头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |             Packet Identifier MSB             |
+---------------------------------------------------------+
|  byte2  |             Packet Identifier LSB             |
+---------------------------------------------------------+
载荷

同 SUBSCRIBE 包一样,SUBACK 包的载荷也是一个列表,其中的每个元素是 SUBSCRIBE 包中各个订阅的结果码(return code),它在顺序上必须和 SUBSCRIBE 包中的主题顺序一致

每个结果码占一个字节,结构如下:

Bit 7 6 5 4 3 2 1 0
byte 1 X 0 0 0 0 0 X X

其中,只有第 7 个 字节和第 0,1 字节被使用,其余的位保留,值为0。结果码共 4 种情况,其含义如下表所示:

结果码 含义 说明
0x00 成功 对QoS最大值为0的包
0x01 成功 对QoS最大值为1的包
0x02 成功 对QoS最大值为2的包
0x80 失败

举个完整的载荷的🌰:

描述 7 6 5 4 3 2 1 0
byte 1 成功 - QoS 最大值为 0 0 0 0 0 0 0 0 0
byte 2 成功 - QoS 最大值为 2 0 0 0 0 0 0 1 0
byte 3 失败 1 0 0 0 0 0 0 0

UNSUBSCRIBE Packet

方向

客户端 -> 服务端

说明

UNSUBSCRIBE 包是客户端用来取消订阅的。

固定头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  1  |  0  |  1  |  0  |  0  |  0  |  1  |  0  |
+---------------------------------------------------------+
|  byte2  |                Remaining Length               |
+---------------------------------------------------------+
可变头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |             Packet Identifier MSB             |
+---------------------------------------------------------+
|  byte2  |             Packet Identifier LSB             |
+---------------------------------------------------------+
载荷

同 SUBACK 包一样,只是没有 maximum QoS 字段。如下是一个例子:

Description 7 6 5 4 3 2 1 0
Topic Filter
byte 1 Length MSB (0) 0 0 0 0 0 0 0 0
byte 2 Length LSB (3) 0 0 0 0 0 0 1 1
byte 3 ‘a’ (0x61) 0 1 1 0 0 0 0 1
byte 4 ‘/’ (0x2F) 0 0 1 0 1 1 1 1
byte 5 ‘b’ (0x62) 0 1 1 0 0 0 1 0
Topic Filter
byte 6 Length MSB (0) 0 0 0 0 0 0 0 0
byte 7 Length LSB (3) 0 0 0 0 0 0 1 1
byte 8 ‘c’ (0x63) 0 1 1 0 0 0 1 1
byte 9 ‘/’ (0x2F) 0 0 1 0 1 1 1 1
byte 10 ‘d’ (0x64) 0 1 1 0 0 1 0 0

该例子是取消对 a/b 主题和 c/d 主题的订阅。

对于客户端的取消订阅消息,服务端对取消订阅的主题的处理逻辑是:和之前客户端订阅的主题一个字符一个字符的比较,当且仅当它们完全一样时,才进行取消

UNSUBACK Packet

方向

服务端 -> 客户端

说明

UNSUBACK 包是 SUBACK 包的响应

固定头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  1  |  0  |  1  |  1  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
|  byte2  |  0  |  0  |  0  |  0  |  0  |  0  |  1  |  0  |
+---------------------------------------------------------+
可变头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |             Packet Identifier MSB             |
+---------------------------------------------------------+
|  byte2  |             Packet Identifier LSB             |
+---------------------------------------------------------+
载荷

PINGREQ Packet

方向

客户端 -> 服务端

说明

PINGREQ 包的作用如下:

  • 告诉服务端自己还活着
  • 探测服务端是否还活着
  • 探测网络是否可用
固定头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  1  |  1  |  0  |  0  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
|  byte2  |  0  |  0  |  0  |  0  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
可变头部

载荷

PINGRESP Packet

方向

服务端 -> 客户端

说明

PINGRESP 是服务端对 PINGREQ 包的响应,它表明服务端还活着

固定头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  1  |  1  |  0  |  1  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
|  byte2  |  0  |  0  |  0  |  0  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
可变头部

载荷

DISCONNECT Packet

方向

客户端 -> 服务端

说明

DISCONNECT 包是客户端发送给服务端的最后一个包,用来告诉服务端自己即将断开连接。

固定头部
+---------------------------------------------------------+
|   Bit   |  7  |  6  |  5  |  4  |  3  |  2  |  1  |  0  |
+---------------------------------------------------------+
|  byte1  |  1  |  1  |  1  |  0  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
|  byte2  |  0  |  0  |  0  |  0  |  0  |  0  |  0  |  0  |
+---------------------------------------------------------+
可变头部

载荷

附加说明

服务质量(QoS)

QoS 是 Quality of Service 的缩写,即“服务质量”,共有 3 种不同的消息投送服务质量。

QoS 0

表示尽最大能力投送消息,不保证消息一定被接收。在此等级的服务质量下,对于某个数据包,发送者仅仅发送一次到 broker,不管它有没有被接收者接收;因此对接收者而言,相同的包要么会被接收一次,要么一次都不能被接收。

如下是消息发送的时序图:

+------------+              +------------+
|   Sender   |              |  Receiver  |
+-----+------+              +------+-----+
      |                            |
      |                            |
      |                            |
      |  PUBLISH(QoS 0, DUP=0)     |
      |--------------------------->|
      |                            |
      |                            |
      |                           +++
      |                           | |
      |                           | | Deliver message
      |                           | |
      |                           +++
      |                            |
QoS 1

该服务质量在上一服务质量上做了提升:

  • 发送者在发送数据包之前,需要将消息暂存起来;
  • 在发送消息后,需要等待接收者的确认。若长时间(自己定义)没有收到接收者的确认,需要重发该消息,直到收到接收者的确认响应为止,才算真正完成该数据包的投递。之后才能删除该消息。

考虑一种场景:

发送者发送的消息成功投递给了接收者,但接收者响应的确认包,在网络传输中丢失。发送者等待确认包一段时间无果,变会重新发送该消息,此时接收者会再次收到此消息!这就是说,在 QoS 1 服务等级下,接收者可能多次收到同一消息。接收者需要处理好这种情况。

如下是消息发送的时序图:

+------------+               +------------+
|   Sender   |               |  Receiver  |
+-----+------+               +------+-----+
      |                             |
     +++                            |
     | +-+                          |
     | | | Store message            |
     | +<+                          |
     +++                            |      
      |                             |
      |  PUBLISH(QoS 1, DUP=0)      |
      |---------------------------->|
      |                             |
      |                            +++
      |                            | |
      |                            | | Deliver message
      |                            | |
      |                            +++
      |         PUBACK              |
      |<----------------------------|
      |                             |
     +++                            |
     | +-+                          |
     | | |Discard message           |
     | +<+                          |
     +++                            |
      |                             |

需要注意的是:

当一个包未真正完成发送时,它所使用的包 ID 不能再次被使用;只有在完成后,才能被再次使用。

QoS 2

该方式是质量最高(也最繁琐)的服务。当数据包投送完成时,它保证一个数据包一定被接收且仅被接收一次。

该服务质量的实现策略有 2 种,但最终达到的效果是一致的。下面分别给出消息在两种策略下的传送的时序图:

方式 A

+------------+                    +------------+
|   Sender   |                    |  Receiver  |
+-----+------+                    +------+-----+
      |                                  |
     +++                                 |
     | +-+                               |
     | | |Store message                  |
     | +<+                               |
     +++                                 |
      |                                  |
      |  PUBLISH(QoS 2, DUP=0)           |
      |--------------------------------->|
      |                                  |
      |                                 +++
      |                                 | +-+
      |                                 | | | Store message
      |                                 | +<+
      |                                 +++
      |                                  |
      |           PUBREC                 |
      |<---------------------------------|
      |                                  |
     +++                                 |
     | +-+                               |
     | | | Discard message, and          |
     | | | store PUBREC packet id        |
     | +<+                               |
     +++                                 |
      |                                  |
      |         PUBREL                   |
      |--------------------------------->|
      |                                  |
      |                                  |
      |                                 +++
      |                                 | |
      |                                 | | Deliver message
      |                                 | |
      |                                 +++
      |                                  |
      |                                  |
      |                                 +++
      |                                 | +-+
      |                                 | | | Discard message
      |                                 | +<+
      |                                 +++
      |                                  |
      |         PUBCOMP                  |
      |<---------------------------------|
      |                                  |
     +++                                 |
     | +-+                               |
     | | |Discard stored state           |
     | +<+                               |
     +++                                 |
      |                                  |

方式B

+------------+                  +------------+
|   Sender   |                  |  Receiver  |
+-----+------+                  +------+-----+
      |                                |
     +++                               |
     | +-+                             |
     | | |Store message                |
     | +<+                             |
     +++                               |
      |                                |
      |  PUBLISH(QoS 2, DUP=0)         |
      +------------------------------->+
      |                                |
      |                               +++
      |                               | +-+
      |                               | | | Store package id
      |                               | +<+
      |                               +++
      |                                |
      |                                |
      |                               +++
      |                               | | Deliver Message
      |                               +++
      |                                |
      |           PUBREC               |
      +<-------------------------------+
      |                                |
      |                                |
     +++                               |
     | +-+                             |
     | | | Discard message, and        |
     | | | store PUBREC packet id      |
     | +<+                             |
     +++                               |
      |                                |
      |         PUBREL                 |
      +------------------------------->+
      |                                |
      |                                |
      |                               +++
      |                               | +-+
      |                               | | | Discard package id
      |                               | +<+
      |                               +++
      |                                |
      |         PUBCOMP                |
      +<-------------------------------+
      |                                |
     +++                               |
     | +-+                             |
     | | |Discard stored state         |
     | +<+                             |
     +++                               |
      |                                |

下面是对这两种策略中的行为作出的解释:

  1. 首先,客户端向服务器发送 Qos 为 2 的消息;发送前,需要暂存消息,以便重发之用。此时,客户端期待服务端确认包(PUBREC)的到来;若在一定时间(自己定义)内服务端确认包(PUBREC)没有如期到来,就重发消息。
  2. 服务端收到消息后,有两种处理方式:一种(方式A)是暂存消息,先与客户端相互确认后再转发给订阅者;另一种(方式B)是立即转发消息给订阅者,仅保存数据包 ID 即可(防止接收到重复数据),转发完后再与客户端相互确认。不论采用何种方式,服务端都需要对客户端发送的消息进行确认(PUBREC),并期待客户端的确认(PUBREL)到来。若客户端的确认(PUBREL)没有如期到来,此时服务端需要重发确认(PUBREC)。
  3. 当客户端收到 PUBREC 包后,它就能确定服务端已经接收到了此数据包。因此,客户端接下来就可以放心地将此包相关删除。此时消息的所有权便从客户端转移到了服务端。接下来,客户端需要发送 PUBREL 包给服务端,以对服务端的确认包进行确认,并期待服务端 PUBCOMP 通知的到来。若 PUBCOMP 通知在一定时间内没有如期到来,客户端需要重发 PUBREL 包。
  4. 服务端在收到 PUBREL 包后,若没有转发消息给订阅者(方式A),那么它需要立刻进行转发。在此值后便可以删除保存的关于该消息的相关信息了。之后,服务端需要发送 PUBCOMP 包,通知客户端消息已经被转发到各订阅者了。客户端收到通知后,便可以删除存储的与该消息相关的其他信息了。

主题(Topic)

MQTT 的主题是分层级的。层级之间使用左划线(/)进行分隔,如:sensors/computer-1/temperature/cpu

发布者在发布某个消息时,必须明确指定消息的主题,并且主题中不能包含通配符;但订阅者在订阅时,既可以指定明确的主题,也可以指定含有通配符的主题,如:sensors/+/temperature/+

通配符有两种:+#+ 用来通配单一的某层级;# 用来通配剩下的所有层级(因此 # 只能处于主题的最后层级)。

举两个🌰,对于主题 a/b/c/d

如下主题与之匹配:

  • a/b/c/d
  • +/b/c/d
  • a/+/c/d
  • a/+/+/d
  • +/+/+/+
  • #
  • a/#
  • a/b/#
  • a/b/c/#
  • +/b/c/#

而如下主题与之不匹配

  • a/b/c
  • b/+/c/d
  • +/+/+

特别地,主题的层级可以是空字符串,例如:a//b, /a/b, /a/b/。这 3 个主题实际上都有 3 个层级:a//b 的各层级分别为 a, <空字符串>, b/a/b 的各层级分别是 <空字符串>, a, b …… +# 通配符将<空字符串>也当做“有字符串”处理。

这就是说:

  • a//b 可以和 a/+/b 匹配;
  • /a/b 可以和 +/a/b/# 匹配;
  • a/b/ 可以和 a/b/+a/b/# 匹配

FAQ

问题1

在以上的介绍中,我们知道客户端有两个地方可以指定消息的服务质量:一个是在客户端者订阅主题时,可以在订阅消息包中指定 QoS(见 [SUBSCRIBE Packet](#SUBSCRIBE Packet))另一个是在客户端发送消息时,可以指定 QoS(见 [PUBLISH Packet](#PUBLISH Packet))。那么问题来了,它们有啥区别呢?会不会出现矛盾?比如,某客户端订阅时要求 QoS 为 0,而发送消息时指定 QoS 为 2,那服务端到底该采用哪种服务质量进行服务呢?

回答:这并不矛盾。因为客户端在订阅主题时指定的 QoS,是在要求服务端,“当我订阅的主题有消息时,请在该 QoS 下将消息发给我”。该 QoS 针对的是服务端转发消息到客户端的过程;而客户端发送消息时指定的 QoS,是在要求服务端,“我在该 QoS 下,把这段消息发送给你”。这个 QoS 针对的是客户端发送消息到服务端的过程

客户端实现

下面我们用 Python (3.x)来实现一个简单的 MQTT 客户端。该客户端可以连接上 MQTT 服务器,订阅以及发布消息。代码会涉及到 Python Socket 相关的 API,如果你还不太熟悉,可以先看看 Python 官方提供的 文档

client.py

import logging
import socket
import sys
import traceback

from packet.connack_packet import ConnackPacket
from packet.connect_packet import ConnectPacket
from packet.puback_packet import PubackPacket
from packet.pubcomp_packet import PubcompPacket
from packet.publish_packet import PublishPacket
from packet.pubrec_packet import PubrecPacket
from packet.pubrel_packet import PubrelPacket
from packet.suback_packet import SubackPacket
from packet.subscribe_packet import SubscribePacket
from util.common import random_str, merge_dict

logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', stream=sys.stdout, level=logging.DEBUG)

_default_options = {
    "keepalive": 60,
    "will_topic": None,
    "will_message": None,
    "will_retain": None,
    "will_qos": None,
    "clean_session": True,
    "ping_interval": 300,
}

_receive_packet_types = {
    2: ConnackPacket,
    3: PublishPacket,
    4: PubackPacket,
    5: PubrecPacket,
    6: PubrelPacket,
    7: PubcompPacket,
    9: SubackPacket,
}


class Client:

    def __init__(self, host, port=1883, client_id=random_str(6), username=None, password=None, **options):
        self._host = host
        self._port = port
        self._username = username
        self._password = password
        self._client_id = client_id
        self._options = merge_dict(_default_options, options)
        # No default callbacks
        self._on_connect = None
        self._on_message = None
        self._socket = None
        self._packet_id = 0
        self._unack_package_ids = set()
        self._unack_packet = {}

    def connect(self):
        """connect MQTT broker
        """
        self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._socket.setblocking(True)
        self._socket.connect((self._host, self._port))
        # Send connect packet
        packet = ConnectPacket(self._client_id, self._username, self._password, self._options['keepalive'],
                               self._options['will_topic'], self._options['will_message'], self._options['will_retain'],
                               self._options['will_qos'], self._options['clean_session'])
        self._send_packet(packet)

    def reconnect(self):
        """reconnect MQTT broker"""
        pass

    def close(self):
        """close connection and clear the resource
        """
        pass

    def loop_forever(self):
        """Receive data from broker in an loop"""
        while True:
            try:
                packet_type, flags, packet_bytes, remaining_length = self._recv_packet()
                if packet_type not in _receive_packet_types:
                    logging.warning("unknown packet type: %s", packet_type)
                    continue
                packet = _receive_packet_types[packet_type].from_bytes(packet_bytes)
                logging.debug('receive a packet: %s', packet)

                # CONNACK Packet
                if isinstance(packet, ConnackPacket) and self._on_connect:
                    self._on_connect(packet)
                # Publish Packet
                elif isinstance(packet, PublishPacket):
                    if packet.qos == 0:
                        # callback on_message
                        self._on_message(packet)
                    elif packet.qos == 1:
                        # callback on_message
                        self._on_message(packet)
                        # publish ack for publish packet(qos = 1)
                        self._send_packet(PubackPacket(packet.packet_id))
                    elif packet.qos == 2:
                        # Store packet id
                        self._unack_package_ids.add(packet.packet_id)
                        # callback on_message
                        self._on_message(packet)
                        # send PUBREC packet
                        self._send_packet(PubrecPacket(packet.packet_id))
                # PUBACK Packet
                elif isinstance(packet, PubackPacket) and packet.packet_id in self._unack_packet:
                    self._unack_packet.pop(packet.packet_id)
                # PUBREC Packet
                elif isinstance(packet, PubrecPacket) and packet.packet_id in self._unack_packet:
                    # discard message
                    self._unack_packet.pop(packet.packet_id)
                    # store packet id
                    self._unack_package_ids.add(packet.packet_id)
                    # send PUBREL message
                    self._send_packet(PubrelPacket(packet.packet_id))
                # PUBCOMP Packet
                elif isinstance(packet, PubcompPacket) and packet.packet_id in self._unack_package_ids:
                    self._unack_package_ids.remove(packet.packet_id)
                # PUBREL Packet
                elif isinstance(packet, PubrelPacket) and packet.packet_id in self._unack_package_ids:
                    # discard packet id
                    self._unack_package_ids.remove(packet.packet_id)
                    # send PUBCOMP packet
                    self._send_packet(PubcompPacket(packet.packet_id))

            except KeyboardInterrupt:
                self.close()
            except ConnectionError as e:
                logging.warning("%s", e)
                return
            except Exception as e:
                logging.error("mqtt client occur error: %s", e)
                traceback.print_exc()
                continue

    def on_connect(self, func):
        """
        """
        self._on_connect = func
        return func

    def on_message(self, func):
        """
        """
        self._on_message = func
        return func

    def subscribe(self, topic: str, qos=0, *others_topic_qos):
        """订阅消息
        """
        packet = SubscribePacket(self._next_packet_id(), topic, qos, *others_topic_qos)
        self._send_packet(packet)

    def unsubscribe(self, topics):
        """取消订阅
        """
        pass

    def publish(self, topic: str, message: bytes, qos: int = 0, retain: bool = False, dup: bool = False):
        """发布消息
        """
        if qos != 2:
            dup = False
        publish_packet = PublishPacket(dup, qos, retain, topic, self._next_packet_id(), message)
        self._send_packet(publish_packet)

        if qos != 0:
            # Store message
            self._unack_packet[publish_packet.packet_id] = publish_packet

    def _send_packet(self, packet):
        """发送数据包"""
        logging.debug('send a packet: {}'.format(packet))
        packet_bytes = packet.to_bytes()
        self._socket.sendall(packet_bytes)

    def _recv_packet(self):
        packet_bytes = bytearray()
        # Read first byte
        read_bytes = self._socket.recv(1)
        self._check_recv_data(read_bytes)
        first_byte = read_bytes[0]
        packet_bytes.append(first_byte)
        packet_type = first_byte >> 4
        flags = first_byte & 0x0F

        # Read second byte
        read_bytes = self._socket.recv(1)
        self._check_recv_data(read_bytes)
        second_byte = read_bytes[0]
        packet_bytes.append(second_byte)

        remaining_length = second_byte & 0x7F
        flag_bit = second_byte & 0x80
        multiplier = 1
        while True:
            if flag_bit == 0:
                break
            read_bytes = self._socket.recv(1)
            self._check_recv_data(read_bytes)
            next_byte = read_bytes[0]
            flag_bit = next_byte & 0x80
            remaining_length += (next_byte & 0x7F) * multiplier

        # Read remaining bytes
        while remaining_length > 0:
            read_bytes = self._socket.recv(min(4096, remaining_length))
            remaining_length -= len(read_bytes)
            packet_bytes.extend(read_bytes)
        return packet_type, flags, packet_bytes, remaining_length

    @staticmethod
    def _check_recv_data(data):
        if not data:
            raise ConnectionError('connection is closed')

    def _next_packet_id(self):
        self._packet_id += 1
        return self._packet_id

测试一下效果:

import logging

from client import Client

mqtt_client = Client('127.0.0.1', username='derker', password='123456')


@mqtt_client.on_connect
def on_connect(connack_packet):
    logging.info('[on_connect]: sp = {}, return_code = {}'.format(connack_packet.sp, connack_packet.return_code))
    mqtt_client.subscribe('$SYS/broker/version', 2)
    mqtt_client.publish('hello', bytes('I\'m derker', 'utf8'), 2)


@mqtt_client.on_message
def on_message(message):
    logging.info('[on_message]: {}'.format(message))


if __name__ == '__main__':
    mqtt_client.connect()
    mqtt_client.loop_forever()

打印结果如下:

2019-08-15 20:32:40,870 DEBUG: send a packet: <packet.connect_packet.ConnectPacket object at 0x11711f510>
2019-08-15 20:32:40,876 DEBUG: receive a packet: ConnackPacket(sp = 0, return_code = 0)
2019-08-15 20:32:40,876 INFO: [on_connect]: sp = 0, return_code = 0
2019-08-15 20:32:40,876 DEBUG: send a packet: <packet.subscribe_packet.SubscribePacket object at 0x11711f310>
2019-08-15 20:32:40,876 DEBUG: send a packet: PublishPacket(dup = False, qos = 2, retain = False, topic= hello, packet_id = 2, payload = b"I'm derker")
2019-08-15 20:32:40,877 DEBUG: receive a packet: SubackPacket(packet_id = 1, return_codes = [2])
2019-08-15 20:32:40,877 DEBUG: receive a packet: PublishPacket(dup = 0, qos = 2, retain = True, topic= $SYS/broker/version, packet_id = 1, payload = bytearray(b'mosquitto version 1.6.3'))
2019-08-15 20:32:40,877 INFO: [on_message]: PublishPacket(dup = 0, qos = 2, retain = True, topic= $SYS/broker/version, packet_id = 1, payload = bytearray(b'mosquitto version 1.6.3'))
2019-08-15 20:32:40,877 DEBUG: send a packet: PubrecPacket(packet_id = 1)
2019-08-15 20:32:40,877 DEBUG: receive a packet: PubrecPacket(packet_id = 2)
2019-08-15 20:32:40,877 DEBUG: send a packet: PubrelPacket(packet_id = 2)
2019-08-15 20:32:40,878 DEBUG: receive a packet: PubrelPacket(packet_id = 1)
2019-08-15 20:32:40,878 DEBUG: send a packet: PubcompPacket(packet_id = 1)
2019-08-15 20:32:40,878 DEBUG: receive a packet: PubcompPacket(packet_id = 2)

代码很简单,这里就不做解释了。其中省略了对 Disconnect Packet 的处理,也没有实现消息重发,重连。其中使用到的一些工具方法和各包的数据结构可在 GitHub 中查看。

服务端实现

呃, 不想写了 😃

参考资料

posted @ 2019-08-15 20:39  学数学的程序猿  阅读(15384)  评论(14编辑  收藏  举报