文章目录
通过之前的介绍已经可以将NodeMCU通过WIFI接入阿里云或腾讯云IOT平台,本篇将分上下两部分介绍通过本地建立MQTT服务器,将NodeMCU接入本地服务器。
目前常见的MQTT broker有mosquitto、EMQ、RabbitMQ等,支持的功能完善,安装方法网络上也有很多,这里不再重复介绍。本着学习的目的,这里将自己实现一个简单的MQTT broker,然后部署到局域网的服务器上,再将NodeMCU接入本地MQTT服务器。
MQTT broker的主要任务是接收发布者发布的消息,然后发送给所有订阅相同主题的订阅者,所以作为本地的broker,可以简化很多(不需要集群、不需要数据库保持消息等),只需要做消息的转发即可。当然,作为MQTT broker转发的消息都要符合MQTT协议的定义,还要处理客户端的连接、断开、订阅等功能。本文的实现参考MQTT v3.1.1。
通过学习MQTT协议并实现本地的MQTT broker,可以更好的理解MQTT协议,将来可以使用自定义的通信协议并与本地网关进行通信,最终由网关连接到公网上。这对于本地设备的智能化、网络化将有一定的帮助。
1. 编程语言选择
为了实现MQTT broker首先要实现一个TCP服务器(暂不考虑websocket), 各种语言都支持Socket编程,listen端口就可以实现简单的TCP服务器。MQTT broker要处理多个客户端的订阅、发布消息,所以多线程操作是比较重要的一点。综合网络编程、多线程操作、支持跨平台等特点这里选择的是Google出品的Go。

Go(又称Golang)是由Google出品的编译型语言,语法上类似C语言,支持垃圾回收。通过Goroutine可以很方便的实现大量并发,channel保证线程安全。有C语言基础会觉得Go非常熟悉,由于有垃圾回收功能不用担心内存没有释放,并且Go语言的学习非常快,学习一两周就可以动手做了。并且Go语言还自带代码格式化工具,写完代码后格式统一。
Go语言还支持编译到不同的平台,可以在Windows上运行;也可以编译到ARM Linux平台,这里我们会编译到ARM平台运行,因为功耗比较低并且不需要一直开着电脑。
1.1 开发环境搭建
Go语言的编译环境安装非常简单,首先安装Go语言开发包,进入https://golang.google.cn/dl/选择合适的安装包。新版本的开发包安装后会自动配置环境变量,无需其他配置。
安装好后通过查看版本号测试是否安装成功过:

国内网络环境复杂,使用Go拉取模块可能会比较慢,需要使用GOPROXY来加速下载,执行命令如下:
go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.cn,direct
Go语言可以用任何文本工具编写,这里使用VScode + Go插件的方式开发,安装也很简单。直接在VScode中安装Go插件:

安装好后会提示安装几个辅助开发的工具,直接点Install即可,当前版本这些工具都从Github下载,所以很多教程中手动下载安装的过程也不需要。Go语言的语法,网络的教程很多,这里不多介绍。
2. 整体框架
Go语言有很多第三方模块,其中有一个轻量级并发TCP服务器框架Zinx,代码简洁规范,并且有配套的在线教程文档,非常适合新手学习使用。本文开发的MQTT broker就是以Zinx作为参考,自行实现了简单的TCP服务器,MQTT协议的处理与TCP服务分隔成两个模块,方便添加自定义的协议。
本文实现的MQTT broker整体框架如下:

下面介绍各个模块的作用及实现中需要考虑的细节。
2.1 TCP服务模块
TCP服务模块取名“LWMQ”,主要功能是处理客户端的TCP连接、断开事件,接收客户端发送的数据及发送数据到客户端。启动TCP service先要创建一个结构体:
type Lwmq struct {
Name string
Type string
IP string
Port int
onConn func(cid uint32, conn iface.Iconn)
lock *sync.Mutex
cidPool []byte
}
创建实例代码为:
func NewLwmq() iface.Iservicer {
return &Lwmq{
Name: "LWMQ",
Type: "tcp4",
IP: "0.0.0.0",
Port: 1883,
lock: new(sync.Mutex),
cidPool: make([]byte, 1024),
}
}
参数表示接收任意IP连接1883端口,1883端口是MQTT协议的默认端口。每个连接的客户端都会分配一个不同Id号,表示为cid,每个cid都是从cidPool中选取,最大支持1024个。TCP服务器使用Go标准net库,启动一个Goroutine监听客户端的连接。
目前配置都是写在代码中,后续会提取到一个配置文件,方便修改。
2.1.1 分配cid
每个客户端分配一个cid作用是通过cid直接找到对应的客户端,防止连接客户端过多限制最大1024个连接。cidPool用来分配cid,连接断开后再销毁对应的cid。实现比较简单,创建一个1024大小的切片,有一个连接就在查找第一个0x00的位置填0x01,并返回序号作为cid;连接断开就将cid对应位置清零。这样的目的是限制客户端cid大小在1024以内,如果cid使用每次递增的方式,经过长时间之后cid会变得很大,更严重的问题是如果某个客户端不停的连接、断开,一段时间之后cid会溢出,导致两个客户端使用同样的cid。

cidPool访问可能是多线程的,所以使用sync库中的Mutex同步线程,使用起来比较简单,使用Go提供的defer功能防止忘记Unlock:
s.lock.Lock()
defer s.lock.Unlock()
2.1.2 客户端
每个连接后都会创建一个客户端,表示为:
type Client struct {
...
Cid uint32
Conn iface.Iconn
buffPool [][]byte
ringbuff RingBuff
checkData func(iface.Iclient, uint32) uint32
dispathData func(iface.Iclient, uint32, []byte, uint32) uint32
...
}
其中Cid与连接时分配的cid相同,用来标识一个客户端,Conn对应每个连接,用来读写数据。剩下的四个元素实现客户端的重点,其中buffPool是一系列缓存,每次读数据都会先放到buffPool, 然后放到ringbuff,同时将读数据存储指向buffPool的下一个,这样做的目的是尽快的将数据收下来处理。如果每次从Conn读数据都要make申请一次内存,效率会比较低,而创建客户端时提前分配好内存可以减少很多内存分配操作。这里ringbuff分配了200KB,buffPool分配了10组,每组8KB。

buffPool使用多组的另一个原因是如果客户端发送数据太快,有可能导致接收数据被覆盖,而使用多组buffer,每次都会写向下一个buffer,不会有冲突。Socket缓冲区大小与系统设置有关系,可以用net库的SetReadBuffer()函数修改,这里设置为8KB正常情况下对于MQTT协议是足够了。
每个客户端启动时都会启动一个Goroutine读取连接中的数据,当检查读到一个完整的任务消息后将这个任务放到TCP服务模块的任务列表中,后续由TCP服务模块来调用对应客户端的任务分发函数。这里的数据检查和任务分发函数由应用层填写,目前是按照MQTT协议规定的处理函数,要改成其他协议,只要修改数据检查及任务分发函数即可:
// SetHandler set client handler
func (c *Client) SetHandler(checkData iface.CheckHandler, dispatch iface.DispatchHandler) {
c.checkData = checkData
c.dispathData = dispatch
}
2.1.3 任务处理
TCP服务模块的一个重要任务就是处理各个客户端发来的消息,如果每个客户端收到消息立即进行处理显然是不合适的,所以这里采用任务队列和worker线程池的结构。每个客户端收到完整命令后放到任务列表,同时唤醒worker线程,然后继续接收下面的数据,这样就不会影响数据接收。
worker线程池即启动时就创建一定数量的Goroutine,每个worker唤醒后都会检查任务列表是否为空,如果有任务就取一个来处理,直到任务列表为空则继续休眠。这样做法的好处是不需要频繁的创建Goroutine,并且所有worker都从同一个列表取任务,不会因为某个任务耗时太久或者卡住而影响其他任务的执行。每个任务被执行时的worker是不固定的,任务处理的顺序也可能是不固定的,举个例子,客户端A要发送消息1和2给客户端B,而TCP模块处理消息1时耗时比消息2多,那么消息2会先到达客户端B。
任务处理部分的结构如下:

worker的休眠唤醒使用sync标准模块的Cond功能,空闲时就进入休眠状态,降低CPU资源消耗。
2.2 Log模块
Log模块使用Go的标准log包,增加了log按等级过滤功能,这样调试时打印全部信息,真正运行时只需要打印关键的一些信息。每个等级都创建了一个Logger,可以分别设置打印的格式,输出的位置等参数。
mlog = &Mlog{
logLevel: INFO,
info: log.New(os.Stdout, "[I] ", log.Ldate|log.Ltime),
debug: log.New(os.Stdout, "[D] ", log.Ldate|log.Ltime),
warning: log.New(os.Stdout, "[W] ", log.Ldate|log.Ltime|log.Lshortfile),
err: log.New(os.Stdout, "[E] ", log.Ldate|log.Ltime|log.Lshortfile),
}
打印函数也很简单,例如打印debug等级的log:
func Debug(v ...interface{}) {
if DEBUG >= mlog.logLevel {
mlog.debug.Println(v...)
}
}
2.3 MQTT borker模块
MQTT broker模块就是按照MQTT协议的规定处理不同的命令,这里将填充两个函数给每个连接的客户端:
1. func checkMQTTdata(cl iface.Iclient, size uint32) uint32
用于判断MQTT数据是否完整,也就是根据协议检查收到的数据长度是否大于等于剩余长度加header的长度。
2. func dispathMQTTdata(cl iface.Iclient, cid uint32, buff []byte, size uint32) uint32
用于根据控制报文类型选择不同的处理函数,处理函数放到一个map中方便调用.
var dispatchHandlers = map[byte]dispatchHandler{
CONNECT: HandleCONNECT,
DISCONNECT: HandleDISCONNECT,
SUBSCRIBE: HandleSUBSCRIBE,
UNSUBSCRIBE: HandleUNSUBSCRIBE,
PINGREQ: HandlePINGREQ,
PUBLISH: HandlePUBLISH,
PUBACK: HandlePUBACK,
}
如果要修改为其他协议,只需要修改这两个函数即可,数据的接收、任务分发、连接管理等都是由LWMQ来处理。
2.4 HTTP服务模块
HTTP服务模块用于查看当前在线的设备,及各个设备的订阅主题列表。这里实现比较简单,使用第三方的web框架echo,监听端口1888,当浏览器访问该端口时就返回MQTT在线设备列表信息。
func getdevices() {
e := echo.New()
e.Static("/", "html")
e.Renderer = templates
e.GET("/", devicelist)
e.Start(":1888")
}
// Startservice start http service
func Startservice() {
go getdevices()
}
这里有一点需要注意的是设备信息本身是存放在一个map中,而Go实现的map在每次遍历时的key顺序都不一样,从而显示的列表每次设备的位置都不同。为了解决这个问题,先将map的key放入一个切片中,对这个切片进行排序,然后再遍历就可以实现每次的顺序一致。
var sceneList []string
for k := range dispatcher.Mserver.Mclients {
sceneList = append(sceneList, k)
}
sort.Strings(sceneList)
for idx, k := range sceneList {
......
}
2.5 交叉编译
Go语言可以交叉编译到不同平台,在windows x86平台上进行开发测试,测试完成后再编译部署到其他平台。这里编译到ARM Linux平台,没有选用最流行的树莓派,而是用的某讯出品的著名矿渣N1盒子。盒子的基本参数如下:
硬件参数 | |
---|---|
处理器 | Amgoics905D Cortex-A53 |
eMMC | 8GB |
内存 | 2GB |
网络接口 | 1000Mbps |
其他接口 | 1xHDMI, 2xUSB2.0 |
尺寸 | 110×110×40 |
可以看到N1的配置非常出色,网络上也有很多刷机包可以用,可玩性很高。在不需要IO和显示屏的情况下,与树莓派相比性价比非常高,本身的颜值也很高,并且功耗很低,做一个简单的家庭服务器非常合适。另外,手上的N1刷了小钢炮接了三个硬盘用来挂PT,十分稳定,也不用担心耗电太多,已经稳定运行144天。并且小钢炮支持SMB、FTP、NFS协议,局域网的设备可以直接播放硬盘中的视频、照片等,非常方便。

有点跑题了😁,说回Go的交叉编译,也比较简单,设置好目标系统和芯片架构后编译即可。将编译生成的可执行文件放到N1盒子上就可以运行了,是不是很简单。
SET CGO_ENABLED=0
SET GOOS=linux
SET GOARCH=arm
go build mqtt_go.go
3 连接设备及测试
- 使用SSH登录N1盒子,运行MQTT broker

- PC使用MQTT.fx软件,订阅主题"home/pc",并发布消息到主题"home/android"


- 手机使用MQTT Client软件,订阅主题"home/android",并发布消息到主题"home/pc"


可以看到PC和手机之间可以相互发送消息,实现了设备间通信M2M。
- 通过浏览器访问1888端口可以看到设备信息及订阅列表

4.总结
使用Go语言自制了一个TCP服务模块LWMQ,并在此基础上实现了简单MQTT broker,后续NodeMCU会连到这个服务器上进行数据收发。
Github: https://github.com/songdaw/lwmq