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,148 @@
|
|
|
1
|
+
"""Namespace — sổ đăng ký các kết nối của MỘT gateway.
|
|
2
|
+
|
|
3
|
+
Mỗi gateway (mỗi đường dẫn WebSocket) có namespace riêng, nên phòng "alerts"
|
|
4
|
+
của `/ws/chat` và phòng "alerts" của `/ws/telemetry` là hai phòng khác nhau.
|
|
5
|
+
Giống namespace của socket.io mà NestJS dùng.
|
|
6
|
+
|
|
7
|
+
Ba chỉ mục được giữ song song, tất cả đều là O(1):
|
|
8
|
+
|
|
9
|
+
socket_id -> Socket gửi thẳng cho một kết nối
|
|
10
|
+
room -> {socket_id} gửi cho một phòng
|
|
11
|
+
user_id -> {socket_id} gửi cho một người, kể cả khi họ mở nhiều tab
|
|
12
|
+
|
|
13
|
+
Chỉ mục thứ ba là thứ hay bị bỏ sót. Không có nó thì "gửi thông báo cho user
|
|
14
|
+
X" phải quét toàn bộ kết nối; với vài nghìn kết nối, mỗi lần gửi là một vòng
|
|
15
|
+
lặp toàn bộ.
|
|
16
|
+
|
|
17
|
+
Mọi thao tác ở đây là ĐỒNG BỘ và chỉ chạy trong event loop của tiến trình này
|
|
18
|
+
nên không cần khoá. Nhiều worker thì mỗi worker có sổ riêng — xem adapter.py.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Iterable, Iterator
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
from pymodular.core.logging import get_logger
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from pymodular.core.websocket.socket import Socket
|
|
30
|
+
|
|
31
|
+
log = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Namespace:
|
|
35
|
+
__slots__ = ("_rooms", "_sockets", "_users", "path")
|
|
36
|
+
|
|
37
|
+
def __init__(self, path: str) -> None:
|
|
38
|
+
self.path = path
|
|
39
|
+
self._sockets: dict[str, Socket] = {}
|
|
40
|
+
self._rooms: dict[str, set[str]] = {}
|
|
41
|
+
self._users: dict[str, set[str]] = {}
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------- vào/ra sổ
|
|
44
|
+
def add(self, socket: Socket) -> None:
|
|
45
|
+
self._sockets[socket.id] = socket
|
|
46
|
+
if socket.user_id:
|
|
47
|
+
self._users.setdefault(socket.user_id, set()).add(socket.id)
|
|
48
|
+
|
|
49
|
+
def remove(self, socket: Socket) -> None:
|
|
50
|
+
self._sockets.pop(socket.id, None)
|
|
51
|
+
|
|
52
|
+
for room in list(socket.rooms):
|
|
53
|
+
self.leave(socket, room)
|
|
54
|
+
|
|
55
|
+
if socket.user_id:
|
|
56
|
+
ids = self._users.get(socket.user_id)
|
|
57
|
+
if ids is not None:
|
|
58
|
+
ids.discard(socket.id)
|
|
59
|
+
# Xoá khoá rỗng, nếu không dict phình theo số user từng vào.
|
|
60
|
+
if not ids:
|
|
61
|
+
del self._users[socket.user_id]
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------- phòng
|
|
64
|
+
def join(self, socket: Socket, room: str) -> None:
|
|
65
|
+
self._rooms.setdefault(room, set()).add(socket.id)
|
|
66
|
+
socket.rooms.add(room)
|
|
67
|
+
|
|
68
|
+
def leave(self, socket: Socket, room: str) -> None:
|
|
69
|
+
members = self._rooms.get(room)
|
|
70
|
+
if members is not None:
|
|
71
|
+
members.discard(socket.id)
|
|
72
|
+
if not members:
|
|
73
|
+
del self._rooms[room]
|
|
74
|
+
socket.rooms.discard(room)
|
|
75
|
+
|
|
76
|
+
def room_size(self, room: str) -> int:
|
|
77
|
+
return len(self._rooms.get(room, ()))
|
|
78
|
+
|
|
79
|
+
def sockets_in(self, room: str) -> list[Socket]:
|
|
80
|
+
return [s for sid in self._rooms.get(room, ()) if (s := self._sockets.get(sid))]
|
|
81
|
+
|
|
82
|
+
def sockets_of(self, user_id: str) -> list[Socket]:
|
|
83
|
+
return [s for sid in self._users.get(user_id, ()) if (s := self._sockets.get(sid))]
|
|
84
|
+
|
|
85
|
+
def get(self, socket_id: str) -> Socket | None:
|
|
86
|
+
return self._sockets.get(socket_id)
|
|
87
|
+
|
|
88
|
+
# -------------------------------------------------------------- gửi
|
|
89
|
+
def deliver(
|
|
90
|
+
self,
|
|
91
|
+
event: str,
|
|
92
|
+
data: Any = None,
|
|
93
|
+
*,
|
|
94
|
+
room: str | None = None,
|
|
95
|
+
user: str | None = None,
|
|
96
|
+
socket: str | None = None,
|
|
97
|
+
exclude: Iterable[str] = (),
|
|
98
|
+
) -> int:
|
|
99
|
+
"""Gửi cho các kết nối ĐANG Ở TIẾN TRÌNH NÀY. Trả về số tin đã xếp hàng.
|
|
100
|
+
|
|
101
|
+
Không có tiêu chí nào (`room`/`user`/`socket` đều None) nghĩa là gửi
|
|
102
|
+
cho toàn bộ namespace.
|
|
103
|
+
"""
|
|
104
|
+
skip = set(exclude)
|
|
105
|
+
count = 0
|
|
106
|
+
for target in self._targets(room=room, user=user, socket=socket):
|
|
107
|
+
if target.id in skip or target.closing:
|
|
108
|
+
continue
|
|
109
|
+
if target.emit(event, data):
|
|
110
|
+
count += 1
|
|
111
|
+
return count
|
|
112
|
+
|
|
113
|
+
def _targets(
|
|
114
|
+
self, *, room: str | None, user: str | None, socket: str | None
|
|
115
|
+
) -> Iterator[Socket]:
|
|
116
|
+
if socket is not None:
|
|
117
|
+
found = self._sockets.get(socket)
|
|
118
|
+
if found is not None:
|
|
119
|
+
yield found
|
|
120
|
+
return
|
|
121
|
+
if user is not None:
|
|
122
|
+
yield from self.sockets_of(user)
|
|
123
|
+
return
|
|
124
|
+
if room is not None:
|
|
125
|
+
yield from self.sockets_in(room)
|
|
126
|
+
return
|
|
127
|
+
yield from list(self._sockets.values())
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------ thống kê
|
|
130
|
+
def __len__(self) -> int:
|
|
131
|
+
return len(self._sockets)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def sockets(self) -> list[Socket]:
|
|
135
|
+
return list(self._sockets.values())
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def rooms(self) -> dict[str, int]:
|
|
139
|
+
return {room: len(ids) for room, ids in self._rooms.items()}
|
|
140
|
+
|
|
141
|
+
def stats(self) -> dict[str, Any]:
|
|
142
|
+
return {
|
|
143
|
+
"path": self.path,
|
|
144
|
+
"sockets": len(self._sockets),
|
|
145
|
+
"users": len(self._users),
|
|
146
|
+
"rooms": len(self._rooms),
|
|
147
|
+
"pending": sum(s.pending for s in self._sockets.values()),
|
|
148
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Khuôn tin nhắn và mã đóng kết nối.
|
|
2
|
+
|
|
3
|
+
Một kết nối WebSocket là một ống byte trần: không có URL, không có status code,
|
|
4
|
+
không có content-type. Muốn nhiều loại nghiệp vụ đi chung một kết nối thì phải
|
|
5
|
+
tự quy ước, và quy ước đó chính là "giao thức" dưới đây — tương đương cặp
|
|
6
|
+
`event` + `data` của socket.io mà NestJS dùng.
|
|
7
|
+
|
|
8
|
+
Mọi khung tin là JSON một tầng:
|
|
9
|
+
|
|
10
|
+
{"event": "room.join", "data": {"room": "alerts"}, "id": "c1"}
|
|
11
|
+
|
|
12
|
+
- `event` : tên nghiệp vụ, quyết định handler nào chạy. Bắt buộc.
|
|
13
|
+
- `data` : tham số, tuỳ handler. Thiếu thì hiểu là `null`.
|
|
14
|
+
- `id` : mã do CLIENT tự đặt để ghép câu trả lời với câu hỏi. Tuỳ chọn.
|
|
15
|
+
|
|
16
|
+
Server trả lời bằng cùng khuôn, thêm `ack` = `id` của khung hỏi:
|
|
17
|
+
|
|
18
|
+
{"event": "room.join", "data": {"room": "alerts", "size": 3}, "ack": "c1"}
|
|
19
|
+
{"event": "error", "data": {"code": "forbidden", "message": "..."}, "ack": "c1"}
|
|
20
|
+
|
|
21
|
+
Server đẩy chủ động thì không có `ack`:
|
|
22
|
+
|
|
23
|
+
{"event": "alert.created", "data": {...}}
|
|
24
|
+
|
|
25
|
+
Khung có thêm ô `meta` tuỳ chọn, nội dung do bên gửi tự quyết. Lõi không đọc
|
|
26
|
+
tới nó; nó dành cho các lớp bên ngoài muốn kèm nguồn gốc của tin, ví dụ:
|
|
27
|
+
|
|
28
|
+
{"event": "alert.created", "data": {...}, "meta": {"nguon": "..."}}
|
|
29
|
+
|
|
30
|
+
Vì sao cần `id`/`ack`: WebSocket không ghép cặp request-response như HTTP. Gửi
|
|
31
|
+
ba lệnh liên tiếp rồi nhận ba câu trả lời thì không có cách nào biết cái nào
|
|
32
|
+
của cái nào, trừ khi tự đánh số.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from enum import IntEnum
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from pymodular.core.exceptions import AppError
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CloseCode(IntEnum):
|
|
46
|
+
"""Mã đóng kết nối.
|
|
47
|
+
|
|
48
|
+
1000–2999 do chuẩn WebSocket định nghĩa; 4000–4999 dành cho ứng dụng tự
|
|
49
|
+
đặt. Ở đây dải 44xx cố ý ánh xạ 1-1 với HTTP status quen thuộc (4401 ~ 401,
|
|
50
|
+
4403 ~ 403...) để đọc log không phải tra bảng.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
NORMAL = 1000
|
|
54
|
+
GOING_AWAY = 1001 # server tắt/khởi động lại
|
|
55
|
+
PROTOCOL_ERROR = 1002
|
|
56
|
+
TOO_BIG = 1009 # khung tin vượt ngưỡng
|
|
57
|
+
TRY_AGAIN_LATER = 1013 # client đọc quá chậm, hàng đợi gửi đã đầy
|
|
58
|
+
|
|
59
|
+
BAD_REQUEST = 4400
|
|
60
|
+
UNAUTHORIZED = 4401
|
|
61
|
+
FORBIDDEN = 4403
|
|
62
|
+
NOT_FOUND = 4404
|
|
63
|
+
IDLE_TIMEOUT = 4408 # im lặng quá lâu, coi như đã chết
|
|
64
|
+
CONFLICT = 4409
|
|
65
|
+
TOO_MANY = 4429 # vượt hạn mức kết nối hoặc tần suất gửi
|
|
66
|
+
INTERNAL = 4500
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# AppError mang status_code HTTP; đổi sang mã đóng tương ứng để client dùng
|
|
70
|
+
# chung một bảng lỗi cho cả REST lẫn WebSocket.
|
|
71
|
+
_HTTP_TO_CLOSE: dict[int, CloseCode] = {
|
|
72
|
+
400: CloseCode.BAD_REQUEST,
|
|
73
|
+
401: CloseCode.UNAUTHORIZED,
|
|
74
|
+
403: CloseCode.FORBIDDEN,
|
|
75
|
+
404: CloseCode.NOT_FOUND,
|
|
76
|
+
409: CloseCode.CONFLICT,
|
|
77
|
+
422: CloseCode.BAD_REQUEST,
|
|
78
|
+
429: CloseCode.TOO_MANY,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def close_code_for(exc: BaseException) -> CloseCode:
|
|
83
|
+
if isinstance(exc, AppError):
|
|
84
|
+
return _HTTP_TO_CLOSE.get(exc.status_code, CloseCode.INTERNAL)
|
|
85
|
+
return CloseCode.INTERNAL
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Tên sự kiện do framework giữ chỗ; gateway không được đăng ký trùng.
|
|
89
|
+
EVENT_CONNECTED = "connected"
|
|
90
|
+
EVENT_ERROR = "error"
|
|
91
|
+
EVENT_PING = "ping"
|
|
92
|
+
EVENT_PONG = "pong"
|
|
93
|
+
EVENT_ROOM_JOIN = "room.join"
|
|
94
|
+
EVENT_ROOM_LEAVE = "room.leave"
|
|
95
|
+
|
|
96
|
+
RESERVED_EVENTS = frozenset(
|
|
97
|
+
{EVENT_CONNECTED, EVENT_ERROR, EVENT_PONG, EVENT_ROOM_JOIN, EVENT_ROOM_LEAVE}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ProtocolError(AppError):
|
|
102
|
+
"""Khung tin sai khuôn — không phải JSON, thiếu `event`, hoặc quá dài."""
|
|
103
|
+
|
|
104
|
+
status_code = 400
|
|
105
|
+
error_code = "ws_protocol_error"
|
|
106
|
+
message = "Khung tin không hợp lệ"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(slots=True)
|
|
110
|
+
class Frame:
|
|
111
|
+
event: str
|
|
112
|
+
data: Any = None
|
|
113
|
+
id: str | None = None
|
|
114
|
+
ack: str | None = None
|
|
115
|
+
meta: dict[str, Any] | None = None
|
|
116
|
+
|
|
117
|
+
def to_json(self) -> str:
|
|
118
|
+
payload: dict[str, Any] = {"event": self.event, "data": self.data}
|
|
119
|
+
if self.ack is not None:
|
|
120
|
+
payload["ack"] = self.ack
|
|
121
|
+
if self.meta is not None:
|
|
122
|
+
payload["meta"] = self.meta
|
|
123
|
+
# default=str để datetime/UUID/Decimal trong data không làm chết vòng
|
|
124
|
+
# gửi. Mất kiểu còn hơn mất kết nối.
|
|
125
|
+
return json.dumps(payload, ensure_ascii=False, default=str)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_frame(raw: str) -> Frame:
|
|
129
|
+
"""Đọc khung tin từ client. Ném ProtocolError nếu sai khuôn."""
|
|
130
|
+
try:
|
|
131
|
+
payload = json.loads(raw)
|
|
132
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
133
|
+
raise ProtocolError(f"Không phải JSON hợp lệ: {exc}") from exc
|
|
134
|
+
|
|
135
|
+
if not isinstance(payload, dict):
|
|
136
|
+
raise ProtocolError("Khung tin phải là một object JSON")
|
|
137
|
+
|
|
138
|
+
event = payload.get("event")
|
|
139
|
+
if not isinstance(event, str) or not event:
|
|
140
|
+
raise ProtocolError("Thiếu trường 'event'")
|
|
141
|
+
|
|
142
|
+
frame_id = payload.get("id")
|
|
143
|
+
if frame_id is not None and not isinstance(frame_id, str):
|
|
144
|
+
raise ProtocolError("Trường 'id' phải là chuỗi")
|
|
145
|
+
|
|
146
|
+
return Frame(event=event, data=payload.get("data"), id=frame_id)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def error_frame(exc: BaseException, *, ack: str | None = None, debug: bool = False) -> Frame:
|
|
150
|
+
"""Đổi exception thành khung `error` cùng hình dạng với lỗi HTTP."""
|
|
151
|
+
if isinstance(exc, AppError):
|
|
152
|
+
return Frame(event=EVENT_ERROR, data=exc.to_dict(), ack=ack)
|
|
153
|
+
|
|
154
|
+
data: dict[str, Any] = {"code": "internal_error", "message": "Internal server error"}
|
|
155
|
+
if debug:
|
|
156
|
+
data["details"] = f"{type(exc).__name__}: {exc}"
|
|
157
|
+
return Frame(event=EVENT_ERROR, data=data, ack=ack)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""`WebSocketServer` — điểm vào duy nhất để đẩy tin, dùng được từ mọi nơi.
|
|
2
|
+
|
|
3
|
+
Tương đương `@WebSocketServer() server: Server` của NestJS, nhưng lấy qua DI
|
|
4
|
+
như mọi provider khác:
|
|
5
|
+
|
|
6
|
+
@injectable
|
|
7
|
+
class AlertService:
|
|
8
|
+
def __init__(self, ws: WebSocketServer) -> None:
|
|
9
|
+
self._ws = ws
|
|
10
|
+
|
|
11
|
+
async def raise_alert(self, alert):
|
|
12
|
+
await self._ws.to_room("alerts", "alert.created", {"id": alert.id})
|
|
13
|
+
|
|
14
|
+
Gửi được từ controller HTTP, từ service, từ tác vụ nền — không cần đang ở
|
|
15
|
+
trong một kết nối WebSocket nào. Server tự lo hai việc: gửi cho kết nối trong
|
|
16
|
+
tiến trình này, và đăng tin lên adapter cho các worker khác.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Iterable
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from pymodular.core.config import Settings
|
|
25
|
+
from pymodular.core.container import injectable
|
|
26
|
+
from pymodular.core.logging import get_logger
|
|
27
|
+
from pymodular.core.metrics import ws_connections
|
|
28
|
+
from pymodular.core.websocket.adapter import (
|
|
29
|
+
BroadcastAdapter,
|
|
30
|
+
build_adapter,
|
|
31
|
+
envelope,
|
|
32
|
+
new_origin_id,
|
|
33
|
+
)
|
|
34
|
+
from pymodular.core.websocket.namespace import Namespace
|
|
35
|
+
from pymodular.core.websocket.protocol import CloseCode
|
|
36
|
+
|
|
37
|
+
log = get_logger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@injectable
|
|
41
|
+
class WebSocketServer:
|
|
42
|
+
def __init__(self, settings: Settings) -> None:
|
|
43
|
+
self._config = settings.ws
|
|
44
|
+
self._namespaces: dict[str, Namespace] = {}
|
|
45
|
+
self._origin = new_origin_id()
|
|
46
|
+
self._adapter: BroadcastAdapter = build_adapter(
|
|
47
|
+
self._config.adapter,
|
|
48
|
+
url=self._config.redis_url,
|
|
49
|
+
channel=self._config.channel,
|
|
50
|
+
origin=self._origin,
|
|
51
|
+
)
|
|
52
|
+
self._started = False
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------- namespace
|
|
55
|
+
def namespace(self, path: str) -> Namespace:
|
|
56
|
+
"""Lấy (hoặc tạo) namespace của một đường dẫn WebSocket."""
|
|
57
|
+
found = self._namespaces.get(path)
|
|
58
|
+
if found is None:
|
|
59
|
+
found = self._namespaces[path] = Namespace(path)
|
|
60
|
+
return found
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def namespaces(self) -> dict[str, Namespace]:
|
|
64
|
+
return dict(self._namespaces)
|
|
65
|
+
|
|
66
|
+
def _resolve(self, path: str | None) -> Namespace:
|
|
67
|
+
if path is not None:
|
|
68
|
+
return self.namespace(path)
|
|
69
|
+
if len(self._namespaces) == 1:
|
|
70
|
+
return next(iter(self._namespaces.values()))
|
|
71
|
+
raise RuntimeError(
|
|
72
|
+
"Có nhiều gateway nên phải nói rõ gửi vào namespace nào: "
|
|
73
|
+
f"emit(..., namespace='/ws/chat'). Đang có: {sorted(self._namespaces)}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------ gửi
|
|
77
|
+
async def emit(
|
|
78
|
+
self,
|
|
79
|
+
event: str,
|
|
80
|
+
data: Any = None,
|
|
81
|
+
*,
|
|
82
|
+
namespace: str | None = None,
|
|
83
|
+
room: str | None = None,
|
|
84
|
+
user: str | None = None,
|
|
85
|
+
socket: str | None = None,
|
|
86
|
+
exclude: Iterable[str] = (),
|
|
87
|
+
local_only: bool = False,
|
|
88
|
+
) -> int:
|
|
89
|
+
"""Đẩy một tin. Trả về số kết nối TẠI TIẾN TRÌNH NÀY đã nhận.
|
|
90
|
+
|
|
91
|
+
Số trả về cố ý không tính các worker khác: adapter là fire-and-forget,
|
|
92
|
+
biết chắc bên kia gửi được bao nhiêu thì phải chờ phản hồi, mà chờ thì
|
|
93
|
+
mất hết ý nghĩa "gửi không chặn".
|
|
94
|
+
"""
|
|
95
|
+
target = self._resolve(namespace)
|
|
96
|
+
delivered = target.deliver(
|
|
97
|
+
event, data, room=room, user=user, socket=socket, exclude=exclude
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if not local_only and self._adapter.name != "local":
|
|
101
|
+
await self._adapter.publish(
|
|
102
|
+
envelope(
|
|
103
|
+
self._origin,
|
|
104
|
+
target.path,
|
|
105
|
+
event,
|
|
106
|
+
data,
|
|
107
|
+
room=room,
|
|
108
|
+
user=user,
|
|
109
|
+
socket=socket,
|
|
110
|
+
exclude=exclude,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return delivered
|
|
114
|
+
|
|
115
|
+
async def to_room(self, room: str, event: str, data: Any = None, **kwargs: Any) -> int:
|
|
116
|
+
return await self.emit(event, data, room=room, **kwargs)
|
|
117
|
+
|
|
118
|
+
async def to_user(self, user_id: str, event: str, data: Any = None, **kwargs: Any) -> int:
|
|
119
|
+
"""Gửi cho MỘT người — tới mọi kết nối của họ (nhiều tab, nhiều thiết bị)."""
|
|
120
|
+
return await self.emit(event, data, user=user_id, **kwargs)
|
|
121
|
+
|
|
122
|
+
async def to_socket(self, socket_id: str, event: str, data: Any = None, **kwargs: Any) -> int:
|
|
123
|
+
return await self.emit(event, data, socket=socket_id, **kwargs)
|
|
124
|
+
|
|
125
|
+
async def broadcast(self, event: str, data: Any = None, **kwargs: Any) -> int:
|
|
126
|
+
"""Gửi cho mọi kết nối trong namespace."""
|
|
127
|
+
return await self.emit(event, data, **kwargs)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------- tin từ worker khác
|
|
130
|
+
def _on_remote(self, payload: dict[str, Any]) -> None:
|
|
131
|
+
path = payload.get("ns")
|
|
132
|
+
if not path or path not in self._namespaces:
|
|
133
|
+
return # worker này không phục vụ namespace đó
|
|
134
|
+
self._namespaces[path].deliver(
|
|
135
|
+
payload.get("event", ""),
|
|
136
|
+
payload.get("data"),
|
|
137
|
+
room=payload.get("room"),
|
|
138
|
+
user=payload.get("user"),
|
|
139
|
+
socket=payload.get("socket"),
|
|
140
|
+
exclude=payload.get("exclude") or (),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------ vòng đời
|
|
144
|
+
async def startup(self) -> None:
|
|
145
|
+
if self._started:
|
|
146
|
+
return
|
|
147
|
+
await self._adapter.start(self._on_remote)
|
|
148
|
+
self._started = True
|
|
149
|
+
|
|
150
|
+
async def shutdown(self) -> None:
|
|
151
|
+
"""Đóng mọi kết nối tử tế trước khi tiến trình thoát.
|
|
152
|
+
|
|
153
|
+
Mã 1001 (going away) báo cho client biết đây là restart chứ không phải
|
|
154
|
+
lỗi, để phía client nối lại ngay thay vì chờ hết backoff.
|
|
155
|
+
"""
|
|
156
|
+
for namespace in self._namespaces.values():
|
|
157
|
+
for socket in namespace.sockets:
|
|
158
|
+
await socket.close(CloseCode.GOING_AWAY, "server đang tắt")
|
|
159
|
+
ws_connections.set(0, namespace=namespace.path)
|
|
160
|
+
await self._adapter.stop()
|
|
161
|
+
self._namespaces.clear()
|
|
162
|
+
self._started = False
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------ thống kê
|
|
165
|
+
@property
|
|
166
|
+
def adapter_name(self) -> str:
|
|
167
|
+
return self._adapter.name
|
|
168
|
+
|
|
169
|
+
def stats(self) -> dict[str, Any]:
|
|
170
|
+
return {
|
|
171
|
+
"adapter": self._adapter.name,
|
|
172
|
+
"origin": self._origin,
|
|
173
|
+
"namespaces": [ns.stats() for ns in self._namespaces.values()],
|
|
174
|
+
"connections": sum(len(ns) for ns in self._namespaces.values()),
|
|
175
|
+
}
|