fastapi-modular 0.1.0__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.
- fastapi_modular-0.1.0.dist-info/METADATA +377 -0
- fastapi_modular-0.1.0.dist-info/RECORD +69 -0
- fastapi_modular-0.1.0.dist-info/WHEEL +4 -0
- fastapi_modular-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_modular-0.1.0.dist-info/licenses/LICENSE +21 -0
- pymodular/__init__.py +74 -0
- pymodular/cli/__init__.py +0 -0
- pymodular/cli/clean.py +39 -0
- pymodular/cli/configure_env.py +569 -0
- pymodular/cli/cong_cu.py +111 -0
- pymodular/cli/info.py +62 -0
- pymodular/cli/install.py +83 -0
- pymodular/cli/main.py +247 -0
- pymodular/cli/new_module.py +492 -0
- pymodular/cli/new_project.py +471 -0
- pymodular/cli/serve.py +59 -0
- pymodular/core/__init__.py +0 -0
- pymodular/core/clock.py +15 -0
- pymodular/core/compat.py +39 -0
- pymodular/core/config.py +495 -0
- pymodular/core/container.py +354 -0
- pymodular/core/context.py +78 -0
- pymodular/core/controller.py +208 -0
- pymodular/core/error_handlers.py +272 -0
- pymodular/core/exceptions.py +104 -0
- pymodular/core/guards.py +117 -0
- pymodular/core/lifespan.py +150 -0
- pymodular/core/logging.py +88 -0
- pymodular/core/metrics.py +190 -0
- pymodular/core/schemas.py +105 -0
- pymodular/core/websocket/__init__.py +31 -0
- pymodular/core/websocket/adapter.py +192 -0
- pymodular/core/websocket/gateway.py +735 -0
- pymodular/core/websocket/namespace.py +148 -0
- pymodular/core/websocket/protocol.py +157 -0
- pymodular/core/websocket/server.py +175 -0
- pymodular/core/websocket/socket.py +241 -0
- pymodular/discovery.py +180 -0
- pymodular/factory.py +126 -0
- pymodular/infrastructure/__init__.py +1 -0
- pymodular/infrastructure/database/__init__.py +8 -0
- pymodular/infrastructure/database/base.py +228 -0
- pymodular/infrastructure/database/circuit.py +207 -0
- pymodular/infrastructure/database/factory.py +88 -0
- pymodular/infrastructure/database/memory.py +112 -0
- pymodular/infrastructure/database/mongo.py +186 -0
- pymodular/infrastructure/database/repository.py +188 -0
- pymodular/infrastructure/database/sql.py +520 -0
- pymodular/infrastructure/kafka/__init__.py +26 -0
- pymodular/infrastructure/kafka/broker.py +231 -0
- pymodular/infrastructure/kafka/consumers.py +371 -0
- pymodular/infrastructure/kafka/metrics.py +17 -0
- pymodular/infrastructure/mqtt/__init__.py +35 -0
- pymodular/infrastructure/mqtt/client.py +292 -0
- pymodular/infrastructure/mqtt/consumers.py +219 -0
- pymodular/infrastructure/mqtt/metrics.py +17 -0
- pymodular/infrastructure/mqtt/patterns.py +116 -0
- pymodular/infrastructure/rabbitmq/__init__.py +33 -0
- pymodular/infrastructure/rabbitmq/broker.py +616 -0
- pymodular/infrastructure/rabbitmq/consumers.py +450 -0
- pymodular/infrastructure/rabbitmq/metrics.py +34 -0
- pymodular/infrastructure/rabbitmq/patterns.py +64 -0
- pymodular/infrastructure/redis/__init__.py +31 -0
- pymodular/infrastructure/redis/client.py +362 -0
- pymodular/infrastructure/redis/metrics.py +20 -0
- pymodular/infrastructure/redis/pubsub.py +262 -0
- pymodular/middleware/__init__.py +0 -0
- pymodular/middleware/request_context.py +164 -0
- pymodular/py.typed +0 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Kết nối MQTT: giữ một phiên lâu dài, gửi tin, và nuôi vòng đọc tin về.
|
|
2
|
+
|
|
3
|
+
MQTT là TUỲ CHỌN. `APP_MQTT__ENABLED` mặc định false thì lớp này nằm im.
|
|
4
|
+
|
|
5
|
+
pip install 'fastapi-modular[mqtt]' # cài aiomqtt + ghi sẵn APP_MQTT__* vào .env
|
|
6
|
+
|
|
7
|
+
Về tự nối lại: aiomqtt KHÔNG tự nối lại (khác aio-pika). Nó cố ý để việc đó cho
|
|
8
|
+
người dùng, nên lớp này chạy một vòng
|
|
9
|
+
`connect -> subscribe -> đọc -> đứt -> chờ -> lặp lại` với backoff. Mọi đăng ký
|
|
10
|
+
topic được khai lại sau mỗi lần nối, vì broker chỉ nhớ chúng khi phiên là
|
|
11
|
+
persistent (`clean_session=false` + `client_id` cố định).
|
|
12
|
+
|
|
13
|
+
Gửi tin trong lúc đang đứt thì ném `ServiceUnavailableError`, trừ khi gọi với
|
|
14
|
+
`fire_and_forget=True`. Không có hàng đợi chờ gửi ở phía client: giữ tin trong
|
|
15
|
+
RAM rồi hứa gửi sau là một lời hứa mà tiến trình chết là mất — muốn bảo đảm thì
|
|
16
|
+
đó là việc của QoS 1/2 với phiên persistent, hoặc của RabbitMQ/Kafka.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import contextlib
|
|
23
|
+
import json
|
|
24
|
+
import uuid
|
|
25
|
+
from collections.abc import Awaitable, Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
from urllib.parse import urlparse
|
|
28
|
+
|
|
29
|
+
from pymodular.core.compat import TimeoutErrors
|
|
30
|
+
from pymodular.core.config import Settings
|
|
31
|
+
from pymodular.core.container import injectable
|
|
32
|
+
from pymodular.core.exceptions import ComponentNotEnabledError, ServiceUnavailableError
|
|
33
|
+
from pymodular.core.logging import get_logger
|
|
34
|
+
from pymodular.infrastructure.mqtt.metrics import mqtt_publish_failed, mqtt_published
|
|
35
|
+
from pymodular.infrastructure.mqtt.patterns import narrow_filters, validate_topic
|
|
36
|
+
|
|
37
|
+
log = get_logger(__name__)
|
|
38
|
+
|
|
39
|
+
DEFAULT_URL = "mqtt://localhost:1883"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_aiomqtt() -> Any:
|
|
43
|
+
try:
|
|
44
|
+
import aiomqtt
|
|
45
|
+
except ModuleNotFoundError as exc:
|
|
46
|
+
raise ComponentNotEnabledError(
|
|
47
|
+
"APP_MQTT__ENABLED=true nhưng chưa cài thư viện aiomqtt. "
|
|
48
|
+
"Chạy `pip install 'fastapi-modular[mqtt]'`, hoặc đặt APP_MQTT__ENABLED=false nếu "
|
|
49
|
+
"dự án này không dùng MQTT."
|
|
50
|
+
) from exc
|
|
51
|
+
return aiomqtt
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_url(url: str) -> dict[str, Any]:
|
|
55
|
+
"""Tách mqtt://user:pass@host:port thành tham số cho aiomqtt."""
|
|
56
|
+
parsed = urlparse(url)
|
|
57
|
+
if parsed.scheme not in ("mqtt", "mqtts"):
|
|
58
|
+
raise ValueError(f"APP_MQTT__URL phải bắt đầu bằng mqtt:// hoặc mqtts://, nhận {url!r}")
|
|
59
|
+
return {
|
|
60
|
+
"hostname": parsed.hostname or "localhost",
|
|
61
|
+
"port": parsed.port or (8883 if parsed.scheme == "mqtts" else 1883),
|
|
62
|
+
"username": parsed.username or None,
|
|
63
|
+
"password": parsed.password or None,
|
|
64
|
+
"tls": parsed.scheme == "mqtts",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def safe_url(url: str) -> str:
|
|
69
|
+
parsed = urlparse(url)
|
|
70
|
+
if not parsed.password:
|
|
71
|
+
return url
|
|
72
|
+
cong = f":{parsed.port}" if parsed.port else ""
|
|
73
|
+
return f"{parsed.scheme}://{parsed.username or ''}:***@{parsed.hostname}{cong}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@injectable
|
|
77
|
+
class MqttClient:
|
|
78
|
+
def __init__(self, settings: Settings) -> None:
|
|
79
|
+
self._config = settings.mqtt
|
|
80
|
+
self._client: Any = None
|
|
81
|
+
self._task: asyncio.Task[None] | None = None
|
|
82
|
+
self._closing = False
|
|
83
|
+
self._connected = asyncio.Event()
|
|
84
|
+
self._dang_ky: dict[str, int] = {} # topic -> qos
|
|
85
|
+
self._router: Callable[[Any], Awaitable[None]] | None = None
|
|
86
|
+
self._lan_dut = 0
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------- vòng đời
|
|
89
|
+
@property
|
|
90
|
+
def enabled(self) -> bool:
|
|
91
|
+
return self._config.enabled
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def connected(self) -> bool:
|
|
95
|
+
return self._connected.is_set()
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def url(self) -> str:
|
|
99
|
+
return safe_url(self._config.url)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def client_id(self) -> str:
|
|
103
|
+
return self._config.client_id or f"pymodular-{uuid.uuid4().hex[:8]}"
|
|
104
|
+
|
|
105
|
+
def subscribe_topic(self, topic: str, qos: int = 1) -> None:
|
|
106
|
+
"""Ghi nhận một topic cần đăng ký. Gọi TRƯỚC startup().
|
|
107
|
+
|
|
108
|
+
Giữ mức QoS cao nhất khi hai handler cùng nghe một topic: đăng ký ở
|
|
109
|
+
QoS 0 rồi lại đòi QoS 1 thì broker giao theo mức đã đăng ký, tin sẽ
|
|
110
|
+
lặng lẽ mất khi mạng chớp.
|
|
111
|
+
"""
|
|
112
|
+
self._dang_ky[topic] = max(self._dang_ky.get(topic, 0), qos)
|
|
113
|
+
|
|
114
|
+
def _de_dang_ky(self) -> dict[str, int]:
|
|
115
|
+
"""Danh sách thật sự gửi lên broker: đã bỏ bộ lọc bị bộ lọc khác bao trọn.
|
|
116
|
+
|
|
117
|
+
Đăng ký chồng nhau ("thiet-bi/#" và "thiet-bi/+/nhiet-do") thì broker
|
|
118
|
+
giao MỘT tin thành HAI lần — mỗi đăng ký một bản — và mọi handler khớp
|
|
119
|
+
sẽ chạy hai lượt. Đo trên mosquitto: gửi 1 tin, handler chạy 4 lượt.
|
|
120
|
+
Đăng ký cái rộng nhất rồi tự chia tin trong tiến trình thì đúng một lượt.
|
|
121
|
+
"""
|
|
122
|
+
gon = narrow_filters(self._dang_ky)
|
|
123
|
+
if bo := sorted(set(self._dang_ky) - set(gon)):
|
|
124
|
+
log.debug("mqtt.subscription_gop", bo_qua=bo, giu=sorted(gon))
|
|
125
|
+
return gon
|
|
126
|
+
|
|
127
|
+
def set_router(self, router: Callable[[Any], Awaitable[None]]) -> None:
|
|
128
|
+
"""Ai nhận tin về: `MqttRunner` cắm vào đây."""
|
|
129
|
+
self._router = router
|
|
130
|
+
|
|
131
|
+
async def startup(self) -> None:
|
|
132
|
+
if not self._config.enabled:
|
|
133
|
+
log.debug("mqtt.disabled")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
_require_aiomqtt()
|
|
137
|
+
parse_url(self._config.url) # sai dạng thì báo ngay, đừng đợi vòng nối lại
|
|
138
|
+
if self._config.url == DEFAULT_URL:
|
|
139
|
+
log.info("mqtt.default_url", url=DEFAULT_URL, hint="chưa đặt APP_MQTT__URL?")
|
|
140
|
+
if not self._config.clean_session and not self._config.client_id:
|
|
141
|
+
log.warning(
|
|
142
|
+
"mqtt.session_khong_ben",
|
|
143
|
+
hint="clean_session=false cần APP_MQTT__CLIENT_ID cố định, "
|
|
144
|
+
"không thì mỗi lần khởi động là một phiên mới và tin giữ lại bị bỏ",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
self._closing = False
|
|
148
|
+
self._task = asyncio.create_task(self._vong_ket_noi(), name="mqtt-connection")
|
|
149
|
+
|
|
150
|
+
# Chờ một nhịp cho lần nối đầu, để log khởi động nói đúng trạng thái.
|
|
151
|
+
with contextlib.suppress(*TimeoutErrors):
|
|
152
|
+
await asyncio.wait_for(
|
|
153
|
+
self._connected.wait(), self._config.connect_timeout_seconds
|
|
154
|
+
)
|
|
155
|
+
if not self.connected:
|
|
156
|
+
log.warning(
|
|
157
|
+
"mqtt.starting_degraded",
|
|
158
|
+
url=self.url,
|
|
159
|
+
hint="app vẫn chạy; sẽ nối lại ngầm cho tới khi được",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def _vong_ket_noi(self) -> None:
|
|
163
|
+
"""Nối, đăng ký, đọc tới khi đứt, chờ rồi làm lại. Không bao giờ bỏ cuộc."""
|
|
164
|
+
aiomqtt = _require_aiomqtt()
|
|
165
|
+
tham_so = parse_url(self._config.url)
|
|
166
|
+
tls = tham_so.pop("tls")
|
|
167
|
+
delay = self._config.reconnect_delay_seconds
|
|
168
|
+
ma_so = self.client_id
|
|
169
|
+
|
|
170
|
+
while not self._closing:
|
|
171
|
+
try:
|
|
172
|
+
async with aiomqtt.Client(
|
|
173
|
+
**tham_so,
|
|
174
|
+
identifier=ma_so,
|
|
175
|
+
keepalive=self._config.keepalive_seconds,
|
|
176
|
+
clean_session=self._config.clean_session,
|
|
177
|
+
tls_context=_tls_context() if tls else None,
|
|
178
|
+
) as client:
|
|
179
|
+
self._client = client
|
|
180
|
+
dang_ky = self._de_dang_ky()
|
|
181
|
+
for topic, qos in sorted(dang_ky.items()):
|
|
182
|
+
await client.subscribe(topic, qos=qos)
|
|
183
|
+
self._connected.set()
|
|
184
|
+
delay = self._config.reconnect_delay_seconds
|
|
185
|
+
log.info(
|
|
186
|
+
"mqtt.connected",
|
|
187
|
+
url=self.url,
|
|
188
|
+
client_id=ma_so,
|
|
189
|
+
topics=sorted(dang_ky),
|
|
190
|
+
)
|
|
191
|
+
async for message in client.messages:
|
|
192
|
+
if self._router is not None:
|
|
193
|
+
await self._router(message)
|
|
194
|
+
except asyncio.CancelledError:
|
|
195
|
+
raise
|
|
196
|
+
except Exception as exc: # noqa: BLE001 - đứt kiểu gì cũng nối lại
|
|
197
|
+
if self._closing:
|
|
198
|
+
return
|
|
199
|
+
self._lan_dut += 1
|
|
200
|
+
log.warning(
|
|
201
|
+
"mqtt.connection_lost",
|
|
202
|
+
url=self.url,
|
|
203
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
204
|
+
retry=delay,
|
|
205
|
+
)
|
|
206
|
+
finally:
|
|
207
|
+
self._connected.clear()
|
|
208
|
+
self._client = None
|
|
209
|
+
|
|
210
|
+
if self._closing:
|
|
211
|
+
return
|
|
212
|
+
await asyncio.sleep(delay)
|
|
213
|
+
delay = min(delay * 2, self._config.max_reconnect_delay_seconds)
|
|
214
|
+
|
|
215
|
+
async def shutdown(self) -> None:
|
|
216
|
+
self._closing = True
|
|
217
|
+
if self._task is not None:
|
|
218
|
+
self._task.cancel()
|
|
219
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
220
|
+
await self._task
|
|
221
|
+
self._task = None
|
|
222
|
+
self._connected.clear()
|
|
223
|
+
log.info("mqtt.disconnected")
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------- gửi
|
|
226
|
+
async def publish(
|
|
227
|
+
self,
|
|
228
|
+
topic: str,
|
|
229
|
+
payload: Any = None,
|
|
230
|
+
*,
|
|
231
|
+
qos: int = 1,
|
|
232
|
+
retain: bool = False,
|
|
233
|
+
fire_and_forget: bool = False,
|
|
234
|
+
) -> bool:
|
|
235
|
+
"""Gửi một tin. Trả về True nếu broker đã nhận.
|
|
236
|
+
|
|
237
|
+
qos=0 gửi rồi thôi, không xác nhận — nhanh nhất, mất cũng không biết
|
|
238
|
+
qos=1 ít nhất một lần: có xác nhận, nhưng CÓ THỂ TRÙNG khi mạng chớp
|
|
239
|
+
qos=2 đúng một lần: chắc nhất, tốn hai vòng bắt tay, chậm nhất
|
|
240
|
+
|
|
241
|
+
`retain=True` bảo broker GIỮ tin này làm giá trị hiện tại của topic:
|
|
242
|
+
client nào đăng ký sau cũng nhận được ngay bản mới nhất mà không phải
|
|
243
|
+
chờ lần cập nhật kế tiếp. Đúng cho trạng thái (nhiệt độ, bật/tắt), sai
|
|
244
|
+
cho sự kiện (nút vừa được bấm) — người nối vào sau sẽ tưởng nút vừa mới
|
|
245
|
+
bấm lần nữa.
|
|
246
|
+
"""
|
|
247
|
+
validate_topic(topic)
|
|
248
|
+
if not self._config.enabled:
|
|
249
|
+
raise ComponentNotEnabledError(
|
|
250
|
+
"MQTT đang tắt (APP_MQTT__ENABLED=false) nên không gửi được."
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
client = self._client
|
|
254
|
+
if client is None or not self.connected:
|
|
255
|
+
mqtt_publish_failed.inc(topic=topic)
|
|
256
|
+
if fire_and_forget:
|
|
257
|
+
log.warning("mqtt.publish_skipped", topic=topic, reason="chưa kết nối")
|
|
258
|
+
return False
|
|
259
|
+
raise ServiceUnavailableError("Chưa kết nối được MQTT broker")
|
|
260
|
+
|
|
261
|
+
body = payload if isinstance(payload, (bytes, str)) else json.dumps(
|
|
262
|
+
payload, ensure_ascii=False, default=str
|
|
263
|
+
)
|
|
264
|
+
try:
|
|
265
|
+
await client.publish(topic, payload=body, qos=qos, retain=retain)
|
|
266
|
+
except Exception as exc:
|
|
267
|
+
mqtt_publish_failed.inc(topic=topic)
|
|
268
|
+
if fire_and_forget:
|
|
269
|
+
log.warning("mqtt.publish_failed", topic=topic, error=str(exc))
|
|
270
|
+
return False
|
|
271
|
+
raise ServiceUnavailableError(f"Gửi MQTT thất bại: {exc}") from exc
|
|
272
|
+
|
|
273
|
+
mqtt_published.inc(topic=topic)
|
|
274
|
+
log.debug("mqtt.published", topic=topic, qos=qos, retain=retain)
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
def stats(self) -> dict[str, Any]:
|
|
278
|
+
return {
|
|
279
|
+
"enabled": self._config.enabled,
|
|
280
|
+
"connected": self.connected,
|
|
281
|
+
"url": self.url if self._config.enabled else None,
|
|
282
|
+
"client_id": self._config.client_id or "(tự sinh)",
|
|
283
|
+
"topics": sorted(self._de_dang_ky()),
|
|
284
|
+
"listeners": sorted(self._dang_ky),
|
|
285
|
+
"disconnects": self._lan_dut,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _tls_context() -> Any:
|
|
290
|
+
import ssl
|
|
291
|
+
|
|
292
|
+
return ssl.create_default_context()
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Nhận tin MQTT — tương đương `@rabbitmq_subscriber` của RabbitMQ, cho thiết bị IoT.
|
|
2
|
+
|
|
3
|
+
@injectable
|
|
4
|
+
class DenListener:
|
|
5
|
+
@mqtt_subscriber("nha/+/den", qos=1)
|
|
6
|
+
async def trang_thai(self, payload: dict, meta: dict) -> None:
|
|
7
|
+
print(meta["topic"], payload)
|
|
8
|
+
|
|
9
|
+
Khác RabbitMQ ở chỗ **không có hàng đợi thử lại và không có DLQ**. MQTT không
|
|
10
|
+
cho client từ chối một tin: nhận là xong, broker coi như đã giao. Nên khi
|
|
11
|
+
handler ném lỗi, khung ghi log và ĐI TIẾP — bịa ra vòng thử lại trong tiến
|
|
12
|
+
trình sẽ chặn mọi tin phía sau của cùng một kết nối.
|
|
13
|
+
|
|
14
|
+
Cần chắc chắn không mất việc thì đẩy sang chỗ có hàng đợi ngay trong handler:
|
|
15
|
+
nhận tin MQTT rồi `broker.publish(...)` vào RabbitMQ, xử lý nặng ở đó.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import inspect
|
|
21
|
+
import json
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, get_type_hints
|
|
25
|
+
|
|
26
|
+
from pydantic import BaseModel, ValidationError
|
|
27
|
+
|
|
28
|
+
from pymodular.core.config import Settings
|
|
29
|
+
from pymodular.core.container import _REGISTRY, container, injectable, request_scope
|
|
30
|
+
from pymodular.core.context import new_request_id, reset_request_id, set_request_id
|
|
31
|
+
from pymodular.core.logging import get_logger
|
|
32
|
+
from pymodular.infrastructure.mqtt.client import MqttClient
|
|
33
|
+
from pymodular.infrastructure.mqtt.metrics import (
|
|
34
|
+
mqtt_handler_failed,
|
|
35
|
+
mqtt_received,
|
|
36
|
+
mqtt_unrouted,
|
|
37
|
+
)
|
|
38
|
+
from pymodular.infrastructure.mqtt.patterns import matches, validate_topic_filter
|
|
39
|
+
|
|
40
|
+
log = get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
_SPEC_ATTR = "__mqtt_subscriber__"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class MqttSpec:
|
|
47
|
+
topic: str
|
|
48
|
+
qos: int = 1
|
|
49
|
+
cls: type | None = None
|
|
50
|
+
fn: Callable | None = None
|
|
51
|
+
model: type[BaseModel] | None = None
|
|
52
|
+
wants_meta: bool = False
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def label(self) -> str:
|
|
56
|
+
return f"{self.cls.__name__}.{self.fn.__name__}" if self.cls and self.fn else self.topic
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def mqtt_subscriber(topic: str, *, qos: int = 1) -> Callable[[Callable], Callable]:
|
|
60
|
+
"""Gắn method vào một topic MQTT (có thể chứa `+` và `#`).
|
|
61
|
+
|
|
62
|
+
topic bộ lọc: "nha/bep/den" khớp đúng, "nha/+/den" một tầng bất kỳ,
|
|
63
|
+
"nha/#" mọi tầng phía dưới
|
|
64
|
+
qos mức bảo đảm khi broker GIAO tin cho mình:
|
|
65
|
+
0 = có thì nhận, mất không biết (số đo dày đặc, mất vài cái
|
|
66
|
+
không sao)
|
|
67
|
+
1 = ít nhất một lần — mặc định. Handler PHẢI chịu được tin
|
|
68
|
+
trùng, vì mạng chớp là broker giao lại.
|
|
69
|
+
2 = đúng một lần, chậm hơn hẳn; chỉ dùng khi xử lý trùng thật
|
|
70
|
+
sự gây hại (trừ tiền, mở khoá)
|
|
71
|
+
|
|
72
|
+
Hai handler cùng một topic ở hai mức QoS khác nhau thì lấy mức CAO HƠN cho
|
|
73
|
+
lần đăng ký với broker.
|
|
74
|
+
"""
|
|
75
|
+
validate_topic_filter(topic)
|
|
76
|
+
if qos not in (0, 1, 2):
|
|
77
|
+
raise ValueError(f"qos phải là 0, 1 hoặc 2 — nhận {qos!r}")
|
|
78
|
+
|
|
79
|
+
def decorate(fn: Callable) -> Callable:
|
|
80
|
+
if not inspect.iscoroutinefunction(fn):
|
|
81
|
+
raise RuntimeError(f"{fn.__name__} phải là `async def`")
|
|
82
|
+
setattr(fn, _SPEC_ATTR, MqttSpec(topic=topic, qos=qos))
|
|
83
|
+
return fn
|
|
84
|
+
|
|
85
|
+
return decorate
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def discover_mqtt_subscribers() -> list[MqttSpec]:
|
|
89
|
+
"""Quét mọi provider đã đăng ký để tìm method mang @mqtt_subscriber."""
|
|
90
|
+
found: list[MqttSpec] = []
|
|
91
|
+
for cls in _REGISTRY.values():
|
|
92
|
+
for fn in vars(cls).values():
|
|
93
|
+
spec: MqttSpec | None = getattr(fn, _SPEC_ATTR, None)
|
|
94
|
+
if spec is None:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
params = list(inspect.signature(fn).parameters.values())[1:]
|
|
98
|
+
if not params or len(params) > 2:
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
f"{cls.__name__}.{fn.__name__}: chữ ký phải là "
|
|
101
|
+
"(self, payload) hoặc (self, payload, meta)"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
hints = get_type_hints(fn)
|
|
105
|
+
annotation = hints.get(params[0].name)
|
|
106
|
+
model = (
|
|
107
|
+
annotation
|
|
108
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel)
|
|
109
|
+
else None
|
|
110
|
+
)
|
|
111
|
+
found.append(
|
|
112
|
+
MqttSpec(
|
|
113
|
+
topic=spec.topic,
|
|
114
|
+
qos=spec.qos,
|
|
115
|
+
cls=cls,
|
|
116
|
+
fn=fn,
|
|
117
|
+
model=model,
|
|
118
|
+
wants_meta=len(params) == 2,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return sorted(found, key=lambda s: s.topic)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@injectable
|
|
125
|
+
class MqttRunner:
|
|
126
|
+
"""Đăng ký topic với client và chia tin về đúng handler."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, client: MqttClient, settings: Settings) -> None:
|
|
129
|
+
self._client = client
|
|
130
|
+
self._config = settings.mqtt
|
|
131
|
+
self._specs: list[MqttSpec] = []
|
|
132
|
+
|
|
133
|
+
async def startup(self) -> None:
|
|
134
|
+
if not self._config.enabled:
|
|
135
|
+
return
|
|
136
|
+
self._specs = discover_mqtt_subscribers()
|
|
137
|
+
if not self._specs:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Đăng ký TRƯỚC khi client mở kết nối, để lần nối đầu tiên đã có đủ
|
|
141
|
+
# topic — và mọi lần nối lại sau đó cũng dùng đúng danh sách này.
|
|
142
|
+
for spec in self._specs:
|
|
143
|
+
self._client.subscribe_topic(spec.topic, spec.qos)
|
|
144
|
+
self._client.set_router(self._giao)
|
|
145
|
+
log.info(
|
|
146
|
+
"mqtt.listeners_registered",
|
|
147
|
+
count=len(self._specs),
|
|
148
|
+
topics=sorted({s.topic for s in self._specs}),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def _giao(self, message: Any) -> None:
|
|
152
|
+
topic = str(message.topic)
|
|
153
|
+
mqtt_received.inc(topic=topic)
|
|
154
|
+
|
|
155
|
+
khop = [spec for spec in self._specs if matches(spec.topic, topic)]
|
|
156
|
+
if not khop:
|
|
157
|
+
# Broker giao một topic không ai nghe: gần như luôn là đăng ký thừa
|
|
158
|
+
# hoặc gõ nhầm bộ lọc. Im lặng ở đây thì không cách nào biết.
|
|
159
|
+
mqtt_unrouted.inc(topic=topic)
|
|
160
|
+
log.debug("mqtt.unrouted", topic=topic)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
for spec in khop:
|
|
164
|
+
await self._chay(spec, topic, message)
|
|
165
|
+
|
|
166
|
+
async def _chay(self, spec: MqttSpec, topic: str, message: Any) -> None:
|
|
167
|
+
token = set_request_id(new_request_id())
|
|
168
|
+
try:
|
|
169
|
+
async with request_scope():
|
|
170
|
+
payload = _doc(message.payload)
|
|
171
|
+
if spec.model is not None:
|
|
172
|
+
try:
|
|
173
|
+
payload = spec.model.model_validate(payload)
|
|
174
|
+
except ValidationError as exc:
|
|
175
|
+
# Không có DLQ để đẩy vào — thiết bị gửi sai khuôn thì
|
|
176
|
+
# ghi log kèm topic để còn lần ra đúng con nào.
|
|
177
|
+
log.error(
|
|
178
|
+
"mqtt.payload_invalid",
|
|
179
|
+
handler=spec.label,
|
|
180
|
+
topic=topic,
|
|
181
|
+
error=str(exc),
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
instance = container.resolve(spec.cls) # type: ignore[arg-type]
|
|
186
|
+
if spec.wants_meta:
|
|
187
|
+
meta = {
|
|
188
|
+
"topic": topic,
|
|
189
|
+
"filter": spec.topic,
|
|
190
|
+
"qos": int(getattr(message, "qos", spec.qos)),
|
|
191
|
+
"retain": bool(getattr(message, "retain", False)),
|
|
192
|
+
}
|
|
193
|
+
await spec.fn(instance, payload, meta) # type: ignore[misc]
|
|
194
|
+
else:
|
|
195
|
+
await spec.fn(instance, payload) # type: ignore[misc]
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
# Ném ra khỏi đây sẽ làm đứt vòng đọc, tức là mọi handler khác im
|
|
198
|
+
# theo cho tới lần nối lại. Một tin hỏng không đáng giá vậy.
|
|
199
|
+
mqtt_handler_failed.inc(topic=spec.topic)
|
|
200
|
+
log.exception("mqtt.handler_failed", handler=spec.label, topic=topic, error=str(exc))
|
|
201
|
+
finally:
|
|
202
|
+
reset_request_id(token)
|
|
203
|
+
|
|
204
|
+
def stats(self) -> dict[str, Any]:
|
|
205
|
+
return {
|
|
206
|
+
"listeners": [
|
|
207
|
+
{"handler": spec.label, "topic": spec.topic, "qos": spec.qos}
|
|
208
|
+
for spec in self._specs
|
|
209
|
+
]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _doc(payload: Any) -> Any:
|
|
214
|
+
"""Thiết bị hay gửi chuỗi thuần ("ON", "23.5") chứ không phải JSON."""
|
|
215
|
+
raw = payload.decode("utf-8", errors="replace") if isinstance(payload, bytes) else str(payload)
|
|
216
|
+
try:
|
|
217
|
+
return json.loads(raw)
|
|
218
|
+
except (TypeError, ValueError):
|
|
219
|
+
return raw
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Số đo của lớp MQTT."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pymodular.core.metrics import Counter, registry
|
|
6
|
+
|
|
7
|
+
mqtt_published = registry.register(Counter("mqtt_published_total", "Số tin đã gửi lên broker"))
|
|
8
|
+
mqtt_publish_failed = registry.register(
|
|
9
|
+
Counter("mqtt_publish_failed_total", "Số lần gửi tin thất bại")
|
|
10
|
+
)
|
|
11
|
+
mqtt_received = registry.register(Counter("mqtt_received_total", "Số tin nhận được"))
|
|
12
|
+
mqtt_handler_failed = registry.register(
|
|
13
|
+
Counter("mqtt_handler_failed_total", "Số tin handler xử lý lỗi")
|
|
14
|
+
)
|
|
15
|
+
mqtt_unrouted = registry.register(
|
|
16
|
+
Counter("mqtt_unrouted_total", "Số tin nhận được mà không handler nào khớp")
|
|
17
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Khớp topic MQTT: `+` một tầng, `#` mọi tầng còn lại.
|
|
2
|
+
|
|
3
|
+
Khác hẳn routing key của AMQP dù nhìn na ná:
|
|
4
|
+
|
|
5
|
+
AMQP "alert.*" * = đúng một từ, # = nhiều từ, ngăn bằng dấu chấm
|
|
6
|
+
MQTT "alert/+" + = đúng một tầng, # = nhiều tầng, ngăn bằng dấu /
|
|
7
|
+
|
|
8
|
+
`#` phải là ký tự CUỐI và chiếm trọn một tầng: "nha/#" hợp lệ, "nha/#/den" và
|
|
9
|
+
"nha#" thì không. Broker sẽ từ chối, nhưng nó từ chối lúc chạy còn hàm dưới đây
|
|
10
|
+
từ chối ngay lúc khai báo decorator.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pymodular.core.exceptions import BadRequestError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_topic_filter(topic_filter: str) -> None:
|
|
19
|
+
if not topic_filter:
|
|
20
|
+
raise BadRequestError("Topic MQTT không được rỗng")
|
|
21
|
+
tang = topic_filter.split("/")
|
|
22
|
+
for i, phan in enumerate(tang):
|
|
23
|
+
if "#" in phan:
|
|
24
|
+
if phan != "#":
|
|
25
|
+
raise BadRequestError(
|
|
26
|
+
f"Topic {topic_filter!r} sai: '#' phải chiếm trọn một tầng (vd 'nha/#')"
|
|
27
|
+
)
|
|
28
|
+
if i != len(tang) - 1:
|
|
29
|
+
raise BadRequestError(
|
|
30
|
+
f"Topic {topic_filter!r} sai: '#' phải nằm ở cuối cùng"
|
|
31
|
+
)
|
|
32
|
+
elif "+" in phan and phan != "+":
|
|
33
|
+
raise BadRequestError(
|
|
34
|
+
f"Topic {topic_filter!r} sai: '+' phải chiếm trọn một tầng (vd 'nha/+/den')"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_topic(topic: str) -> None:
|
|
39
|
+
"""Topic để GỬI thì không được chứa ký tự đại diện."""
|
|
40
|
+
if not topic:
|
|
41
|
+
raise BadRequestError("Topic MQTT không được rỗng")
|
|
42
|
+
if "+" in topic or "#" in topic:
|
|
43
|
+
raise BadRequestError(
|
|
44
|
+
f"Topic {topic!r} chứa ký tự đại diện — chỉ dùng được khi ĐĂNG KÝ NGHE, "
|
|
45
|
+
"không dùng để gửi"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def matches(topic_filter: str, topic: str) -> bool:
|
|
50
|
+
"""Topic cụ thể có khớp bộ lọc không."""
|
|
51
|
+
loc = topic_filter.split("/")
|
|
52
|
+
that = topic.split("/")
|
|
53
|
+
|
|
54
|
+
# Theo chuẩn MQTT, ký tự đại diện ở tầng đầu KHÔNG chạm tới topic hệ thống
|
|
55
|
+
# ($SYS/...). Không có luật này thì một handler nghe "#" sẽ hút cả số liệu
|
|
56
|
+
# nội bộ của broker.
|
|
57
|
+
if that and that[0].startswith("$") and loc[0] in ("+", "#"):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
for i, phan in enumerate(loc):
|
|
61
|
+
if phan == "#":
|
|
62
|
+
return True # nuốt mọi tầng còn lại, kể cả không còn tầng nào
|
|
63
|
+
if i >= len(that):
|
|
64
|
+
return False
|
|
65
|
+
if phan != "+" and phan != that[i]:
|
|
66
|
+
return False
|
|
67
|
+
return len(loc) == len(that)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def covers(rong: str, hep: str) -> bool:
|
|
71
|
+
"""Bộ lọc `rong` có bao trọn `hep` không — tức mọi topic khớp `hep` đều khớp `rong`.
|
|
72
|
+
|
|
73
|
+
Cần để KHÔNG đăng ký hai bộ lọc chồng nhau lên broker. Đăng ký cả
|
|
74
|
+
"thiet-bi/#" lẫn "thiet-bi/+/nhiet-do" thì mosquitto giao MỘT tin thành HAI
|
|
75
|
+
lần (mỗi đăng ký một bản), và handler chạy gấp đôi. Đo được: gửi 1 tin,
|
|
76
|
+
handler chạy 4 lượt.
|
|
77
|
+
|
|
78
|
+
Cách sửa là chỉ đăng ký bộ lọc rộng nhất rồi tự chia tin ở trong tiến trình.
|
|
79
|
+
|
|
80
|
+
Chú ý không dùng `matches()` để thay hàm này: `matches("a/+", "a/#")` trả về
|
|
81
|
+
True vì nó coi "#" là một tầng chữ thường, trong khi "a/+" hoàn toàn KHÔNG
|
|
82
|
+
bao được "a/#" (thiếu "a/b/c").
|
|
83
|
+
"""
|
|
84
|
+
a = rong.split("/")
|
|
85
|
+
b = hep.split("/")
|
|
86
|
+
for i, phan in enumerate(a):
|
|
87
|
+
if phan == "#":
|
|
88
|
+
return True # nuốt trọn phần còn lại của b
|
|
89
|
+
if i >= len(b):
|
|
90
|
+
return False # a còn đòi thêm tầng, b hết
|
|
91
|
+
if b[i] == "#":
|
|
92
|
+
return False # b rộng hơn ở đây (a không phải "#")
|
|
93
|
+
if phan == "+":
|
|
94
|
+
continue # + bao được mọi tầng đơn, kể cả "+"
|
|
95
|
+
if phan != b[i]:
|
|
96
|
+
return False
|
|
97
|
+
return len(a) == len(b)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def narrow_filters(loc: dict[str, int]) -> dict[str, int]:
|
|
101
|
+
"""Bỏ bộ lọc bị bộ lọc khác bao trọn; QoS dồn về cái còn lại (lấy mức cao nhất).
|
|
102
|
+
|
|
103
|
+
Vào: {"thiet-bi/#": 0, "thiet-bi/+/nhiet-do": 1}
|
|
104
|
+
Ra: {"thiet-bi/#": 1}
|
|
105
|
+
"""
|
|
106
|
+
con: dict[str, int] = {}
|
|
107
|
+
for hep, qos in sorted(loc.items()):
|
|
108
|
+
bao = next(
|
|
109
|
+
(r for r in loc if r != hep and covers(r, hep)),
|
|
110
|
+
None,
|
|
111
|
+
)
|
|
112
|
+
if bao is None:
|
|
113
|
+
con[hep] = max(con.get(hep, 0), qos)
|
|
114
|
+
else:
|
|
115
|
+
con[bao] = max(con.get(bao, 0), qos, loc[bao])
|
|
116
|
+
return con
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Lớp RabbitMQ — TUỲ CHỌN, và độc lập với mọi thứ khác.
|
|
2
|
+
|
|
3
|
+
Dự án không dùng RabbitMQ thì không cần cài gì và không phải đụng tới đây:
|
|
4
|
+
`APP_RABBITMQ__ENABLED` mặc định là false, thư viện `aio-pika` chỉ được import khi
|
|
5
|
+
thật sự bật lên.
|
|
6
|
+
|
|
7
|
+
pip install 'fastapi-modular[rabbitmq]' # cài thư viện + ghi sẵn APP_RABBITMQ__* vào .env
|
|
8
|
+
|
|
9
|
+
Lớp này KHÔNG biết gì về HTTP hay WebSocket, và chúng cũng không biết nó. Muốn
|
|
10
|
+
một sự kiện từ hàng đợi hiện lên màn hình người dùng thì đó là việc của code
|
|
11
|
+
ứng dụng — ba dòng trong consumer của bạn, xem docs/rabbitmq.md.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pymodular.infrastructure.rabbitmq.broker import RabbitBroker
|
|
17
|
+
from pymodular.infrastructure.rabbitmq.consumers import (
|
|
18
|
+
PermanentMessageError,
|
|
19
|
+
RabbitmqRunner,
|
|
20
|
+
discover_rabbitmq_subscribers,
|
|
21
|
+
rabbitmq_subscriber,
|
|
22
|
+
)
|
|
23
|
+
from pymodular.infrastructure.rabbitmq.patterns import validate_pattern, validate_routing_key
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"PermanentMessageError",
|
|
27
|
+
"RabbitBroker",
|
|
28
|
+
"RabbitmqRunner",
|
|
29
|
+
"discover_rabbitmq_subscribers",
|
|
30
|
+
"rabbitmq_subscriber",
|
|
31
|
+
"validate_pattern",
|
|
32
|
+
"validate_routing_key",
|
|
33
|
+
]
|