butterbot-python 3.1.0.dev1__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.
- butterbot/__init__.py +15 -0
- butterbot/app/__init__.py +41 -0
- butterbot/app/bot_app.py +374 -0
- butterbot/app/config.py +156 -0
- butterbot/app/source_manager.py +367 -0
- butterbot/core/__init__.py +46 -0
- butterbot/core/api/__init__.py +9 -0
- butterbot/core/api/base_api.py +32 -0
- butterbot/core/context/README.md +1 -0
- butterbot/core/context/__init__.py +9 -0
- butterbot/core/context/api_registry.py +109 -0
- butterbot/core/context/app_context.py +51 -0
- butterbot/core/context/config_provider.py +14 -0
- butterbot/core/data/__init__.py +15 -0
- butterbot/core/data/base_data.py +29 -0
- butterbot/core/data/base_model.py +197 -0
- butterbot/core/event/README.md +1 -0
- butterbot/core/event/__init__.py +10 -0
- butterbot/core/event/event.py +27 -0
- butterbot/core/event/event_bus.py +276 -0
- butterbot/core/event/subscriber.py +130 -0
- butterbot/core/exceptions.py +90 -0
- butterbot/core/filter/__init__.py +11 -0
- butterbot/core/filter/base_filter.py +74 -0
- butterbot/core/source/README.md +1 -0
- butterbot/core/source/__init__.py +9 -0
- butterbot/core/source/base_source.py +134 -0
- butterbot/core/types/__init__.py +9 -0
- butterbot/core/types/base_type.py +74 -0
- butterbot/sources/__init__.py +1 -0
- butterbot/sources/bilibili/README.md +10 -0
- butterbot/sources/bilibili/__init__.py +20 -0
- butterbot/sources/bilibili/api/__init__.py +5 -0
- butterbot/sources/bilibili/api/bili_api.py +123 -0
- butterbot/sources/bilibili/data/__init__.py +52 -0
- butterbot/sources/bilibili/data/danmaku_gift_data.py +135 -0
- butterbot/sources/bilibili/data/danmaku_guard_data.py +54 -0
- butterbot/sources/bilibili/data/danmaku_msg_data.py +71 -0
- butterbot/sources/bilibili/data/dto/__init__.py +59 -0
- butterbot/sources/bilibili/data/dto/danmaku_gift_dto.py +193 -0
- butterbot/sources/bilibili/data/dto/danmaku_guard_buy_dto.py +54 -0
- butterbot/sources/bilibili/data/dto/danmaku_msg_dto.py +123 -0
- butterbot/sources/bilibili/data/dto/dynamic_dto.py +276 -0
- butterbot/sources/bilibili/data/dto/live_room_dto.py +169 -0
- butterbot/sources/bilibili/data/dto/video_part_dto.py +18 -0
- butterbot/sources/bilibili/data/dynamic_data.py +362 -0
- butterbot/sources/bilibili/data/live_room_data.py +162 -0
- butterbot/sources/bilibili/data/video_part.py +46 -0
- butterbot/sources/bilibili/source/__init__.py +15 -0
- butterbot/sources/bilibili/source/base_polling_source.py +130 -0
- butterbot/sources/bilibili/source/bili_danmaku_source.py +230 -0
- butterbot/sources/bilibili/source/bili_dynamic_source.py +135 -0
- butterbot/sources/bilibili/source/bili_live_source.py +137 -0
- butterbot/sources/bilibili/types/__init__.py +11 -0
- butterbot/sources/bilibili/types/bili_type.py +32 -0
- butterbot/sources/napcat/README.md +10 -0
- butterbot/sources/napcat/__init__.py +20 -0
- butterbot/sources/napcat/api/__init__.py +3 -0
- butterbot/sources/napcat/api/napcat_api.py +316 -0
- butterbot/sources/napcat/data/__init__.py +179 -0
- butterbot/sources/napcat/data/event_data.py +441 -0
- butterbot/sources/napcat/data/segment_data.py +432 -0
- butterbot/sources/napcat/events.py +91 -0
- butterbot/sources/napcat/filters/__init__.py +19 -0
- butterbot/sources/napcat/filters/filters.py +202 -0
- butterbot/sources/napcat/source/__init__.py +5 -0
- butterbot/sources/napcat/source/napcat_source.py +58 -0
- butterbot/sources/napcat/types/__init__.py +5 -0
- butterbot/sources/napcat/types/napcat_type.py +61 -0
- butterbot/utils/README.md +15 -0
- butterbot/utils/__init__.py +11 -0
- butterbot/utils/data_pair.py +27 -0
- butterbot/utils/logging_config.py +521 -0
- butterbot/utils/terminal.py +308 -0
- butterbot/utils/websocket.py +1270 -0
- butterbot_python-3.1.0.dev1.dist-info/METADATA +769 -0
- butterbot_python-3.1.0.dev1.dist-info/RECORD +79 -0
- butterbot_python-3.1.0.dev1.dist-info/WHEEL +4 -0
- butterbot_python-3.1.0.dev1.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
# @Author Fish.zh@outlook.com
|
|
2
|
+
# @Version 1.1
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import random
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from asyncio import QueueFull
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, NewType
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
from aiohttp import ClientSession, ClientWebSocketResponse, WSMsgType
|
|
17
|
+
|
|
18
|
+
ListenerId = NewType("ListenerId", str)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MessageType(Enum):
|
|
22
|
+
"""WebSocket 消息类型枚举"""
|
|
23
|
+
|
|
24
|
+
Text = "text"
|
|
25
|
+
Binary = "binary"
|
|
26
|
+
Ping = "ping"
|
|
27
|
+
Pong = "pong"
|
|
28
|
+
Close = "close"
|
|
29
|
+
Error = "error"
|
|
30
|
+
NONE = "none"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WebSocketState(Enum):
|
|
34
|
+
"""WebSocket 连接状态枚举"""
|
|
35
|
+
|
|
36
|
+
Disconnected = "disconnected"
|
|
37
|
+
Connecting = "connecting"
|
|
38
|
+
CONNECTED = "connected"
|
|
39
|
+
Rconnecting = "reconnecting"
|
|
40
|
+
Closing = "closing"
|
|
41
|
+
Closed = "closed"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WebSocketError(Exception):
|
|
45
|
+
"""WebSocket 基础异常"""
|
|
46
|
+
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ConnectionError(WebSocketError):
|
|
51
|
+
"""连接相关异常"""
|
|
52
|
+
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ListenerEvictedError(WebSocketError):
|
|
57
|
+
"""监听器被驱逐异常"""
|
|
58
|
+
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ListenerClosedError(WebSocketError):
|
|
63
|
+
"""监听器已关闭异常"""
|
|
64
|
+
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class WebSocketConfig:
|
|
70
|
+
"""WebSocket 配置类
|
|
71
|
+
|
|
72
|
+
包含 WebSocket 连接的所有配置参数,包括连接超时、重连策略、
|
|
73
|
+
压缩设置、监听器限制等。
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
uri: WebSocket 服务器地址,必须以 ws:// 或 wss:// 开头
|
|
77
|
+
headers: 连接请求头字典
|
|
78
|
+
heartbeat: 心跳间隔(秒),默认 30.0
|
|
79
|
+
receive_timeout: 接收消息超时时间(秒),默认 60.0
|
|
80
|
+
reconnect_attempts: 最大重连次数,默认 5,0 表示无限重连
|
|
81
|
+
connect_timeout: 连接超时时间(秒),默认 20.0
|
|
82
|
+
send_queue_size: 发送队列大小,默认 1024
|
|
83
|
+
session_timeout: 会话总超时时间(秒),默认 300.0
|
|
84
|
+
backoff_base: 退避基数(秒),默认 1.0
|
|
85
|
+
backoff_max: 最大退避时间(秒),默认 600.0
|
|
86
|
+
jitter_factor: 抖动因子,默认 5.0
|
|
87
|
+
compression: 压缩级别 0-15,默认 15,0 表示不压缩
|
|
88
|
+
verify_ssl: 是否验证 SSL 证书,默认 True
|
|
89
|
+
max_listeners: 最大监听器数量,默认 1000
|
|
90
|
+
listener_buffer_size: 每个监听器的缓冲区大小,默认 100
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
uri: str
|
|
94
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
95
|
+
heartbeat: float = 30.0
|
|
96
|
+
receive_timeout: float = 60.0
|
|
97
|
+
reconnect_attempts: int = 5
|
|
98
|
+
connect_timeout: float = 20.0
|
|
99
|
+
send_queue_size: int = 1024
|
|
100
|
+
session_timeout: float = 300.0
|
|
101
|
+
backoff_base: float = 1.0
|
|
102
|
+
backoff_max: float = 600.0
|
|
103
|
+
jitter_factor: float = 5
|
|
104
|
+
compression: int = 15
|
|
105
|
+
verify_ssl: bool = True
|
|
106
|
+
max_listeners: int = 1000
|
|
107
|
+
listener_buffer_size: int = 100
|
|
108
|
+
|
|
109
|
+
def __post_init__(self):
|
|
110
|
+
"""配置验证
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: URI 格式不正确或配置参数无效时抛出
|
|
114
|
+
"""
|
|
115
|
+
if not self.uri.startswith(("ws://", "wss://")):
|
|
116
|
+
raise ValueError("URI must start with ws:// or wss://")
|
|
117
|
+
if self.heartbeat <= 0:
|
|
118
|
+
raise ValueError("Heartbeat must be positive")
|
|
119
|
+
if self.reconnect_attempts < 0:
|
|
120
|
+
raise ValueError("Reconnect attempts cannot be negative")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_CLOSE_SENTINEL = object()
|
|
124
|
+
"""放入监听器队列的关闭哨兵.
|
|
125
|
+
|
|
126
|
+
``close()`` 需要唤醒已经阻塞在 ``queue.get()`` 上的消费者——只置标志位的话,
|
|
127
|
+
等待者会永远挂在那里等一条永不到来的消息。哨兵被 ``get()`` 识别后转换为
|
|
128
|
+
``ListenerClosedError``,因此不会被误当成业务消息。
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class WebSocketListener:
|
|
133
|
+
"""WebSocket 消息监听器
|
|
134
|
+
|
|
135
|
+
为每个订阅者提供独立的消息队列,支持异步迭代和超时控制。
|
|
136
|
+
当队列满时自动丢弃最旧的消息。
|
|
137
|
+
|
|
138
|
+
Attributes:
|
|
139
|
+
id: 监听器唯一标识符
|
|
140
|
+
queue: 消息队列
|
|
141
|
+
created_at: 创建时间戳
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(self, buffer_size: int = 100):
|
|
145
|
+
"""初始化监听器
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
buffer_size: 消息队列缓冲区大小,默认 100
|
|
149
|
+
"""
|
|
150
|
+
self.id = ListenerId(str(uuid.uuid4()))
|
|
151
|
+
self.queue = asyncio.Queue(maxsize=buffer_size)
|
|
152
|
+
self.created_at = time.time()
|
|
153
|
+
self._closed = False
|
|
154
|
+
|
|
155
|
+
async def put(self, message: Any, msg_type: MessageType) -> bool:
|
|
156
|
+
"""放入消息到队列
|
|
157
|
+
|
|
158
|
+
如果队列已满,自动丢弃最旧的消息以腾出空间。
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
message: 消息内容
|
|
162
|
+
msg_type: 消息类型
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
bool: 放入成功返回 True,失败返回 False
|
|
166
|
+
"""
|
|
167
|
+
if self._closed:
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
self.queue.put_nowait((message, msg_type))
|
|
172
|
+
return True
|
|
173
|
+
except QueueFull:
|
|
174
|
+
# 队列满时丢弃最旧的消息
|
|
175
|
+
try:
|
|
176
|
+
self.queue.get_nowait() # 丢弃一个旧消息
|
|
177
|
+
self.queue.put_nowait((message, msg_type)) # 放入新消息
|
|
178
|
+
return True
|
|
179
|
+
except QueueFull:
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
async def get(self, timeout: float | None = None) -> tuple[Any, MessageType]:
|
|
183
|
+
"""获取消息(阻塞)
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
timeout: 超时时间(秒),None 表示无限等待
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Tuple[Any, MessageType]: 消息内容和类型
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ListenerClosedError: 监听器已关闭时抛出
|
|
193
|
+
asyncio.TimeoutError: 超时时抛出
|
|
194
|
+
asyncio.CancelledError: 任务被取消时抛出
|
|
195
|
+
"""
|
|
196
|
+
if self._closed:
|
|
197
|
+
raise ListenerClosedError("Listener %s is closed" % self.id)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
if timeout is None:
|
|
201
|
+
item = await self.queue.get()
|
|
202
|
+
else:
|
|
203
|
+
item = await asyncio.wait_for(self.queue.get(), timeout=timeout)
|
|
204
|
+
except asyncio.CancelledError:
|
|
205
|
+
raise
|
|
206
|
+
except Exception as e:
|
|
207
|
+
if self._closed:
|
|
208
|
+
raise ListenerClosedError("Listener %s is closed" % self.id) from e
|
|
209
|
+
raise
|
|
210
|
+
|
|
211
|
+
if item[0] is _CLOSE_SENTINEL:
|
|
212
|
+
# 等待期间监听器被关闭:把哨兵放回去,让其他等待者也能醒
|
|
213
|
+
self._requeue_close_sentinel()
|
|
214
|
+
raise ListenerClosedError("Listener %s is closed" % self.id)
|
|
215
|
+
return item
|
|
216
|
+
|
|
217
|
+
def get_nowait(self) -> tuple[Any, MessageType] | None:
|
|
218
|
+
"""非阻塞获取消息
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Optional[Tuple[Any, MessageType]]: 消息内容和类型,无数据时返回 None
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ListenerClosedError: 监听器已关闭时抛出
|
|
225
|
+
"""
|
|
226
|
+
if self._closed:
|
|
227
|
+
raise ListenerClosedError("Listener %s is closed" % self.id)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
item = self.queue.get_nowait()
|
|
231
|
+
except asyncio.QueueEmpty:
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
if item[0] is _CLOSE_SENTINEL:
|
|
235
|
+
self._requeue_close_sentinel()
|
|
236
|
+
raise ListenerClosedError("Listener %s is closed" % self.id)
|
|
237
|
+
return item
|
|
238
|
+
|
|
239
|
+
def _requeue_close_sentinel(self) -> None:
|
|
240
|
+
"""把关闭哨兵放回队列,使后续/并发的等待者同样会醒来."""
|
|
241
|
+
try:
|
|
242
|
+
self.queue.put_nowait((_CLOSE_SENTINEL, MessageType.Close))
|
|
243
|
+
except QueueFull:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
def close(self) -> None:
|
|
247
|
+
"""关闭监听器,清空队列并唤醒所有等待者
|
|
248
|
+
|
|
249
|
+
幂等。清空积压消息后放入关闭哨兵——只置 ``_closed`` 标志的话,
|
|
250
|
+
已经阻塞在 ``get()`` 上的消费者不会被唤醒,会一直挂着。
|
|
251
|
+
"""
|
|
252
|
+
if self._closed:
|
|
253
|
+
return
|
|
254
|
+
self._closed = True
|
|
255
|
+
# 清空队列以释放等待的消费者
|
|
256
|
+
while not self.queue.empty():
|
|
257
|
+
try:
|
|
258
|
+
self.queue.get_nowait()
|
|
259
|
+
except asyncio.QueueEmpty:
|
|
260
|
+
break
|
|
261
|
+
self._requeue_close_sentinel()
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def is_closed(self) -> bool:
|
|
265
|
+
"""检查监听器是否已关闭
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
bool: 已关闭返回 True,否则返回 False
|
|
269
|
+
"""
|
|
270
|
+
return self._closed
|
|
271
|
+
|
|
272
|
+
def __aiter__(self):
|
|
273
|
+
"""返回异步迭代器"""
|
|
274
|
+
return self
|
|
275
|
+
|
|
276
|
+
async def __anext__(self):
|
|
277
|
+
"""异步迭代下一个消息
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Tuple[Any, MessageType]: 消息内容和类型
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
StopAsyncIteration: 监听器关闭时抛出
|
|
284
|
+
"""
|
|
285
|
+
try:
|
|
286
|
+
return await self.get()
|
|
287
|
+
except ListenerClosedError:
|
|
288
|
+
raise StopAsyncIteration
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class AioHttpWebSocketConnection:
|
|
292
|
+
"""基于 aiohttp 的 WebSocket 连接管理
|
|
293
|
+
|
|
294
|
+
负责底层的 WebSocket 连接建立、消息收发和资源清理。
|
|
295
|
+
自动处理压缩协商失败等边界情况。
|
|
296
|
+
|
|
297
|
+
Attributes:
|
|
298
|
+
config: WebSocket 配置对象
|
|
299
|
+
logger: 日志记录器
|
|
300
|
+
websocket: aiohttp WebSocket 响应对象
|
|
301
|
+
session: aiohttp 客户端会话
|
|
302
|
+
state: 当前连接状态
|
|
303
|
+
metrics: 连接指标统计
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
def __init__(self, config: WebSocketConfig, logger: logging.Logger):
|
|
307
|
+
"""初始化连接管理器
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
config: WebSocket 配置对象
|
|
311
|
+
logger: 日志记录器实例
|
|
312
|
+
"""
|
|
313
|
+
self.config = config
|
|
314
|
+
self.logger = logger
|
|
315
|
+
|
|
316
|
+
self.websocket: ClientWebSocketResponse | None = None
|
|
317
|
+
self.session: ClientSession | None = None
|
|
318
|
+
self.state = WebSocketState.Disconnected
|
|
319
|
+
|
|
320
|
+
# 指标
|
|
321
|
+
self.metrics = {
|
|
322
|
+
"connection_attempts": 0,
|
|
323
|
+
"successful_connections": 0,
|
|
324
|
+
"failed_connections": 0,
|
|
325
|
+
"messages_sent": 0,
|
|
326
|
+
"messages_received": 0,
|
|
327
|
+
"bytes_sent": 0,
|
|
328
|
+
"bytes_received": 0,
|
|
329
|
+
"errors": 0,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async def connect(self) -> None:
|
|
333
|
+
"""建立 WebSocket 连接
|
|
334
|
+
|
|
335
|
+
如果已经处于连接中或已连接状态,直接返回。
|
|
336
|
+
支持自动处理压缩协商失败的情况。
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
ConnectionError: 连接失败时抛出
|
|
340
|
+
"""
|
|
341
|
+
if self.state in [WebSocketState.Connecting, WebSocketState.CONNECTED]:
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
self.state = WebSocketState.Connecting
|
|
345
|
+
self.metrics["connection_attempts"] += 1
|
|
346
|
+
self.logger.info("Connecting to %s", self.config.uri)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
# 创建 aiohttp 会话
|
|
350
|
+
timeout = aiohttp.ClientTimeout(
|
|
351
|
+
total=self.config.session_timeout,
|
|
352
|
+
connect=self.config.connect_timeout,
|
|
353
|
+
sock_connect=self.config.connect_timeout,
|
|
354
|
+
sock_read=self.config.receive_timeout,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
self.session = ClientSession(timeout=timeout)
|
|
358
|
+
|
|
359
|
+
# 建立 WebSocket 连接
|
|
360
|
+
self.websocket = await self.session.ws_connect(
|
|
361
|
+
self.config.uri,
|
|
362
|
+
headers=self.config.headers,
|
|
363
|
+
heartbeat=self.config.heartbeat,
|
|
364
|
+
compress=self.config.compression,
|
|
365
|
+
verify_ssl=self.config.verify_ssl,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
self.state = WebSocketState.CONNECTED
|
|
369
|
+
self.metrics["successful_connections"] += 1
|
|
370
|
+
self.logger.info("Connected to %s", self.config.uri)
|
|
371
|
+
|
|
372
|
+
except Exception as e:
|
|
373
|
+
self.state = WebSocketState.Disconnected
|
|
374
|
+
self.metrics["failed_connections"] += 1
|
|
375
|
+
|
|
376
|
+
# 清理资源
|
|
377
|
+
if self.session:
|
|
378
|
+
await self.session.close()
|
|
379
|
+
self.session = None
|
|
380
|
+
|
|
381
|
+
self.logger.error("Connection failed: %s, error: %s", self.config.uri, e)
|
|
382
|
+
if "wbits=" in str(e):
|
|
383
|
+
self.logger.error("Detected zlib wbits compression error")
|
|
384
|
+
if self.config.compression > 0:
|
|
385
|
+
self.logger.info("Enabled compression reconnection")
|
|
386
|
+
self.config.compression = 15
|
|
387
|
+
else:
|
|
388
|
+
self.logger.info("Disable compression reconnection")
|
|
389
|
+
self.config.compression = 0
|
|
390
|
+
|
|
391
|
+
raise ConnectionError("Connection failed: %s" % e) from e
|
|
392
|
+
|
|
393
|
+
async def close(self) -> None:
|
|
394
|
+
"""关闭 WebSocket 连接并清理资源"""
|
|
395
|
+
if self.state == WebSocketState.Closed:
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
self.state = WebSocketState.Closing
|
|
399
|
+
self.logger.debug("Closing connection")
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
if self.websocket:
|
|
403
|
+
await self.websocket.close()
|
|
404
|
+
except Exception as e:
|
|
405
|
+
self.logger.error("WebSocket close error: %s", e)
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
if self.session:
|
|
409
|
+
await self.session.close()
|
|
410
|
+
except Exception as e:
|
|
411
|
+
self.logger.error("Session close error: %s", e)
|
|
412
|
+
finally:
|
|
413
|
+
self.websocket = None
|
|
414
|
+
self.session = None
|
|
415
|
+
self.state = WebSocketState.Closed
|
|
416
|
+
self.logger.info("Connection closed")
|
|
417
|
+
|
|
418
|
+
async def send(self, message: str | bytes | dict) -> None:
|
|
419
|
+
"""发送消息
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
message: 要发送的消息,支持字符串、字节或字典(自动转为 JSON)
|
|
423
|
+
|
|
424
|
+
Raises:
|
|
425
|
+
ConnectionError: 未连接时抛出
|
|
426
|
+
Exception: 发送失败时抛出
|
|
427
|
+
"""
|
|
428
|
+
if self.state != WebSocketState.CONNECTED or not self.websocket:
|
|
429
|
+
raise ConnectionError("Not connected")
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
# 格式化消息
|
|
433
|
+
if isinstance(message, dict):
|
|
434
|
+
formatted = json.dumps(message)
|
|
435
|
+
elif isinstance(message, bytes):
|
|
436
|
+
formatted = message
|
|
437
|
+
else:
|
|
438
|
+
formatted = str(message)
|
|
439
|
+
|
|
440
|
+
# 发送消息
|
|
441
|
+
if isinstance(formatted, str):
|
|
442
|
+
await self.websocket.send_str(formatted)
|
|
443
|
+
else:
|
|
444
|
+
await self.websocket.send_bytes(formatted)
|
|
445
|
+
|
|
446
|
+
self.metrics["messages_sent"] += 1
|
|
447
|
+
self.metrics["bytes_sent"] += len(formatted)
|
|
448
|
+
|
|
449
|
+
except Exception as e:
|
|
450
|
+
self.metrics["errors"] += 1
|
|
451
|
+
self.logger.error("Send error: %s", e)
|
|
452
|
+
raise
|
|
453
|
+
|
|
454
|
+
async def receive(self) -> tuple[Any, MessageType]:
|
|
455
|
+
"""接收消息
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
Tuple[Any, MessageType]: 消息数据和类型
|
|
459
|
+
|
|
460
|
+
Raises:
|
|
461
|
+
ConnectionError: 未连接时抛出
|
|
462
|
+
asyncio.TimeoutError: 接收超时时抛出
|
|
463
|
+
Exception: 接收失败时抛出
|
|
464
|
+
"""
|
|
465
|
+
if self.state != WebSocketState.CONNECTED or not self.websocket:
|
|
466
|
+
raise ConnectionError("Not connected")
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
# 接收消息
|
|
470
|
+
msg = await self.websocket.receive(timeout=self.config.receive_timeout)
|
|
471
|
+
|
|
472
|
+
# 处理不同类型的消息
|
|
473
|
+
if msg.type == WSMsgType.TEXT:
|
|
474
|
+
self.metrics["messages_received"] += 1
|
|
475
|
+
self.metrics["bytes_received"] += len(msg.data)
|
|
476
|
+
return msg.data, MessageType.Text
|
|
477
|
+
|
|
478
|
+
elif msg.type == WSMsgType.BINARY:
|
|
479
|
+
self.metrics["messages_received"] += 1
|
|
480
|
+
self.metrics["bytes_received"] += len(msg.data)
|
|
481
|
+
return msg.data, MessageType.Binary
|
|
482
|
+
|
|
483
|
+
elif msg.type == WSMsgType.PING:
|
|
484
|
+
return msg.data, MessageType.Ping
|
|
485
|
+
|
|
486
|
+
elif msg.type == WSMsgType.PONG:
|
|
487
|
+
return msg.data, MessageType.Pong
|
|
488
|
+
|
|
489
|
+
elif msg.type == WSMsgType.CLOSE:
|
|
490
|
+
return msg.data, MessageType.Close
|
|
491
|
+
|
|
492
|
+
elif msg.type == WSMsgType.ERROR:
|
|
493
|
+
self.metrics["errors"] += 1
|
|
494
|
+
self.logger.error("WebSocket error: %s", msg.data)
|
|
495
|
+
return msg.data, MessageType.Error
|
|
496
|
+
|
|
497
|
+
else:
|
|
498
|
+
# 未知消息类型
|
|
499
|
+
return msg.data, MessageType.NONE
|
|
500
|
+
|
|
501
|
+
except TimeoutError:
|
|
502
|
+
raise
|
|
503
|
+
except Exception as e:
|
|
504
|
+
self.metrics["errors"] += 1
|
|
505
|
+
self.logger.error("Receive error: %s", e)
|
|
506
|
+
raise
|
|
507
|
+
|
|
508
|
+
def is_connected(self) -> bool:
|
|
509
|
+
"""检查连接是否处于活动状态
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
bool: 已连接且 WebSocket 未关闭返回 True
|
|
513
|
+
"""
|
|
514
|
+
return (
|
|
515
|
+
self.state == WebSocketState.CONNECTED
|
|
516
|
+
and self.websocket is not None
|
|
517
|
+
and not self.websocket.closed
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class ReconnectionStrategy:
|
|
522
|
+
"""重连策略管理
|
|
523
|
+
|
|
524
|
+
实现指数退避 + 随机抖动的重连策略。
|
|
525
|
+
|
|
526
|
+
Attributes:
|
|
527
|
+
config: WebSocket 配置对象
|
|
528
|
+
attempt_count: 当前重连尝试次数
|
|
529
|
+
last_attempt_time: 上次尝试时间戳
|
|
530
|
+
"""
|
|
531
|
+
|
|
532
|
+
def __init__(self, config: WebSocketConfig):
|
|
533
|
+
"""初始化重连策略
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
config: WebSocket 配置对象
|
|
537
|
+
"""
|
|
538
|
+
self.config = config
|
|
539
|
+
self.attempt_count = 0
|
|
540
|
+
self.last_attempt_time = 0.0
|
|
541
|
+
|
|
542
|
+
def should_reconnect(self) -> bool:
|
|
543
|
+
"""检查是否应该继续重连
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
bool: 未达到最大重连次数返回 True,``reconnect_attempts=0`` 表示无限重连
|
|
547
|
+
"""
|
|
548
|
+
if self.config.reconnect_attempts == 0:
|
|
549
|
+
# 配置文档一直声明 0 = 无限重连,而原实现是 `0 < 0` → 立即放弃,
|
|
550
|
+
# 恰好把"永不停止"配成了"一次都不试"。
|
|
551
|
+
return True
|
|
552
|
+
return self.attempt_count < self.config.reconnect_attempts
|
|
553
|
+
|
|
554
|
+
def get_delay(self) -> float:
|
|
555
|
+
"""计算下次重连的延迟时间
|
|
556
|
+
|
|
557
|
+
使用指数退避算法:delay = min(base * 2^(n-1), max) + jitter
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
float: 延迟时间(秒)
|
|
561
|
+
"""
|
|
562
|
+
if self.attempt_count == 0:
|
|
563
|
+
return 0.0
|
|
564
|
+
|
|
565
|
+
# 指数退避
|
|
566
|
+
delay = min(
|
|
567
|
+
self.config.backoff_base * (2 ** (self.attempt_count - 1)),
|
|
568
|
+
self.config.backoff_max,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# 随机抖动
|
|
572
|
+
jitter = random.uniform(0, self.config.jitter_factor)
|
|
573
|
+
return delay + jitter
|
|
574
|
+
|
|
575
|
+
def on_attempt(self) -> None:
|
|
576
|
+
"""记录一次重连尝试"""
|
|
577
|
+
self.attempt_count += 1
|
|
578
|
+
self.last_attempt_time = time.time()
|
|
579
|
+
|
|
580
|
+
def on_success(self) -> None:
|
|
581
|
+
"""重连成功,重置计数器"""
|
|
582
|
+
self.attempt_count = 0
|
|
583
|
+
|
|
584
|
+
def get_state(self) -> dict[str, Any]:
|
|
585
|
+
"""获取当前重连状态
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
Dict[str, Any]: 包含尝试次数、上次尝试时间、最大次数的字典
|
|
589
|
+
"""
|
|
590
|
+
return {
|
|
591
|
+
"attempt_count": self.attempt_count,
|
|
592
|
+
"last_attempt_time": self.last_attempt_time,
|
|
593
|
+
"max_attempts": self.config.reconnect_attempts,
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class AsyncWebSocketClient:
|
|
598
|
+
"""异步 WebSocket 客户端 - 使用监听器模式
|
|
599
|
+
|
|
600
|
+
高性能异步 WebSocket 客户端,支持多监听器、自动重连、
|
|
601
|
+
消息广播和完善的指标统计。
|
|
602
|
+
|
|
603
|
+
Attributes:
|
|
604
|
+
config: WebSocket 配置对象
|
|
605
|
+
logger: 日志记录器
|
|
606
|
+
connection: 底层连接管理器
|
|
607
|
+
reconnection: 重连策略管理器
|
|
608
|
+
_listeners: 监听器字典
|
|
609
|
+
_running: 运行状态标志
|
|
610
|
+
"""
|
|
611
|
+
|
|
612
|
+
def __init__(
|
|
613
|
+
self,
|
|
614
|
+
uri: str,
|
|
615
|
+
logger: logging.Logger | None = None,
|
|
616
|
+
headers: dict[str, str] | None = None,
|
|
617
|
+
heartbeat: float = 30.0,
|
|
618
|
+
receive_timeout: float = 60.0,
|
|
619
|
+
reconnect_attempts: int = 5,
|
|
620
|
+
connect_timeout: float = 20.0,
|
|
621
|
+
send_queue_size: int = 1024,
|
|
622
|
+
session_timeout: float = 300.0,
|
|
623
|
+
backoff_base: float = 1.0,
|
|
624
|
+
backoff_max: float = 60.0,
|
|
625
|
+
jitter_factor: float = 0.5,
|
|
626
|
+
compression: int = 15,
|
|
627
|
+
verify_ssl: bool = True,
|
|
628
|
+
max_listeners: int = 1000,
|
|
629
|
+
listener_buffer_size: int = 100,
|
|
630
|
+
):
|
|
631
|
+
"""初始化异步 WebSocket 客户端
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
uri: WebSocket 服务器地址
|
|
635
|
+
logger: 可选的日志记录器,默认使用模块日志
|
|
636
|
+
headers: 可选的连接请求头
|
|
637
|
+
heartbeat: 心跳间隔(秒),默认 30.0
|
|
638
|
+
receive_timeout: 接收超时(秒),默认 60.0
|
|
639
|
+
reconnect_attempts: 最大重连次数,默认 5
|
|
640
|
+
connect_timeout: 连接超时(秒),默认 20.0
|
|
641
|
+
send_queue_size: 发送队列大小,默认 1024
|
|
642
|
+
session_timeout: 会话超时(秒),默认 300.0
|
|
643
|
+
backoff_base: 退避基数(秒),默认 1.0
|
|
644
|
+
backoff_max: 最大退避(秒),默认 60.0
|
|
645
|
+
jitter_factor: 抖动因子,默认 0.5
|
|
646
|
+
compression: 压缩级别,默认 15
|
|
647
|
+
verify_ssl: 验证 SSL,默认 True
|
|
648
|
+
max_listeners: 最大监听器数,默认 1000
|
|
649
|
+
listener_buffer_size: 监听器缓冲区大小,默认 100
|
|
650
|
+
"""
|
|
651
|
+
# 创建配置
|
|
652
|
+
self.config = WebSocketConfig(
|
|
653
|
+
uri=uri,
|
|
654
|
+
headers=headers or {},
|
|
655
|
+
heartbeat=heartbeat,
|
|
656
|
+
receive_timeout=receive_timeout,
|
|
657
|
+
reconnect_attempts=reconnect_attempts,
|
|
658
|
+
connect_timeout=connect_timeout,
|
|
659
|
+
send_queue_size=send_queue_size,
|
|
660
|
+
session_timeout=session_timeout,
|
|
661
|
+
backoff_base=backoff_base,
|
|
662
|
+
backoff_max=backoff_max,
|
|
663
|
+
jitter_factor=jitter_factor,
|
|
664
|
+
compression=compression,
|
|
665
|
+
verify_ssl=verify_ssl,
|
|
666
|
+
max_listeners=max_listeners,
|
|
667
|
+
listener_buffer_size=listener_buffer_size,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
# 设置日志
|
|
671
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
672
|
+
|
|
673
|
+
# 核心组件
|
|
674
|
+
self.connection = AioHttpWebSocketConnection(self.config, self.logger)
|
|
675
|
+
self.reconnection = ReconnectionStrategy(self.config)
|
|
676
|
+
|
|
677
|
+
# 监听器管理
|
|
678
|
+
self._listeners: dict[ListenerId, WebSocketListener] = {}
|
|
679
|
+
self._listeners_lock = threading.Lock()
|
|
680
|
+
|
|
681
|
+
# 状态控制
|
|
682
|
+
self._running = False
|
|
683
|
+
self._main_task: asyncio.Task | None = None
|
|
684
|
+
|
|
685
|
+
# 发送队列
|
|
686
|
+
self._send_queue = asyncio.Queue(maxsize=self.config.send_queue_size)
|
|
687
|
+
|
|
688
|
+
@property
|
|
689
|
+
def running(self) -> bool:
|
|
690
|
+
"""检查客户端是否正在运行
|
|
691
|
+
|
|
692
|
+
Returns:
|
|
693
|
+
bool: 正在运行返回 True
|
|
694
|
+
"""
|
|
695
|
+
return self._running
|
|
696
|
+
|
|
697
|
+
async def __aenter__(self):
|
|
698
|
+
"""异步上下文管理器入口
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
AsyncWebSocketClient: 客户端实例
|
|
702
|
+
"""
|
|
703
|
+
await self.start()
|
|
704
|
+
return self
|
|
705
|
+
|
|
706
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
707
|
+
"""异步上下文管理器出口
|
|
708
|
+
|
|
709
|
+
Args:
|
|
710
|
+
exc_type: 异常类型
|
|
711
|
+
exc_val: 异常值
|
|
712
|
+
exc_tb: 异常追踪
|
|
713
|
+
"""
|
|
714
|
+
await self.stop()
|
|
715
|
+
|
|
716
|
+
async def start(self) -> None:
|
|
717
|
+
"""启动客户端
|
|
718
|
+
|
|
719
|
+
启动主事件循环,开始处理连接、发送和接收。
|
|
720
|
+
如果已经在运行,直接返回。
|
|
721
|
+
"""
|
|
722
|
+
if self._running:
|
|
723
|
+
return
|
|
724
|
+
|
|
725
|
+
self._running = True
|
|
726
|
+
self._main_task = asyncio.create_task(self._main_loop())
|
|
727
|
+
self.logger.info("WebSocket client started")
|
|
728
|
+
|
|
729
|
+
async def stop(self) -> None:
|
|
730
|
+
"""停止客户端(外部入口)
|
|
731
|
+
|
|
732
|
+
优雅地取消主任务,然后清理监听器与连接。幂等。
|
|
733
|
+
|
|
734
|
+
主任务自身**不得**走这条路径:``await self._main_task`` 在主任务里执行
|
|
735
|
+
会抛 ``RuntimeError``(Task 不能 await 自己),而此时 ``_running``
|
|
736
|
+
已被置 False,异常传出后 finally 里的再次 stop() 会被开头的早退挡掉,
|
|
737
|
+
于是监听器与 aiohttp session 全部泄漏。内部收尾请用 :meth:`_shutdown`。
|
|
738
|
+
"""
|
|
739
|
+
self._running = False
|
|
740
|
+
main_task = self._main_task
|
|
741
|
+
self._main_task = None
|
|
742
|
+
|
|
743
|
+
if main_task is not None and main_task is not asyncio.current_task():
|
|
744
|
+
self.logger.debug("WebSocket client stopping")
|
|
745
|
+
main_task.cancel()
|
|
746
|
+
try:
|
|
747
|
+
await main_task
|
|
748
|
+
except asyncio.CancelledError:
|
|
749
|
+
pass
|
|
750
|
+
except Exception as e:
|
|
751
|
+
self.logger.error("Main task ended with error: %s", e)
|
|
752
|
+
|
|
753
|
+
await self._shutdown()
|
|
754
|
+
|
|
755
|
+
async def _shutdown(self) -> None:
|
|
756
|
+
"""清理监听器与底层连接(幂等,不触碰主任务)
|
|
757
|
+
|
|
758
|
+
供 :meth:`stop` 和 :meth:`_main_loop` 的 finally 共用:
|
|
759
|
+
无论从外部停止还是主循环自己退出,资源释放都走同一条路径。
|
|
760
|
+
"""
|
|
761
|
+
self._running = False
|
|
762
|
+
|
|
763
|
+
# 关闭所有监听器
|
|
764
|
+
with self._listeners_lock:
|
|
765
|
+
listeners = list(self._listeners.values())
|
|
766
|
+
self._listeners.clear()
|
|
767
|
+
for listener in listeners:
|
|
768
|
+
listener.close()
|
|
769
|
+
|
|
770
|
+
# 关闭连接
|
|
771
|
+
await self.connection.close()
|
|
772
|
+
|
|
773
|
+
self.logger.info("WebSocket client stopped")
|
|
774
|
+
|
|
775
|
+
async def create_listener(self, buffer_size: int | None = None) -> ListenerId:
|
|
776
|
+
"""创建消息监听器
|
|
777
|
+
|
|
778
|
+
如果监听器数量达到上限,自动淘汰最旧的监听器。
|
|
779
|
+
|
|
780
|
+
Args:
|
|
781
|
+
buffer_size: 缓冲区大小,默认使用配置值
|
|
782
|
+
|
|
783
|
+
Returns:
|
|
784
|
+
ListenerId: 新监听器的唯一标识符
|
|
785
|
+
"""
|
|
786
|
+
if buffer_size is None:
|
|
787
|
+
buffer_size = self.config.listener_buffer_size
|
|
788
|
+
|
|
789
|
+
listener = WebSocketListener(buffer_size)
|
|
790
|
+
evicted: WebSocketListener | None = None
|
|
791
|
+
|
|
792
|
+
with self._listeners_lock:
|
|
793
|
+
# 检查监听器数量限制。淘汰只在锁内做 dict 操作,
|
|
794
|
+
# 关闭动作留到临界区之外——在持有非重入锁时调用会重入同一把锁的
|
|
795
|
+
# remove_listener,是一条确定的死锁路径。
|
|
796
|
+
if len(self._listeners) >= self.config.max_listeners:
|
|
797
|
+
evicted = self._pop_oldest_listener_locked()
|
|
798
|
+
|
|
799
|
+
self._listeners[listener.id] = listener
|
|
800
|
+
|
|
801
|
+
if evicted is not None:
|
|
802
|
+
evicted.close()
|
|
803
|
+
self.logger.warning(
|
|
804
|
+
"Evicted oldest listener due to max listeners: %s", evicted.id
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
self.logger.debug("Listener created: %s", listener.id)
|
|
808
|
+
return listener.id
|
|
809
|
+
|
|
810
|
+
async def remove_listener(self, listener_id: ListenerId) -> None:
|
|
811
|
+
"""移除指定监听器
|
|
812
|
+
|
|
813
|
+
Args:
|
|
814
|
+
listener_id: 要移除的监听器 ID
|
|
815
|
+
"""
|
|
816
|
+
with self._listeners_lock:
|
|
817
|
+
listener = self._listeners.pop(listener_id, None)
|
|
818
|
+
|
|
819
|
+
if listener:
|
|
820
|
+
listener.close()
|
|
821
|
+
self.logger.debug("Listener removed: %s", listener_id)
|
|
822
|
+
|
|
823
|
+
async def get_message(
|
|
824
|
+
self, listener_id: ListenerId, timeout: float | None = None
|
|
825
|
+
) -> tuple[Any, MessageType]:
|
|
826
|
+
"""从指定监听器获取消息(阻塞)
|
|
827
|
+
|
|
828
|
+
Args:
|
|
829
|
+
listener_id: 监听器 ID
|
|
830
|
+
timeout: 超时时间(秒),None 表示无限等待
|
|
831
|
+
|
|
832
|
+
Returns:
|
|
833
|
+
Tuple[Any, MessageType]: 消息内容和类型
|
|
834
|
+
|
|
835
|
+
Raises:
|
|
836
|
+
ListenerEvictedError: 监听器不存在时抛出
|
|
837
|
+
ListenerClosedError: 监听器已关闭时抛出
|
|
838
|
+
asyncio.TimeoutError: 超时时抛出
|
|
839
|
+
"""
|
|
840
|
+
with self._listeners_lock:
|
|
841
|
+
listener = self._listeners.get(listener_id)
|
|
842
|
+
|
|
843
|
+
if not listener:
|
|
844
|
+
raise ListenerEvictedError("Listener %s not found" % listener_id)
|
|
845
|
+
|
|
846
|
+
return await listener.get(timeout)
|
|
847
|
+
|
|
848
|
+
def get_message_nowait(
|
|
849
|
+
self, listener_id: ListenerId
|
|
850
|
+
) -> tuple[Any, MessageType] | None:
|
|
851
|
+
"""从指定监听器非阻塞获取消息
|
|
852
|
+
|
|
853
|
+
Args:
|
|
854
|
+
listener_id: 监听器 ID
|
|
855
|
+
|
|
856
|
+
Returns:
|
|
857
|
+
Optional[Tuple[Any, MessageType]]: 消息内容和类型,无数据返回 None
|
|
858
|
+
|
|
859
|
+
Raises:
|
|
860
|
+
ListenerEvictedError: 监听器不存在时抛出
|
|
861
|
+
ListenerClosedError: 监听器已关闭时抛出
|
|
862
|
+
"""
|
|
863
|
+
with self._listeners_lock:
|
|
864
|
+
listener = self._listeners.get(listener_id)
|
|
865
|
+
|
|
866
|
+
if not listener:
|
|
867
|
+
raise ListenerEvictedError("Listener %s not found" % listener_id)
|
|
868
|
+
|
|
869
|
+
return listener.get_nowait()
|
|
870
|
+
|
|
871
|
+
async def send(self, message: str | bytes | dict) -> None:
|
|
872
|
+
"""发送消息到 WebSocket 服务器
|
|
873
|
+
|
|
874
|
+
消息会被放入发送队列,由后台任务异步发送。
|
|
875
|
+
|
|
876
|
+
Args:
|
|
877
|
+
message: 要发送的消息,支持字符串、字节或字典
|
|
878
|
+
|
|
879
|
+
Raises:
|
|
880
|
+
ConnectionError: 客户端未运行时抛出
|
|
881
|
+
WebSocketError: 发送队列满时抛出
|
|
882
|
+
"""
|
|
883
|
+
if not self._running:
|
|
884
|
+
raise ConnectionError("Client not running")
|
|
885
|
+
|
|
886
|
+
try:
|
|
887
|
+
self._send_queue.put_nowait(message)
|
|
888
|
+
except QueueFull:
|
|
889
|
+
raise WebSocketError("Send queue is full")
|
|
890
|
+
|
|
891
|
+
def _pop_oldest_listener_locked(self) -> WebSocketListener | None:
|
|
892
|
+
"""摘除创建时间最早的监听器并返回它(**调用方必须已持有锁**)
|
|
893
|
+
|
|
894
|
+
只做字典操作、不做任何 ``await``,也不调用 ``close()``——
|
|
895
|
+
关闭动作由调用方在释放锁之后执行。
|
|
896
|
+
|
|
897
|
+
Returns:
|
|
898
|
+
被摘除的监听器,没有监听器时返回 None
|
|
899
|
+
"""
|
|
900
|
+
if not self._listeners:
|
|
901
|
+
return None
|
|
902
|
+
|
|
903
|
+
oldest_id = min(
|
|
904
|
+
self._listeners, key=lambda lid: self._listeners[lid].created_at
|
|
905
|
+
)
|
|
906
|
+
return self._listeners.pop(oldest_id, None)
|
|
907
|
+
|
|
908
|
+
async def _broadcast_message(self, message: Any, msg_type: MessageType) -> None:
|
|
909
|
+
"""广播消息到所有监听器
|
|
910
|
+
|
|
911
|
+
如果某个监听器队列满,该监听器会被自动移除。
|
|
912
|
+
|
|
913
|
+
Args:
|
|
914
|
+
message: 消息内容
|
|
915
|
+
msg_type: 消息类型
|
|
916
|
+
"""
|
|
917
|
+
listeners_to_remove = []
|
|
918
|
+
|
|
919
|
+
with self._listeners_lock:
|
|
920
|
+
listeners = list(self._listeners.values())
|
|
921
|
+
|
|
922
|
+
for listener in listeners:
|
|
923
|
+
if not await listener.put(message, msg_type):
|
|
924
|
+
# 监听器队列满,标记为移除
|
|
925
|
+
listeners_to_remove.append(listener.id)
|
|
926
|
+
|
|
927
|
+
# 移除无法处理消息的监听器
|
|
928
|
+
for listener_id in listeners_to_remove:
|
|
929
|
+
await self.remove_listener(listener_id)
|
|
930
|
+
self.logger.warning("Listener evicted due to buffer full: %s", listener_id)
|
|
931
|
+
|
|
932
|
+
def get_metrics(self) -> dict[str, Any]:
|
|
933
|
+
"""获取客户端运行指标
|
|
934
|
+
|
|
935
|
+
Returns:
|
|
936
|
+
Dict[str, Any]: 包含连接指标、重连状态、监听器统计的字典
|
|
937
|
+
"""
|
|
938
|
+
connection_metrics = self.connection.metrics.copy()
|
|
939
|
+
reconnection_state = self.reconnection.get_state()
|
|
940
|
+
|
|
941
|
+
with self._listeners_lock:
|
|
942
|
+
active_listeners = len(self._listeners)
|
|
943
|
+
|
|
944
|
+
return {
|
|
945
|
+
"connection": connection_metrics,
|
|
946
|
+
"reconnection": reconnection_state,
|
|
947
|
+
"listeners": {
|
|
948
|
+
"active": active_listeners,
|
|
949
|
+
"max": self.config.max_listeners,
|
|
950
|
+
},
|
|
951
|
+
"running": self._running,
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
async def _main_loop(self) -> None:
|
|
955
|
+
"""主事件循环
|
|
956
|
+
|
|
957
|
+
管理连接生命周期,协调发送和接收任务。
|
|
958
|
+
处理连接断开、重连和异常恢复。
|
|
959
|
+
|
|
960
|
+
**首次连接也在循环内**,与重连走同一条 :meth:`_handle_disconnected`
|
|
961
|
+
路径(首次的退避为 0,立即尝试)。原实现把首连放在 while 之前,
|
|
962
|
+
首连失败会直接落进 finally —— 重连策略对"服务端还没起来"这种最常见的
|
|
963
|
+
场景完全不生效。
|
|
964
|
+
"""
|
|
965
|
+
self.logger.debug("Main loop started")
|
|
966
|
+
|
|
967
|
+
try:
|
|
968
|
+
while self._running:
|
|
969
|
+
# 处理连接状态(含首次连接)
|
|
970
|
+
if not self.connection.is_connected():
|
|
971
|
+
await self._handle_disconnected()
|
|
972
|
+
continue
|
|
973
|
+
|
|
974
|
+
# 并行处理发送和接收
|
|
975
|
+
send_task = asyncio.create_task(self._process_send_queue())
|
|
976
|
+
recv_task = asyncio.create_task(self._process_receive())
|
|
977
|
+
|
|
978
|
+
done, pending = await asyncio.wait(
|
|
979
|
+
[send_task, recv_task], return_when=asyncio.FIRST_COMPLETED
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
# 取消未完成的任务,并等它们真正结束(否则退出时会出现
|
|
983
|
+
# "Task was destroyed but it is pending!" 告警)
|
|
984
|
+
for task in pending:
|
|
985
|
+
task.cancel()
|
|
986
|
+
if pending:
|
|
987
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
988
|
+
|
|
989
|
+
# 处理异常
|
|
990
|
+
for task in done:
|
|
991
|
+
if task.cancelled():
|
|
992
|
+
continue
|
|
993
|
+
exc = task.exception()
|
|
994
|
+
if exc is not None:
|
|
995
|
+
self.logger.error("Task error: %s", exc)
|
|
996
|
+
|
|
997
|
+
except asyncio.CancelledError:
|
|
998
|
+
pass
|
|
999
|
+
except Exception as e:
|
|
1000
|
+
self.logger.error("Main loop error: %s", e)
|
|
1001
|
+
finally:
|
|
1002
|
+
# 只做资源清理,不要 await 主任务自己
|
|
1003
|
+
await self._shutdown()
|
|
1004
|
+
self.logger.debug("Main loop ended")
|
|
1005
|
+
|
|
1006
|
+
async def _handle_disconnected(self) -> None:
|
|
1007
|
+
"""处理连接(首次或断线重连)
|
|
1008
|
+
|
|
1009
|
+
根据重连策略决定是否继续尝试,并执行指数退避等待。
|
|
1010
|
+
超过最大次数时只置 ``_running=False``,让 :meth:`_main_loop` 自然退出、
|
|
1011
|
+
由它的 finally 统一清理——不能在这里 ``await self.stop()``,
|
|
1012
|
+
那等于在主任务内部 await 主任务自己。
|
|
1013
|
+
"""
|
|
1014
|
+
if not self.reconnection.should_reconnect():
|
|
1015
|
+
self.logger.error("Max reconnection attempts reached")
|
|
1016
|
+
self._running = False
|
|
1017
|
+
return
|
|
1018
|
+
|
|
1019
|
+
delay = self.reconnection.get_delay()
|
|
1020
|
+
if delay > 0:
|
|
1021
|
+
self.logger.info("Reconnection delay: %.2fs", delay)
|
|
1022
|
+
await asyncio.sleep(delay)
|
|
1023
|
+
|
|
1024
|
+
self.reconnection.on_attempt()
|
|
1025
|
+
self.logger.info("Connection attempt: %s", self.reconnection.attempt_count)
|
|
1026
|
+
|
|
1027
|
+
try:
|
|
1028
|
+
await self.connection.connect()
|
|
1029
|
+
self.reconnection.on_success()
|
|
1030
|
+
except ConnectionError as e:
|
|
1031
|
+
self.logger.error("Connection failed: %s", e)
|
|
1032
|
+
|
|
1033
|
+
async def _process_send_queue(self) -> None:
|
|
1034
|
+
"""处理发送队列
|
|
1035
|
+
|
|
1036
|
+
持续从队列取出消息并发送,处理连接中断时的消息回退。
|
|
1037
|
+
"""
|
|
1038
|
+
while self._running:
|
|
1039
|
+
if not self.connection.is_connected():
|
|
1040
|
+
# 直接返回,交还控制权给 _main_loop 去走重连。
|
|
1041
|
+
# 原来这里 sleep(0.1) 后 continue:两个子任务都永不结束,
|
|
1042
|
+
# _main_loop 一直卡在 asyncio.wait 上,断线后既 100% CPU 忙等
|
|
1043
|
+
# 又永远不会重连。
|
|
1044
|
+
return
|
|
1045
|
+
try:
|
|
1046
|
+
message = await asyncio.wait_for(self._send_queue.get(), timeout=0.1)
|
|
1047
|
+
except TimeoutError:
|
|
1048
|
+
continue
|
|
1049
|
+
|
|
1050
|
+
try:
|
|
1051
|
+
await self.connection.send(message)
|
|
1052
|
+
except asyncio.CancelledError:
|
|
1053
|
+
# 将消息放回队列并重新抛出
|
|
1054
|
+
await self._send_queue.put(message)
|
|
1055
|
+
raise
|
|
1056
|
+
except ConnectionError:
|
|
1057
|
+
# 连接不可用,将消息重新入队并退出,触发重连
|
|
1058
|
+
await self._send_queue.put(message)
|
|
1059
|
+
break
|
|
1060
|
+
except Exception as e:
|
|
1061
|
+
self.logger.error("Send processing error: %s", e)
|
|
1062
|
+
finally:
|
|
1063
|
+
try:
|
|
1064
|
+
self._send_queue.task_done()
|
|
1065
|
+
except Exception:
|
|
1066
|
+
pass
|
|
1067
|
+
|
|
1068
|
+
async def _process_receive(self) -> None:
|
|
1069
|
+
"""处理接收消息
|
|
1070
|
+
|
|
1071
|
+
持续接收消息并广播到所有监听器,处理超时和连接错误。
|
|
1072
|
+
"""
|
|
1073
|
+
while self._running:
|
|
1074
|
+
if not self.connection.is_connected():
|
|
1075
|
+
# 同 _process_send_queue:返回主循环触发重连,而不是原地忙等
|
|
1076
|
+
return
|
|
1077
|
+
try:
|
|
1078
|
+
message, msg_type = await self.connection.receive()
|
|
1079
|
+
|
|
1080
|
+
# 广播消息到所有监听器
|
|
1081
|
+
await self._broadcast_message(message, msg_type)
|
|
1082
|
+
|
|
1083
|
+
if msg_type == MessageType.Close:
|
|
1084
|
+
# 服务端主动关闭:连接已经不可用,必须回到主循环走重连。
|
|
1085
|
+
# 原实现只是广播完继续 while,而 is_connected() 已为 False,
|
|
1086
|
+
# 于是掉进上面那个 0.1s 忙等分支,永不重连。
|
|
1087
|
+
self.logger.warning("Received CLOSE frame from server")
|
|
1088
|
+
await self.connection.close()
|
|
1089
|
+
return
|
|
1090
|
+
|
|
1091
|
+
except TimeoutError:
|
|
1092
|
+
# 只是没有消息,继续等待
|
|
1093
|
+
continue
|
|
1094
|
+
except asyncio.CancelledError:
|
|
1095
|
+
raise
|
|
1096
|
+
except Exception as e:
|
|
1097
|
+
self.logger.error("Receive processing error: %s", e)
|
|
1098
|
+
# 接收错误通常意味着连接问题,关闭连接触发重连
|
|
1099
|
+
try:
|
|
1100
|
+
await self.connection.close()
|
|
1101
|
+
except Exception:
|
|
1102
|
+
pass
|
|
1103
|
+
break
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
class SyncWebSocketClient:
|
|
1107
|
+
"""同步 WebSocket 客户端包装器
|
|
1108
|
+
|
|
1109
|
+
为同步代码提供 WebSocket 客户端功能,内部在后台线程运行异步事件循环。
|
|
1110
|
+
所有方法都是线程安全的。
|
|
1111
|
+
|
|
1112
|
+
Attributes:
|
|
1113
|
+
_client: 底层异步客户端实例
|
|
1114
|
+
_loop: 事件循环
|
|
1115
|
+
_thread: 后台线程
|
|
1116
|
+
_running: 运行状态
|
|
1117
|
+
"""
|
|
1118
|
+
|
|
1119
|
+
def __init__(self, *args, **kwargs):
|
|
1120
|
+
"""初始化同步客户端
|
|
1121
|
+
|
|
1122
|
+
Args:
|
|
1123
|
+
*args: 传递给 AsyncWebSocketClient 的位置参数
|
|
1124
|
+
**kwargs: 传递给 AsyncWebSocketClient 的关键字参数
|
|
1125
|
+
"""
|
|
1126
|
+
self._client = AsyncWebSocketClient(*args, **kwargs)
|
|
1127
|
+
self._loop = asyncio.new_event_loop()
|
|
1128
|
+
self._thread = None
|
|
1129
|
+
self._running = False
|
|
1130
|
+
|
|
1131
|
+
def start(self) -> None:
|
|
1132
|
+
"""启动客户端(在后台线程中运行事件循环)
|
|
1133
|
+
|
|
1134
|
+
如果已经在运行,直接返回。启动后会等待客户端真正就绪。
|
|
1135
|
+
"""
|
|
1136
|
+
if self._running:
|
|
1137
|
+
return
|
|
1138
|
+
|
|
1139
|
+
self._running = True
|
|
1140
|
+
|
|
1141
|
+
def run_loop():
|
|
1142
|
+
asyncio.set_event_loop(self._loop)
|
|
1143
|
+
try:
|
|
1144
|
+
self._loop.run_until_complete(self._client.start())
|
|
1145
|
+
self._loop.run_forever()
|
|
1146
|
+
finally:
|
|
1147
|
+
self._loop.close()
|
|
1148
|
+
|
|
1149
|
+
self._thread = threading.Thread(target=run_loop, daemon=True)
|
|
1150
|
+
self._thread.start()
|
|
1151
|
+
|
|
1152
|
+
# 等待客户端真正启动
|
|
1153
|
+
for i in range(10):
|
|
1154
|
+
if self._client._running:
|
|
1155
|
+
break
|
|
1156
|
+
time.sleep(0.1)
|
|
1157
|
+
|
|
1158
|
+
def stop(self) -> None:
|
|
1159
|
+
"""停止客户端
|
|
1160
|
+
|
|
1161
|
+
优雅地停止事件循环和后台线程,清理资源。
|
|
1162
|
+
如果已经停止,直接返回。
|
|
1163
|
+
"""
|
|
1164
|
+
if not self._running:
|
|
1165
|
+
return
|
|
1166
|
+
|
|
1167
|
+
self._running = False
|
|
1168
|
+
|
|
1169
|
+
# 在事件循环线程中停止客户端
|
|
1170
|
+
future = asyncio.run_coroutine_threadsafe(self._client.stop(), self._loop)
|
|
1171
|
+
future.result(timeout=10) # 等待停止完成
|
|
1172
|
+
|
|
1173
|
+
# 停止事件循环
|
|
1174
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
1175
|
+
|
|
1176
|
+
if self._thread:
|
|
1177
|
+
self._thread.join(timeout=5)
|
|
1178
|
+
|
|
1179
|
+
def create_listener(self, buffer_size: int | None = None) -> str:
|
|
1180
|
+
"""创建消息监听器(同步)
|
|
1181
|
+
|
|
1182
|
+
Args:
|
|
1183
|
+
buffer_size: 缓冲区大小,默认使用配置值
|
|
1184
|
+
|
|
1185
|
+
Returns:
|
|
1186
|
+
str: 监听器 ID 字符串
|
|
1187
|
+
"""
|
|
1188
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
1189
|
+
self._client.create_listener(buffer_size), self._loop
|
|
1190
|
+
)
|
|
1191
|
+
return future.result(timeout=10)
|
|
1192
|
+
|
|
1193
|
+
def remove_listener(self, listener_id: ListenerId) -> None:
|
|
1194
|
+
"""移除监听器(同步)
|
|
1195
|
+
|
|
1196
|
+
Args:
|
|
1197
|
+
listener_id: 要移除的监听器 ID
|
|
1198
|
+
"""
|
|
1199
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
1200
|
+
self._client.remove_listener(listener_id), self._loop
|
|
1201
|
+
)
|
|
1202
|
+
future.result(timeout=10)
|
|
1203
|
+
|
|
1204
|
+
def get_message(
|
|
1205
|
+
self, listener_id: ListenerId, timeout: float | None = None
|
|
1206
|
+
) -> tuple[Any, MessageType]:
|
|
1207
|
+
"""获取消息(同步阻塞)
|
|
1208
|
+
|
|
1209
|
+
Args:
|
|
1210
|
+
listener_id: 监听器 ID
|
|
1211
|
+
timeout: 超时时间(秒),包含线程调度时间
|
|
1212
|
+
|
|
1213
|
+
Returns:
|
|
1214
|
+
Tuple[Any, MessageType]: 消息内容和类型
|
|
1215
|
+
"""
|
|
1216
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
1217
|
+
self._client.get_message(listener_id, timeout), self._loop
|
|
1218
|
+
)
|
|
1219
|
+
return future.result(timeout=timeout)
|
|
1220
|
+
|
|
1221
|
+
def get_message_nowait(
|
|
1222
|
+
self, listener_id: ListenerId
|
|
1223
|
+
) -> tuple[Any, MessageType] | None:
|
|
1224
|
+
"""非阻塞获取消息(同步)
|
|
1225
|
+
|
|
1226
|
+
Args:
|
|
1227
|
+
listener_id: 监听器 ID
|
|
1228
|
+
|
|
1229
|
+
Returns:
|
|
1230
|
+
Optional[Tuple[Any, MessageType]]: 消息内容和类型,无数据返回 None
|
|
1231
|
+
"""
|
|
1232
|
+
return self._client.get_message_nowait(listener_id)
|
|
1233
|
+
|
|
1234
|
+
def send(self, message: str | bytes | dict) -> None:
|
|
1235
|
+
"""发送消息(同步)
|
|
1236
|
+
|
|
1237
|
+
Args:
|
|
1238
|
+
message: 要发送的消息,支持字符串、字节或字典
|
|
1239
|
+
"""
|
|
1240
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
1241
|
+
self._client.send(message), self._loop
|
|
1242
|
+
)
|
|
1243
|
+
future.result(timeout=10)
|
|
1244
|
+
|
|
1245
|
+
def get_metrics(self) -> dict[str, Any]:
|
|
1246
|
+
"""获取客户端指标(同步)
|
|
1247
|
+
|
|
1248
|
+
Returns:
|
|
1249
|
+
Dict[str, Any]: 客户端运行指标字典
|
|
1250
|
+
"""
|
|
1251
|
+
return self._client.get_metrics()
|
|
1252
|
+
|
|
1253
|
+
def __enter__(self):
|
|
1254
|
+
"""上下文管理器入口
|
|
1255
|
+
|
|
1256
|
+
Returns:
|
|
1257
|
+
SyncWebSocketClient: 客户端实例
|
|
1258
|
+
"""
|
|
1259
|
+
self.start()
|
|
1260
|
+
return self
|
|
1261
|
+
|
|
1262
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
1263
|
+
"""上下文管理器出口
|
|
1264
|
+
|
|
1265
|
+
Args:
|
|
1266
|
+
exc_type: 异常类型
|
|
1267
|
+
exc_val: 异常值
|
|
1268
|
+
exc_tb: 异常追踪
|
|
1269
|
+
"""
|
|
1270
|
+
self.stop()
|