MQTT使用小记

MQTT全称Message Queue Telemetry Transport,是一个针对轻量级的发布/订阅式消息传输场景的协议,同时也是被推崇的物联网传输协议。MQTT详细的介绍文章可以从官方网站获得,所以这里就不进行详细的展开了,而是针对这些天的使用经历与感受做一番纪录。

MQTT开源的iOS客户端有以下几种:

MQTTKit Marquette Moscapsule Musqueteer MQTT-Client MqttSDK CocoaMQTT
Obj-C Obj-C Swift Obj-C Obj-C Obj-C Swift
Mosquitto Mosquitto Mosquitto Mosquitto native native native

以上开源库我只看过部分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:

brew install mosquitto

安装完成后就可以在mosquitto.conf文件中更改相应的配置了。接着进入根目录(也可以指定$PATH到mosquitto可执行文件的目录),执行以下命令运行mosquitto:

// -c 读取配置
// -d 后台运行
// -v 打印详细日志
./sbin/mosquitto -c etc/mosquitto/mosquitto.conf -d -v

如果要重启mosquitto服务,可以先kill掉,再重启:

tripleCC:1.4.8 songruiwang$ ps -A | grep mosquitto
55417 ??         0:00.05 ./sbin/mosquitto -c etc/mosquitto/mosquitto.conf -d -v
tripleCC:1.4.8 songruiwang$ kill -9 55417

现在要说明的是第二种方式,通过源码编译生成mosquitto可执行文件(好处是可以通过lldb对mosquitto进行调试,能更好地熟悉运行机制)。

下载mosquitto源码后进入根目录,然后执行以下命令:

// 禁用TLS_PSK,并且声称Debug版本(后续lldb调试需要用到符号表)
// 如果openssl是通过brew进行安装,就需要手动指定OPENSSL_ROOT_DIR和OPENSSL_INCLUDE_DIR环境变量
// 但是后来发现即使指定了,在编译时符号表中还是找不到TLS_PSK相关的函数
cmake -DWITH_TLS_PSK=OFF -DWITH_TLS=OFF -DCMAKE_BUILD_TYPE=Debug 
make install

终端会提示无法拷贝可执行文件mosquitto,这个问题无伤大雅。可以手动拷贝到$PATH指定的目录下,也可以直接进入mosquitto所在目录运行:

tripleCC:src songruiwang$ lldb mosquitto
(lldb) target create "mosquitto"
Current executable set to 'mosquitto' (x86_64).
(lldb) b mqtt3_packet_handle
Breakpoint 1: where = mosquitto`mqtt3_packet_handle + 16 at read_handle.c:36, address = 0x0000000100018eb0
(lldb) r

这样当客户端连接到broker时,就可以对mosquitto进行逐行调试了:

Process 57680 launched: '/Users/songruiwang/Desktop/mosquitto/src/mosquitto' (x86_64)
1463049645: mosquitto version 1.4.8 (build date 2016-05-12 18:36:15+0800) starting
1463049645: Using default config.
1463049645: Opening ipv4 listen socket on port 1883.
1463049645: Opening ipv6 listen socket on port 1883.
1463049659: New connection from 127.0.0.1 on port 1883.
Process 57680 stopped
* thread #1: tid = 0xba449, 0x0000000100018eb0 mosquitto`mqtt3_packet_handle(db=0x000000010002f4f0, context=0x0000000100201990) + 16 at read_handle.c:36, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x0000000100018eb0 mosquitto`mqtt3_packet_handle(db=0x000000010002f4f0, context=0x0000000100201990) + 16 at read_handle.c:36
   33   
   34   int mqtt3_packet_handle(struct mosquitto_db *db, struct mosquitto *context)
   35   {
-> 36       if(!context) return MOSQ_ERR_INVAL;
   37   
   38       switch((context->in_packet.command)&0xF0){
   39           case PINGREQ:
(lldb) p *context
(mosquitto) $0 = {
  sock = 6
  protocol = mosq_p_invalid
  address = 0x0000000100200db0 "127.0.0.1"
  id = 0x0000000000000000 <no value available>
  username = 0x0000000000000000 <no value available>
  password = 0x0000000000000000 <no value available>
  keepalive = 60
  last_mid = 0
  state = mosq_cs_new
  last_msg_in = 39584
  last_msg_out = 39584
  ......

这里安利一款代码阅读器Understand(和window下的SourceInsight很相似,都很强大!)

lldb很多命令和gdb相似,具体更多命令可以在lldb中执行help进行查看。
更加详细的使用教程可以参考Mosquitto简要教程(安装/使用/测试)

使用Wireshark抓取报文

测试时使用的host一般为lo0,即本地回环地址,所以选择对应的过滤器:

对端口进行过滤(这里使用的是1883端口):

然后连接客户端和服务端,就可以看见对应的MQTT报文了:

在一些linux嵌入式环境下,无法通过Wireshark抓取报文,可以使用tcpdump抓取生成pcap文件,然后使用ftp等协议将文件传回到电脑,再使用Wireshark打开:

// 这里还是用回环地址举例
tcpdump -i lo0 'tcp port 1883' -s 65535 -w packet.pcap

MQTT协议的实践

MQTT协议消息类型

为了能够更好地熟悉协议,我用struct+protocol的方式重写了CocoaMQTT的代码(SwiftMQTT)。CocoaMQTT库使用的是传统的面相对象编程方式,所以阅读起来并没有什么障碍,只不过小小吐槽下代码风格。

MQTT协议总共有14种消息类型,使用枚举表示如下:

public enum SwiftMQTTMessageType : UInt8 {
    case Connect        = 0x10
    case ConnAck        = 0x20
    case Publish        = 0x30
    case PubAck         = 0x40
    case PubRec         = 0x50
    case PubRel         = 0x60
    case PubComp        = 0x70
    case Subscribe      = 0x80
    case SubAck         = 0x90
    case Unsubscribe    = 0xA0
    case UnsubBack      = 0xB0
    case PingReq        = 0xC0
    case PingResp       = 0xD0
    case Disconnect     = 0xE0
}

以上消息可由"固定报头"+"可变报头"+"有效载荷"三部分组成。

固定报头由"类型+标志位"+"剩余长度"组成,可以使用protocol表示第一部分:

public protocol SwiftMQTTCommandProtocol {
    var command: UInt8 {get set}
    var messageType: SwiftMQTTMessageType {get set}
    var dupFlag: Bool {get set}
    var qosLevel: SwiftMQTTQosLevel {get set}
    var retain: Bool {get set}
}

extension SwiftMQTTCommandProtocol {
    /**
     * +---------------+----------+-----------+--------+
     * |    7 6 5 4    |     3    |    2 1    |   0    |
     * |  Message Type | DUP flag | QoS level | RETAIN |
     * +---------------+----------+-----------+--------+
     */
    public var messageType: SwiftMQTTMessageType {
        get { return SwiftMQTTMessageType(rawValue: command & 0xF0) ?? .Connect }
        set { command = newValue.rawValue | (command & 0x0F) }
    }
    public var dupFlag: Bool {
        get { return Bool((command >> 3) & 0x01) }
        set { command = (UInt8(newValue) << 3) | (command & 0xF7) }
    }
    public var qosLevel: SwiftMQTTQosLevel {
        get { return SwiftMQTTQosLevel(rawValue: (command >> 1) & 0x03) ?? .AtMostOnce }
        set { command = newValue.rawValue << 1 | (command & 0xF9 ) }
    }
    public var retain: Bool {
        get { return Bool(command & 0x01) }
        set { command = UInt8(newValue) | (command & 0xFE) }
    }
}

剩余长度等于"可变报头"+"有效载荷"各自的长度相加,这两者表示如下:

public protocol SwiftMQTTVariableHeaderProtocol {
     var variableHeader: NSData {get}
}

extension SwiftMQTTVariableHeaderProtocol {
    public var variableHeader: NSData { return NSData() }
}

public protocol SwiftMQTTPayloadProtocol {
    var payload: NSData {get}
}

extension SwiftMQTTPayloadProtocol {
    public var payload: NSData { return NSData() }
}

为了减少没有这两个部分的消息结构体的代码量,所以协议扩展中先返回空数据。

然后就可以定义并实现一个固定报头的总协议了:

public protocol SwiftMQTTFixedHeaderProtocol : SwiftMQTTCommandProtocol, SwiftMQTTVariableHeaderProtocol, SwiftMQTTPayloadProtocol {
    var remainingLength: UInt32 {get}
}

extension SwiftMQTTFixedHeaderProtocol {
    public var remainingLength: UInt32 {
        let remainingLength = variableHeader.length + payload.length
        guard remainingLength <= kSwiftMQTTMaxRemainingLength else {
            SMPrint("the size of remaining length field should be below \(kSwiftMQTTMaxRemainingLength).")
            return UInt32(kSwiftMQTTMaxRemainingLength)
        }
        return UInt32(remainingLength)
    }
}

有了所有发送消息的组成部分之后,就可以对数据进行编码了:

public protocol SwiftMQTTMessageProtocol : SwiftMQTTFixedHeaderProtocol {
    var data: NSData {get}
}

extension SwiftMQTTMessageProtocol {
    public var data: NSData {
        let data = NSMutableData()
        data.appendByte(command)
        data.appendData(remainingLength.data)
        data.appendData(variableHeader)
        data.appendData(payload)
        return data
    }
}

这里以Connect报文为例,结合以上协议,构成一个有效的消息结构体。

首先让SwiftMQTTConnectMessage遵守SwiftMQTTMessageProtocol协议,以此获得固定报头解析以及编码等能力:

public struct SwiftMQTTConnectMessage : SwiftMQTTMessageProtocol {
    public var command = UInt8(0x00)
    ...
}

由于command是固定报头类型和标志的必要载体,所以必须在结构体中实现。那么问题来了,MQTT协议的消息有14种,于是就需要在14种结构体种都实现一次这个成员变量,如果使用面向对象的方式,在公共子类中呈现这个成员变量就行了。这里是第一个让我感觉面向协议方式在实现MQTT不顺手的地方。

Connect报文的可变报头中分为四个部分:协议名,协议级别,连接标志和保持连接。这几个部分可以使用两个协议来实现:

public protocol SwiftMQTTConnectFlagProtocol {
    var connectFlag: UInt8 {get set}
    var usernameFlag: Bool {get set}
    var passwordFlag: Bool {get set}
    var willRetain: Bool {get set}
    var willQos: SwiftMQTTQosLevel {get set}
    var willFlag: Bool {get set}
    var cleanSession: Bool {get set}
}

extension SwiftMQTTConnectFlagProtocol {
    /**
     * +----------+----------+------------+---------+----------+--------------+----------+
     * |     7    |    6     |      5     |  4  3   |     2    |       1      |     0    |
     * | username | password | willretain | willqos | willflag | cleansession | reserved |
     * +----------+----------+------------+---------+----------+--------------+----------+
     */
    public var usernameFlag: Bool {
        get { return Bool((connectFlag & 0x80) >> 7) }
        set { connectFlag = (UInt8(newValue) << 7) | (connectFlag & 0x7F) }
    }
    public var passwordFlag: Bool {
        get { return Bool((connectFlag & 0x40) >> 6) }
        set { connectFlag = (UInt8(newValue) << 6) | (connectFlag & 0xBF) }
    }
    public var willRetain: Bool {
        get { return Bool((connectFlag & 0x20) >> 5) }
        set { connectFlag = (UInt8(newValue) << 5) | (connectFlag & 0xDF) }
    }
    public var willQos: SwiftMQTTQosLevel {
        get { return SwiftMQTTQosLevel(rawValue: (connectFlag & 0x18) >> 3) ?? .AtMostOnce }
        set { connectFlag = (UInt8(newValue.rawValue) << 3) | (connectFlag & 0xE7) }
    }
    public var willFlag: Bool {
        get { return Bool((connectFlag & 0x08) >> 2) }
        set { connectFlag = (UInt8(newValue) << 2) | (connectFlag & 0xFA) }
    }
    public var cleanSession: Bool {
        get { return Bool((connectFlag & 0x04) >> 1) }
        set { connectFlag = (UInt8(newValue) << 1) | (connectFlag & 0xFD) }
    }
}

protocol SwiftMQTTClientProtocol {
    var protocolName: String {get}
    var protocolLevel: UInt8 {get}
    var keepalive: UInt16 {get}
    var clientId: String {get}
    var account: SwiftMQTTAccount? {get}
    var will: SwiftMQTTWill? {get}
}

extension SwiftMQTTClientProtocol {
    var protocolName: String { return "MQTT" }
    var protocolLevel: UInt8 { return 0x04 }
    var keepalive: UInt16 { return 60 }
}

这样Connect报文结构体已经有了所有需要的协议,接下来主要的工作就是实现真正的variableHeader和payload了:

public var variableHeader: NSData {
    let variableHeader = NSMutableData()
    variableHeader.appendMQTTString(protocolName)
    variableHeader.appendByte(protocolLevel)
    variableHeader.appendByte(connectFlag)
    variableHeader.appendUInt16(keepalive)
    return variableHeader
}
public var payload: NSData {
    let payload = NSMutableData()
    // 客户端标识符->遗嘱主题->遗嘱消息->用户名->密码
    payload.appendMQTTString(clientId)
    if let willTopic = will?.willTopic {
        payload.appendMQTTString(willTopic)
    }
    if let willMessage = will?.willMessage {
        payload.appendMQTTString(willMessage)
    }
    if let username = account?.username {
        payload.appendMQTTString(username)
    }
    if let password = account?.password {
        payload.appendMQTTString(password)
    }
    return payload
}

至此,Connect的主要部分都已经构建完成。接下来以ConAck报文为例,实现从broker中返回的报文。

由于需要解析从broker中返回的报文,所以定义一个返回消息类型协议:

public protocol SwiftMQTTAckMessageProtocol: SwiftMQTTCommandProtocol {
    init?(_ bytes: [UInt8], command: UInt8)
}

最终SwiftMQTTConnAckMessage结构体如下:

public struct SwiftMQTTConnAckMessage : SwiftMQTTAckMessageProtocol {
    public var command = UInt8(0x00)
    public var sessionPresent: Bool
    public var connectReturnCode: SwiftMQTTConnectReturnCode
    public init?(_ bytes: [UInt8], command: UInt8) {
        guard bytes.count == 2 else { return nil }
        sessionPresent = Bool(bytes[0])
        connectReturnCode = SwiftMQTTConnectReturnCode(rawValue: bytes[1]) ?? .Accepted
        self.command = command
    }
}

这里又产生了第二个让我不是很舒服的地方:在protocol extension中实现有效的init非常麻烦(暂且不论在protocol extension中实现init的必要性)。下面是一个不完全的实现方式:

protocol MessageProtocol {
    var messageId : UInt16 { get set }
    init()
    init?(_ bytes: [UInt8])
}

extension Message {
    init?(_ bytes: [UInt8]) {
        guard bytes.count == 2 else { return nil }
        messageId = UInt16(bytes[0]) << 8 + UInt16(bytes[1])
    }
}

struct Message: MessageProtocol {
    var messageId: UInt16
    init() {
        messageId = 0
    }
}

为了能在protocol extension实现一个默认的init?(_ bytes: [UInt8])方法,就必须要多定义一个没什么意义的init()方法。这让我直接放弃了这个念头,转而直接在每个消息类型的struct中实现对应的解析init方法,虽然这样会让部分代码重复。

至此,MQTT协议的消息类型实现差不多完成了,因为后续的12种消息和前面这2种大同小异。

MQTT协议消息解析

和CocoaMQTT一样,SwiftMQTT也是使用GCDAsyncSocket来进行socket通信。在调用GCDAsyncSocket实例的readData系列方法并读取到数据后,便可以从以下代理方法中解析读取到的数据:

- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag

需要注意的是,如果使用的是按照缓存排列每次读取固定子节的方法:

- (void)readDataToLength:(NSUInteger)length withTimeout:(NSTimeInterval)timeout tag:(long)tag;

那么只要有一次读取错误,就会影响到后续所有数据的读取。

解析返回报文的主要方法如下:

mutating func unpackData(data: NSData, part: SwiftMQTTMessagePart, nextReader:SwiftMQTTMessageDecoderNextReader) {
    let bytes = data.bytesArray
    switch part {
    case .Header:
        messageHeader = unpackHeader(bytes)
        // 读取一个字节的剩余长度
        nextReader(length: 1, part: .Length)
    case .Length:
        messageLengthBytes.appendContentsOf(bytes)
        // 如果最高位为0,则剩余长度已确定
        if Bool(bytes[0] & 0x80) {
            // 继续读取一个字节的剩余长度
            nextReader(length: 1, part: .Length)
        } else {
            // 获取剩余长度
            let messageLength = unpackLength(messageLengthBytes)
            if messageLength > 0 {
                // 读取可变报头和payload
                nextReader(length: messageLength, part: .Content)
            } else {
                // 没有可变报头和payload,不需要再进行读取操作,直接解包
                unpackContent()
            }
            // 重置长度缓存
            messageLengthBytes.removeAll()
        }
    case .Content:
        // 解析可变报头和payload
        unpackContent(bytes)
    }
}

报文分三个部分进行读取。需要注意的是读取剩余长度时,需要循环读取一个字节,以便确定剩余长度的最高字节。

小结

最后对比各个协议库,如果需要使用到MQTT的大部分功能,那么阅读Mosquitto源码会是个不错的选择,毕竟其实现的功能还是相对完善的。

而对于这次实践,总感觉有些地方使用面向协议没有面向对象来的更加简洁,不过这也是利弊的权衡吧,还是在可以接受的范围。

参考链接

MosquittoDocumentation

MQTT中文文档

MQTT英文文档

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,520评论 25 707
  • iOS开发中,关于MQTT的三方库主要有两种。 基于C实现的Mosquitto库。当然直接去调用C的接口并不是特别...
    Noskthing阅读 24,479评论 20 22
  • 官网地址 http://activemq.apache.org/apollo/documentation/mqtt...
    AISpider阅读 3,011评论 0 7
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,601评论 18 139
  • 毕业是个伤心的事情,要离开好多人,尤其是岳老师,那个女神老师,从第一次见面就征服了孩子们的眼睛,然后是他们的心,每...
    nataemma阅读 591评论 0 1