物联网通讯是物联网的一个核心内容,目前物联网的通讯协议并没有一个统一的标准,比较常见的有 MQTT、
CoAP、DDS、XMPP 等,在这其中,MQTT(消息队列遥测传输协议)应该是应用最广泛的标准之一
MQTT 的全称为 Message Queue Telemetry Transport,是在 1999 年,由 IBM 的 Andy Stanford-Clark
和 Arcom 的 Arlen Nipper 为了一个通过卫星网络连接输油管道的项目开发的。为了满足低电量消耗和低网络
带宽的需求,MQTT 协议在设计之初就包含了以下一些特点:
2010 年,IBM 公开发布了 MQTT 3.1 版本。在 2014 年,MQTT 协议正式成为了 OASIS(结构化信息标准促进组织)的标准协议。随着多年的发展,MQTT 协议的重点也不再只是嵌入式系统,而是更广泛的物联网(Internet of Things)世界了
基于 TCP 协议的应用层协议;
采用 C/S 架构;
使用订阅/发布模式,将消息的发送方和接受方解耦;
提供 3 种消息的 QoS(Quality of Service): 至多一次,最少一次,只有一次;
收发消息都是异步的,发送方不需要等待接收方应答。
MQTT 跟传统的消息队列相比,有以下一些区别
MQTT 协议可以为大量的低功率、工作网络环境不可靠的物联网设备提供通讯保障。而它的应用范围也不仅如此,在移动互联网领域也大有作为:很多 Android App 的推送功能,都是基于 MQTT 实现的,也有一些 IM的实现,是基于 MQTT 的。
MQTT 的通信是通过发布/订阅的方式来实现的,消息的发布方和订阅方通过这种方式
来进行解耦,它们没有直接地连接,它们需要一个中间方。在 MQTT 里面我们称之为 Broker,用来进行消息
的存储和转发。一次典型的 MQTT 消息通信流程如下所示:
我们将发送方称为 Publisher,将订阅方称为 Subscriber
任何终端,嵌入式设备也好,服务器也好,只要运行了 MQTT 的库或者代码,我们都称为 MQTT 的 Client。
Publisher 和 Subscriber 都属于 Client,Pushlisher 或者 Subscriber 只取决于该 Client 当前的状态——是在
发布还是在订阅消息。当然,一个 Client 可以同时是 Publisher 和 Subscriber。
MQTT Client 库在很多语言中都有实现,包括 Android、Arduino、Ruby、C、C++、C#、Go、iOS、
Java、JavaScript,以及 .NET 等
各语言库的实现:https://github.com/mqtt/mqtt.org/wiki/libraries
使用 Node.js 的 MQTT Client 库来进行演示,所以需要先安装 Node.js,然后安装
MQTT Client 的 Node.js 包:
npm install mqtt -g
Broker 负责接收 Publisher 的消息,并发送给相应的 Subscriber,它是整个 MQTT 订阅/发布
的核心。在实际应用中,一个 MQTT Broker 还应该提供以下一些功能:
1.可以横向扩展,比如集群,来满足大量的 Client 接入;
2.可以扩展接入业务系统;
3.易于监控,满足高可用性。
阿里云、腾讯云、青云之类的云服务商提供的 MQTT 服务,其实就可以理解为他们提供
了满足上述要求的 MQTT Broker。
可以使用一个公共的 MQTT Broker —— iot.eclipse.org 做演示
MQTT 协议的数据包格式非常简单,一个 MQTT 协议数据包由下面三个部分组成:
固定头(Fixed header):存在于所有的 MQTT 数据包中,用于表示数据包类型及对应标识,表明数据包
大小;
可变头(Variable header):存在于部分类型的 MQTT 数据包中,具体内容由相应类型的数据包决定;
消息体(Payload):存在于部分 MQTT 数据包中,存储消息的具体数据。
固定头格式:
固定头的第一个字节的高 4 位 bit 用于指定该数据包的类型,MQTT 的数据包有以下一些类型:
固定头的低 4 位 bit 用于指定数据包的 Flag,不同的数据包类型,其 Flag 的定义是不一样的,每种数据包对应
的 Flag 如下:
从固定头的第 2 字节开始是用于标识 MQTT 数据包长度的字段,最少一个字节,最大四个字节,每一个字节的
低 7 位用于标识值,范围为 0~127。最高位的 1 位是标识位,用来说明是否有后续字节来标识长度。例如:标
识为 0,代表为没有后续字节;标识为 1,代表后续还有一个字节用于标识包长度。MQTT 协议规定最多可以
用四个字节来标识包长度。
所以这四个字节最多可以标识的包长度为:(0xFF, 0xFF, 0xFF, 0x7F) = 268435455 字节,约 256M,这个是
MQTT 协议中数据包的最大长度。
连接的建立由 Client 端发起,Client 端首先向 Broker 发送一个 CONNECT 数据包,CONNECT 数据包包含以
下内容(这里略过 Fixed header)。
在 CONNECT 数据包可变头中,含有以下信息。
协议名称(Protocol Name):值固定为字符 “MQTT”。
协议版本(Protocol Level):对 MQTT 3.1.1 来说,值为 4。
用户名标识(User Name Flag):消息体中是否有用户名字段,1bit,0 或者 1。
密码标识(Password Flag):消息体中是否有密码字段,1bit,0 或者 1。
遗愿消息 Retain 标识(Will Retain):标识遗愿消息是否是 Retain 消息,1bit,0 或者 1
遗愿消息 QOS 标识(Will QOS):标识遗愿消息的 QOS,2bit,0、1 或者 2
遗愿标识(Will Flag):标识是否使用遗愿消息,1bit,0 或者 1
会话清除标识(Clean Session):标识 Client 是否建立一个持久化的会话,1bit,0 或者 1,当 Clean Session 的标识设为 0 时,代表 Client 希望建立一个持久会话的连接,Broker 将存储该 Client 订阅的主题和未接受的消息,否则 Broker 不会存储这些数据,同时在建立连接时清除这个 Client 之前存在的持久化会话所保存的数据。
连接保活(Keep Alive): 设置一个单位为秒的时间间隔,Client 和 Broker 之间在这个时间间隔之内需要至少有一次消息交互,否则 Client 和 Broker 会认为它们之间的连接已经断开
CONNECT 数据包的消息体中包含以下数据。
客户端标识符(Client Identifier):Client Identifier 是用来标识 Client 身份的字段,在 MQTT 3.1.1 的版本中,这个字段的长度是 1 到 23 个字节,而且只能包含数字和 26 个字母(包括大小写),Broker 通过这个字段来区分不同的 Client。所以在连接的时候,Client 应该保证它的 Identifier 是唯一的,通常我们可以使用比如 UUID,唯一的设备硬件标识,或者 Android 设备的 DEVICE_ID 等作为 Client Identifier 的取值来源。
MQTT 协议中要求 Client 连接时必须带上 Client Identifier,但是也允许 Broker 在实现时 Client Identifier
为空,这时 Broker 会为 Client 分配一个内部唯一的 Identifier。如果你需要使用持久化会话,那就必须自己为Client 设定一个唯一的 Identifier。
用户名(Username):如果可变头中的用户名标识设为 1,那么消息体中将包含用户名字段,Broker 可以使用用户名和密码来对接入的 Client 进行验证,只允许已授权的 Client 接入。注意不同的 Client 需要使用不同的 Client Identifier,但它们可以使用同样的用户名和密码进行连接。
密码(Password):如果可变头中的密码标识设为 1,那么消息体中将包含密码字段。
遗愿主题(Will Topic):如果可变头中的遗愿标识设为 1,那么消息体中将包含遗愿主题,当 Client 非正常地中断连接的时候,Broker 将向指定的遗愿主题中发布遗愿消息。
遗愿消息(Will Message):如果可变头中的遗愿标识设为 1,那么消息体中将包含遗愿消息,当 Client 非正常地中断连接的时候,Broker 将向指定的遗愿主题中发布由该字段指定的内容。
当 Broker 收到 Client 的 CONNECT 数据包之后,将检查并校验 CONNECT 数据包的内容,之后回复 Client一个 CONNACK 数据包。
CONNACK 数据包包含以下内容(这里略过 Fixed header)。
CONNACK 数据包的可变头中,含有以下信息。
会话存在标识(Session Present Flag):用于标识在 Broker 上,是否已存在该 Client(用 Client Identifier区分)的持久性会话,1bit,0 或者 1。
当 Client 在连接时设置 Clean Session=1,则 CONNACK 中的Session Present Flag 始终为 0;
当 Client 在连接时设置 Clean Session=0,那么就有两种情况——
如果Broker 上面保存了这个 Client 之前留下的持久性会话,那么 CONNACK 中的 Session Present Flag 值为 1;
如果 Broker 没有保存该 Client 的任何会话数据,那么 CONNACK 中的 Session Present Flag 值为 0。
Session Present Flag 这个特性是在 MQTT 3.1.1 版本中新加入的,之前的版本中并没有这个标识。
连接返回码(Connect Return code):用于标识 Client 是 Broker 的连接是否建立成功,连接返回码有以下一些值:
Return Code 4 在 MQTT 协议中的含义是 Username 和 Password 的格式不正确,但是在大部分的 Broker 实现中,在使用错误的用户名密码时,得到的返回码也是 4。所以这里认为 4 就是代表错误的用户名或密码。
Return Code 5 一般在 Broker 不使用用户名和密码而使用 IP 地址或者 Client Identifier 进行验证的时候使用,来标识 Client 没有通过验证。
Return Code 2 代表的是 Client Identifier 格式不规范,比如长度超过 23 个字符,包含了不允许的
字符等(部分 Broker 的实现在协议标准上做了扩展,比如允许超过 23 个字符的 Client Identifer 等)
CONNACK 没有消息体。
当 Client 向 Broker 发送 CONNECT 数据包并获得 Return Code 为 0 的 CONNACK 包后,就代表连接建立成功,可以发布和接受消息了。
Client 主动关闭连接的流程非常简单,只需要向 Broker 发送一个 DISCONNECT 数据包就可以了。
DISCONNECT 数据包没有可变头(Variable header)和消息体(Payload)。在 Client 发送完
DISCONNECT 之后,就可以关闭底层的 TCP 连接了,不需要等待 Broker 的回复(Broker 也不会对
DISCONNECT 数据包回复)。
为什么需要在关闭 TCP 连接之前,发送一个和 Broker 没有交互的 DISCONNECT数据包,而不是直接关闭底层的 TCP 连接?
这里涉及到 MQTT 协议的一个特性,Broker 需要判断 Client 是否正常地断开连接:
当 Broker 收到 Client 的 DISCONNECT 数据包的时候,它认为 Client 是正常地断开连接,那么它会丢弃当前连接指定的遗愿消息(Will Message)。如果 Broker 检测到 Client 连接丢失,但又没有收到
DISCONNECT 消息包,它会认为 Client 是非正常断开连接,就会向在连接的时候指定的遗愿主题(Will
Topic)发布遗愿消息(Will Message)。
MQTT 协议规定 Broker 在没有收到 Client 的 DISCONNECT 数据包之前都应该保持和 Client 连接,只有Broker 在 Keep Alive 的时间间隔里,没有收到 Client 的任何 MQTT 数据包的时候会主动关闭连接。一些Broker 的实现在 MQTT 协议上做了一些拓展,支持 Client 的连接管理,可以主动地断开和某个 Client 的连接。Broker 主动关闭连接之前不会向 Client 发送任何 MQTT 数据包,直接关闭底层的 TCP 连接就完事了
在这里使用 Node.js 的 MQTT 库,请确保已安装 Node.js,并通过 npm install mqtt --save 安装了
MQTT 库。
这里使用一个私人的 Broker:iot.eclipse.org
persistent_connection.js文件
// 引用 MQTT 库:
var mqtt = require('mqtt');
//建立连接
var client = mqtt.connect('mqtt://iot.eclipse.org', {
clientId: "mqtt_sample_id_1",
clean: false
});
//这里通过 ClientID 选项指定 Client Identifier,并通过 Clean 选项设定 Clean Session 为 false,代表我们要建立一个持久化会话的连接
//通过捕获 connect 事件将 CONNACK 包 Return Code 和 Session Present Flag 打印出来,然后断开连接
client.on('connect', function (connack) {
console.log(`return code: ${connack.returnCode}, sessionPresent:${connack.sessionPresent}`)
client.end()
});
终端上运行:
node persistent_connection.js
只需要将 Clean 选项设为 true,就可以建立一个非持久会话的连接了
MQTT 基于订阅与发布的消息模型,MQTT 协议的订阅与发布是基于主题的(Topic),一个典型的 MQTT 消息发送与接收的流程如下
和传统的队列有点不同,如果 ClientB 在 ClientA 发布消息之后再订阅 Topic1,ClientB 不会收到该条消息。
MQTT 通过订阅与发布模型对消息的发布者和订阅者进行解耦,发布者在发布消息时并不需要订阅方也连接到Broker,只要订阅方之前订阅过相应主题,那么它在连接到 Broker 之后就可以收到发布方在它离线期间发布的消息。为了方便起见,我们称这种消息为离线消息。
接收离线的消息需要 Client 使用持久化会话,且发布时消息的 QoS 大于 1。
Publisher 和 Subscriber 是相对于 Topic 来说的身份,如果一个 Client 向某个 Topic 发布消息,那么它就是Publisher;如果一个 Client 订阅了某个 Topic,那么它就是 Subscriber。在上面的例子中,ClientA 是
Publisher, ClientB 是 Subscriber。
Sender 和 Receiver 是相对于消息传输方向的身份,仍然是上面的例子:
当 ClientA 发布消息时,它发送给 Broker 一条消息,那么 ClientA 是 Sender,Broker 是 Receiver;
当 Broker 转发消息给 ClientB 时,Broker 是 Sender,ClientB 是 Receiver。
Publisher/Subscriber、Sender/Receiver 这两组概念最大的区别就是,Publisher 和 Subscriber 只可能是Client。而 Sender/Receiver 有可能是 Client 和 Broker
PUBLISH 数据包是用于在 Sender 和 Receiver 之间传输消息数据的,也就是说,当 Publisher 要向某个
Topic 发布一条消息的时候,Publisher 会向 Broker 发送一个 PUBLISH 数据包;当 Broker 要将一条消息转发给订阅了某条主题的 Subscriber 时,Broker 也会向 Subscriber 发送一条 PUBLISH 数据包。PUBLISH 数据包的内容如下。
消息重复标识(DUP flag):1bit,0 或者 1,当 DUP flag = 1 的时候,代表该消息是一条重发消息,因
Receiver 没有确认收到之前的消息而重新发送的。这个标识只在 QoS 大于 0 的消息中使用。
QoS:2bit,0、1 或者 2,代表 PUBLISH 消息的 QoS level
Retain 标识(Retain flag):1bit,0 或者 1,在从 Client 发送到 Broker 的 PUBLISH 消息中被设为 1 的时候,Broker 应该保存该条消息,当之后有任何新的 Subscriber 订阅 PUBLISH 消息中指定的主题时,都会先收到该条消息,这种消息也叫 Retained 消息;在从 Broker 发送到 Client 的 PUBLISH 消息中被设为 1 的时候,代表该条消息是一条 Retained 消息。
数据包标识( Packet Identifier):2bit,用来标识一个唯一数据包,数据包标识只需要保证在从 Sender 到Receiver 的一次消息交互(比如发送、应答为一次交互)中保持唯一。只在 QoS 大于 1 的消息中使用,因为只有 QoS 大于 1 的消息有应答流程。
主题名称(Topic Name):主题名称是一个 UTF-8 编码的字符串,用来命名该消息发布到哪一个主题,Topic Name 可以是长度大于等于 1 任何一个字符串(可包含空格),但是在实际项目中,我们最好还是遵循以下一些最优方法。
主题名称应该包含层级,不同的层级用 / 划分,比如,2 楼 201 房间的温度感应器可以用这个主
题:“home/2ndfloor/201/temperature”。
主题名称开头不要使用 /,例如:“/home/2ndfloor/201/temperature”。
不要在主题中使用空格。
只使用 ASCII 字符。
主题名称在可读的前提下尽量短。
主题是大小写敏感的,“Home” 和 “home” 是两个不同的主题。
可以将设备的唯一标识加到主题中,比如:“warehouse/shelf/shelf1_ID/status”。
主题尽量精确,不要使用泛用的主题,例如在 201 房间有三个传感器,温度、亮度和湿度,那么你应该使用三个主题名称:“home/2ndfloor/201/temperature”、“home/2ndfloor/201/brightness”和“home/2ndfloor/201/humidity”,而不是让三个传感器都使用“home/2ndfloor/201”。
以 $ 开头的主题属于 Broker 预留的系统主题,通常用于发布 Broker 的内部统计信息,比如
$SYS/broker/clients/connected,应用程序不要使用 $ 开头的主题收发数据。
PUBLISH 消息的消息体中包含的是该消息要发送的具体数据,数据可以是任何格式的,二进制数据、文本、JSON 等,由应用程序来定义。在实际生产中,我们可以使用 JSON、Protocol Buffer 等对数据进行编码。
当 Receiver 收到来自 Sender 的 PUBLISH 消息时,根据 QoS 的不同,还有后续的应答流程
当 PUBLISH 消息的 QoS=0 时, Receiver 不做任何应答。
var mqtt = require('mqtt')
var client = mqtt.connect('mqtt://iot.eclipse.org', {
clientId: "mqtt_sample_publisher_1",
clean: false
})
client.on('connect', function (connack) {
if(connack.returnCode == 0){
client.publish("home/2ndfloor/201/temperature", JSON.stringify({current: 25}), {qos:
1}, function (err) {
if(err == undefined) {
console.log("Publish finished")
client.end()
}else{
console.log("Publish failed")
}
})
}else{
console.log(`Connection failed: ${connack.returnCode}`)
}
})