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源碼會是個不錯的選擇,畢竟其實現的功能還是相對完善的。
而對于這次實踐,總感覺有些地方使用面向協議沒有面向對象來的更加簡潔,不過這也是利弊的權衡吧,還是在可以接受的范圍。