Python 客戶端類庫之paho-mqtt學習總結

授客發表於2024-09-22

實踐環境

Python 3.9.13

paho-mqtt 2.1.0

簡介

Eclipse Paho MQTT Python客戶端類庫實現了MQTT 協議版本 5.0, 3.1.1, 和3.1。

該類庫提供一個客戶端類,允許應用連線到MQTT代理併發布訊息,訂閱主題並檢索釋出的訊息。同時還提供了一個寫其它輔助函式,使向MQTT伺服器釋出一次性訊息變得非常簡單。

支援 Python 3.7+。

MQTT協議是一種機器對機器(M2M)/“物聯網”連線協議。它被設計為一種極其輕量級的釋出/訂閱訊息傳輸,對於需要小程式碼佔用和/或網路頻寬非常昂貴的遠端連線非常有用。

安裝

pip install paho-mqtt

已知限制

以下是已知的未實現的MQTT功能。

clean_sessionFalse時,會話僅儲存在記憶體中,不會持久化。這意味著當客戶端重新啟動時(不僅僅是重新連線,通常是因為程式重新啟動而重新建立物件),會話就會丟失。這可能會導致訊息丟失。

客戶端會話的以下部分丟失:

  • 已從伺服器接收到但尚未完全確認的 QoS 2 訊息。

    由於客戶端會盲目確認任何PUBCOMP(QoS 2 事務的最後一條訊息),因此它不會掛起,但會丟失此 QoS 2 訊息。

  • 已傳送到伺服器但尚未完全確認的 QoS 1 和 QoS 2 訊息。

    這意味著傳遞給 publish()的訊息可能會丟失。這可以透過讓傳遞給 publish() 的所有訊息都有相應的on_publish() 呼叫或使用wait_for_publish來緩解。

    這也意味著代理在會話中可能有 QoS2 訊息。由於客戶端從一個空會話開始,它不知道它,並將重用mid。這還沒有解決。

此外,當clean_sessionTrue時,此類庫將在網路重新連線時重新發布 QoS > 0訊息。這意味著 QoS > 0訊息不會丟失。但標準規定,我們應該丟棄傳送釋出包的任何訊息。設定為True意味著不符合標準,QoS 2 可能會被接收兩次。

如果只需要一次交付的 QoS 2 保證,則應設定clean_session=False

用法與API

API詳細線上文件:https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html

示例:https://github.com/eclipse/paho.mqtt.python/tree/master/examples

開始

下面是一個非常簡單的示例,它訂閱代理$SYS主題樹並列印出結果訊息:

# -*- coding:utf-8 -*-

import paho.mqtt.client as mqtt

def on_connect(client, userdata, flags, reason_code, properties):
    '''客戶端從伺服器接收到 CONNACK 響應時的回撥'''

    print(f"Connected with result code {reason_code}")  # 成功連線時 reason_code 值為 Success

    # 在on_connect()中執行訂閱操作,意味著如果應用失去連線並且重新連線後,訂閱將被續訂。
    if reason_code == 'Success':
        client.subscribe('$SYS/#')

def on_disconnect(client, userdata, flags, reason_code, properties):
    print(f'Disconnected with result code {reason_code}')


def on_message(client, userdata, msg):
    '''從伺服器收到 PUBLISH 訊息時的回撥。'''
    print(msg.topic + ' ' + str(msg.payload)) # 輸出值形如 $SYS/broker/version b'mosquitto version 2.0.18'

mqttc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
mqttc.on_connect = on_connect
mqttc.on_disconnect = on_disconnect
mqttc.on_message = on_message
 
# client.username_pw_set('testacc', 'test1234') # 設定訪問賬號和密碼

mqttc.connect("mqtt.eclipseprojects.io", 1883, 60)

# 阻塞呼叫,處理網路流量、分派回撥和處理重新連線
# 有其它提供執行緒介面和手動介面的loop*()函式可用
mqttc.loop_forever()

說明:

  1. Client.username_pw_set(username: str | None, password: str | None = None*) → None

    為代理身份驗證設定使用者名稱和密碼(可選)。

    必須在connect()之前呼叫才能生效。需要支援MQTT v3.1或更高版本的代理。

    • 引數:
      • username – 要進行身份驗證的使用者名稱。需要與客戶端id沒有關係。必須是字串[MQTT-3.1.3-11]。設定為“None”可將客戶端重置為不使用使用者名稱/密碼進行代理身份驗證。
      • password – 用於身份驗證的密碼。可選,如果不需要,則設定為None。如果為字串r,那麼它將被編碼為UTF-8。
  2. Client.connect(host: str, port: int = 1883, keepalive: int = 60, bind_address: str = '', bind_port: int = 0, clean_start: bool | Literal[3] = 3, properties: Properties|None = None) → MQTTErrorCode
    

    連線到遠端代理。這是一個阻塞呼叫,用於建立底層連線並傳輸CONNECT資料包。請注意,在收到並處理CONNACK之前,連線狀態不會更新(這需要一個正在執行的網路迴圈,請參閱loop_start, loop_forever, loop…)).
    引數

    • host– 遠端代理的主機名或IP地址。

    • port– 要連線的伺服器主機的網路埠。預設為1883。請注意,SSL/TLS上MQTT的預設埠是8883,因此如果使用TLS_set()可能需要提供埠。

    • keepalive - 設定心跳的時間,單位是秒。這個值告訴MQTT客戶端,在沒有接收到任何通訊的情況下,多久應該傳送一個PING請求給伺服器,以保持連線,預設60秒。

    • clean_start -(僅限MQTT v5.0)TrueFalseMQTT_CLEAN_START_FIRST_ONLY。總是設定MQTT v5.0 clean_start標誌、從不或僅在第一次成功連線時。設定clean_start標誌後,MQTT會話資料(如未完成的訊息和訂閱)在成功連線時被清除。對於MQTT v3.1.1,Clientclean_session引數應用於類似的結果。

    • properties (Properties) –( 僅僅限MQTT v5.0)需要在MQTT連線包傳送的的MQTT v5.0 屬性。

客戶端(Client)

Client類一般使用流程如下:

  1. 建立客戶端例項
  2. 使用connect*() 函式之一連線到代理
  3. 呼叫其中一個loop*()函式來維護代理的網路流量
  4. 使用subscribe()訂閱主題並接收訊息
  5. 使用publish()將訊息釋出到代理
  6. 使用disconnect()斷開與代理的連線

將呼叫回撥以允許應用程式根據需要處理事件。這些回撥如下所述。

網路迴圈

這些功能是Client背後的驅動力。如果它們沒有被呼叫,傳入的網路資料將不會被處理,傳出的網路資料也不會被髮送。管理網路環路有四種選擇。這裡描述了三個,第四個在下面的“外部事件迴圈支援”中描述。不要混合使用不同的loop函式。

loop_start() / loop_stop()
mqttc.loop_start()

while True:
    temperature = sensor.blocking_read()
    mqttc.publish("paho/temperature", temperature)

mqttc.loop_stop()

這些函式實現了網路迴圈的執行緒介面。在connect*()之前或之後呼叫loop_start()一次,會在後臺執行一個執行緒來自動呼叫loop()。這釋放了主執行緒,用於可能阻塞的其他工作。此呼叫還處理與代理的重新連線。呼叫loop_stop() 以停止後臺執行緒。如果呼叫disconnect(),迴圈也會停止。

loop_forever()
mqttc.loop_forever(retry_first_connection=False)

這是網路迴圈的阻塞形式,在客戶端呼叫disconnect()之前不會返回(即呼叫mqttc.disconnect()後會停止阻塞,繼續執行其後的程式碼)。它會自動處理重新連線。
除了使用connect_async時的第一次連線嘗試外,使用retry_first_connection=True 使其重試第一次連線。
警告:這可能會導致客戶端保持連線到不存在的主機而不會出現失敗。

loop()
run = True
while run:
    rc = mqttc.loop(timeout=1.0)
    if rc != 0:
        # need to handle error, possible reconnecting or stopping the application

定期呼叫以處理網路事件。此呼叫觸發select()等待,直到網路套接字可用於讀取或寫入,如果套接字可用,則處理流入/流出的資料。此函式最多阻塞timeout秒。timeout不能超過客戶端的keepalive值,否則代理會定期斷開客戶端的連線。
使用這種迴圈,需要自己處理重新連線策略。

回撥

與paho-mqtt互動的介面包括各種回撥,當發生某些事件時,類庫會呼叫這些回撥。

回撥是在程式碼中定義的函式,用於實現對這些事件要求的操作。這可能只是列印收到的訊息,也可能是更復雜的行為。

回撥API是有版本的,所選版本是我們提供給客戶端建構函式的CallbackAPIVersion。目前支援兩個版本:

  • CallbackAPIVersion.VERSION1:這是paho-mqtt 2.0版本之前使用的歷史版本。它是在引入CallbackAPIVersion之前使用的API。此版本已棄用,將在paho-mqtt 3.0版本中刪除。
  • CallbackAPIVersion.VERSION2:此版本在協議MQTT 3.x和MQTT 5.x之間更為一致。它也更適用於MQTT 5.x,因為reason_code和屬性始終在可獲取時提供。建議所有使用者升級到此版本。強烈建議MQTT 5.x使用者使用。

存在以下回撥:

  • on_connec():當收到代理返回CONNACK時被呼叫。呼叫可能是針對被拒絕的連線,請檢查reason_code以檢視連線是成功還是被拒絕。
  • on_connect_fail():當TCP連線建立失敗時,由loop_forever()loop_start()呼叫。當直接使用connect()reconnect()時,不會呼叫此回撥。它僅由loop_start()loop_forever()製造的自動(重新)連線後被呼叫
  • on_disconnect():當連線關閉時被呼叫。
  • on_message():收到代理返回的MQTT訊息時被呼叫。
  • on_publish():當MQTT訊息傳送到代理時被呼叫。取決於QoS級別,回撥在不同時刻被呼叫:
    • 對於QoS==0,一旦訊息透過網路傳送,就會呼叫它。這可能是在相應的publish()返回之前。
    • 對於QoS==1,當收到代理返回的對應訊息的PUBACK時呼叫它
    • 對於QoS==2,當收到代理返回的對應訊息的PUBCOMP時,會呼叫它
  • on_subscribe():當收到代理返回的SUBACK時被呼叫
  • on_unsubscribe:當收到代理返回的UNSUBACK時被呼叫
  • on_log():當類庫記錄一條訊息時被呼叫
  • onSocket_openonSocket_closeonSocket_register_writeonSocket_unregister_write:用於外部迴圈支援(External event loop support)的回撥。詳見下文。

參閱線上文件檢視有關每個回撥的特徵。

訂閱示例
# -*- coding:utf-8 -*-

import paho.mqtt.client as mqtt

def on_subscribe(client, userdata, mid, reason_code_list, properties):
    # 由於我們只訂閱了一個通道,reason_code_list只包含一個條目
    # print(reason_code_list) #輸出: [ReasonCode(Suback, 'Granted QoS 0')]
    if reason_code_list[0].is_failure:
        print(f"Broker rejected you subscription: {reason_code_list[0]}")
    else:
        print(f"Broker granted the following QoS: {reason_code_list[0].value}")

def on_unsubscribe(client, userdata, mid, reason_code_list, properties):
    #注意,reason_code_list僅存在於MQTTv5中,在MQTTv3中,它將始終為空
    if len(reason_code_list) == 0 or not reason_code_list[0].is_failure:
        print("unsubscribe succeeded (if SUBACK is received in MQTTv3 it success)")
    else:
        print(f"Broker replied with failure: {reason_code_list[0]}")
    client.disconnect()

def on_message(client, userdata, message):
    # userdata是我們選擇提供的資料結構,這裡為一個列表(透過下方的 mqttc.user_data_set([])設定,該函式引數即為userdata引數值
    userdata.append(message.payload)
    # 假設只想處理10條訊息
    if len(userdata) >= 10:
        client.unsubscribe("$SYS/#")

def on_connect(client, userdata, flags, reason_code, properties):
    if reason_code.is_failure:
        print(f"Failed to connect: {reason_code}. loop_forever() will retry connection")
    else:
        # 應該始終在 on_connect 回撥中訂閱以確保在重新連線時訂閱依舊存在。
        client.subscribe("$SYS/#")

mqttc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
mqttc.on_connect = on_connect
mqttc.on_message = on_message
mqttc.on_subscribe = on_subscribe
mqttc.on_unsubscribe = on_unsubscribe

mqttc.user_data_set([]) # 設定 userdata
mqttc.connect("mqtt.eclipseprojects.io")
mqttc.loop_forever() # 當呼叫client.disconnect()後繼續執行以下程式碼

print(f"Received the following message: {mqttc.user_data_get()}")
釋出示例
# -*- coding:utf-8 -*-

import time
import paho.mqtt.client as mqtt

def on_publish(client, userdata, mid, reason_code, properties):
    '''reason_code和properties將僅出現在MQTTv5中。在MQTTv3中始終未設定
    使用不存在`uncaked_publish`中的`mid`呼叫`on_publish()`。這是由於不可避免的競爭情形:
    * publish() 返回已傳送訊息的mid。
    * 主執行緒將publish()返回的mid新增到uncaked_publish中
    * loop_start執行緒呼叫on_publish()
    雖然不太可能(因為on_publish()將在網路往返後呼叫),但是這是一種可能發生的競爭情形
    避免競爭情形的最佳解決方案是使用publish()中的msg_info。還可以嘗試使用已確認的mid列表,而不是從待處理列表中刪除
    但是請記住,mid可以重複使用!
    reason_code和properties將僅出現在MQTTv5中。在MQTTv3中始終未設定
    '''
    try:
        userdata.remove(mid)
    except KeyError:
        print("on_publish() is called with a mid not present in unacked_publish")
        print("This is due to an unavoidable race-condition:")
        print("* publish() return the mid of the message sent.")
        print("* mid from publish() is added to unacked_publish by the main thread")
        print("* on_publish() is called by the loop_start thread")
        print("While unlikely (because on_publish() will be called after a network round-trip),")
        print(" this is a race-condition that COULD happen")
        print("")
        print("The best solution to avoid race-condition is using the msg_info from publish()")
        print("We could also try using a list of acknowledged mid rather than removing from pending list,")
        print("but remember that mid could be re-used !")

unacked_publish = set()
mqttc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
mqttc.on_publish = on_publish

mqttc.user_data_set(unacked_publish)
mqttc.connect("mqtt.eclipseprojects.io")
mqttc.loop_start()

# 應用生產一些訊息
msg_info = mqttc.publish("paho/test/topic", "my message", qos=1)
unacked_publish.add(msg_info.mid)

msg_info2 = mqttc.publish("paho/test/topic", "my message2", qos=1)
unacked_publish.add(msg_info2.mid)

# 等待所有訊息被髮布
while len(unacked_publish):
    time.sleep(0.1)

# 由於上述描述的競爭狀態, 以下等待所有訊息釋出完成的方式更安全
msg_info.wait_for_publish()
msg_info2.wait_for_publish()

mqttc.disconnect()
mqttc.loop_stop()

說明:

  1. Client.max_inflight_messages_set(inflight: int) → None

    設定一次可以透過其網路流的QoS>0的訊息的最大數量(可以簡單理解為允許多大數量的QoS>0的訊息被同時進行傳輸處理)。預設值為20。

  2. Client.max_queued_messages_set(queue_size:int)→ Client
    設定傳出訊息佇列中的最大訊息數量。0表示無限制。

  3. MQTTMessageInfo.wait_for_publish(timeout: float | None = None) → None

    阻塞,直到與此物件關聯的訊息被髮布,或者直到超時發生。如果timeoutNone,則永遠不會超時。將超時設定為正數秒,例如1,2,以啟用超時。
    丟擲:

    • ValueError–如果訊息因傳出佇列已滿而未排隊。
    • RuntimeError-如果訊息因其他原因未釋出。
  4. 實踐過程中發現,採用多執行緒併發釋出訊息時,如果伺服器因為限流的原因不返回訊息確認,那麼執行一小段時間後,出現訊息無法釋出成功的情況(不報錯,但是訊息無法抵達broker),透過合理的引數呼叫以上三個函式,可以緩解這個問題。

Logger

客戶端會發出一些日誌訊息,這些訊息在故障排除過程中可能很有用。啟用日誌最簡單的方法是呼叫enable_logger()。可以提供自定義記錄器或使用預設記錄器

示例:

import logging
import paho.mqtt.client as mqtt

logging.basicConfig(level=logging.DEBUG)

mqttc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
mqttc.enable_logger()

mqttc.connect("mqtt.eclipseprojects.io", 1883, 60)
mqttc.loop_start()

# Do additional action needed, publish, subscribe, ...
[...]

還可以定義一個on_log回撥,它將接收所有日誌訊息的副本。例子:

import paho.mqtt.client as mqtt

def on_log(client, userdata, paho_log_level, messages):
    if paho_log_level == mqtt.LogLevel.MQTT_LOG_ERR:
        print(message)

mqttc = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
mqttc.on_log = on_log

mqttc.connect("mqtt.eclipseprojects.io", 1883, 60)
mqttc.loop_start()

# Do additional action needed, publish, subscribe, ...
[...]

Paho日誌級別和標準日誌級別的對應關係如下:

Paho logging
MQTT_LOG_ERR logging.ERROR
MQTT_LOG_WARNING logging.WARNING
MQTT_LOG_NOTICE logging.INFO (no direct equivalent)
MQTT_LOG_INFO logging.INFO
MQTT_LOG_DEBUG logging.DEBUG

外部事件迴圈支援

為了支援其他網路迴圈,如asyncio(參見示例),類庫公開了一些方法和回撥來支援這些用例。

存在以下迴圈方法:

  • loop_read:應該在套接字可讀取時呼叫。
  • loop_write:應該在套接字可寫並且類庫需要寫入資料時呼叫。
  • loop_misc:應每隔幾秒鐘呼叫一次,以處理訊息重試和ping。

用虛擬碼表示如下:

while run:
    if need_read:
        mqttc.loop_read()
    if need_write:
        mqttc.loop_write()
    mqttc.loop_misc()

    if not need_read and not need_write:
        # But don't wait more than few seconds, loop_misc() need to be called regularly
        wait_for_change_in_need_read_or_write()
    updated_need_read_and_write()

棘手的部分是實現updated_need_read_and_write並等待條件變更。為了支援這一點,存在以下方法:

  • socket:當TCP連線開啟時返回socket物件。此呼叫對於基於select迴圈特別有用。請參閱examples/loop_select.py

  • want_write():如果有資料等待寫入,則返回True。這接近於上述虛擬碼的need_writew,但還是應該檢查套接字是否可寫。

  • 回撥函式on_socket_*

    • on_socket_open:在套接字開啟時呼叫。
    • on_socket_open:在套接字開啟時呼叫。
    • on_socket_close:當套接字即將關閉時呼叫。
    • on_socket_register_write:當客戶端想要在套接字上寫入資料時呼叫
    • on_socket_unregister_write:當套接字上沒有更多資料要寫入時呼叫。

    回撥對於事件迴圈特別有用,在事件迴圈中,可以註冊或登出用於讀寫的套接字。請參閱examples/loop_asyncio.py 獲取示例。

回撥總是按以下順序呼叫:

  • on_socket_open

  • 0或者更多次:

    • on_socket_register_write
    • on_socket_unregister_write
  • on_socket_close

全域性輔助函式

客戶端模組還提供了一些全域性輔助函式。

topic_matches_sub(sub, topic)可用於檢查主題(topic)是否與訂閱(subscription)匹配。
例如:

主題foo/bar 將與訂閱foo/#+/bar匹配
主題non/matching 將不匹配訂閱non/+/+

釋出

此模組提供了兩個輔助函式single()multiple(),允許以一次性方式直接釋出訊息。換句話說,它們對於有一個/多個訊息要釋出到代理,然後斷開連線而不需要其他任何東西的情況非常有用。

提供的兩個函式是single()multiple()

這兩個函式都支援MQTT v5.0,但目前不允許在連線或傳送訊息時設定任何屬性。

Single

釋出一條訊息到代理,然後徹底斷開連線。

例子:

import paho.mqtt.publish as publish

publish.single("paho/test/topic", "payload", hostname="mqtt.eclipseprojects.io")

Multiple

釋出多條訊息到代理,然後徹底斷開連線。

例子:

from paho.mqtt.enums import MQTTProtocolVersion
import paho.mqtt.publish as publish

msgs = [{'topic':"paho/test/topic", 'payload':"multiple 1"},
    ("paho/test/topic", "multiple 2", 0, False)]
publish.multiple(msgs, hostname="mqtt.eclipseprojects.io", protocol=MQTTProtocolVersion.MQTTv5)

訂閱

此模組提供了兩個輔助函式simple()callback(),以允許直接訂閱和處理訊息。

這兩個函式都支援MQTT v5.0,但目前不允許在連線或傳送訊息時設定任何屬性。

Simple

訂閱一組主題並返回收到的訊息。這是一個阻塞函式。
例子:

import paho.mqtt.subscribe as subscribe

msg = subscribe.simple("paho/test/topic", hostname="mqtt.eclipseprojects.io")
print("%s %s" % (msg.topic, msg.payload))

使用回撥(Callback)

訂閱一組主題,並使用使用者提供的回撥處理收到的訊息。

例子:

import paho.mqtt.subscribe as subscribe

def on_message_print(client, userdata, message):
    print("%s %s" % (message.topic, message.payload))
    userdata["message_count"] += 1
    if userdata["message_count"] >= 5:
        # it's possible to stop the program by disconnecting
        client.disconnect()

subscribe.callback(on_message_print, "paho/test/topic", hostname="mqtt.eclipseprojects.io", userdata={"message_count": 0})

參考連線

https://github.com/eclipse/paho.mqtt.python

https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html

相關文章