mqttxx 2.0.2__py3-none-any.whl → 3.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mqttxx/__init__.py +13 -5
- mqttxx/client.py +295 -152
- mqttxx/config.py +14 -0
- mqttxx/conventions.py +8 -5
- mqttxx/events.py +340 -0
- mqttxx/protocol.py +189 -14
- mqttxx/rpc.py +63 -30
- mqttxx-3.1.2.dist-info/METADATA +910 -0
- mqttxx-3.1.2.dist-info/RECORD +13 -0
- mqttxx-2.0.2.dist-info/METADATA +0 -490
- mqttxx-2.0.2.dist-info/RECORD +0 -12
- {mqttxx-2.0.2.dist-info → mqttxx-3.1.2.dist-info}/LICENSE +0 -0
- {mqttxx-2.0.2.dist-info → mqttxx-3.1.2.dist-info}/WHEEL +0 -0
- {mqttxx-2.0.2.dist-info → mqttxx-3.1.2.dist-info}/top_level.txt +0 -0
mqttxx/__init__.py
CHANGED
|
@@ -3,21 +3,25 @@
|
|
|
3
3
|
提供:
|
|
4
4
|
- MQTTClient: MQTT 连接管理(自动重连、订阅队列化、TLS/SSL 支持)
|
|
5
5
|
- RPCManager: 双向对等 RPC 调用(超时控制、权限检查、并发限制)
|
|
6
|
+
- EventChannelManager: 高吞吐事件广播通道(单向、无返回值)
|
|
6
7
|
- 配置对象: MQTTConfig, TLSConfig, AuthConfig, RPCConfig 等
|
|
7
|
-
- 协议定义: RPCRequest, RPCResponse
|
|
8
|
+
- 协议定义: RPCRequest, RPCResponse, EventMessage
|
|
8
9
|
- 异常系统: 统一错误码和异常层次
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
|
-
__version__ = "
|
|
12
|
+
__version__ = "3.1.0"
|
|
12
13
|
__author__ = "MQTTX Team"
|
|
13
14
|
|
|
14
15
|
# 核心客户端
|
|
15
|
-
from .client import MQTTClient
|
|
16
|
+
from .client import MQTTClient, RawAPI
|
|
16
17
|
|
|
17
18
|
# RPC 管理器
|
|
18
19
|
from .rpc import RPCManager
|
|
19
20
|
from .conventions import ConventionalRPCManager
|
|
20
21
|
|
|
22
|
+
# Event Channel 管理器
|
|
23
|
+
from .events import EventChannelManager, EventMessage
|
|
24
|
+
|
|
21
25
|
# 配置对象
|
|
22
26
|
from .config import (
|
|
23
27
|
MQTTConfig,
|
|
@@ -31,7 +35,7 @@ from .config import (
|
|
|
31
35
|
from .protocol import (
|
|
32
36
|
RPCRequest,
|
|
33
37
|
RPCResponse,
|
|
34
|
-
parse_message
|
|
38
|
+
# parse_message 是内部 API,不再导出
|
|
35
39
|
)
|
|
36
40
|
|
|
37
41
|
# 异常系统
|
|
@@ -54,9 +58,13 @@ from .exceptions import (
|
|
|
54
58
|
__all__ = [
|
|
55
59
|
# MQTT 客户端
|
|
56
60
|
"MQTTClient",
|
|
61
|
+
"RawAPI", # Raw API (逃生通道)
|
|
57
62
|
# RPC 管理器
|
|
58
63
|
"RPCManager",
|
|
59
64
|
"ConventionalRPCManager", # 约定式 RPC(强约束系统)
|
|
65
|
+
# Event Channel 管理器
|
|
66
|
+
"EventChannelManager",
|
|
67
|
+
"EventMessage",
|
|
60
68
|
# 配置对象
|
|
61
69
|
"MQTTConfig",
|
|
62
70
|
"TLSConfig",
|
|
@@ -66,7 +74,7 @@ __all__ = [
|
|
|
66
74
|
# 协议定义
|
|
67
75
|
"RPCRequest",
|
|
68
76
|
"RPCResponse",
|
|
69
|
-
|
|
77
|
+
# parse_message 已移除 - 内部 API
|
|
70
78
|
# 异常系统
|
|
71
79
|
"ErrorCode",
|
|
72
80
|
"MQTTXError",
|
mqttxx/client.py
CHANGED
|
@@ -1,15 +1,166 @@
|
|
|
1
1
|
# aiomqtt 高级封装 - 基于纯 async/await 架构
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import
|
|
4
|
+
import os
|
|
5
5
|
import ssl
|
|
6
|
-
from typing import Callable, Optional
|
|
6
|
+
from typing import Callable, Optional, Any, TYPE_CHECKING
|
|
7
7
|
from loguru import logger
|
|
8
8
|
import aiomqtt
|
|
9
|
+
from paho.mqtt.matcher import MQTTMatcher
|
|
9
10
|
|
|
10
11
|
from .config import MQTTConfig
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .client import MQTTClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RawAPI:
|
|
18
|
+
"""Raw MQTT API (逃生通道)
|
|
19
|
+
|
|
20
|
+
绕过协议层,直接操作 bytes。
|
|
21
|
+
|
|
22
|
+
警告:
|
|
23
|
+
这是逃生通道,仅在以下场景使用:
|
|
24
|
+
- 自定义协议 (非 JSON/RPC)
|
|
25
|
+
- 性能敏感场景 (避免编解码)
|
|
26
|
+
- 与第三方系统集成
|
|
27
|
+
|
|
28
|
+
示例:
|
|
29
|
+
# 订阅原始 bytes
|
|
30
|
+
client.raw.subscribe("sensor/+", lambda t, b: process(b))
|
|
31
|
+
|
|
32
|
+
# 发布原始 bytes
|
|
33
|
+
await client.raw.publish("topic", b"\\x01\\x02\\x03")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, client: "MQTTClient"):
|
|
37
|
+
"""初始化 Raw API
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
client: MQTTClient 实例
|
|
41
|
+
"""
|
|
42
|
+
self._client = client
|
|
43
|
+
|
|
44
|
+
def subscribe(self, pattern: str, handler: Callable[[str, bytes], Any]):
|
|
45
|
+
"""订阅原始消息 (bytes)
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
pattern: MQTT topic pattern (支持通配符 +/#)
|
|
49
|
+
handler: 回调函数
|
|
50
|
+
- 签名: async def handler(topic: str, payload: bytes)
|
|
51
|
+
- topic: 实际收到消息的 topic
|
|
52
|
+
- payload: 原始 bytes 数据 (未解码)
|
|
53
|
+
|
|
54
|
+
并发行为:
|
|
55
|
+
- 同一 pattern 的多个 handlers 按注册顺序**顺序调用**(非并发)
|
|
56
|
+
- 如需并发处理,请在 handler 内部使用 asyncio.create_task()
|
|
57
|
+
|
|
58
|
+
示例:
|
|
59
|
+
# 基础用法
|
|
60
|
+
async def on_message(topic: str, payload: bytes):
|
|
61
|
+
data = json.loads(payload.decode('utf-8'))
|
|
62
|
+
print(f"收到消息: {topic} -> {data}")
|
|
63
|
+
|
|
64
|
+
client.raw.subscribe("sensor/+/data", on_message)
|
|
65
|
+
|
|
66
|
+
# 顺序处理(默认)
|
|
67
|
+
async def handler_seq(topic: str, payload: bytes):
|
|
68
|
+
await process_data(payload) # 阻塞后续 handlers
|
|
69
|
+
|
|
70
|
+
# 并发处理(手动)
|
|
71
|
+
async def handler_async(topic: str, payload: bytes):
|
|
72
|
+
asyncio.create_task(process_async(payload)) # 不阻塞
|
|
73
|
+
"""
|
|
74
|
+
# 追踪 handlers (辅助结构)
|
|
75
|
+
if pattern not in self._client._raw_handlers:
|
|
76
|
+
self._client._raw_handlers[pattern] = []
|
|
77
|
+
self._client._raw_handlers[pattern].append(handler)
|
|
78
|
+
|
|
79
|
+
# 同步到 MQTTMatcher (核心匹配引擎)
|
|
80
|
+
self._client._raw_matcher[pattern] = self._client._raw_handlers[pattern]
|
|
81
|
+
|
|
82
|
+
# 订阅 MQTT topic
|
|
83
|
+
self._client._subscriptions.add(pattern)
|
|
84
|
+
if self._client._client:
|
|
85
|
+
asyncio.create_task(self._client._do_subscribe(pattern))
|
|
86
|
+
else:
|
|
87
|
+
logger.info(f"Raw 订阅已队列化 (等待连接) - pattern: {pattern}")
|
|
88
|
+
|
|
89
|
+
logger.debug(f"Raw 订阅成功 - pattern: {pattern}")
|
|
90
|
+
|
|
91
|
+
async def publish(self, topic: str, payload: bytes, qos: int = 0):
|
|
92
|
+
"""发布原始消息 (bytes)
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
topic: 目标主题
|
|
96
|
+
payload: 消息载荷 (bytes)
|
|
97
|
+
qos: QoS 等级 (0/1/2)
|
|
98
|
+
|
|
99
|
+
异常:
|
|
100
|
+
aiomqtt.MqttError: 发布失败
|
|
101
|
+
"""
|
|
102
|
+
if not self._client._client:
|
|
103
|
+
logger.warning(f"发布失败:客户端未连接 - topic: {topic}")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
await self._client._client.publish(topic, payload, qos=qos)
|
|
108
|
+
except aiomqtt.MqttError as e:
|
|
109
|
+
logger.error(f"发布失败 - topic: {topic}, error: {e}")
|
|
110
|
+
|
|
111
|
+
def unsubscribe(self, pattern: str, handler: Optional[Callable] = None):
|
|
112
|
+
"""取消订阅(修复 P0-B)
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
pattern: MQTT topic pattern
|
|
116
|
+
handler: 要移除的 handler(None = 移除所有)
|
|
117
|
+
|
|
118
|
+
示例:
|
|
119
|
+
# 移除特定 handler
|
|
120
|
+
client.raw.unsubscribe("test/+", my_handler)
|
|
121
|
+
|
|
122
|
+
# 移除所有 handler
|
|
123
|
+
client.raw.unsubscribe("test/+")
|
|
124
|
+
"""
|
|
125
|
+
if pattern not in self._client._raw_handlers:
|
|
126
|
+
logger.debug(f"Raw 取消订阅失败:pattern 不存在 - {pattern}")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
if handler is None:
|
|
130
|
+
# 移除所有 handler
|
|
131
|
+
del self._client._raw_handlers[pattern]
|
|
132
|
+
del self._client._raw_matcher[pattern]
|
|
133
|
+
self._client._subscriptions.discard(pattern)
|
|
134
|
+
|
|
135
|
+
# 发送 MQTT UNSUBSCRIBE
|
|
136
|
+
if self._client._client:
|
|
137
|
+
asyncio.create_task(self._client._client.unsubscribe(pattern))
|
|
138
|
+
|
|
139
|
+
logger.debug(f"Raw 取消订阅(全部)- pattern: {pattern}")
|
|
140
|
+
else:
|
|
141
|
+
# 移除特定 handler
|
|
142
|
+
handlers = self._client._raw_handlers[pattern]
|
|
143
|
+
if handler in handlers:
|
|
144
|
+
handlers.remove(handler)
|
|
145
|
+
|
|
146
|
+
# 如果没有 handler 了,完全取消订阅
|
|
147
|
+
if not handlers:
|
|
148
|
+
del self._client._raw_handlers[pattern]
|
|
149
|
+
del self._client._raw_matcher[pattern]
|
|
150
|
+
self._client._subscriptions.discard(pattern)
|
|
151
|
+
|
|
152
|
+
if self._client._client:
|
|
153
|
+
asyncio.create_task(self._client._client.unsubscribe(pattern))
|
|
154
|
+
|
|
155
|
+
logger.debug(
|
|
156
|
+
f"Raw 取消订阅(全部,最后一个 handler)- pattern: {pattern}"
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
logger.debug(
|
|
160
|
+
f"Raw 取消订阅(部分)- pattern: {pattern}, 剩余 {len(handlers)} 个 handler"
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
logger.debug(f"Raw 取消订阅失败:handler 不存在 - pattern: {pattern}")
|
|
13
164
|
|
|
14
165
|
|
|
15
166
|
class MQTTClient:
|
|
@@ -19,6 +170,35 @@ class MQTTClient:
|
|
|
19
170
|
- aiomqtt 基于 paho-mqtt 封装,成熟稳定
|
|
20
171
|
- 不自动重连,需要手动实现重连循环(官方推荐模式)
|
|
21
172
|
- 使用 `async for message in client.messages` 异步迭代器
|
|
173
|
+
|
|
174
|
+
设计约束:
|
|
175
|
+
- **单 Event Loop 设计**: 所有方法(subscribe/unsubscribe/handler)
|
|
176
|
+
必须在同一个 asyncio event loop 中调用
|
|
177
|
+
- **不支持多线程/多 loop 并发**: 如果需要在多个线程中使用,
|
|
178
|
+
请为每个线程创建独立的 MQTTClient 实例
|
|
179
|
+
|
|
180
|
+
并发安全:
|
|
181
|
+
- ✅ 单 loop 内并发调用 subscribe/unsubscribe:安全
|
|
182
|
+
|
|
183
|
+
示例:
|
|
184
|
+
# ✅ 正确:单 loop
|
|
185
|
+
async def main():
|
|
186
|
+
client = MQTTClient(config)
|
|
187
|
+
await client.connect()
|
|
188
|
+
client.raw.subscribe("topic", handler1) # 安全
|
|
189
|
+
client.raw.subscribe("topic", handler2) # 安全
|
|
190
|
+
|
|
191
|
+
# ❌ 错误:多线程
|
|
192
|
+
def thread1():
|
|
193
|
+
client.raw.subscribe("topic", handler1) # 不安全
|
|
194
|
+
|
|
195
|
+
def thread2():
|
|
196
|
+
client.raw.subscribe("topic", handler2) # 不安全
|
|
197
|
+
|
|
198
|
+
# ✅ 正确:多线程各自创建实例
|
|
199
|
+
def thread1():
|
|
200
|
+
client1 = MQTTClient(config)
|
|
201
|
+
asyncio.run(client1.connect())
|
|
22
202
|
"""
|
|
23
203
|
|
|
24
204
|
def __init__(self, config: MQTTConfig):
|
|
@@ -33,12 +213,29 @@ class MQTTClient:
|
|
|
33
213
|
self.config = config
|
|
34
214
|
self._client: Optional[aiomqtt.Client] = None
|
|
35
215
|
self._subscriptions: set[str] = set() # 订阅列表(用于重连恢复)
|
|
36
|
-
|
|
216
|
+
|
|
217
|
+
# Raw 订阅(使用 MQTTMatcher 进行通配符匹配)
|
|
218
|
+
# _raw_matcher 是核心匹配引擎(用于 iter_match)
|
|
219
|
+
# _raw_handlers 是辅助追踪结构(因 MQTTMatcher 不支持 getitem/contains)
|
|
220
|
+
self._raw_matcher = MQTTMatcher()
|
|
221
|
+
self._raw_handlers: dict[str, list[Callable]] = {}
|
|
222
|
+
|
|
37
223
|
self._running = False
|
|
38
224
|
self._connected = False # 修复 P0-2:真实连接状态标志
|
|
39
225
|
self._reconnect_task: Optional[asyncio.Task] = None
|
|
40
226
|
self._message_task: Optional[asyncio.Task] = None
|
|
41
227
|
|
|
228
|
+
# 消息处理队列和 Workers(修复 P0-A)
|
|
229
|
+
self._message_queue: asyncio.Queue = asyncio.Queue(
|
|
230
|
+
maxsize=config.message_queue_maxsize
|
|
231
|
+
)
|
|
232
|
+
self._workers: list[asyncio.Task] = []
|
|
233
|
+
# Worker 数量:默认 CPU核数×2(IO-bound 最优)
|
|
234
|
+
self._num_workers = config.num_workers or (os.cpu_count() or 1) * 2
|
|
235
|
+
|
|
236
|
+
# Raw API (逃生通道)
|
|
237
|
+
self._raw_api = RawAPI(self)
|
|
238
|
+
|
|
42
239
|
async def connect(self):
|
|
43
240
|
"""连接到 MQTT Broker
|
|
44
241
|
|
|
@@ -51,6 +248,12 @@ class MQTTClient:
|
|
|
51
248
|
|
|
52
249
|
self._running = True
|
|
53
250
|
|
|
251
|
+
# 启动消息处理
|
|
252
|
+
for i in range(self._num_workers):
|
|
253
|
+
worker = asyncio.create_task(self._worker(i), name=f"mqtt_worker_{i}")
|
|
254
|
+
self._workers.append(worker)
|
|
255
|
+
logger.info(f"MQTT 已启动 {self._num_workers} 个消息处理 worker")
|
|
256
|
+
|
|
54
257
|
self._reconnect_task = asyncio.create_task(
|
|
55
258
|
self._reconnect_loop(), name="mqtt_reconnect"
|
|
56
259
|
)
|
|
@@ -60,6 +263,15 @@ class MQTTClient:
|
|
|
60
263
|
self._running = False
|
|
61
264
|
self._connected = False # 修复 P0-2:标记为未连接
|
|
62
265
|
|
|
266
|
+
# 等待 workers 处理完队列(修复 P0-A)
|
|
267
|
+
if self._workers:
|
|
268
|
+
# 等待所有 workers 完成(最多 5 秒)
|
|
269
|
+
_, pending = await asyncio.wait(self._workers, timeout=5.0)
|
|
270
|
+
# 取消未完成的 workers
|
|
271
|
+
for w in pending:
|
|
272
|
+
w.cancel()
|
|
273
|
+
self._workers.clear()
|
|
274
|
+
|
|
63
275
|
# 取消后台任务
|
|
64
276
|
if self._reconnect_task:
|
|
65
277
|
self._reconnect_task.cancel()
|
|
@@ -68,15 +280,18 @@ class MQTTClient:
|
|
|
68
280
|
except asyncio.CancelledError:
|
|
69
281
|
pass
|
|
70
282
|
|
|
71
|
-
#
|
|
283
|
+
# 取消消息处理任务(修复 P0-D)
|
|
284
|
+
# 这会导致 _reconnect_loop 中的 async with 块退出,aiomqtt 自动清理连接
|
|
72
285
|
if self._message_task:
|
|
73
286
|
self._message_task.cancel()
|
|
74
287
|
try:
|
|
75
288
|
await self._message_task
|
|
76
289
|
except asyncio.CancelledError:
|
|
77
290
|
pass
|
|
78
|
-
|
|
79
|
-
|
|
291
|
+
|
|
292
|
+
# aiomqtt.Client 使用 async with 管理连接,退出时自动清理
|
|
293
|
+
# 不需要显式调用 disconnect()(该方法不存在)
|
|
294
|
+
self._client = None
|
|
80
295
|
|
|
81
296
|
logger.info("MQTT 客户端已断开")
|
|
82
297
|
|
|
@@ -190,14 +405,14 @@ class MQTTClient:
|
|
|
190
405
|
await asyncio.sleep(interval)
|
|
191
406
|
|
|
192
407
|
async def _message_loop(self):
|
|
193
|
-
"""
|
|
408
|
+
"""消息接收循环(修复 P0-A:只负责接收,不处理)
|
|
194
409
|
|
|
195
|
-
使用 async for
|
|
410
|
+
使用 async for 迭代消息,将消息放入队列由 workers 处理
|
|
196
411
|
|
|
197
|
-
|
|
198
|
-
-
|
|
199
|
-
-
|
|
200
|
-
-
|
|
412
|
+
关键改进:
|
|
413
|
+
- 接收和处理分离:避免高吞吐下任务爆炸
|
|
414
|
+
- 有界队列:maxsize 可配置(默认 100k),作为保险丝
|
|
415
|
+
- 队列满时阻塞:形成自然背压,CPU/延迟会变明显(扩容信号)
|
|
201
416
|
|
|
202
417
|
异常处理:
|
|
203
418
|
- asyncio.CancelledError:任务被取消,退出循环
|
|
@@ -208,19 +423,9 @@ class MQTTClient:
|
|
|
208
423
|
return
|
|
209
424
|
|
|
210
425
|
try:
|
|
211
|
-
# region 调用代码溯源(aiomqtt.Client.messages)
|
|
212
|
-
# aiomqtt.Client.messages 是一个异步迭代器
|
|
213
|
-
# 内部调用:
|
|
214
|
-
# paho.mqtt.client.Client._handle_on_message()
|
|
215
|
-
# → 将消息放入队列
|
|
216
|
-
# → aiomqtt 从队列中取出消息
|
|
217
|
-
# GitHub:https://github.com/sbtinstruments/aiomqtt/blob/main/aiomqtt/client.py#L400
|
|
218
|
-
# endregion
|
|
219
426
|
async for message in self._client.messages:
|
|
220
|
-
#
|
|
221
|
-
|
|
222
|
-
self._handle_message(message), name=f"handle_{message.topic}"
|
|
223
|
-
)
|
|
427
|
+
# 将消息放入队列(阻塞等待,形成自然背压)
|
|
428
|
+
await self._message_queue.put(message)
|
|
224
429
|
except asyncio.CancelledError:
|
|
225
430
|
logger.info("消息处理任务已取消")
|
|
226
431
|
raise
|
|
@@ -230,120 +435,80 @@ class MQTTClient:
|
|
|
230
435
|
except Exception as e:
|
|
231
436
|
logger.exception(f"消息循环异常: {e}")
|
|
232
437
|
|
|
233
|
-
async def
|
|
234
|
-
"""
|
|
235
|
-
Args:
|
|
236
|
-
message: aiomqtt.Message 对象
|
|
438
|
+
async def _worker(self, worker_id: int):
|
|
439
|
+
"""消息处理 Worker
|
|
237
440
|
|
|
238
|
-
|
|
239
|
-
1. 检查 payload 大小
|
|
240
|
-
2. 解码 UTF-8
|
|
241
|
-
3. 解析 JSON
|
|
242
|
-
4. 使用 protocol.parse_message() 验证和解析
|
|
243
|
-
5. 路由到对应处理器
|
|
244
|
-
"""
|
|
441
|
+
从队列中取消息并处理,支持并发控制
|
|
245
442
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if len(message.payload) > self.config.max_payload_size:
|
|
249
|
-
logger.warning(
|
|
250
|
-
f"Payload 过大,已忽略 - "
|
|
251
|
-
f"topic: {message.topic}, size: {len(message.payload)}"
|
|
252
|
-
)
|
|
253
|
-
return
|
|
443
|
+
Args:
|
|
444
|
+
worker_id: Worker ID(用于日志)
|
|
254
445
|
|
|
446
|
+
设计要点:
|
|
447
|
+
- 并发上限可控(默认 16 个 worker)
|
|
448
|
+
- 异常隔离(worker 崩溃不影响其他 worker)
|
|
449
|
+
- 优雅退出(_running=False 时退出)
|
|
450
|
+
"""
|
|
451
|
+
while self._running:
|
|
255
452
|
try:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return
|
|
270
|
-
|
|
271
|
-
# 解析消息类型(使用 dataclass 替代 Box)
|
|
272
|
-
msg = parse_message(data)
|
|
273
|
-
|
|
274
|
-
# 路由到对应处理器
|
|
275
|
-
topic_str = str(message.topic)
|
|
276
|
-
handler = self._message_handlers.get(topic_str)
|
|
277
|
-
|
|
278
|
-
if handler:
|
|
279
|
-
# 调用处理器
|
|
280
|
-
if asyncio.iscoroutinefunction(handler):
|
|
281
|
-
await handler(topic_str, msg)
|
|
282
|
-
else:
|
|
283
|
-
handler(topic_str, msg)
|
|
284
|
-
else:
|
|
285
|
-
# 修复 P0-3:RPC 请求无处理器时,立即返回错误响应(防止调用方超时)
|
|
286
|
-
from .protocol import RPCRequest, RPCResponse
|
|
287
|
-
if isinstance(msg, RPCRequest):
|
|
288
|
-
logger.warning(
|
|
289
|
-
f"收到 RPC 请求但无处理器 - topic: {topic_str}, method: {msg.method}"
|
|
290
|
-
)
|
|
291
|
-
# 立即发送错误响应
|
|
292
|
-
error_response = RPCResponse(
|
|
293
|
-
request_id=msg.request_id,
|
|
294
|
-
error=f"No RPC handler registered for topic: {topic_str}"
|
|
295
|
-
)
|
|
296
|
-
await self.publish(msg.reply_to, json.dumps(error_response.to_dict()), qos=1)
|
|
297
|
-
else:
|
|
298
|
-
logger.debug(f"收到消息(无处理器)- topic: {topic_str}")
|
|
299
|
-
|
|
300
|
-
except MessageError as e:
|
|
301
|
-
logger.error(f"消息解析失败: {e}")
|
|
302
|
-
except Exception as e:
|
|
303
|
-
logger.exception(f"消息处理失败: {e}")
|
|
304
|
-
|
|
305
|
-
def subscribe(self, topic: str, handler: Optional[Callable] = None):
|
|
306
|
-
"""订阅主题
|
|
307
|
-
|
|
308
|
-
修复点:
|
|
309
|
-
- ✅ P0-1: 未连接时不崩溃,队列化订阅
|
|
453
|
+
# 从队列取消息(带超时,避免无法退出) asyncio.Queue 是原子安全的
|
|
454
|
+
message = await asyncio.wait_for(self._message_queue.get(), timeout=1.0)
|
|
455
|
+
# 处理消息
|
|
456
|
+
await self._handle_message(message)
|
|
457
|
+
except asyncio.TimeoutError:
|
|
458
|
+
# 队列为空,继续等待
|
|
459
|
+
continue
|
|
460
|
+
except asyncio.CancelledError:
|
|
461
|
+
logger.debug(f"Worker {worker_id} 被取消")
|
|
462
|
+
break
|
|
463
|
+
except Exception as e:
|
|
464
|
+
logger.exception(f"Worker {worker_id} 异常: {e}")
|
|
465
|
+
# 继续运行,不退出
|
|
310
466
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
签名:async def handler(topic: str, message: RPCRequest | RPCResponse)
|
|
315
|
-
或:def handler(topic: str, message: RPCRequest | RPCResponse)
|
|
467
|
+
@property
|
|
468
|
+
def raw(self) -> RawAPI:
|
|
469
|
+
"""访问 Raw MQTT API (逃生通道)
|
|
316
470
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
- 如果已连接,立即订阅
|
|
320
|
-
- 如果未连接,等待连接后自动订阅
|
|
471
|
+
Returns:
|
|
472
|
+
RawAPI 实例
|
|
321
473
|
|
|
322
474
|
示例:
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
475
|
+
client.raw.subscribe("topic", handler)
|
|
476
|
+
await client.raw.publish("topic", b"data")
|
|
477
|
+
"""
|
|
478
|
+
return self._raw_api
|
|
326
479
|
|
|
327
|
-
|
|
480
|
+
async def _handle_message(self, message: aiomqtt.Message):
|
|
481
|
+
"""处理单条消息(传输层,只处理 bytes)
|
|
328
482
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
483
|
+
核心改变:
|
|
484
|
+
1. 不再解析 JSON/协议
|
|
485
|
+
2. 将 payload (bytes) 分发给 raw handlers
|
|
486
|
+
3. 不再区分 RPC/Event(让上层处理)
|
|
332
487
|
|
|
333
|
-
|
|
488
|
+
Args:
|
|
489
|
+
message: aiomqtt.Message 对象
|
|
334
490
|
"""
|
|
335
|
-
|
|
336
|
-
|
|
491
|
+
topic_str = str(message.topic)
|
|
492
|
+
payload = message.payload # bytes 类型
|
|
337
493
|
|
|
338
|
-
#
|
|
339
|
-
if
|
|
340
|
-
|
|
494
|
+
# 检查 payload 大小(防御 DoS)
|
|
495
|
+
if len(payload) > self.config.max_payload_size:
|
|
496
|
+
logger.warning(
|
|
497
|
+
f"Payload 过大,已忽略 - topic: {topic_str}, size: {len(payload)}"
|
|
498
|
+
)
|
|
499
|
+
return
|
|
341
500
|
|
|
342
|
-
#
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
501
|
+
# === 唯一职责:将 bytes 分发给所有匹配的 handlers ===
|
|
502
|
+
# MQTTMatcher.iter_match 返回值列表,不是 (pattern, handlers) 元组
|
|
503
|
+
for handlers in self._raw_matcher.iter_match(topic_str):
|
|
504
|
+
for handler in handlers:
|
|
505
|
+
try:
|
|
506
|
+
if asyncio.iscoroutinefunction(handler):
|
|
507
|
+
await handler(topic_str, payload) # 传递 bytes!
|
|
508
|
+
else:
|
|
509
|
+
handler(topic_str, payload)
|
|
510
|
+
except Exception as e:
|
|
511
|
+
logger.exception(f"Handler 异常 - topic: {topic_str}, error: {e}")
|
|
347
512
|
|
|
348
513
|
async def _do_subscribe(self, topic: str):
|
|
349
514
|
"""执行订阅(内部方法)
|
|
@@ -372,37 +537,15 @@ class MQTTClient:
|
|
|
372
537
|
if not self._subscriptions:
|
|
373
538
|
return
|
|
374
539
|
|
|
375
|
-
|
|
540
|
+
# 创建快照,避免遍历时被修改(修复 P0-C)
|
|
541
|
+
topics = list(self._subscriptions)
|
|
542
|
+
logger.info(f"恢复 {len(topics)} 个订阅...")
|
|
376
543
|
|
|
377
|
-
for topic in
|
|
544
|
+
for topic in topics:
|
|
378
545
|
await self._do_subscribe(topic)
|
|
379
546
|
|
|
380
547
|
logger.success("订阅恢复完成")
|
|
381
548
|
|
|
382
|
-
async def publish(self, topic: str, payload: str, qos: int = 0):
|
|
383
|
-
"""发布消息
|
|
384
|
-
|
|
385
|
-
修复点:
|
|
386
|
-
- ✅ P1-2: 未连接时记录警告
|
|
387
|
-
|
|
388
|
-
Args:
|
|
389
|
-
topic: 目标主题
|
|
390
|
-
payload: 消息载荷(字符串)
|
|
391
|
-
qos: QoS 等级(0/1/2)
|
|
392
|
-
|
|
393
|
-
异常:
|
|
394
|
-
aiomqtt.MqttError: 发布失败
|
|
395
|
-
"""
|
|
396
|
-
if not self._client:
|
|
397
|
-
logger.warning(f"发布失败:客户端未连接 - topic: {topic}")
|
|
398
|
-
return
|
|
399
|
-
|
|
400
|
-
try:
|
|
401
|
-
await self._client.publish(topic, payload, qos=qos)
|
|
402
|
-
logger.debug(f"消息已发布 - topic: {topic}, qos: {qos}")
|
|
403
|
-
except aiomqtt.MqttError as e:
|
|
404
|
-
logger.error(f"发布失败 - topic: {topic}, error: {e}")
|
|
405
|
-
|
|
406
549
|
def _create_tls_context(self) -> ssl.SSLContext:
|
|
407
550
|
"""创建 TLS 上下文
|
|
408
551
|
|
mqttxx/config.py
CHANGED
|
@@ -119,6 +119,16 @@ class MQTTConfig:
|
|
|
119
119
|
reconnect: 重连配置
|
|
120
120
|
max_queued_messages: 最大排队消息数(0=无限)
|
|
121
121
|
max_payload_size: 最大消息载荷大小(字节,防止 DoS 攻击)
|
|
122
|
+
message_queue_maxsize: 消息队列大小限制(默认 100,000)
|
|
123
|
+
- 语义:"几乎无限",仅作保险丝防止 OOM
|
|
124
|
+
- 队列满时行为:阻塞等待(背压信号)
|
|
125
|
+
- 触发背压时:CPU/延迟升高 → 扩容信号
|
|
126
|
+
- Python 字面量分隔符:100_000 = 100000(下划线仅用于可读性)
|
|
127
|
+
num_workers: Worker 数量(默认 None = CPU核数×2)
|
|
128
|
+
- None(默认):CPU核数 × 2(适合 IO-bound 负载)
|
|
129
|
+
- 自定义值:根据 handler 类型调整
|
|
130
|
+
- CPU-bound handler:设为 CPU核数
|
|
131
|
+
- IO-bound handler:设为 CPU核数 × 2~4
|
|
122
132
|
log_level: 日志级别(DEBUG|INFO|WARNING|ERROR)
|
|
123
133
|
|
|
124
134
|
示例:
|
|
@@ -165,6 +175,10 @@ class MQTTConfig:
|
|
|
165
175
|
max_queued_messages: int = 0 # 0 = 无限
|
|
166
176
|
max_payload_size: int = 1024 * 1024 # 1MB
|
|
167
177
|
|
|
178
|
+
# 消息处理(修复 P0-A)
|
|
179
|
+
message_queue_maxsize: int = 100_000 # 10W 条 队列最大容量(保险丝)
|
|
180
|
+
num_workers: Optional[int] = None # Worker 数量(None = CPU核数×2)
|
|
181
|
+
|
|
168
182
|
# 日志级别
|
|
169
183
|
log_level: str = "INFO"
|
|
170
184
|
|
mqttxx/conventions.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# 约定式 RPC 管理器 - 去角色设计
|
|
2
2
|
|
|
3
|
-
from typing import Any, Optional
|
|
3
|
+
from typing import Any, Optional, Type
|
|
4
4
|
from loguru import logger
|
|
5
5
|
|
|
6
6
|
from .client import MQTTClient
|
|
7
7
|
from .config import RPCConfig
|
|
8
8
|
from .rpc import RPCManager, AuthCallback
|
|
9
|
+
from .protocol import Codec, JSONCodec
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class ConventionalRPCManager(RPCManager):
|
|
@@ -50,6 +51,7 @@ class ConventionalRPCManager(RPCManager):
|
|
|
50
51
|
my_topic: str,
|
|
51
52
|
config: Optional[RPCConfig] = None,
|
|
52
53
|
auth_callback: Optional[AuthCallback] = None,
|
|
54
|
+
codec: Type[Codec] = JSONCodec, # 新增
|
|
53
55
|
):
|
|
54
56
|
"""初始化约定式 RPC 管理器
|
|
55
57
|
|
|
@@ -58,9 +60,10 @@ class ConventionalRPCManager(RPCManager):
|
|
|
58
60
|
my_topic: 本节点的 topic(自动订阅,自动注入到 reply_to)
|
|
59
61
|
config: RPC 配置(可选)
|
|
60
62
|
auth_callback: 权限检查回调(可选)
|
|
63
|
+
codec: 编解码器(默认 JSONCodec)
|
|
61
64
|
|
|
62
65
|
自动行为:
|
|
63
|
-
- 自动订阅 my_topic
|
|
66
|
+
- 自动订阅 my_topic(接收请求和响应)
|
|
64
67
|
- 自动绑定消息处理器
|
|
65
68
|
|
|
66
69
|
示例:
|
|
@@ -76,12 +79,12 @@ class ConventionalRPCManager(RPCManager):
|
|
|
76
79
|
# 多层级
|
|
77
80
|
rpc = ConventionalRPCManager(client, my_topic="region/zone/device")
|
|
78
81
|
"""
|
|
79
|
-
super().__init__(client, config, auth_callback)
|
|
82
|
+
super().__init__(client, config, auth_callback, codec)
|
|
80
83
|
|
|
81
84
|
self._my_topic = my_topic
|
|
82
85
|
|
|
83
|
-
#
|
|
84
|
-
|
|
86
|
+
# 自动设置(订阅响应主题)
|
|
87
|
+
self.setup(my_topic)
|
|
85
88
|
|
|
86
89
|
logger.info(f"ConventionalRPCManager 已初始化 - my_topic: {my_topic}")
|
|
87
90
|
|