NodeMCU学习系列(五) ---- 连接本地服务器(上)

通过之前的介绍已经可以将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
eMMC8GB
内存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

码云:https://gitee.com/songdaw/lwmq

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值