快速上手和理解物联网开发平台

孤者浪人 提交于 2020-02-22 16:43:29

快速理解和上手物联网开发平台

导读

关于物联网

5G 时代到来,物联网进入飞速发展阶段。越来越多的人也投入到了物联网项目的开发和物联网创业浪潮。

然而物联网应用开发并非想象中的那么容易。物联网是一种体系架构,它涉及的方面很多,芯片、传感器、网络、软件、平台以及服务。实际情况中一个物联网应用在开发过程中往往都会有明确的分工,硬件电路、嵌入式软件、云平台开发、物联网安全等等。

传统嵌入式软件工程师投身物联网项目开发,他们需要去了解和选择合适的物联网通讯协议,制定设备和云端的消息传输协议,但是他们并不关心云端是如何接受和存储设备消息,如何管理设备的生命周期;后端开发工程师需要去了解和学习新的适合物联网开发的通讯协议,需要去搭建管道服务实现设备和云端实现双向通信,他们也并不关心和了解设备如何连接上网络。所以,在物联网项目开发中,一个专业的复合型人才显得尤为重要。

关于本课程

国内的物联网平台主要有中国电信物联网开放平台、中移物联OneNet、华为 OceanConnect、阿里物联网、百度天工等。这些物联网开放平台支持的设备接入协议包括MQTT、CoAP、HTTP、ModBus等,可提供2G/3G/4G、5G、NB-IOT、WIFI、Bluetooth等不同网络设备的接入方案,既满足长连接的实施性需求,也可以满足短连接的低功耗需求,主要的功能包括设备生命周期管理、设备注册、设备影子、在线调试、固件升级、实时监控、分组管理、设备日志管理等等。

本文目的是基于 EMQX 快速上手搭建一个简易的物联网开发平台,提出并解决在物联网应用开发中可能遇到的问题。通讯协议采用最为广泛的 MQTT 协议,开发语言为 Java。实现设备上报数据的处理,远程控制设备以及物联网应用中一些常用的功能。专题中的设备并非实际物联网设备,只是用 MQTT Client 库和 MQTT.fx 工具模拟真实设备的使用场景。

MQTT.fx 下载:

https://mqttfx.jensd.de/index.php/download?spm=a2c4g.11186623.2.16.6bd058003X7RW2

本文既是对自己前段时间的所得做一个总结,也是对那些即将或者已经从传统 Web 项目,转向物联网应用开发的程序猿做一个知识导向,为即将到来的物联网浪潮做知识储备。

自我介绍

我叫张武,本科物联网专业,现在一家物联网创业公司担任后端 Java 开发工程师。

从大学第一个物联网项目智能拐杖算起,在物联网行业已经摸爬滚打了四年有余。四年的时间不长但也不算短,四年的时间让我认定物联网是个可以为之奋斗十年甚至二十年的方向。对于物联网这个领域,自己接触时间并非很长,也并非有一个全面的认知,但却一直在有一个更好认知的道路上缓缓前行。

本文介绍

一、深入了解 MQTT 协议

MQTT 是当下应用最为广泛的物联网通讯协议,专门针对低带宽和不稳定网络环境而设计。虽然已经存在了很多语言版本的 MQTT Client 开发包,对 MQTT 底层协议有了很好的封装,但是对于开发人员而言,深入了解 MQTT 协议和其底层设计原理,还是很有必要的。

二、走进 EMQX

MQTT Broker 有很多,本专题采用 EMQX 作为 MQTT Broker。EMQX 社区活跃度高,文档相对完善,功能丰富。本节会一一去解读 EMQX 的关键概念和功能。

三、MQTT Client 库实践

MQTT 客户端整个生命周期的行为可以概括为:建立连接、订阅主题、接收消息并处理、向指定主题发布消息、取消订阅、断开连接。本节在不修改任何配置文件运行 EMQX 的前提下,以一个客户端连接并发布、处理消息为例,讲解各个行为所需要的参数和意义。

四、MySQL 认证和访问控制

设备的认证和访问控制是物联网系统中非常重要的一环,这种重要性不亚于登录和权限控制之于一个 Web 管理系统。EMQX 提供很多插件来实现认证和访问控制,本专题基于 MySQL 插件实现。

五、处理设备上行数据

本节会基于 EMQX 设计一套完整的设备上行数据处理方案,包括主题设计,服务消息质量选取等等。

六、下发设备下行数据

EMQX 提供开放的 API 给设备下发控制指令,本节会学习如何使用 API 并结合到业务系统中。

七、功能规划和实现

本节会带大家理解和实现部分物联网开放平台中一些常用的功能,包括设备连接认证和访问控制,设备生命周期管理,设备日志管理,OTA 管理、设备影子、RRPC 式调用等等。

为了不影响阅读,实现不会去粘贴大量代码,笔者可以对照着源码进行理解。源码仅为功能演示性代码。

源码地址:

https://gitee.com/izhangwu_123/iot-show.git

一、深入了解 MQTT 协议

1.1 MQTT 协议简介与基本概念

搞 IT 的应该都知道 HTTP 协议,一次请求一次相应。同 HTTP 一样,MQTT 也是基于 TCP 传输层协议之上的应用层协议。MQTT 要求设备和云端通过心跳机制保持长连接,以此来达到云端实时监控和管理设备。

MQTT 协议规范:

http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html

MQTT 消息模型

MQTT 是一个客户端服务端架构的发布/订阅模式的消息传输协议。在 MQTT 通讯模型中包括 MQTT Broker 和 MQTT Client,消息流转不是端到端的,而是 Client 与 Broker 之间的。

一个 MQTT Client 既可以作为消息发布者 Publisher,也可以作为消息订阅者 Subscriber。订阅者连接上 Broker 之后,订阅某个主题,就能收到发布者往该主题上发送的消息。

MQTT消息模型

MQTT Broker

MQTT Broker 是 MQTT Client 之间消息转发的中转站。业界成熟的 MQTT Broker 也有很多,下面是对比图。

MQTT Broker对比

本专题综合考虑,采用 EMQX 作为 MQTT Broker。EMQX 为国人创建,社区活跃度较高,文档相对完善,部署方便,功能全面,可拓展性较高。笔者了解和使用的一个物联网开放平台就是使用 EMQX 搭建的。

MQTT Client

MQTT Client 库在很多语言中都有具体的实现,包括 c、java、go、python、node.js 等。本文是基于 java 的 MQTT Client 库 Eclipse Paho Java Client 实现。

MQTT 特点

  • 使用发布/订阅消息模式,提供了一对多的消息分发和应用之间的解耦。
  • 提供三种等级的消息服务质量:最多一次、至少一次和仅有一次。
  • 很小的传输消耗和协议数据交换,最大限度减少网络流量
  • 异常连接断开发生时,能通知到相关各方。

适用场景

  • 物联网 M2M 通信,物联网大数据采集
  • Android 消息推送,Web 消息推送
  • 移动即时消息,例如 Facebook Messenger
  • 智能硬件、智能家具、智能电器

1.2 MQTT 主题

订阅和发布必须要有主题,只有当订阅了某个主题之后,只能收到相应主题发送过来的消息,以此来达到设备和云端的通讯。一个 MQTT Client 可以同时订阅多个主题,同一个主题也可以被多个 MQTT Client 订阅。

主题层级分隔符

斜杠用于分割主题的每个层级,为主题提供一个分层结构。

单层通配符

加号 + 用于单个主题层级匹配的通配符。在主题过滤器的任意层级都可以使用单层通配符,包括第一个和最后一个层次。然而它必须占据过滤器的整个层级。也可以在主题过滤器的多个层级使用它。

例如 device/+ 的主题可以匹配 /device/1/device/2,但是不能匹配 /device/1/1

多层通配符

# 号用于匹配层级中任何层级的通配符。多层通配符表示它的父级和任意数量的自层级。多层通配符必须位于它自己的层级或者跟在主题层级分隔符的后面。不管哪种情况,它都必须是主题过滤器的最后一个字符。

例如 device/# 的主题都可以匹配 /device/1/device/2/device/1/1 的主题消息。

$ 号开头的主题

服务端不能将 $ 字符开头的主题名匹配通配符(#+)开头的主题过滤器。服务端应该阻止客户端使用这种主题名与其它客户端交换消息。服务端实现可以将 $ 开头的主题名用作其他目的。例如我们后面讲到的 MQTT 服务端 EMQX 就使用 $SYS 作为系统主题,客户端订阅相应的系统主题可以感知设备上下线状态,服务端运行数据等等,后面会详细讲解。

1.3 MQTT 控制报文格式

MQTT Client 和 Broker 之间通过交换 MQTT 控制报文来通信。控制报文有很多,MQTT Client 向 MQTT Broker 发送连接报文,发布订阅主题消息报文等等。这一节描述这些报文的格式。 MQTT 控制报文格式部分组成:

  1. 固定报文头,所有控制报文都包含
  2. 可变报文头,部分控制报文包含
  3. 有效负载 Payload,部分控制报文包含

1.3.1 固定报文头

Bit 7 6 5 4 3 2 1 0
字节 1 MQTT 数据包类型 用于指定控制报文类型的标志位
从第二个字节开始 剩余长度

固定报文头的高 4 位用于指定数据包的类型:

名字 报文流动方向 描述
Reserved 0 禁止 保留
CONNECT 1 客户端到服务端 客户端请求连接服务器
CONNACK 2 服务端到客户端 连接报文确认
PUBLISH 3 两个方向都允许 客户端向服务器发送消息
PUBACK 4 两个方向都允许 Qos1消息发布收到确认
PUBREC 5 两个方向都允许 发布收到(保证交付第一步)
PUBREL 6 两个方向都允许 发布释放(保证交付第二步)
PUBCOMP 7 两个方向都允许 Qos2 消息发布完成(保证消息第三步)
SUBSCRIBE 8 客户端到服务端 客户端请求订阅
SUBACK 9 服务端到客户端 订阅请求报文确认
UNSUBSCRIBE 10 客户端到服务端 客户端取消订阅请求
UNSUBACK 11 服务端到客户端 取消订阅报文确认
PINGREQ 12 客户端到服务端 心跳请求
PINGRESP 13 服务端到客户端 心跳响应
DISCONNECT 14 客户端到服务端 客户端断开连接
Reserved 15 禁止 保留

固定报文头的低四位包含每个 MQTT 控制报文类型特定的标识。表格中的“保留”的标志位,都是保留给以后使用,这里不用过多在意。

控制报文 固定报文标识 Bit 3 Bit2 Bit1 Bit0
CONNECT 保留 0 0 0 0
CONNACK 保留 0 0 0 0
PUBLISH 用于 MQTT 3.1.1 DUP QoS QoS RETAIN
PUBACK 保留 0 0 0 0
PUBREC 保留 0 0 0 0
PUBREL 保留 0 0 1 0
PUBCOMP 保留 0 0 0 0
SUBSCRIBE 保留 0 0 1 0
SUBACK 保留 0 0 0 0
UNSUBSCRIBE 保留 0 0 1 0
UNSUBACK 保留 0 0 0 0
PINGREQ 保留 0 0 0 0
PINGRESP 保留 0 0 0 0
DISCONNECT 保留 0 0 0 0
  • DUP:控制报文的重复分发标识
  • QoS:有两位,PUBLISH 报文服务质量等级(0、1、2)
  • RETAIN :PUBLISH 报文保留标识

介绍 MQTT 特点的时候,我们提到过 MQTT Client 向 Broker 发送消息的时候可以指定消息质量:最多一次,最少一次和只有一次。至少一次肯定就意味着消息可能会重复发送,DUP 就用于标识是否是重复的报文,消息重发在 MQTT Client 库中已经默认你实现。RETAIN 保留消息,后面会详细讲到。

剩余长度

从第二字节开始,表示剩余长度。剩余长度用于标识可变报文头和负载数据的总的字节数,不包含其本身的字节数。

剩余长度使用一个变长的编码方案,最小是一个字节,最大是 4 个字节。每一个字节的最高位标识后面一个字节也是剩余长度标识,低 7 位有效位用于编码数据。所以按照最大是 4 个字节算,允许发送 (0xFF, 0xFF, 0xFF, 0x7F)大小的控制报文,约 256M。

所以一个 MQTT 控制报文最大传输的数据约为 256M。

字节数 最小值 最大值
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)

1.3.2 可变报文头

某些 MQTT 控制报文包含一个可变报头部分。它在固定报头和负载之间。可变报头的内容根据报文类型的不同而不同。可变报头的报文标识符(Packet Identifier)字段存在于在多个类型的报文里。

报文标识符 Packet Identifier

报文标识符只存在某些特定的控制报文中,如 SUBSCRIBE,UNSUBSCRIBE 和 PUBLISH(QoS 大于 0),用于标识该报文的唯一性。客户端向服务器发送这些唯一性的报文,服务器相应的,会带上该报文标识回复 ACK 给客户端。另外的对于 PUBLISH(QoS 大于 0)的报文,当消息重发的时候,报文标识符也不会改变。

客户端和服务端彼此独立地分配报文标识符。因此,客户端服务端组合使用相同的报文标识符可以实现并发的消息交换。

1.3.3 有效负载

某些 MQTT 控制报文在报文的最后部分包含一个有效载荷。对于 PUBLISH 来说有效载荷就是应用消息。

1.4 MQTT 控制报文

MQTT 的控制报文基本都是一一对应的,比如 CONNECT(客户端请求连接服务端)和 CONNECTACK(服务端给客户端回复连接确认)、PINGREQ(客户端向服务器发送心跳)和 PINGRESP(服务端返回客户端回复心跳确认)。

MQTT 控制报文较多,这里只讲解 CONNECT 和 PUBLISH 这两个最重要的,更多的控制报文可以自行通过 MQTT 协议规范学习。

1.4.1 CONNECT

MQTT Client 向 MQTT Broker 建立好 TCP 连接的时候,必须在规定时间内向 Broker 发送的 CONNECT 连接报文,否则连接将会被关闭。

CONNECT 固定报文头

Bit 7 6 5 4 3 2 1 0
字节 1 0 0 0 1 0 0 0 0
从第二个字节开始 剩余长度

CONNECT 可变报文头

CONNECT 报文的可变报头按下列次序包含四个字段:

  • 协议名(Protocol Name)
  • 协议级别(Protocol Level)
  • 连接标志(Connect Flags)
  • 保持连接(Keep Alive)

其中协议名固定为 MQTT,协议级别为 4。对于 3.1.1 版协议,协议级别字段的值是 4(0x04)。

连接标志共一个字节,8 位,分别代表:

Bit 7 6 5 4 3 2 1 0
username flag Password Flag Will Retain Will Qos Will flag Clean Session 保留位,默认为 0

保持连接共两个字节,单位为秒(s)。

有效负载 Payload

CONNECT 报文的有效载荷(Payload)包含一个或多个以长度为前缀的字段,可变报头中的标志决定是否包含这些字段。如果包含的话,必须按这个顺序出现:客户端标识符、遗嘱主题、遗嘱消息、用户名、密码。

CONNECT 总结

CONNECT 是整个 MQTT 消息通信的入口,包含着一些非常重要的消息。这种重要性不亚于一个 Web 系统必须要输入用户名密码登录。

清除会话 Clean Session

在连接标志字节的第 1 位设置。一个 MQTT 连接代表一个会话。为 1 是表示 true 清除会话;0 表示 false 持久会话。当客户端指定持久会话时,即时客户端断线,断线期间 Broker 会保留其他客户端向该客户端订阅的主题上发送的消息。当客户端重新上线后,会接收到离线期间的消息。

遗愿消息 Last Will

在连接标志字节的第 2、3、4、5 位设置,设置存在遗愿消息的时候,有效负载中还必须包含遗愿消息主题和消息体。

客户端连接服务器如果指定遗愿消息,那么该主题的遗愿消息会驻留在 MQTT Broker 中。当 MQTT 客户端异常下线时(客户端断开前未向服务器发送 DISCONNECT 消息),MQTT 消息服务器会发布遗愿消息。MQTT 主动通过 DISCONNECT 报文断开与服务器连接时,则不会发送遗愿消息。

保持时间 Keep Alive

保活时间指在客户端传输完成一个控制报文的时刻到发送下一个报文的时刻,两者之间允许空闲的最大时间间隔。如果没有任何其它的控制报文可以发送,客户端必须发送一个 PINGREQ 报文(MQTT Client 包会帮我们定时发送心跳报文),否则 MQTT Broker 会强制关闭和 Client 之间的连接。这种情况经常出现在嵌入式设备网络不稳定的时候。

客户端标识符 Client Identifier

MQTT Broker 使用客户端标识符(ClientId)来识别客户端的唯一性。连接服务器的每个客户端必须都要唯一的客户端标识符。当有重复的 clientId 的 MQTT Client 连接 MQTT Broker 的时候,将会出现先连接的 Client 连接被强制关闭,后连接的 Client 连接被成功建立,在加上 MQTT Client 库会自动重连的机制,就会出现相同 clientId 的客户端交替连接重连,这也是很多新手经常犯的错误。

Username 和 Password 也可以作为客户端的唯一标识存在,但是这些字段可以在连接标识字节的 6、7 位设置为非必须,地位没有 clientId 高,后面的物联网开发平台连接认证设计会详细讲到这点。

MQTT Broker 允许 1 到 23 位不定长的客户端标识符,但是这些客户端标识符只能包含这些字符:

abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890

连接返回码 Code

返回码相应 描述
0 0x00连接已接受 连接已被服务端接受
1 0x01 连接已拒绝,不支持的协议版本 服务端不支持客户端请求的 MQTT 协议级别
2 0x02 连接已拒绝,不合格的客户端标识符 客户端标识符是正确的 UTF-8 编码,但服务端不允许使用
3 0x03 连接已拒绝,服务端不可用 网络连接已建立,但 MQTT 服务不可用
4 0x04 连接已拒绝,无效的用户名或密码 用户名或密码的数据格式无效
5 0x05 连接已拒绝,未授权 客户端未被授权连接到此服务器
6-255 保留 保留

1.4.2 PUBLISH

PUBLISH 控制报文是指从客户端和服务端之间传输一个应用消息。

PUBLISH 固定报文头

Bit 7 6 5 4 3 2 1 0
字节 1 0 0 1 1 DUP QoS QoS RETAIN
从第二个字节开始 剩余长度

重发标志 DUP

如果 DUP 标志被设置为 0,表示这是客户端或服务端第一次请求发送这个 PUBLISH 报文。如果 DUP 标志被设置为 1,表示这可能是一个早前报文请求的重发。

服务质量等级 QoS

在后面内容会详细讲解。

保留标志 RETAIN

设置发布的消息是否为保留消息,如果为 true,那么消息会一直驻留在 Broker 中,新上线的 Client 如果订阅了该主题的消息,那么就会收到该保留消息。这个功能使用的场景也非常多。

保留消息越来越多,势必会对服务器造成资源消耗,清除不必要的保留消息有两种方式:

  • 向该主题发送一个空的消息
  • Broker 设置保留消息的超时时间

PUBLISH 可变头

可变报头按顺序包含主题名和报文标识符。

PUBLISH 有效负载

PUBLISH 有效负载就是要传输的内容。在物联网应用开发中,消息内容格式(JSON 是最为常见的)的选取和通信协议的制定是非常重要的一环。

PUBLISH 相应

PUBLISH 报文的接收者必须按照根据 PUBLISH 报文中的 QoS 等级发送响应,响应中的报文标识符也必须和 PUBLISH 发送的报文标识符一样。

服务质量等级 预期相应
QoS 0 无响应
QoS 1 PUBACK 报文
QoS 2 PUBREC 报文

1.5 消息服务质量 QoS

MQTT 协议中规定了消息服务质量,它保证了在不同的网络环境下消息传递的可靠性,QoS 的设计是 MQTT 协议里的重点。作为专为物联网场景设计的协议,MQTT 的运行场景不仅仅是 PC,而是更广泛的窄带宽网络和低功耗设备,如果能在协议层解决传输质量的问题,将为物联网应用的开发提供极大便利。

MQTT 发布消息不是端到端的,是客户端与服务器之间的。订阅者收到 MQTT 消息的 QoS 级别,最终取决于发布消息的 QoS 和主题订阅的 QoS,准确来说是取两者的最小值。

发布消息的 QoS 主题订阅的 QoS 接受消息的 QoS
0 0 0
0 1 0
0 2 0
1 0 0
1 1 1
1 2 1
2 0 0
2 1 1
2 2 2

QoS0:最多分发一次

当 QoS 为 0 时,消息的分发依赖于底层网络的能力。发布者只会发布一次消息,接收者不会应答消息,发布者也不会储存和重发消息。消息在这个等级下具有最高的传输效率,但可能送达一次也可能根本没送达。

这种情况下,如果开发人员想要保证消息的稳定送达,必须在业务上层设计一套消息重发机制。

QoS1:至少分发一次

当 QoS 为 1 时,可以保证消息至少送达一次。MQTT 通过简单的 ACK 机制来保证 QoS 1。发布者会发布消息,并等待接收者的 PUBACK 报文的应答,如果在规定的时间内没有收到 PUBACK 的应答,发布者会将消息的 DUP 置为 1 并重发消息。接收者接收到 QoS 为 1 的消息时应该回应 PUBACK 报文,接收者可能会多次接受同一个消息,无论 DUP 标志如何,接收者都会将收到的消息当作一个新的消息并发送 PUBACK 报文应答。

QoS2: 仅分发一次

当 QoS 为 2 时,发布者和订阅者通过两次会话来保证消息只被传递一次,这是最高等级的服务质量,消息丢失和重复都是不可接受的。使用这个服务质量等级会有额外的开销。

发布者发布 QoS 为 2 的消息之后,会将发布的消息储存起来并等待接收者回复 PUBREC 的消息,发送者收到 PUBREC 消息后,它就可以安全丢弃掉之前的发布消息,因为它已经知道接收者成功收到了消息。发布者会保存 PUBREC 消息并应答一个 PUBREL,等待接收者回复 PUBCOMP 消息,当发送者收到 PUBCOMP 消息之后会清空之前所保存的状态。

当接收者接收到一条 QoS 为 2 的 PUBLISH 消息时,他会处理此消息并返回一条 PUBREC 进行应答。当接收者收到 PUBREL 消息之后,它会丢弃掉所有已保存的状态,并回复 PUBCOMP。

无论在传输过程中何时出现丢包,发送端都负责重发上一条消息。不管发送端是 Publisher 还是 Broker,都是如此。因此,接收端也需要对每一条命令消息都进行应答。

如何选取 QoS

QoS 级别越高,流程越复杂,系统资源消耗越大。应用程序可以根据自己的网络场景和业务需求,选择合适的 QoS 级别,比如在同一个子网内部的服务间的消息交互往往选用 QoS 0;而通过互联网的实时消息通信往往选用 QoS 1;QoS 2 使用的场景相对少一些,适合一些支付请求之类的要求较高的场景。

本文统一采用消息服务质量 QoS 为 1。

1.6 MQTT 与传统 MQ 对比

传统的消息中间件,例如消息队列 MQ、消息队列 Kafka 等都是面向微服务大数据等领域,负责消息的存储和转发,消息的生产者和消费者都是服务端应用。这种设计很适合服务端技术栈固定、语言平台固定的场景。而移动互联网和 IoT 领域则有所不同,这类场景更侧重于多语言多平台的海量设备接入,消息的生产和消费过程的业务属性很突出,传统的消息中间件并不适合这些领域。

微消息队列 MQTT 在设计上是一个面向移动互联网和 IoT 领域的无状态网关,只关心海量移动端设备的接入、管理和消息传输,消息数据的存储则都会路由给后端存储产品。

适用场景
MQTT 面向移动端场景,移动端场景一般都具备海量设备,单设备数据较少的特点。因此,微消息队列 MQTT 适用于拥有大量在线客户端(很多企业设备端过万,甚至上百万),但每个客户端消息较少的场景。
消息队列 MQ 面向服务端的消息引擎,主要用于服务组件之间的解耦、异步通知、削峰填谷等,服务器规模较小(极少企业服务器规模过万),但需要大量的消息处理,吞吐量要求高。因此,消息队列 MQ 适用于服务端进行大批量的数据处理和分析的场景。

更多详细对比,读者可以参考阿里云物联网开发平台介绍:

https://help.aliyun.com/document_detail/94521.html

二、走进 EMQX

EMQX 官网:

https://www.emqx.io/

EMQX 是业界相对成熟的 MQTT Broker。这里不在去花大篇幅讲解 EMQX 的安装和配置,读者可以去官网学习。本专题的讲解和演示是在 CentOS7 下基于 EMQX 3.2 的版本进行,推荐读者选择在 Linux 环境下 RPM 或者 Docker 方式 EMQX 3.2 及以上版本。

EMQX 中有一些非常重要的概念和功能,本节会带大家一一解读这些概念和功能,起到一个抛砖引玉的作用,因为后面的节是基于这些功能做的封装,以此来实现我们想要的物联网开发平台的功能。

EMQX 服务端口占用情况

端口 端口占用功能
1883 MQTT 协议端口
8083 MQTT/WebSocket 端口
8084 MQTT/SSL/WebSocket 端口
8883 MQTT/SSL 端口
8080 HTTP API 端口
18083 Dashboard 管理控制台端口 (管理控制台 admin public)
6396 集群时,各个TCP用于节点间建立连接与通信
4369 集群时EMQX端口映射服务使用
5359 集群节点数据通道

安装完成之后,通过 ip:18083 可以访问 EMQX 管理界面。

2.1 认证访问控制

众所周知,一个 Web 系统有登录认证和权限控制。只有用户输入正确的用户名和密码才能进入到系统内部,只有用户有特定的权限才能访问某些资源。这一点和 MQTT Client 的认证和访问控制类似:

  • 连接认证:EMQX 会在客户端发送 CONNECT 报文连接的的时候,校验每个连接上的客户端时候有接入系统的权限,若没有则会强制断开连接
  • 访问控制:EMQX 会在设备发送 PUBLISH/SUBSCRIBE 报文发布/订阅的时候,校验客户端是否具有发布/订阅主题的权限,以允许/拒绝相应操作。

生产环境下的连接认证和访问控制都可以借助于 EMQX 的一系列认证/访问控制插件来实现。认证/访问控制插件较为常用的有 JWT、Redis、MySQL、MongDB 和 PostgreSQL。开发人员可以根据自己的技术栈选择一个或者多个插件。

  • 连接认证:通过加载认证插件可开启的多个认证模块组成认证链。在一条认证链上连接的客户端会逐次地进行认证。如果某一个插件认证通过就允许客户端连接。反之继续进到下一个认证插件中进行认证,直到认证完所有的已经加载的认证插件。
  • 访问控制:MQTT 客户端发起订阅/发布请求时,EMQX 消息服务器的访问控制模块会逐条匹配 ACL 规则,直到匹配成功为止。

本文选用 MySQL 认证/访问控制插件,后面的功能都是基于 MySQL 插件实现,MySQL 插件的具体配置在第三章会着重讲解。

2.2 插件系统

除了认证/访问控制插件外,EMQX 还提供了很多其他功能性的插件。比较重要的有 WebHook 插件。还有 EMQX 企业版才有的 Kafka 插件、RabbitMQ 插件以及消息存储插件。

WebHook 插件

WebHook 插件提供了处理设备上行数据的另外一种解决方案。通过加载 WebHook 插件,EMQX 会将客户端的消息和事件通过 HTTP 调用推送给远程服务器。

KafKa、RabbitMQ 等插件

KafKa、RabbitMQ 等插件允许 EMQX 将消息推送到第三方的消息队列中,从而达到消息解耦的目的。但是这种功能只有 EMQX 企业版才拥有,网上也有一些教程讲解如何自己编写插件来实现消息桥接到消息队列。开发人员也可以采用通过客户端共享订阅的方式收集其他客户端的消息,在发送消息队列的方式。

2.3 共享订阅

共享订阅相当于订阅端的负载均衡功能。一般的发布订阅模式下只有一个发布者一个订阅者。这种情况下,如果订阅节点发生故障,就会导致发布者的消息丢失但是,如果只是单纯的增加订阅节点数量,就会产生大量的重复消息,非常浪费性能,订阅节点还需要消息去重处理。共享订阅能解决这一问题。

如果多个客户端共享订阅某一个主题消息,当往该主题上发布消息的时候,共享订阅者们会根据某种策略负载均衡的消费消息。

共享策略

MQTT 协议并没有规定 Server 应当使用什么负载均衡策略。但是 EMQ X 提供了 random、round_robin、sticky、hash 四种策略供用户自行选择。

  • random:在所有共享订阅会话中随机选择一个发送消息
  • round_robin:按照订阅顺序轮流选择
  • sticky:使用 random 策略随机选择一个订阅会话,持续使用至该会话取消订阅或断开连接再重复这一流程
  • hash:对发送者的 ClientId 进行 hash 操作,根据 hash 结果选择订阅会话

共享订阅方式

共享订阅支持两种使用方式:

订阅前缀 使用实例
$queue/{filter} $queue/dev2App/+
$share//{filter} $share/1/app2dev/+

$share//{filter} 更为常用。$share//{filter} 这几个的字段的含义分别是:

  • $share 前缀表明这将是一个共享订阅

  • {group} 是一个不包含 /+ 以及 # 的字符串。订阅会话通过使用相同的 {group} 表示共享同一个订阅,匹配该订阅的消息每次只会发布给其中一个会话

  • {filter} 即非共享订阅中的主题过滤器

2.4 系统主题

EMQX 消息服务器周期性地发布自身状态、消息统计、客户端上下线事件到以 $SYS/ 开头系统主题。

SYS 主题路径以 SYS/brokers/{node}/ 开头。其中 {node} 是指产生该事件消息所在的节点名称,node 可以在 emqx.conf 中进行修改。

这其中客户端上下线系统主题最为常用。服务端指定一个或者多个客户端共享订阅系统上下线主题,可以收到设备上下线事件消息,从而更新数据库中设备的实时性网络状态。

主题 说明
SYS/brokers/SYS/brokers/{node}/clients/${clientid}/connected 上线事件
SYS/brokers/SYS/brokers/{node}/clients/${clientid}/disconnected 下线事件

2.5 管理监控 API

EMQX 自身提供很多开放的 API,监听端口是 8080,用户可以在 emqx.conf 中修改 API 的监听端口。

用户可以通过 REST API 查询 MQTT 客户端连接(Connections)、会话(Sessions)、订阅(Subscriptions)和路由(Routes)信息,还可以检索和监控服务器的性能指标和统计数据。

调用 EMQX 提供的 API 必须携带 HTTP Basic 格式的认证(Authentication)信息。因此,需要使用 Dashboard 的应用菜单栏中来创建的 AppId 和 AppSecret 进行认证。

在众多 API 中,最为关键的就是 HTTP 发布接口,应用服务器或 Web 服务器可通过该接口发布 MQTT 消息。共享订阅、WebHook 插件和消息桥接到队列插件可以帮助我们处理设备的上行数据,那么 HTTP API 接口就可以实现远程控制设备这一重要功能。

2.6 EMQX 集群

和大多数消息中间件一样,EMQX 支持集群功能。集群原理可以简述成一下几个方面:

  1. 为了让 MQTT 客户端均衡的连接到 EMQX 集群中的节点,可以采用 Nginx 或者 HAProxy 等反向代理服务。
  2. MQTT 客户端订阅主题时,所在节点订阅成功后广播通知其他节点:某个主题被本节点订阅。同一集群中的所有节点都会复制一份主题 -> 节点映射的路由表。
  3. MQTT 客户端发布消息时,所在节点会根据消息主题,检索订阅并路由消息到相关节点。

具体集群的详细配置会在后面内容讲到。

三、 MQTT Client 库实践

安装并启动 EMQX 服务,不修改任何配置,能通过 ip:18083 访问到 EMQX 管理后台。MQTT Client 采用 Java 语言的,擅长其他语言的可以采用相应的语言的 MQTT Client 库进行实战。

3.1 建立连接

建立 MQTT Client 向 MQTT Broker 的连接包括以下几个重要参数:

  • 指定 MQTT Broker 基本信息接入地址与端口
  • 指定传输类型是 TCP 还是 MQTT over WebSocket
  • 如果启用 TLS 需要选择协议版本并携带相应的的证书
  • Broker 启用了认证鉴权则客户端需要携带相应的 MQTT Username Password 信息,默认 EMQX 采用匿名认证,所有客户端都能访问连接进来
  • 配置客户端参数如 keepalive 时长、clean session 回话保留标志位、MQTT 协议版本、遗嘱消息(LWT)等
 String host = "tcp://192.168.38.156";
        String username = "iot";
        String password = "123456";
        String client_id = "iot";
        String willTopic = "will";
        String willMessage = "will message";
        try {
            // host为主机名,client_id一般以客户端唯一标识符表示,MemoryPersistence设置clientid的保存形式,默认为以内存保存
            MqttClient client = new MqttClient(host, client_id);
            // MQTT的连接设置
            MqttConnectOptions options = new MqttConnectOptions();
            // 设置是否清空session,默认为true,这里如果设置为false表示服务器会保留客户端的连接记录,这里设置为true表示每次连接到服务器都以新的身份连接
            options.setCleanSession(true);
            // 设置连接的用户名,可以不设置
            options.setUserName(username);
            // 设置连接的密码
            options.setPassword(password.toCharArray());
            // 设置超时时间 单位为秒,emqx连接超时时间为一分钟
            options.setConnectionTimeout(10);
            // 设置会话心跳时间 单位为秒 服务器会每隔1.5*T周期内判断设备的在线状态
            options.setKeepAliveInterval(60);
            //设置遗愿消息
            options.setWill(willTopic,willMessage.getBytes(),0,false);
            client.connect(options);
            System.out.println("连接成功");
        }catch (MqttException e){
            System.out.println("连接异常");
        }

3.2 订阅主题

连接建立后可以订阅主题消息,能接收到任何客户端向该主题消息的消息:

  • 指定主题过滤器 Topic,订阅的时候支持主题通配符 +# 的使用
  • 指定 QoS,根据客户端库和 Broker 的实现可选 Qos 0 1 2
  • 订阅主题可能因为网络问题、Broker 端 ACL 规则限制而失败,默认 EMQX 为所有客户端都可以发布和订阅主题消息
        String host = "tcp://192.168.38.156";
        String username = "iot";
        String password = "123456";
        String client_id = "iot";
        String sub_topic = "iot_test";
        try {
            // host为主机名,client_id一般以客户端唯一标识符表示,MemoryPersistence设置clientid的保存形式,默认为以内存保存
            MqttClient client = new MqttClient(host, client_id);
            // MQTT的连接设置
            MqttConnectOptions options = new MqttConnectOptions();
            // 设置是否清空session,默认为true,这里如果设置为false表示服务器会保留客户端的连接记录,这里设置为true表示每次连接到服务器都以新的身份连接
            options.setCleanSession(true);
            // 设置连接的用户名,可以不设置
            options.setUserName(username);
            // 设置连接的密码
            options.setPassword(password.toCharArray());
            // 设置超时时间 单位为秒,emqx连接超时时间为一分钟
            options.setConnectionTimeout(10);
            // 设置会话心跳时间 单位为秒 服务器会每隔1.5*T周期内判断设备的在线状态
            options.setKeepAliveInterval(60);

            client.connect(options);
            System.out.println("连接成功");
            //设置消息接受回调函数
            client.setCallback(new MqttCallback() {
                @Override
                public void connectionLost(Throwable cause) {
                }
                @Override
                public void messageArrived(String topic, MqttMessage message) throws Exception {
                    System.out.println("接收到其他客户端向该客户端订阅的主题上发送消息");
                }
                @Override
                public void deliveryComplete(IMqttDeliveryToken token) {
                    System.out.println("deliveryComplete");
                }
            });
            client.subscribe(sub_topic);
        }catch (MqttException e){
            System.out.println("连接异常");
        }

3.3 发布消息

向指定主题发布消息,包括以下重要参数:

  • 指定目标主题,注意该主题不能包含通配符 +#,若主题中包含通配符可能会导致消息发布失败、客户端断开等情况
  • 指定消息 QoS 级别
  • 指定消息体内容,消息体内容大小不能超出 Broker 设置最大消息大小MQTT 协议规定最大约 256M,EMQX 默认为 1M,可以修改
  • 指定消息 Retain 保留消息标志位
 String host = "tcp://192.168.38.156";
        String username = "iot";
        String password = "123456";
        String client_id = "pub_iot";
        String pub_topic = "iot_test";
        String pub_message = "123456";
        try {
            // host为主机名,client_id一般以客户端唯一标识符表示,MemoryPersistence设置clientid的保存形式,默认为以内存保存
            MqttClient client = new MqttClient(host, client_id);
            // MQTT的连接设置
            MqttConnectOptions options = new MqttConnectOptions();
            // 设置是否清空session,默认为true,这里如果设置为false表示服务器会保留客户端的连接记录,这里设置为true表示每次连接到服务器都以新的身份连接
            options.setCleanSession(true);
            // 设置连接的用户名,可以不设置
            options.setUserName(username);
            // 设置连接的密码
            options.setPassword(password.toCharArray());
            // 设置超时时间 单位为秒,emqx连接超时时间为一分钟
            options.setConnectionTimeout(10);
            // 设置会话心跳时间 单位为秒 服务器会每隔1.5*T周期内判断设备的在线状态
            options.setKeepAliveInterval(60);
            //设置遗愿消息
            client.connect(options);
            client.publish(pub_topic,pub_message.getBytes(),1,false);
            System.out.println("连接成功");
        }catch (MqttException e){
            System.out.println("连接异常");
        }

四、 MySQL 认证和访问控制

4.1 MySQL 认证

修改 emqx.conf,关闭匿名认证:

allow_anonymous = false

修改 emqx_auth_mysql.conf 配置信息:

配置项 示例
auth.mysql.server 192.168.38.1:3306
auth.mysql.pool 8
auth.mysql.username root
auth.mysql.password root
auth.mysql.database iot
auth.mysql.auth_query select password from mqtt_user where client_id = '%c'and is_enable=true and is_delete=false
auth.mysql.password_hash plain
auth.mysql.super_query select is_superuser from mqtt_user where client_id = '%c'
  • auth.mysql.server:远程 MySQL 服务的地址和端口号
  • auth.mysql.username:远程 MySQL 服务登陆的用户名
  • auth.mysql.password:远程 MySQL 服务登录的密码,此处为明文,因为 password_hash 设置为了 plain。为了安全 password_hash 可取值 md5、sha、sha256 和 bcrypt,相对应的 password 也要更改成相应的加密过后的值
  • auth.mysql.database:数据库名
  • auth.mysql.auth_query:关键性的认证 SQL 语句,会将 SQL 语句从数据库中查出的 password 和 MQTT Client 传入的 password 比较,若相等且都不为空则认证通过,反之拒绝认证。is_enable=true 表示设备启用,is_delete 表示设备未被删除,这两点在第六节设备禁用和删除功能时会详细讲解。
  • auth.mysql.super_query:是否为管理员用户。不重要。

% c 代表 CONNECT 连接报文中传入的 client_id,% u 代表 username。

本文采用 client_id 和 password 作为设备在 EMQX 中的唯一标识。

mqtt_user 建表语句:

CREATE TABLE `mqtt_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(100) DEFAULT NULL,
  `password` varchar(100) DEFAULT NULL,
  `client_id` varchar(100) DEFAULT NULL COMMENT '设备唯一id',
  `is_superuser` int(1) DEFAULT '0' COMMENT '是否是超级用于',
  `generate_date` datetime DEFAULT NULL COMMENT '生成时间',
  `is_delete` bit(1) DEFAULT b'0' COMMENT '是否删除',
  `is_enable` bit(1) DEFAULT b'1' COMMENT '是否启用',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;

测试 MySQL 认证插件:

查看源码中 DeviceConnect 类。

控制台输出:

09:43:48.488 [main] ERROR com.ijuvenile.DeviceConnectTest - connect failure
09:43:48.496 [main] ERROR com.ijuvenile.DeviceConnectTest - 无权连接

mqtt_user 表中插件一条客户端记录:

INSERT INTO `mqtt_user` VALUES ('8', 'iot_connect_test', '123456', 'iot_connect_test', '0', '2019-11-25 23:20:24', FALSE, TRUE);

控制台输出:

09:50:11.699 [main] INFO com.ijuvenile.DeviceConnectTest - connect successful

4.2 主题设计

把主题规划放在此处其实也是做一个承上启下的目的。客户端连接成功之后,就会通过发布/订阅相关主题和云端进行数据互通,如何控制客户端发布/订阅主题的行为就是 EMQX ACL 需要解决的事情,也就是下一节 MySQL 访问控制需要解决的问题。所以本节内容着重讲解我们物联网开发平台的主题设计。

常见的设计思路有两种:

  1. 在主题上设计。例如主题设计为:{productKey}/{clientid}/dev2app/{messageId}/{commandType}/{version}。主题上区分设备指令的类型、产品 key、消息 id、客户端 clientid 等等,业务系统拿到主题在通过字符串截取获取相应的字段。
  2. 消息体中设计。在消息体中设计协议区分设备指令类型、消息 id 等等。

本文采用第二种。

设备用于发布数据的主题名格式为:

{productKey}/{clientid}/dev2app

设备用于订阅的接受云端下发指令的主题名格式为:

{productKey}/{clientid}/app2dev

不管是设备上报还是云端下发,消息体统一采用 JSON 格式:

{
    "ty": "ota",
    "id": "12345",
    "ver": "V1.0",
    "ts": "1679433146",
    "data": "payload"
}
  • ty:消息类型。OTA 远程升级、log 设备日志、RRPC 同步调用等等。
  • id:消息唯一标识,QoS 为 1 情况下用于消息去重。
  • ver:协议版本。
  • ts:Unix 时间戳,消息生成时间。
  • data:业务消息体。

4.3 MySQL 访问控制

EMQX ACL 访问控制可以分成四部分:

  1. 允许/拒绝
  2. MQTT Client 连接时的 ip/MQTT Client 连接时的 username/MQTT Client 连接时的 client_id
  3. 发布/订阅/发布和订阅
  4. 主题 Topic

MySQL 访问控制也应该基于此来设计。当客户端登录连接上 EMQX,去 MySQL 中查找该客户端全部满足的 ACL,然后逐条匹配,任何一条匹配通过则允许访问行为,全部都没匹配成功则拒绝访问行为。

设置所有 ACL 规则不能匹配时拒绝访问:

acl_nomatch = deny

EMQX 有一个默认的 ACL 规则,EMQX 启动时会加载到内存。默认的 ACL 规则会影响系统访问控制设计,所以全部注释掉。

%%{allow, {user, "dashboard"}, subscribe, ["$SYS/#"]}.
%%{allow, {ipaddr, "127.0.0.1"}, pubsub, ["$SYS/#", "#"]}.
%%{deny, all, subscribe, ["$SYS/#", {eq, "#"}]}.
%%{allow, all}.

修改 emqxauthmysql.conf 配置信息:

auth.mysql.acl_query = select allow, ipaddr, username, clientid, access, topic from mqtt_acl where ipaddr = '%a' or username = '%u' or  clientid = '%c' 
**%a: ipaddr、%u: username、%c: clientid、$all任何

mqtt_acl 建表语句:

CREATE TABLE `mqtt_acl` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `allow` int(1) DEFAULT NULL COMMENT '0: deny, 1: allow',
  `ipaddr` varchar(60) DEFAULT NULL COMMENT 'IpAddress',
  `username` varchar(100) DEFAULT NULL COMMENT 'Username',
  `clientid` varchar(100) DEFAULT NULL COMMENT 'ClientId',
  `access` int(2) NOT NULL COMMENT '1: subscribe, 2: publish, 3: pubsub',
  `topic` varchar(100) NOT NULL DEFAULT '' COMMENT 'Topic Filter',
  `generateDate` datetime DEFAULT NULL,
  `is_delete` bit(1) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8;

插入 clientis=iot_connect_test 客户端的访问控制权限,productKey 这里设置成 111,后面 4.4 会详细讲解设备动态注册策略。

INSERT INTO `mqtt_acl` VALUES ('1', '1', null, null, 'iot_connect_test', '1', '111/iot_connect_test/app2dev', '2019-01-24 10:57:11', '\0');
INSERT INTO `mqtt_acl` VALUES ('2', '1', null, null, 'iot_connect_test', '2', '111/iot_connect_test/dev2app', '2019-01-24 10:57:12', '\0');

测试 MySQL publish 权限控制:

查看源码中 DevicePublishAcl 类。

测试结果发现,修改了主题 Topic 为正确的 111/iot_connect_test/dev2app,控制台返回的结果始终为:连接成功,发送完成。

20:21:06.606 [main] INFO com.ijuvenile.DeviceConnectTest - connect successful
20:21:08.871 [MQTT Call: iot_connect_test] INFO com.ijuvenile.DeviceConnectTest - deliveryComplete->true

难道客户端发布权限设置不生效?于是用 Wireshark 抓包软件分析 MQTT 数据包,发现两个主题的的 PUBLISH 包都有 PUBACK 回复,以此我推断 PUBLISH 权限控制不生效。

好奇之下,我用 MQTT.fx 客户端模拟订阅 111/iotconnecttest/dev2app、1111/iotconnecttest/dev2app 两个主题消息,然后更改主题运行测试代码,发现如下信息:拥有发布主题权限的客户端的消息可以通过 EMQX 流转到订阅的客户端,没有发布主题权限的客户端的消息直接丢弃。发布权限控制其实生效了。

带着问题我询问了 EMQX 官方人员,我曾一度以为这是 EMQX 的 Bug,却得到这样子的回复:发布主题权限只是控制是否转发主题消息,不控制消息是否可达 EMQX,是否能接受 PUBACK 回复。在 MQTT5 中 PUBACK 回复会指定 code 来区分。虽然问题得到了解决,但个人感觉这样子挺浪费资源的,可能和 EMQX 的设计有关系吧。

测试 MySQL subscribe 权限控制:

查看源码 DeviceSubscribeAcl 类。

没有权限 subscribe 直接抛出异常:

20:44:51.642 [main] INFO com.ijuvenile.DeviceConnectTest - connect successful
20:44:51.654 [main] ERROR com.ijuvenile.DeviceConnectTest - MqttException

五、 处理设备上行数据

在物联网项目开发中,被问的最多的一个问题就是:

**如何接受设备的上行数据,并且保证接受机制的稳定可靠?

EMQX 提供了很多种解决方案,开源版本可以通过共享订阅或者 WebHook 机制来处理,需要付费的企业版本则可以通过 RabbitMQ、KafKa、RocketMQ 插件来将消息直接流转到队列中去,再由队列消费者处理消息。本节重点讲解共享订阅和 WebHook 机制这两种处理方案。

5.1 共享订阅

5.1.1 共享订阅主题设计

消息主题

一个或者多个客户端通过共享订阅主题的方式,实现基于共享策略接受其他设备发往该主题上的消息。设备上报数据主题为:

{productKey}/{clientid}/dev2app

所以共享订阅主题设计为:

$queue/+/+/dev2app或者$share/<group>/+/+/dev2app

上下线主题

EMQX 提供了系统主题感知设备上下线事件,共享订阅设备上下线事件消息主题为:

$queue/$SYS/brokers/+/clients/+/connected
$queue/$SYS/brokers/+/clients/+/disconnected

5.1.2 共享订阅客户端权限控制

生产环境下,为了防止单点故障,往往需要多个共享订阅客户端来接受设备上报消息。共享客户端也需要经过 MySQL 插件的认证和访问控制。采用 clientid=share_client1,share_client2、password=123456 的两个共享订阅客户端进行演示。

mqtt_user 表中插件共享客户端记录:

INSERT INTO `mqtt_user` VALUES ('3', 'share_client1', '123456', 'share_client1', '0', '2019-11-28 11:15:41', FALSE, TRUE);
INSERT INTO `mqtt_user` VALUES ('4', 'share_client2', '123456', 'share_client2', '0', '2019-11-28 12:07:30', FALSE, TRUE);

mqtt_acl 表中插入共享客户端访问控制权限:

INSERT INTO `mqtt_acl` VALUES ('3', '1', null, null, 'share_client1', '1', '$SYS/#', '2019-01-24 10:57:12', '\0');
INSERT INTO `mqtt_acl` VALUES ('4', '1', null, null, 'share_client1', '1', '+/+/dev2app', '2019-01-24 10:57:13', '\0');
INSERT INTO `mqtt_acl` VALUES ('6', '1', null, null, 'share_client2', '1', '$SYS/#', '2019-11-28 12:13:02', '\0');
INSERT INTO `mqtt_acl` VALUES ('7', '1', null, null, 'share_client1', '1', '+/+/dev2app', '2019-11-28 12:13:24', '\0');

5.1.3 消息流转

客户端接收到消息可以直接处理,可以选择将消息转发到 MQ 队列中,在由消费者处理消息,从而达到消息接受和消息处理解耦的目的。通过 MQ 解耦这部分读者可以自行扩展。

5.1.4 实现

具体实现看源码 share-subscribe 模块。

5.2 WebHook 机制

WebHook 机制更为简单,EMQX 收到客户端事件通过 WebHook 推送到远端服务器。

修改 emqx/web/hook.conf 配置文件:

这里只开启上下线和发布消息的 WebHook 机制,读者也可以开启其他的事件 hook。

开启 WebHook 插件:

emqx_ctl plugins load emqx_web_hook

编写云端代码:

@RestController
public class MessageWebHook {
    private Logger logger = LoggerFactory.getLogger(MessageWebHook.class);
    @PostMapping("/webhook")
    public void messageHandler(@RequestBody String message){
        logger.info(message);
    JSONObject webhook = JSONObject.parseObject(message);
        String action = webhook.getString("action");
        if("client_connected".equals(action)){
            //设备上线,device表中状态改成上线
            logger.info("设备上线");
        }else if("client_disconnected".equals(action)){
            //设备离线,device表中状态改成离线
            logger.info("设备下线");
        }else if("message_publish".equals(action)){
            //设备上报消息
            logger.info("设备上报消息");
        }
    }
}

测试接受客户端上线消息:

{
    "action": "client_connected",
    "client_id": "iot_mqttfx",
    "username": "iot_mqttfx",
    "keepalive": 60,
    "ipaddress": "192.168.38.1",
    "proto_ver": 4,
    "connected_at": 1574929409,
    "conn_ack": 0
}

测试接受客户端发布主题消息:

{
    "action": "message_publish",
    "from_client_id": "iot_mqttfx",
    "from_username": "iot_mqttfx",
    "topic": "111/iot_mqttfx/dev2app",
    "qos": 1,
    "retain": false,
    "payload": "111",
    "ts": 1574929411
}

测试客户端下线消息:

{
    "action": "client_disconnected",
    "client_id": "iot_mqttfx",
    "username": "iot_mqttfx",
    "reason": "normal"
}

reason 为 normal,表示设备通过发布 DISCONNECT 报文主动掉线,其他类型表示 Broker 主动断开与客户端的连接,这种情况的原因是因为设备真实网络状态差。

5.3 消息去重

因为服务端订阅的 QoS 为 1,意味着消息会有重复接受的可能性。无论是共享订阅还是 WebHook 方式接受设备上行数据都应该设计消息去重机制,本文消息的去重依赖于协议中的消息 id。

设备上报的消息中有 messageId,接收到消息首先判断 Redis 中是否存在该 messageId 的 key 值,如果存在则说明这是一条重复的消息,不需要处理。不存在则处理消息,并往 Redis 中插入该 messageId,并设置 10 个小时过期时间。

实际情况中,也有一种业务需求是消息延时收到,比如 10 分钟就不处理该消息,这种情况下可以根据消息 payload 中的 ts 和消息接收到的时间做一个对比来判断该指令消息是否需要处理。

六、下行数据处理方案

常用的下行数据处理方案有两种:

  1. 基于 EMQX 方案。EMQX 提供了一个开放的 REST API,开发人员只需要调用就可以。
  2. 基于 MQTT Client 方案。设备订阅 {productKey}/{clientid}/app2dev 主题,只需要指定一个客户端往该主题发送主题消息就行了。

本文采用第一种方案,也是最常用的方案。

发布消息 API:

POST http(s)://host:8080/api/v3/mqtt/publish

请求参数消息体:

{
  "topic": "test_topic",
  "payload": "hello",
  "qos": 1,
  "retain": false,
  "client_id": "mqttjs_ab9069449e"
}

发布接口采用 basic 认证,需要在管理后端创建应用,拿到应用的 appId 和 appSecret 信息:

客户端订阅主题消息,测试下发控制指令给设备。

具体实现查看源码 DeviceControlService 类。

运行结果,code 为 0 表示发布成功。

同理,下行数据消息服务质量为 1 意味着设备端可能会收到多次相同 messageId 的消息,也需要做消息去重处理,处理方式和流程同设备上行消息去重类似,设备端通过缓存 messageId 达到去重效果。

七、功能规划和实现

7.1 EMQX 集群

准备两台 EMQX 节点,节点分别位于 IP 为 192.168.38.153 和 192.168.38.154 的两台主机,两个 EMQX 节点都开启了插件认证。另外准备了一台 IP 为 192.168.38.138 的主机安装 Nginx 服务。

EMQX 集群需要占用的端口:

端口号 功能
6396 集群时,各个 TCP 用于节点间建立连接与通信
4369 集群时 EMQX 端口映射服务使用
5359 集群节点数据通道

修改节点名称

node.name = emqx@192.168.38.153
node.name = emqx@192.168.38.154

EMQX 集群策略:

策略 说明
manual 手动命令创建集群
static 静态节点列表自动集群
mcast UDP 组播方式自动集群
dns DNS A 记录自动集群
etcd 通过 etcd 自动集群
k8s Kubernetes 服务自动集群

手动命令创建集群

emqx_ctl cluster join emqx@192.168.38.153

集群创建完成:

集群创建完成

修改 Nginx 配置,配置每个节点权重为 1,实现客户端连接负载均衡的功能。集群中的任何一个 EMQX 服务宕机,与该节点的连接将断开,如果客户端做了重连机制,那么客户端重连 的时候将通过 Nginx 连接上集群中其他的可以的 EMQX 服务,保证整个系统的高可用。

Nginx 是性能非常好的 HTTP 反向代理服务器,在 TCP 负载上性能稍微差一点。熟悉 HAProxy 的读者也可以使用 HAProxy。

stream {
        server{
                listen      1883 ;
                proxy_pass backend ;
     }
        upstream backend {
                server 192.168.38.153:1883 weight=1 max_fails=2 fail_timeout=30s;
                server 192.168.38.154:1883 weight=1 max_fails=2 fail_timeout=30s;
        }
}

运行多个测试连接 Nginx 负载服务的 1883 端口,发现设备随机的连接到 EMQX 集群中的各个节点,集群搭建成功。

7.2 设备生命周期管理

7.2.1 设备动态注册

在现有设计中,设备连接上 EMQX 需要 productKey、clientid 和 password 等参数,前面内容使用的 productKey 是手动指定的 111,实际情况这种方式肯定是不允许的。本节内容将详细说明设备如何通过动态注册从云端获取 clientid。

设备动态注册流程:

  1. 创建产品(不同产品区分不同类型设备),获取 productKey 和 productSecret 作为后续设备从云端请求 clientid 和连接 EMQX 的参数。
  2. 设备携带 ProductKey、ProductSecret 和设备 mac 信息,通过 HTTP 请求去云端拿取 client_id 并持久化到设备存储中。后续设备运行如果存在 clientid 就不在从云端获取。
  3. 云端新增 mqtt_user 和 mqtt_acl 记录,并在 device 表中新增一条设备记录。
  4. 设备拿到 client_id 请求连接 EMQX,并订阅 {productKey}/{clientid}/app2dev 主题。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-N8IPvES2-1582348185934)(https://images.gitbook.cn/dfafaab0-18cf-11ea-9808-c37d00298725)]

7.2.2 设备上下线状态管理

前面我们就提到过共享订阅和 WebHook 接受设备上报数据。云端通过共享订阅或者 WebHook 感知设备上下线之后,可以存储设备的上下线记录到数据库中,并更新数据库中 device 表中的网络状态。

实际设备的离线和云端收到设备离线的消息实际上是有一段时间间隔的。设备在连接 EMQX 的时候会指定 keepalive,EMQX 默认配置是在 1.5 个 keepalive 周期内没有收到设备的心跳数据和其他的报文数据,会认为设备离线,从而云端通过共享订阅和 WebHook 能接收到该设备离线事件消息。

7.2.3 设备删除与禁用

设备上线后,默认是启用状态。也有发布 {productKey}/{clientid}/dev2app 和订阅 {productKey}/{clientid}/app2dev 的主题权限。

设备禁用包括以下几个步骤:

  1. 强制关闭当前设备和 Broker 连接。EMQX 监控管理 API 提供 Broker 强制关闭和 Client 的 MQTT 长连接。
  2. mqtt_user 表中 is_enable 设置为 false,不允许该设备后续连接
  3. 修改 device 表中设备启用状态

设备删除包括以下几个步骤:

  1. 强制关闭当前设备和 Broker 连接
  2. mqtt_user 表中 is_delete 设置为 true,不允许该设备后续连接
  3. 修改 device 表中设备删除状态

7.3 RRPC

MQTT 协议是基于 PUB/SUB 的异步通信模式,云端下发控制指令给设备,设备收到指令回复给云端是异步完成的。

RRPC 是一种伪同步的调用机制。云端调用 EMQX API 下发命令给设备,然后阻塞线程,隔一段时间从 Redis 中查看是否设备有 RRPC 返回指令,有就获取到返回。另一方面设备端收到控制指令,即时给云端反馈指令执行结果。

RRPC 包括以下几个步骤:

  1. 调用 EMQX API 下发命令给设备,2 分钟内每隔 100ms 从 Redis 中获取 RRPC 命令的返回结果
  2. 设备执行完指令后,将指令执行结果发布到 EMQX
  3. 云端收到指令,将结果存储到 Redis 中
  4. 步骤 1 获取到执行结果,完成此次请求,否则超过一定时间请求失败。

下发控制命令协议:

{
    "ty": "rrpc",
    "id": "12345",
    "ver": "V1.0",
    "ts": "1679433146",
    "data": "{\"rrpcid\":\"h72ndh\",\"data\":\"123456\"}"
}

设备上报响应数据命令协议,rrpcid 值为下发控制命令的 rrpcid:

{
    "ty": "rrpc_rsp",
    "id": "54321",
    "ver": "V1.0",
    "ts": "1679433146",
    "data": "{\"rrpcid\":\"h72ndh\",\"data\":\"654321\"}"
}

7.4 设备 OTA 管理

在物联网应用中,设备往往都是通过 OTA 技术进行软件升级。为了一台一台通过烧录程序升级设备固件非常麻烦和耗时。(这里只讲实现步骤和方向,笔者可以结合项目的具体业务实现)

OTA 功能包括以下几个步骤:

  1. 上传固件至云服务器。
  2. 设备在线时,云端下发 OTA 控制指令给设备,告知设备需要升级的版本和固件下载的地址
  3. 设备收到指令解析到固件下载的地址,去远端服务器下载固件升级。

7.5 设备日志管理

生产环境下,为了方便调试和查找问题,需要记录并查看设备的日志。设备的日志记录在设备端存储磁盘中。为了方便查看设备日志就需要设备主动将磁盘中的日志文件上传到远程服务器上。(这里只讲实现步骤和方向,笔者可以结合项目的具体业务实现)

设备日志功能包括以下几个步骤:

  1. 设备在线时,云端下发 log 日志控制指令给设备
  2. 设备收到指令,将一定时间段内的设备日志通过 MQTT 或者 HTTP 上传到远程服务器。
  3. 远程服务器将设备上传的日志进行存储和归档,方便开发人员进行查看。

八、总结

到此,本文的内容已经全部讲解完毕。就像导读中说的一样,本文也是对自己上一阶段的学习的一个总结。学习之路永无止境。本文也只是对读者起到一个抛砖引玉的作用,希望读者保持持续学习的心态,在物联网的道路上继续前行。

本人水平也有限,文中有不正确的地方,读者可以加我个人微信共同交流。后期将聚力与一个开源项目,实现物联网系统中一整套后端管理系统以及设备与云端之间的管道服务。

学习完本文,这里提出几个可以继续深入学习的方向。

CoAP 协议

CoAP 是另外一种物联网协议,建立在 UDP 协议之上。它不需要设备保持长连接状态,非常适用于只上报设备数据,不需要控制设备的场景。

虚拟设备调试

虚拟设备区分于真实的设备,在项目初期阶段,虚拟设备有助于产品的调试。虚拟设备一般在网页上操作,这就要用到 WebSocket 技术。EMQX 支持 WebSocket 连接,准确来说是 MQTT over WebSocket。虚拟设备也需要经过 EMQX 认证和访问控制,读者可以研究一下 JWT 插件。JWT 和 MySQL 插件结合为虚拟调试设备和真实设备提供认证和访问控制功能。

Netty

本文是基于不需要区分产品的基础上讲解的,换句话讲默认认为产品和设备都是同一个公司的或者个人的。像阿里的 IOT Hub,每个人都可以在上面创建产品设备,自己设备产生的消息不会流转到其他用户那里,就可以借助 Netty Server 和 Client 模型来实现消息的流转。

笔者个人微信号:zwqsz666,笔者运营的微信公众号:秃头让我们变强。期待与大家的再次相遇。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!