MQTT全称Message Queue Telemetry Transport,是一个针对轻量级的发布/订阅式消息传输场景的协议,同时也是被推崇的物联网传输协议。MQTT详细的介绍文章可以从官方网站获得,所以这里就不进行详细的展开了,而是针对这些天的使用经历与感受做一番纪录。
MQTT开源的iOS客户端有以下几种:
1 | |MQTTKit |Marquette|Moscapsule|Musqueteer|MQTT-Client|MqttSDK|CocoaMQTT| |
以上开源库我只看过部分MQTTKit、MQTT-Client、CocoaMQTT的开源代码,总体来说MQTT-Client支持的功能更多全面一些。如果只是对协议本身进行学习不考虑功能的话,可以阅读CocoaMQTT,也可以阅读我重写的SwiftMQTT,因为代码量相对前面两个库少了很多。
而MQTT的broker一般选择Mosquitto,Mosquitto是一个由C编写的集客户端和服务端为一体的开源项目,所以相对来说风格较为友好,可以无障碍地阅读并调试源码(开源地址)。可以看到,以上客户端开源库中的前四种就是基于Mosquitto的一层封装。
Mosquitto的安装和使用
Mosquitto在Linux下的安装相对比Mac-OS简单很多,主要是因为openssl的一些路径问题,后者需要多一些步骤。Mac-OS下可以通过两种方法运行Mosquitto,一种是通过brew命令安装Mosquitto:
1 | brew install mosquitto |
安装完成后就可以在mosquitto.conf文件中更改相应的配置了。接着进入根目录(也可以指定$PATH到mosquitto可执行文件的目录),执行以下命令运行mosquitto:
1 | // -c 读取配置 |
如果要重启mosquitto服务,可以先kill掉,再重启:
1 | tripleCC:1.4.8 songruiwang$ ps -A | grep mosquitto |
现在要说明的是第二种方式,通过源码编译生成mosquitto可执行文件(好处是可以通过lldb对mosquitto进行调试,能更好地熟悉运行机制)。
下载mosquitto源码后进入根目录,然后执行以下命令:
1 | // 禁用TLS_PSK,并且声称Debug版本(后续lldb调试需要用到符号表) |
终端会提示无法拷贝可执行文件mosquitto,这个问题无伤大雅。可以手动拷贝到$PATH指定的目录下,也可以直接进入mosquitto所在目录运行:
1 | tripleCC:src songruiwang$ lldb mosquitto |
这样当客户端连接到broker时,就可以对mosquitto进行逐行调试了:
1 | Process 57680 launched: '/Users/songruiwang/Desktop/mosquitto/src/mosquitto' (x86_64) |
这里安利一款代码阅读器Understand(和window下的SourceInsight很相似,都很强大!)
lldb很多命令和gdb相似,具体更多命令可以在lldb中执行help进行查看。
更加详细的使用教程可以参考Mosquitto简要教程(安装/使用/测试)
使用Wireshark抓取报文
测试时使用的host一般为lo0,即本地回环地址,所以选择对应的过滤器:
对端口进行过滤(这里使用的是1883端口):
然后连接客户端和服务端,就可以看见对应的MQTT报文了:
在一些linux嵌入式环境下,无法通过Wireshark抓取报文,可以使用tcpdump抓取生成pcap文件,然后使用ftp等协议将文件传回到电脑,再使用Wireshark打开:
1 | // 这里还是用回环地址举例 |
MQTT协议的实践
MQTT协议消息类型
为了能够更好地熟悉协议,我用struct+protocol的方式重写了CocoaMQTT的代码(SwiftMQTT)。CocoaMQTT库使用的是传统的面相对象编程方式,所以阅读起来并没有什么障碍,只不过小小吐槽下代码风格。
MQTT协议总共有14种消息类型,使用枚举表示如下:
1 | public enum SwiftMQTTMessageType : UInt8 { |
以上消息可由”固定报头”+”可变报头”+”有效载荷”三部分组成。
固定报头由”类型+标志位”+”剩余长度”组成,可以使用protocol表示第一部分:
1 | public protocol SwiftMQTTCommandProtocol { |
剩余长度等于”可变报头”+”有效载荷”各自的长度相加,这两者表示如下:
1 | public protocol SwiftMQTTVariableHeaderProtocol { |
为了减少没有这两个部分的消息结构体的代码量,所以协议扩展中先返回空数据。
然后就可以定义并实现一个固定报头的总协议了:
1 | public protocol SwiftMQTTFixedHeaderProtocol : SwiftMQTTCommandProtocol, SwiftMQTTVariableHeaderProtocol, SwiftMQTTPayloadProtocol { |
有了所有发送消息的组成部分之后,就可以对数据进行编码了:
1 | public protocol SwiftMQTTMessageProtocol : SwiftMQTTFixedHeaderProtocol { |
这里以Connect报文为例,结合以上协议,构成一个有效的消息结构体。
首先让SwiftMQTTConnectMessage遵守SwiftMQTTMessageProtocol协议,以此获得固定报头解析以及编码等能力:
1 | public struct SwiftMQTTConnectMessage : SwiftMQTTMessageProtocol { |
由于command是固定报头类型和标志的必要载体,所以必须在结构体中实现。那么问题来了,MQTT协议的消息有14种,于是就需要在14种结构体种都实现一次这个成员变量,如果使用面向对象的方式,在公共子类中呈现这个成员变量就行了。这里是第一个让我感觉面向协议方式在实现MQTT不顺手的地方。
Connect报文的可变报头中分为四个部分:协议名,协议级别,连接标志和保持连接。这几个部分可以使用两个协议来实现:
1 | public protocol SwiftMQTTConnectFlagProtocol { |
这样Connect报文结构体已经有了所有需要的协议,接下来主要的工作就是实现真正的variableHeader和payload了:
1 | public var variableHeader: NSData { |
至此,Connect的主要部分都已经构建完成。接下来以ConAck报文为例,实现从broker中返回的报文。
由于需要解析从broker中返回的报文,所以定义一个返回消息类型协议:
1 | public protocol SwiftMQTTAckMessageProtocol: SwiftMQTTCommandProtocol { |
最终SwiftMQTTConnAckMessage结构体如下:
1 | public struct SwiftMQTTConnAckMessage : SwiftMQTTAckMessageProtocol { |
这里又产生了第二个让我不是很舒服的地方:在protocol extension中实现有效的init非常麻烦(暂且不论在protocol extension中实现init的必要性)。下面是一个不完全的实现方式:
1 | protocol MessageProtocol { |
为了能在protocol extension实现一个默认的init?(_ bytes: [UInt8])方法,就必须要多定义一个没什么意义的init()方法。这让我直接放弃了这个念头,转而直接在每个消息类型的struct中实现对应的解析init方法,虽然这样会让部分代码重复。
至此,MQTT协议的消息类型实现差不多完成了,因为后续的12种消息和前面这2种大同小异。
MQTT协议消息解析
和CocoaMQTT一样,SwiftMQTT也是使用GCDAsyncSocket来进行socket通信。在调用GCDAsyncSocket实例的readData系列方法并读取到数据后,便可以从以下代理方法中解析读取到的数据:
1 | - (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag |
需要注意的是,如果使用的是按照缓存排列每次读取固定子节的方法:
1 | - (void)readDataToLength:(NSUInteger)length withTimeout:(NSTimeInterval)timeout tag:(long)tag; |
那么只要有一次读取错误,就会影响到后续所有数据的读取。
解析返回报文的主要方法如下:
1 | mutating func unpackData(data: NSData, part: SwiftMQTTMessagePart, nextReader:SwiftMQTTMessageDecoderNextReader) { |
报文分三个部分进行读取。需要注意的是读取剩余长度时,需要循环读取一个字节,以便确定剩余长度的最高字节。
小结
最后对比各个协议库,如果需要使用到MQTT的大部分功能,那么阅读Mosquitto源码会是个不错的选择,毕竟其实现的功能还是相对完善的。
而对于这次实践,总感觉有些地方使用面向协议没有面向对象来的更加简洁,不过这也是利弊的权衡吧,还是在可以接受的范围。