MQTT

Zhuangwei Kang發表於2018-09-16

MQTT

簡介:MQTT由IBM公司開發,是一個即時通訊協議,也是一個物聯網傳輸協議,主要用於輕量級的訂閱/釋出式的訊息傳輸。其設計目的主要是為低頻寬和不穩定網路環境下的物聯網裝置提供服務。

MQTT中的概念

  • 訂閱(Subscribtion):
    訂閱包含主題篩選器(Topic Filter)和最大服務質量(QoS)。訂閱會與一個會話(Session)關聯。一個會話可以包含多個訂閱。每一個會話中的每個訂閱都有一個不同的主題篩選器。
  • 會話(Session):
    每個客戶端與伺服器建立連線後就是一個會話,客戶端和伺服器之間有狀態互動。會話存在於一個網路之間,也可能在客戶端和伺服器之間跨越多個連續的網路連線。
  • 主題名(Topic Name):
    連線到一個應用程式訊息的標籤,該標籤與伺服器的訂閱相匹配。伺服器會將訊息傳送給訂閱所匹配標籤的每個客戶端。
    需要注意的是,MQTT中訊息主題按照層級命名,使用 ‘/’ 進行分割
    此外,主題中可以使用萬用字元進行多個主題或多層級的訂閱,有兩種常見的萬用字元:
    1. 單層萬用字元 +:單層萬用字元只能匹配一層的主題,例如:China/Beijing/+,可以匹配的只有Beijing這個主題下面一層的主題,例如Xicheng, DongCheng, Xuanwu等等。
    2. 多層萬用字元 #:顧名思義,多層萬用字元就是可以匹配多個層級的主題,例如:China/#,可以匹配到的主題可能有:China/Beijing/Dongcheng, China/Shanghai/PuDong,等等。
  • 主題篩選器(Topic Filter):
    一個對主題名萬用字元篩選器,在訂閱表示式中使用,表示訂閱所匹配到的多個主題。
  • 負載(Payload):
    訊息訂閱者所具體接收的內容。

MQTT中的角色

MQTT

  • Publisher和Subscriber為客戶端,Broker為伺服器端,訊息主題為訊息型別,Broker根據Topic過濾訊息,並將訊息向客戶端推送。
  • MQTT中用QoS表示服務質量,MQTT協議中有三種服務質量(QoS):
    1. QoS =0,至多一次,可能會出現丟包的情況,使用在對實時性要求不高的情況,例如,將此服務質量與通訊環境感測器資料一起使用。 對於是否丟失個別讀取或是否稍後立即釋出新的讀取並不重要。
    2. QoS =1,至少一次,保證包會到達目的地,但是可能出現重包。
    3. QoS =2, 剛好一次,保證包會到達目的地,且不會出現重包的現象。

客戶端

  • Publisher和Subscriber都屬於客戶端。
  • 釋出應用訊息給其它相關的客戶端。
  • 訂閱以請求接受相關的應用訊息。
  • 取消訂閱以移除接受應用訊息的請求。
  • 從服務端斷開連線。

伺服器端

  • 伺服器端即所謂的MQTT Broker伺服器。
  • 接受來自客戶端的網路連線。
  • 接受客戶端釋出的應用訊息。
  • 處理客戶端的訂閱和取消訂閱請求。
  • 轉發應用訊息給符合條件的已訂閱客戶端。
  • MQTT提供的公共伺服器端(Broker)有:
    • test.mosquitto.org
    • broker.hivemq.com
    • iot.eclipse.org

配置私有的MQTT伺服器

通常情況,出於安全考慮,一般使用私有的MQTT伺服器端,MQTT的本地服務由Mosquitto支援。設定MQTT私有伺服器端的方法如下(環境為Ubuntu16.04):

# Install Mosquitto and Mosquitto-clients(optional)
sudo apt-get install mosquitto

# 預設情況下,ubuntu會自動啟動Mosquitto服務,所以無需顯式啟動服務,此時可以檢視mosquitto狀態:
sudo systemctl satus mosquitto

mqtt test

如果你只是想執行一個本地的MQTT服務,現在已經OK了。在mosquitto服務啟動之後,你可以使用伺服器的域名或者IP地址訪問,MQTT伺服器預設埠為1883。問題很明顯,雖然我們設定了本地的私有MQTT伺服器端,但是任何人都可以通過IP訪問這臺伺服器,所以我們需要為mosquitto設定使用者名稱和密碼,只有擁有使用者名稱和密碼的客戶端才可連線到伺服器。

Mosquitto客戶端提供了為mosquitto設定密碼的命令 mosquitto_passwd,這個命令其實就是將我們設定的使用者名稱和密碼copy進/etc/mosquitto/passwd這個檔案:

sudo mosquitto_passwd -c /etc/mosquitto/passwd <username>
# 執行上面命令的時候會提示輸入兩次密碼

在生成了密碼檔案之後,我們需要告訴mosquitto服務,以後如果有客戶端想建立連線請驗證使用者名稱和密碼,具體操作如下:

sudo bash -c 'sudo echo -e "allow_anonymous false\npassword_file /etc/mosquitto/passwd" > /etc/mosquitto/conf.d/default.conf'

上面的命令建立default.conf並輸入引號裡面的命令,可以看到我們禁止了anonymous連線,並且指定了密碼所在的檔案。

然後,重啟mosquitto服務,讓設定生效

sudo systemctl restart mosquitto

重新測試一下
MQTT with username and password

從上面的測試結果看出,現在我們的mosquitto伺服器已經有了username和password的feature了。

MQTT Python API(paho-mqtt)

pip install paho-mqtt

注: paho-mqtt這個庫提供的函式主要是客戶端的函式
另外,在paho-mqtt庫中,有一種重要的函式–回撥函式。簡單說一下回撥函式,通常情況下,我們寫應用程式程式碼時經常引入一些API,我們主動呼叫這些API裡的函式,稱為直調。反過來,如果讓API呼叫我們定義好的函式,這就稱為回撥。在phao-client這個庫中,on_connect, on_message, on_subscribe, on_publish等等這些均為回撥函式, 這些回撥函式由中間函式呼叫。簡單看一下paho-client中的callback 是如何實現的(以subscribe為例):

# 假設我們自定義的on_subscribe回撥函式如下
# 先不要管為什麼要在函式中指定這些引數,後面會用到
def on_subscribe(client, userdata, mid, granted_qos):
    print('Subscribed message: ', str(mid))

# 然後我們使用如下語句設定回撥
client.on_subscribe = on_subscribe

# 下面解釋上面這行程式碼,檢視paho-mqtt原始碼
@property
def on_subscribe(self):
    """If implemented, called when the broker responds to a subscribe
    request."""
    return self._on_subscribe

@on_subscribe.setter
def on_subscribe(self, func):
    """ Define the suscribe callback implementation.

    Expected signature is:
        subscribe_callback(client, userdata, mid, granted_qos)

    client:         the client instance for this callback
    userdata:       the private user data as set in Client() or userdata_set()
    mid:            matches the mid variable returned from the corresponding
                    subscribe() call.
    granted_qos:    list of integers that give the QoS level the broker has
                    granted for each of the different subscription requests.
    """
    with self._callback_mutex:
        self._on_subscribe = func

從上面的程式碼可以看出, subscribe為Client類的一個property,我們使用的是subscribe屬性的setter方法,設定類成員變數_on_subscribe的值。
接下來,我們發出subscribe請求,下面paho-client處理subscribe請求的函式,函式的前半部分基本是對客戶端傳入引數topic的檢查,忽略。從最後三行程式碼可以看出,客戶端傳送了topic_qos_list這條訊息給了MQTT伺服器端。

    def subscribe(self, topic, qos=0):
        topic_qos_list = None

        if isinstance(topic, tuple):
            topic, qos = topic

        if isinstance(topic, basestring):
            ...
        elif isinstance(topic, list):
            ...

        if topic_qos_list is None:
            raise ValueError("No topic specified, or incorrect topic type.")

        if any(self._filter_wildcard_len_check(topic) != MQTT_ERR_SUCCESS for topic, _ in topic_qos_list):
            raise ValueError('Invalid subscription filter.')

        if self._sock is None:
            return (MQTT_ERR_NO_CONN, None)

        return self._send_subscribe(False, topic_qos_list)

在paho-mqtt中有一個函式_handle_suback來處理伺服器返回給客戶端subscribe請求的響應訊息。具體訊息接收的過程有好幾個步驟,大體經過的函式有:loop –> loop_read –> _packet_read –> _packet_handle –> _handle_suback

def _handle_suback(self):
     self._easy_log(MQTT_LOG_DEBUG, "Received SUBACK")
     pack_format = "!H" + str(len(self._in_packet['packet']) - 2) + 's'
     (mid, packet) = struct.unpack(pack_format, self._in_packet['packet'])
     pack_format = "!" + "B" * len(packet)
     granted_qos = struct.unpack(pack_format, packet)

     with self._callback_mutex:
         if self.on_subscribe:
             with self._in_callback:  # Don't call loop_write after _send_publish()
                 self.on_subscribe(self, self._userdata, mid, granted_qos)

     return MQTT_ERR_SUCCESS

好了,終於看到了在哪裡呼叫了回撥函式,現在明白了為什麼要在建立on_subscribe的時候指定那些引數了吧。因為這些引數可能對回撥函式本身沒什麼用,BUT,中間函式(也就是這裡的_handle_suback)認為它們有用,並且在呼叫回撥函式的時候傳入了這些引數,所以我們定義的時候需要有這些引數。

下面簡單介紹paho-client的一些基本操作,只是簡單列舉一些函式,具體更多的可以檢視官方Documentation

# import mqtt客戶端
import paho.mqtt.client as mqtt

# 建立客戶端, client_id為必須引數,其餘為可選引數
client = mqtt.Client(client_id=””, clean_session=True, userdata=None, protocol=MQTTv311, transport=”tcp”)


'''
# 當客戶端與伺服器端連線成功後,伺服器端會給客戶端返回一個Ack訊息,這個Ack會呼叫回撥方法on_connect()來顯示連線狀態,使用者可以自定義回撥方法的內容
params:
    rc: return code,表示伺服器端返回的連線狀態, 可能的值有:
        0: 連線成功
        1: 連線拒絕 --– 協議版本錯誤
        2. 連線拒絕 --- 客戶端身份驗證錯誤
        3. 連線拒絕 --- 伺服器不存在
        4. 連線拒絕 --- 使用者名稱/密碼錯誤
        5. 連線拒絕 --- 未授權錯誤
        6-255. 連線拒絕 --- 當前不可用
'''
def on_connect(client, userdata, flags, rc):
    if rc==0:
        client.connected_flag = True
        print("connected OK Returned code=",rc)
    else:
        client.connected_flag = False
        print("Bad connection Returned code=",rc)

# 設定自定義的on_connect回撥函式
client.on_connect = on_connect

'''
on_message()回撥函式
當訂閱者收到Broker釋出的訊息之後,on_message()被呼叫
params:
    message:
        :type MQTTMessage
        :attrs topic, payload, qos, retain
'''
def on_message(client, userdata, message):
    print("message received " ,str(message.payload.decode("utf-8")))
    print("message topic=",message.topic)
    print("message qos=",message.qos)
    print("message retain flag=",message.retain)

client.on_message = on_message

'''
連線伺服器端, host為broker的IP或者domain name
params:
    host: 伺服器端的IP地址或者Domain name
    keepalive: 客戶端和伺服器端互動的最長時間,當客戶端和Broker之間沒有互動的時候,客戶端ping伺服器端的頻率,單位為秒
    bind_address: 在多網路卡情況下,將客戶端和某一區域性網路卡的IP地址繫結
'''
cient.connect(host, port=1883, keepalive=60, bind_address="")

'''
Loop Start
loop_start()函式呼叫一次loop()函式
loop()函式的作用為:讀取、寫入接收快取區的或者傳送緩衝區中的資料,並呼叫對應的回撥函式。此外,loop函式還可以在連線斷開的時候,重新建立與伺服器端的連線.
'''
client.loop_start()

# 此外,可以通過connect_flag來標記連線狀態,主要用於等待連線成功
while client.connected_flag is False:
    time.sleep()

'''
Publish Message
只有topic和payload為必須引數,其餘可選
當客戶端呼叫publish()方法時,會返回MQTTMessageInfo物件,該物件包含的屬性和方法有:
    attr:
        rc(return code):
            MQTT_ERR_SUCCESS, MQTT_ERR_NO_CONN, MQTT_ERR_QUEUE_SIZE
        mid(message id)
        is_published
    function:
        wait_for_publish()
當訊息被髮送給Broker之後,on_publish()回撥方法會被呼叫
'''
client.publish(topic='$topic', payload='$payload', qos=0, retain=False)

'''
Subscribe Message
此函式的引數有三種型別:
1. Simple string and integer
    example: subscribe('my/topic', 0)
2. String and integer tuple
    example: subscribe(('my/topic', 0))
3. List of string and integer tuples
    exmaple: subscribe([('my/topic1', 0), ('my/topic', 2)])

return: (result, mid)
    :type tuple

當Broker收到訂閱者的訂閱請求之後,on_subscribe()回撥函式會被呼叫
'''
client.subscribe(topic, qos=0)

# 結束loop
client.loop_stop()

相關文章