2018年12月4日
MQTT 在 IoT 的應用:Broker、Topic、QoS、Retained 與遺囑訊息一次搞懂
為什麼 IoT 不適合直接用 HTTP 控制設備
我之前的家電控制是這樣做的:每個 ESP8266 在內網開一個 HTTP 服務,數據機設定 Port Forwarding,手機從外網打 http://家裡的IP:Port/on 就能開燈。做出來是能用,但問題很多:
- 設備要對外開 Port:只要知道 IP 和 Port,任何人都能控制,等於把家電直接暴露在網路上
- 沒有固定 IP 就不能用:換到沒有固定 IP 的環境,或是在電信業者的 NAT 後面,外面根本連不進來
- 手機不知道設備現在的狀態:要知道燈是開是關,只能一直輪詢每個設備
- 一對多很麻煩:想讓「手機」和「排程程式」都知道澆花系統啟動了,就要各自打一次 API
MQTT(Message Queuing Telemetry Transport)就是為這種場景設計的協定。它最早是 IBM 在 1999 年為了透過衛星監控油管而設計的,目標就是在頻寬小、網路不穩、設備資源有限的環境下可靠地傳訊息,現在是 OASIS 標準(3.1.1 版),也是 IoT 領域最常見的通訊協定之一。
MQTT 的基本模型:所有人都連到 Broker
MQTT 採用**發布/訂閱(Publish/Subscribe)**模型,角色只有兩種:
- Broker:訊息的中轉站,負責接收所有發布的訊息,再轉送給有訂閱的人。常見的實作有 Mosquitto、EMQ X、HiveMQ
- Client:所有連到 Broker 的程式都是 Client,同一個 Client 可以同時發布和訂閱。ESP8266、手機 App、後端排程都是 Client
這個模型最大的好處是:每個 Client 都是主動往外連到 Broker。設備在 NAT 後面也沒關係,因為連線是它自己發起的,Broker 再透過這條已經建立的 TCP 連線把訊息推回去。數據機不用開任何 Port,前面說的問題一次解決大半。
另外,發布者和訂閱者彼此不知道對方存在,只知道 Topic:澆花系統只管發布「我開始澆水了」,有幾個人在聽、是誰在聽,它完全不需要知道。
MQTT 的封包也非常精簡,固定標頭最少只有 2 個 Byte,比 HTTP 動輒幾百 Byte 的 Header 輕量很多,對記憶體只有幾十 KB 的 ESP8266 很友善。
Topic:用斜線分層的主題
Topic 是一個用 / 分層的字串,例如:
home/livingroom/light/set
home/livingroom/light/state
home/garden/watering/state
Topic 不需要事先建立,發布時寫什麼就是什麼。訂閱時可以使用兩種萬用字元:
| 萬用字元 | 意義 | 範例 | 符合 |
|---|---|---|---|
+ | 剛好一層 | home/+/light/state | home/livingroom/light/state、home/bedroom/light/state |
# | 零到多層,只能放在最後 | home/garden/# | home/garden、home/garden/watering/state |
注意 + 和 # 只能用在訂閱,發布時一定要寫完整的 Topic。
Topic 怎麼設計
Topic 設計好,後面的訂閱就會很省事。我的原則是:
- 由大到小:
{位置}/{區域}/{設備}/{用途},越後面越細,這樣萬用字元才好用 - 指令與狀態分開:
.../set是「請你變成這樣」,.../state是「我現在是這樣」。手機發set,設備收到、執行完之後再發state。如果兩者共用一個 Topic,設備會收到自己發出的狀態,分不清是指令還是回報 - 不要用
/開頭:/home/light的第一層會是一個空字串,容易和home/light搞混 - 不要用
$開頭:$SYS/開頭的 Topic 是 Broker 保留用來發布自身狀態的(例如連線數)
QoS:三種投遞保證
MQTT 在每一則訊息上都可以指定 QoS(Quality of Service):
- QoS 0:最多一次(At most once)。送出去就不管了,沒有確認,網路一斷就遺失。適合高頻率、掉一筆也無所謂的資料,例如每秒回報一次的溫度
- QoS 1:至少一次(At least once)。接收端要回
PUBACK,沒收到確認就重送,所以可能重複。大部分的指令都用這個等級 - QoS 2:剛好一次(Exactly once)。透過四次往返確保不重複也不遺失,但延遲與負擔最大,在 IoT 設備上很少真的需要
有兩個容易誤會的地方:
- QoS 是「每一段」各自的:發布者到 Broker 是一段,Broker 到訂閱者是另一段。發布者用 QoS 1 發布,但訂閱者用 QoS 0 訂閱,Broker 轉送給它時就會降級成 QoS 0。實際的保證是兩者中比較低的那個
- QoS 1 會重複:設備要能承受同一個指令收到兩次。所以指令的內容要用「變成什麼狀態」(
ON、OFF),不要用「切換」(TOGGLE)——收到兩次ON還是開,收到兩次TOGGLE就變回關了
Retained Message:新訂閱者馬上知道目前狀態
一般的訊息,Broker 轉送完就丟了。手機 App 打開時才訂閱 home/+/+/state,要等到每個設備下一次發布狀態才知道燈是開是關。
發布時加上 Retain 旗標,Broker 會把這個 Topic 的最後一則訊息保留下來,之後只要有人訂閱,就馬上收到這一則。所以:
- 狀態(
state)用 Retained:App 一打開就能拿到每個設備的最新狀態 - 指令(
set)不要用 Retained:否則設備每次重新連線都會再收到一次舊的指令,例如「三天前的開燈」
要清除一個 Topic 的 Retained Message,就發布一則內容為空的 Retained 訊息。
Clean Session:離線期間的訊息要不要保留
Client 連線時會帶一個 Clean Session 旗標:
true:每次連線都是全新的,斷線期間的訊息全部不保留,重新連線後也要重新訂閱false(Persistent Session):Broker 會依 Client ID 記住這個 Client 的訂閱,斷線期間發給它的 QoS 1、QoS 2 訊息會先排隊,重新連線後補送
對控制指令來說,要不要補送其實要想清楚:設備斷線 10 分鐘,回來後補收到「10 分鐘前的開燈指令」是合理的;但如果是「澆水 5 分鐘」這種有時效的指令,晚了 2 小時才執行反而有問題。我的做法是設備用 Persistent Session,但在指令裡帶上時間戳,設備發現指令太舊就丟掉。
Keep Alive 與遺囑訊息:知道設備斷線了
Wi-Fi 斷線、設備斷電時,TCP 連線不一定會馬上被發現是斷的。MQTT 用兩個機制處理:
- Keep Alive:Client 連線時宣告一個秒數(例如 30 秒),在這段時間內如果沒有其他封包,就送一個
PINGREQ。Broker 超過 1.5 倍的時間都沒收到任何東西,就判定這個 Client 已經斷線 - 遺囑訊息(Last Will and Testament,LWT):Client 連線時先告訴 Broker:「如果我不正常斷線,請幫我發布這則訊息」
把兩者結合,就能做出設備的上下線狀態:
- 設備連線時設定遺囑:Topic
home/livingroom/light/status、內容offline、Retained - 連線成功後,自己發布一則
online到同一個 Topic,也是 Retained - 設備斷電,Broker 在 Keep Alive 逾時後代為發布
offline
App 訂閱 home/+/+/status,就能即時知道哪些設備離線了,而且因為是 Retained,打開 App 的當下就看得到。
使用情境:用 MQTT 改寫家電控制
架構改成這樣:
- 一台雲端主機跑 Mosquitto,只開放 TLS 的 8883 埠,關閉匿名登入
- 每個 ESP8266 用自己的帳號連到 Broker,訂閱自己的
setTopic,發布自己的state與status - 手機 App 訂閱
home/+/+/state與home/+/+/status顯示所有設備,按下按鈕時發布到對應的set - 自動澆花的排程程式也是一個 Client,時間到了就發布
home/garden/watering/set
Mosquitto 的設定:
# mosquitto.conf
listener 8883
cafile /etc/mosquitto/certs/ca.crt
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key
allow_anonymous false
password_file /etc/mosquitto/passwd
acl_file /etc/mosquitto/acl
ACL 限制每個帳號只能碰自己的 Topic,避免其中一個設備的帳號被拿走後能控制全部:
# acl:客廳燈只能讀自己的指令、寫自己的狀態
user livingroom-light
topic read home/livingroom/light/set
topic write home/livingroom/light/state
topic write home/livingroom/light/status
# 手機 App 可以讀所有狀態、送所有指令
user mobile-app
topic read home/+/+/state
topic read home/+/+/status
topic write home/+/+/set
ESP8266 端使用 Arduino 的 PubSubClient 函式庫:
#include <ESP8266WiFi.h>
#include <PubSubClient.h>
WiFiClientSecure net;
PubSubClient mqtt(net);
const char* SET_TOPIC = "home/livingroom/light/set";
const char* STATE_TOPIC = "home/livingroom/light/state";
const char* STATUS_TOPIC = "home/livingroom/light/status";
void onMessage(char* topic, byte* payload, unsigned int length) {
String cmd;
for (unsigned int i = 0; i < length; i++) cmd += (char)payload[i];
// 用「目標狀態」而不是「切換」,重複收到也不會出錯
if (cmd == "ON") digitalWrite(RELAY_PIN, HIGH);
if (cmd == "OFF") digitalWrite(RELAY_PIN, LOW);
mqtt.publish(STATE_TOPIC, cmd.c_str(), true); // 回報狀態,Retained
}
void connectMqtt() {
while (!mqtt.connected()) {
// Client ID 要唯一;最後四個參數是遺囑:Topic、QoS、Retain、內容
if (mqtt.connect("livingroom-light", MQTT_USER, MQTT_PASS,
STATUS_TOPIC, 1, true, "offline")) {
mqtt.publish(STATUS_TOPIC, "online", true);
mqtt.subscribe(SET_TOPIC, 1);
} else {
delay(5000); // 連不上就等一下再試,不要瘋狂重連
}
}
}
void setup() {
pinMode(RELAY_PIN, OUTPUT);
WiFi.begin(WIFI_SSID, WIFI_PASS);
net.setFingerprint(BROKER_FINGERPRINT); // 驗證 Broker 憑證
mqtt.setServer(BROKER_HOST, 8883);
mqtt.setCallback(onMessage);
}
void loop() {
if (!mqtt.connected()) connectMqtt();
mqtt.loop(); // 處理收到的訊息與 Keep Alive,一定要一直呼叫
}
測試時用 Mosquitto 附的指令工具很方便:
# 監看所有設備的狀態(-v 會一起印出 Topic)
mosquitto_sub -h broker.example.com -p 8883 --cafile ca.crt \
-u mobile-app -P '***' -t 'home/+/+/state' -v
# 送出開燈指令
mosquitto_pub -h broker.example.com -p 8883 --cafile ca.crt \
-u mobile-app -P '***' -t 'home/livingroom/light/set' -m 'ON' -q 1
和原本的做法比較:
| 原本:HTTP + Port Forwarding | 改成 MQTT | |
|---|---|---|
| 連線方向 | 外面連進家裡 | 設備主動連出去 |
| 需要固定 IP | 需要 | 不需要(Broker 要有) |
| 驗證與加密 | 沒有 | 帳號密碼 + TLS + ACL |
| 得知設備狀態 | 輪詢每個設備 | 訂閱 Retained 的 state |
| 得知設備離線 | 打不通才知道 | 遺囑訊息即時通知 |
| 多個程式要收到同一個事件 | 各自呼叫 | 各自訂閱同一個 Topic |
注意事項
- Client ID 一定要唯一:兩個 Client 用同一個 Client ID 連線,Broker 會把先連的那個踢掉。如果兩個設備都設定了自動重連,就會互相踢來踢去、一直斷線。燒錄程式時最容易複製貼上忘了改
- ESP8266 的 PubSubClient 有限制:發布只支援 QoS 0,訂閱支援 QoS 0 和 1;預設的封包大小上限是 128 Byte,訊息太長會直接發不出去,要調整
MQTT_MAX_PACKET_SIZE loop()裡不能有長時間的delay():mqtt.loop()沒被呼叫,Keep Alive 就送不出去,Broker 會以為設備斷線並發布遺囑。需要等待的邏輯要改用millis()計時- TLS 在 ESP8266 上很吃記憶體:TLS 握手需要不少 RAM,憑證驗證用 Fingerprint 比較省資源,但伺服器換憑證時,所有設備都要重新燒錄
- 重連要有間隔:Broker 重啟時,所有設備會同時重連。每個設備的重連間隔最好加一點隨機值,不要全部在同一秒湧進來
- 不要什麼都用 QoS 2:每則訊息四次往返,在不穩定的 Wi-Fi 上延遲會很明顯。溫度這種持續回報的資料用 QoS 0,指令用 QoS 1,再讓設備的處理邏輯能承受重複
- 小心訂閱
#:訂閱全部 Topic 在除錯時很方便,但在正式環境,Broker 會把所有訊息都推給這個 Client,設備一多流量就很可觀 - Broker 是單點:所有設備都依賴它,Broker 掛掉整個系統就停了。至少要讓 Mosquitto 開機自動啟動、異常自動重啟,並監控它的狀態
一句話總結
MQTT 讓 IoT 設備主動連到 Broker,用 Topic 發布與訂閱訊息,解決了設備在 NAT 後面、要對外開 Port 的問題;設計時把指令與狀態分成不同 Topic,狀態用 Retained 讓新訂閱者馬上拿到,指令用 QoS 1 並設計成重複收到也沒關係,再用 Keep Alive 加遺囑訊息掌握設備上下線,最後別忘了 TLS、帳號與 ACL,才不會從「任何人都能開你家的燈」變成「任何人都能訂閱你家的燈」。