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閱讀 228,363評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,497評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,305評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,962評論 1 311
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,727評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,193評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,257評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,411評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,945評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,777評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,978評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,519評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,216評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,657評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,960評論 2 373

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,643評論 25 708
  • iOS開發中,關于MQTT的三方庫主要有兩種。 基于C實現的Mosquitto庫。當然直接去調用C的接口并不是特別...
    Noskthing閱讀 24,553評論 20 22
  • 官網地址 http://activemq.apache.org/apollo/documentation/mqtt...
    AISpider閱讀 3,059評論 0 7
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,782評論 18 139
  • 畢業是個傷心的事情,要離開好多人,尤其是岳老師,那個女神老師,從第一次見面就征服了孩子們的眼睛,然后是他們的心,每...
    nataemma閱讀 606評論 0 1