springbootAI 1.8.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.
- spring/__init__.py +66 -0
- spring/ai/__init__.py +78 -0
- spring/ai/advisors.py +139 -0
- spring/ai/annotations.py +74 -0
- spring/ai/autoconfig.py +481 -0
- spring/ai/core.py +391 -0
- spring/ai/etl.py +188 -0
- spring/ai/memory.py +109 -0
- spring/ai/observability.py +129 -0
- spring/ai/providers.py +789 -0
- spring/ai/resilience.py +258 -0
- spring/ai/tools.py +106 -0
- spring/ai/vectorstore.py +303 -0
- spring/annotations/__init__.py +188 -0
- spring/annotations/cache.py +126 -0
- spring/annotations/cloud.py +207 -0
- spring/annotations/conditional.py +272 -0
- spring/annotations/core.py +864 -0
- spring/annotations/messaging.py +107 -0
- spring/aop/__init__.py +4 -0
- spring/aop/cloud_aop.py +404 -0
- spring/aop/comprehensive_aop.py +1015 -0
- spring/aop/method_interceptor.py +19 -0
- spring/aop/proxy_factory.py +55 -0
- spring/cloud/__init__.py +76 -0
- spring/cloud/discovery.py +364 -0
- spring/cloud/feign.py +469 -0
- spring/cloud/gateway.py +452 -0
- spring/cloud/load_balancer.py +149 -0
- spring/cloud/seata.py +557 -0
- spring/cloud/sentinel.py +525 -0
- spring/cloud/tracer.py +337 -0
- spring/config/__init__.py +21 -0
- spring/config/binding.py +206 -0
- spring/config/config_loader.py +405 -0
- spring/context/__init__.py +13 -0
- spring/context/application_context.py +589 -0
- spring/context/bean_definition.py +70 -0
- spring/context/bean_factory.py +1052 -0
- spring/context/registry.py +58 -0
- spring/context/scanner.py +106 -0
- spring/core/__init__.py +3 -0
- spring/core/graceful_shutdown.py +196 -0
- spring/core/typing_utils.py +50 -0
- spring/csv/__init__.py +52 -0
- spring/csv/annotations.py +402 -0
- spring/csv/converters.py +69 -0
- spring/csv/easy_csv.py +95 -0
- spring/csv/exceptions.py +27 -0
- spring/csv/reader.py +195 -0
- spring/csv/writer.py +155 -0
- spring/data/__init__.py +54 -0
- spring/data/page.py +181 -0
- spring/data/repository.py +274 -0
- spring/data/specification.py +228 -0
- spring/datasource/__init__.py +66 -0
- spring/datasource/annotations.py +133 -0
- spring/datasource/context.py +69 -0
- spring/datasource/dynamic.py +148 -0
- spring/event/__init__.py +7 -0
- spring/event/publisher.py +69 -0
- spring/excel/__init__.py +51 -0
- spring/excel/annotations.py +405 -0
- spring/excel/converters.py +231 -0
- spring/excel/easy_excel.py +94 -0
- spring/excel/exceptions.py +31 -0
- spring/excel/reader.py +254 -0
- spring/excel/style.py +95 -0
- spring/excel/writer.py +197 -0
- spring/i18n/__init__.py +97 -0
- spring/i18n/accessor.py +94 -0
- spring/i18n/auto_config.py +177 -0
- spring/i18n/holder.py +106 -0
- spring/i18n/locale.py +152 -0
- spring/i18n/locale_resolver.py +367 -0
- spring/i18n/message_source.py +250 -0
- spring/i18n/middleware.py +79 -0
- spring/i18n/properties.py +168 -0
- spring/i18n/sources.py +255 -0
- spring/logging/__init__.py +1 -0
- spring/logging/loguru_logger.py +228 -0
- spring/main.py +378 -0
- spring/messaging/__init__.py +1 -0
- spring/messaging/rabbitmq.py +302 -0
- spring/monitoring/__init__.py +1 -0
- spring/monitoring/prometheus.py +199 -0
- spring/orm/__init__.py +258 -0
- spring/orm/database.py +222 -0
- spring/orm/ddl_auto.py +1217 -0
- spring/orm/migration.py +419 -0
- spring/orm/mybatis_integration.py +400 -0
- spring/orm/pymybatis/__init__.py +86 -0
- spring/orm/pymybatis/annotations/__init__.py +30 -0
- spring/orm/pymybatis/annotations/annotations.py +332 -0
- spring/orm/pymybatis/cache/__init__.py +47 -0
- spring/orm/pymybatis/cache/cache.py +371 -0
- spring/orm/pymybatis/cache/redis_cache.py +434 -0
- spring/orm/pymybatis/circuit_breaker/__init__.py +21 -0
- spring/orm/pymybatis/circuit_breaker/circuit_breaker.py +424 -0
- spring/orm/pymybatis/configuration.py +525 -0
- spring/orm/pymybatis/core/__init__.py +10 -0
- spring/orm/pymybatis/core/sql_session.py +1382 -0
- spring/orm/pymybatis/core/sql_session_factory.py +76 -0
- spring/orm/pymybatis/dialect/__init__.py +9 -0
- spring/orm/pymybatis/dialect/dialect.py +445 -0
- spring/orm/pymybatis/dynamic_sql/__init__.py +9 -0
- spring/orm/pymybatis/dynamic_sql/dynamic_sql.py +900 -0
- spring/orm/pymybatis/interceptor/__init__.py +31 -0
- spring/orm/pymybatis/interceptor/interceptor.py +427 -0
- spring/orm/pymybatis/mapper/__init__.py +9 -0
- spring/orm/pymybatis/mapper/mapper.py +540 -0
- spring/orm/pymybatis/metrics/__init__.py +41 -0
- spring/orm/pymybatis/metrics/metrics.py +595 -0
- spring/orm/pymybatis/pool/__init__.py +9 -0
- spring/orm/pymybatis/pool/connection_pool.py +711 -0
- spring/orm/pymybatis/security/__init__.py +19 -0
- spring/orm/pymybatis/security/access_control.py +415 -0
- spring/orm/pymybatis/security/password_encoder.py +293 -0
- spring/orm/pymybatis/security/sensitive_data_masker.py +326 -0
- spring/orm/pymybatis/security/sql_injection_detector.py +675 -0
- spring/orm/pymybatis/transaction/__init__.py +9 -0
- spring/orm/pymybatis/transaction/transaction.py +288 -0
- spring/orm/pymybatis/type_handler/__init__.py +37 -0
- spring/orm/pymybatis/type_handler/type_handler.py +473 -0
- spring/orm/pymybatis/version.py +9 -0
- spring/orm/pymybatis/xml_parser/__init__.py +9 -0
- spring/orm/pymybatis/xml_parser/xml_parser.py +761 -0
- spring/retry/__init__.py +12 -0
- spring/retry/retry_annotations.py +71 -0
- spring/retry/retry_decorator.py +155 -0
- spring/scheduling/__init__.py +3 -0
- spring/scheduling/scheduler.py +389 -0
- spring/security/__init__.py +39 -0
- spring/security/jwt_utils.py +281 -0
- spring/security/replay_protection.py +206 -0
- spring/security/secret_manager.py +226 -0
- spring/security/security_aop.py +248 -0
- spring/security/security_context.py +172 -0
- spring/test/__init__.py +45 -0
- spring/test/slicing.py +341 -0
- spring/tracing/__init__.py +11 -0
- spring/tracing/skywalking.py +229 -0
- spring/tx/__init__.py +52 -0
- spring/tx/events.py +172 -0
- spring/tx/synchronization.py +143 -0
- spring/utils/__init__.py +5 -0
- spring/utils/banner.py +32 -0
- spring/utils/logger.py +73 -0
- spring/utils/redis_client.py +526 -0
- spring/validation/__init__.py +55 -0
- spring/validation/aop.py +141 -0
- spring/validation/constraints.py +357 -0
- spring/validation/exceptions.py +55 -0
- spring/validation/validator.py +139 -0
- spring/web/__init__.py +12 -0
- spring/web/actuator.py +319 -0
- spring/web/exception_handler.py +61 -0
- spring/web/health.py +399 -0
- spring/web/interceptor.py +91 -0
- spring/web/result.py +44 -0
- spring/web/swagger.py +601 -0
- spring/web/web_context.py +755 -0
- spring/websocket/__init__.py +86 -0
- spring/websocket/annotations.py +169 -0
- spring/websocket/broker.py +238 -0
- spring/websocket/exceptions.py +26 -0
- spring/websocket/handler.py +243 -0
- spring/websocket/router.py +526 -0
- spring/websocket/session.py +216 -0
- springbootai-1.8.0.dist-info/METADATA +2796 -0
- springbootai-1.8.0.dist-info/RECORD +175 -0
- springbootai-1.8.0.dist-info/WHEEL +5 -0
- springbootai-1.8.0.dist-info/entry_points.txt +2 -0
- springbootai-1.8.0.dist-info/licenses/LICENSE +7 -0
- springbootai-1.8.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""``WebSocketRouter`` 把注解端点转换为 Starlette/FastAPI WebSocket 路由。
|
|
2
|
+
|
|
3
|
+
支持的端点类型:
|
|
4
|
+
1. ``@ServerEndpoint("/ws/path")`` 标注的类 → ``AnnotatedEndpointHandler`` → WS 路由
|
|
5
|
+
2. ``WebSocketHandler`` 子类实例 → 直接 WS 路由
|
|
6
|
+
3. ``@MessageEndpoint`` + ``@MessageMapping`` 类 → ``MessageEndpointDispatcher`` → WS 路由
|
|
7
|
+
|
|
8
|
+
``WebSocketRouter`` 维护 ``{path: (handler, options)}``;``install(app)`` 把所有路由
|
|
9
|
+
注册到 Starlette/FastAPI 应用。
|
|
10
|
+
|
|
11
|
+
消息帧格式(简化 STOMP,JSON)::
|
|
12
|
+
|
|
13
|
+
客户端 → 服务端:
|
|
14
|
+
{"action": "subscribe", "destination": "/topic/greetings"}
|
|
15
|
+
{"action": "unsubscribe", "destination": "/topic/greetings"}
|
|
16
|
+
{"action": "message", "destination": "/app/greet", "payload": {"name": "Tom"}}
|
|
17
|
+
|
|
18
|
+
服务端 → 客户端(broker 广播):
|
|
19
|
+
{"destination": "/topic/greetings", "payload": {"content": "Hello, Tom!"}}
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import inspect
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, Union
|
|
27
|
+
|
|
28
|
+
from .annotations import (
|
|
29
|
+
MessageEndpoint,
|
|
30
|
+
MessageMappingModel,
|
|
31
|
+
collect_message_mappings,
|
|
32
|
+
)
|
|
33
|
+
from .broker import MessageBrokerConfigurer, broker_registry
|
|
34
|
+
from .exceptions import (
|
|
35
|
+
WebSocketConnectionException,
|
|
36
|
+
WebSocketHandlerException,
|
|
37
|
+
MessageBrokerException,
|
|
38
|
+
)
|
|
39
|
+
from .handler import (
|
|
40
|
+
AnnotatedEndpointHandler,
|
|
41
|
+
WebSocketHandler,
|
|
42
|
+
discover_server_endpoints,
|
|
43
|
+
)
|
|
44
|
+
from .session import (
|
|
45
|
+
WebSocketSession,
|
|
46
|
+
WebSocketSessionRegistry,
|
|
47
|
+
global_session_registry,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger("Spring.WebSocket.Router")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ==================== MessageEndpointDispatcher ====================
|
|
54
|
+
|
|
55
|
+
class MessageEndpointDispatcher(WebSocketHandler):
|
|
56
|
+
"""``@MessageEndpoint`` 类的消息分发处理器。
|
|
57
|
+
|
|
58
|
+
- 维护 ``{destination: MessageMappingModel}`` 路由表
|
|
59
|
+
- 收到消息帧 ``{"destination": "/app/x", "payload": ...}`` 时:
|
|
60
|
+
1. 用 ``MessageBrokerConfigurer.strip_app_prefix`` 剥离 ``/app`` 前缀
|
|
61
|
+
2. 查找 ``@MessageMapping`` 方法并调用
|
|
62
|
+
3. 按方法上的 ``@SendTo`` / ``@SendToUser`` 决定返回值去向
|
|
63
|
+
- 收到 ``subscribe`` 帧时:调用 broker 订阅 + 触发 ``@SubscribeMapping``
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
endpoint_cls: Type,
|
|
69
|
+
instance: Optional[Any] = None,
|
|
70
|
+
configurer: Optional[MessageBrokerConfigurer] = None,
|
|
71
|
+
session_registry: Optional[WebSocketSessionRegistry] = None,
|
|
72
|
+
):
|
|
73
|
+
self._endpoint_cls = endpoint_cls
|
|
74
|
+
self._instance = instance
|
|
75
|
+
self._configurer = configurer or broker_registry
|
|
76
|
+
self._session_registry = session_registry or global_session_registry
|
|
77
|
+
self._models: List[MessageMappingModel] = collect_message_mappings(endpoint_cls)
|
|
78
|
+
# @MessageMapping 路由表(destination -> model)
|
|
79
|
+
self._message_routes: Dict[str, MessageMappingModel] = {
|
|
80
|
+
m.destination: m for m in self._models if not m.is_subscribe and m.destination
|
|
81
|
+
}
|
|
82
|
+
# @SubscribeMapping 路由表
|
|
83
|
+
self._subscribe_routes: Dict[str, MessageMappingModel] = {
|
|
84
|
+
m.subscribe_destination: m for m in self._models if m.is_subscribe and m.subscribe_destination
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def message_routes(self) -> Dict[str, MessageMappingModel]:
|
|
89
|
+
return dict(self._message_routes)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def subscribe_routes(self) -> Dict[str, MessageMappingModel]:
|
|
93
|
+
return dict(self._subscribe_routes)
|
|
94
|
+
|
|
95
|
+
def _get_instance(self) -> Any:
|
|
96
|
+
if self._instance is None:
|
|
97
|
+
self._instance = self._endpoint_cls()
|
|
98
|
+
return self._instance
|
|
99
|
+
|
|
100
|
+
async def after_connection_established(self, session: WebSocketSession) -> None:
|
|
101
|
+
# 默认无 lifecycle;@MessageEndpoint 通常不需要 on_open
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
async def handle_text_message(self, session: WebSocketSession, message: str) -> None:
|
|
105
|
+
try:
|
|
106
|
+
frame = json.loads(message)
|
|
107
|
+
except json.JSONDecodeError as exc:
|
|
108
|
+
raise WebSocketHandlerException(f"非法 JSON 帧: {exc}") from exc
|
|
109
|
+
if not isinstance(frame, dict):
|
|
110
|
+
raise WebSocketHandlerException("消息帧必须是 JSON 对象")
|
|
111
|
+
action = frame.get("action", "message")
|
|
112
|
+
destination = frame.get("destination", "")
|
|
113
|
+
payload = frame.get("payload")
|
|
114
|
+
|
|
115
|
+
if action == "subscribe":
|
|
116
|
+
await self._handle_subscribe(session, destination)
|
|
117
|
+
elif action == "unsubscribe":
|
|
118
|
+
await self._handle_unsubscribe(session, destination)
|
|
119
|
+
elif action == "message":
|
|
120
|
+
await self._handle_message(session, destination, payload)
|
|
121
|
+
else:
|
|
122
|
+
raise WebSocketHandlerException(f"未知 action: {action!r}")
|
|
123
|
+
|
|
124
|
+
async def handle_binary_message(self, session: WebSocketSession, data: bytes) -> None:
|
|
125
|
+
raise WebSocketHandlerException(
|
|
126
|
+
"MessageEndpointDispatcher 仅支持文本(JSON)消息"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def after_connection_closed(self, session: WebSocketSession, reason: str) -> None:
|
|
130
|
+
# 会话退出时清理其所有订阅
|
|
131
|
+
self._configurer.broker.unsubscribe_all(session.id)
|
|
132
|
+
|
|
133
|
+
async def _handle_subscribe(self, session: WebSocketSession, destination: str) -> None:
|
|
134
|
+
if not destination:
|
|
135
|
+
raise MessageBrokerException("subscribe 帧缺少 destination")
|
|
136
|
+
# 1. 如果有 @SubscribeMapping,触发并回发初始数据
|
|
137
|
+
model = self._subscribe_routes.get(destination)
|
|
138
|
+
if model is not None:
|
|
139
|
+
await self._invoke_and_dispatch(model, session, destination, None)
|
|
140
|
+
return
|
|
141
|
+
# 2. 否则注册到 broker
|
|
142
|
+
if self._configurer.is_broker_destination(destination):
|
|
143
|
+
self._configurer.broker.subscribe(destination, session)
|
|
144
|
+
|
|
145
|
+
async def _handle_unsubscribe(self, session: WebSocketSession, destination: str) -> None:
|
|
146
|
+
if not destination:
|
|
147
|
+
return
|
|
148
|
+
self._configurer.broker.unsubscribe(destination, session.id)
|
|
149
|
+
|
|
150
|
+
async def _handle_message(self, session: WebSocketSession, destination: str,
|
|
151
|
+
payload: Any) -> None:
|
|
152
|
+
if not destination:
|
|
153
|
+
raise MessageBrokerException("message 帧缺少 destination")
|
|
154
|
+
# 1. broker destination(/topic /queue):直接发布
|
|
155
|
+
if self._configurer.is_broker_destination(destination):
|
|
156
|
+
await self._configurer.broker.publish(destination, payload, exclude=[session.id])
|
|
157
|
+
return
|
|
158
|
+
# 2. app destination(/app):剥离前缀,路由到 @MessageMapping
|
|
159
|
+
mapping_path = self._configurer.strip_app_prefix(destination)
|
|
160
|
+
if mapping_path is None:
|
|
161
|
+
raise MessageBrokerException(
|
|
162
|
+
f"destination {destination!r} 不在任何已配置前缀下(app/broker)"
|
|
163
|
+
)
|
|
164
|
+
model = self._message_routes.get(mapping_path)
|
|
165
|
+
if model is None:
|
|
166
|
+
raise MessageBrokerException(
|
|
167
|
+
f"未找到 @MessageMapping({mapping_path!r}) 处理方法"
|
|
168
|
+
)
|
|
169
|
+
await self._invoke_and_dispatch(model, session, destination, payload)
|
|
170
|
+
|
|
171
|
+
async def _invoke_and_dispatch(
|
|
172
|
+
self,
|
|
173
|
+
model: MessageMappingModel,
|
|
174
|
+
session: WebSocketSession,
|
|
175
|
+
original_destination: str,
|
|
176
|
+
payload: Any,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""调用映射方法并把返回值按 ``@SendTo`` / ``@SendToUser`` 派发。"""
|
|
179
|
+
instance = self._get_instance()
|
|
180
|
+
method = getattr(instance, model.method_name, None)
|
|
181
|
+
if not callable(method):
|
|
182
|
+
raise WebSocketHandlerException(
|
|
183
|
+
f"方法 {model.method_name!r} 不存在于 {self._endpoint_cls.__name__}"
|
|
184
|
+
)
|
|
185
|
+
try:
|
|
186
|
+
result = method(payload, session) if _accepts_session(method) else method(payload)
|
|
187
|
+
if inspect.isawaitable(result):
|
|
188
|
+
result = await result
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
logger.warning("@MessageMapping %s.%s 抛异常: %s",
|
|
191
|
+
self._endpoint_cls.__name__, model.method_name, exc)
|
|
192
|
+
raise WebSocketHandlerException(str(exc)) from exc
|
|
193
|
+
|
|
194
|
+
if result is None:
|
|
195
|
+
return # 无返回值:不发送
|
|
196
|
+
|
|
197
|
+
# @SendTo:广播到指定 destination
|
|
198
|
+
if model.send_to:
|
|
199
|
+
for dest in model.send_to:
|
|
200
|
+
await self._configurer.broker.publish(dest, result)
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
# @SendToUser / 默认:定向回发给发送者
|
|
204
|
+
if model.send_to_user_broadcast and session.user:
|
|
205
|
+
# 广播给该用户所有会话
|
|
206
|
+
await self._session_registry.send_to_user(
|
|
207
|
+
session.user, {"destination": original_destination, "payload": result}
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
# 仅回发当前会话
|
|
211
|
+
await session.send_json({"destination": original_destination, "payload": result})
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _accepts_session(method: Callable) -> bool:
|
|
215
|
+
"""检测方法签名是否接受第二个 session 参数(``def handle(payload, session)``)。"""
|
|
216
|
+
try:
|
|
217
|
+
sig = inspect.signature(method)
|
|
218
|
+
params = [p for p in sig.parameters.values()
|
|
219
|
+
if p.name != "self" and p.kind not in (
|
|
220
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
221
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
222
|
+
)]
|
|
223
|
+
return len(params) >= 2
|
|
224
|
+
except (TypeError, ValueError):
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ==================== WebSocketRouter ====================
|
|
229
|
+
|
|
230
|
+
class _RouteEntry:
|
|
231
|
+
"""路由表项:``{path: (handler_factory, options)}``。"""
|
|
232
|
+
|
|
233
|
+
__slots__ = ("path", "handler_factory", "name", "subprotocols")
|
|
234
|
+
|
|
235
|
+
def __init__(
|
|
236
|
+
self,
|
|
237
|
+
path: str,
|
|
238
|
+
handler_factory: Callable[[], WebSocketHandler],
|
|
239
|
+
name: Optional[str] = None,
|
|
240
|
+
subprotocols: Optional[List[str]] = None,
|
|
241
|
+
):
|
|
242
|
+
self.path = path
|
|
243
|
+
self.handler_factory = handler_factory
|
|
244
|
+
self.name = name
|
|
245
|
+
self.subprotocols = subprotocols
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class WebSocketRouter:
|
|
249
|
+
"""WebSocket 路由注册表(对齐 Spring ``WebSocketHandlerRegistry``)。
|
|
250
|
+
|
|
251
|
+
用法::
|
|
252
|
+
|
|
253
|
+
router = WebSocketRouter()
|
|
254
|
+
router.add_endpoint("/ws/echo", EchoHandler())
|
|
255
|
+
router.add_endpoint("/ws/chat", ChatHandlerClass, annotated=True)
|
|
256
|
+
router.add_message_endpoint("/ws/app", AppController)
|
|
257
|
+
router.install(app) # 挂载到 FastAPI/Starlette 应用
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
def __init__(
|
|
261
|
+
self,
|
|
262
|
+
session_registry: Optional[WebSocketSessionRegistry] = None,
|
|
263
|
+
configurer: Optional[MessageBrokerConfigurer] = None,
|
|
264
|
+
):
|
|
265
|
+
self._session_registry = session_registry or global_session_registry
|
|
266
|
+
self._configurer = configurer or broker_registry
|
|
267
|
+
self._routes: Dict[str, _RouteEntry] = {}
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def session_registry(self) -> WebSocketSessionRegistry:
|
|
271
|
+
return self._session_registry
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def configurer(self) -> MessageBrokerConfigurer:
|
|
275
|
+
return self._configurer
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def routes(self) -> Dict[str, _RouteEntry]:
|
|
279
|
+
return dict(self._routes)
|
|
280
|
+
|
|
281
|
+
# ==================== 注册 ====================
|
|
282
|
+
|
|
283
|
+
def add_handler(
|
|
284
|
+
self,
|
|
285
|
+
path: str,
|
|
286
|
+
handler: WebSocketHandler,
|
|
287
|
+
name: Optional[str] = None,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""注册一个 ``WebSocketHandler`` 实例到指定路径。"""
|
|
290
|
+
if path in self._routes:
|
|
291
|
+
raise WebSocketConnectionException(f"路径 {path!r} 已注册 WebSocket 端点")
|
|
292
|
+
self._routes[path] = _RouteEntry(path, lambda: handler, name=name)
|
|
293
|
+
|
|
294
|
+
def add_endpoint(
|
|
295
|
+
self,
|
|
296
|
+
path: str,
|
|
297
|
+
endpoint_cls: Type,
|
|
298
|
+
instance: Optional[Any] = None,
|
|
299
|
+
name: Optional[str] = None,
|
|
300
|
+
) -> None:
|
|
301
|
+
"""注册一个 ``@ServerEndpoint`` 标注的类,或一个 ``WebSocketHandler`` 子类。
|
|
302
|
+
|
|
303
|
+
- ``@ServerEndpoint`` 类:用 ``AnnotatedEndpointHandler`` 包装。
|
|
304
|
+
- ``WebSocketHandler`` 子类:直接实例化。
|
|
305
|
+
"""
|
|
306
|
+
if path in self._routes:
|
|
307
|
+
raise WebSocketConnectionException(f"路径 {path!r} 已注册 WebSocket 端点")
|
|
308
|
+
|
|
309
|
+
# @MessageEndpoint 类:用 MessageEndpointDispatcher
|
|
310
|
+
if _is_message_endpoint(endpoint_cls):
|
|
311
|
+
dispatcher = MessageEndpointDispatcher(
|
|
312
|
+
endpoint_cls, instance=instance, configurer=self._configurer,
|
|
313
|
+
session_registry=self._session_registry,
|
|
314
|
+
)
|
|
315
|
+
self._routes[path] = _RouteEntry(path, lambda: dispatcher, name=name)
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
# @ServerEndpoint 类:用 AnnotatedEndpointHandler
|
|
319
|
+
if _is_server_endpoint(endpoint_cls):
|
|
320
|
+
handler = AnnotatedEndpointHandler(endpoint_cls, instance=instance)
|
|
321
|
+
self._routes[path] = _RouteEntry(path, lambda: handler, name=name)
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
# WebSocketHandler 子类
|
|
325
|
+
if isinstance(endpoint_cls, type) and issubclass(endpoint_cls, WebSocketHandler):
|
|
326
|
+
handler = instance if isinstance(instance, endpoint_cls) else endpoint_cls()
|
|
327
|
+
self._routes[path] = _RouteEntry(path, lambda: handler, name=name)
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
raise WebSocketHandlerException(
|
|
331
|
+
f"不支持的端点类型: {endpoint_cls!r}(需为 @ServerEndpoint/@MessageEndpoint/"
|
|
332
|
+
f"WebSocketHandler 子类)"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def add_message_endpoint(
|
|
336
|
+
self,
|
|
337
|
+
path: str,
|
|
338
|
+
endpoint_cls: Type,
|
|
339
|
+
instance: Optional[Any] = None,
|
|
340
|
+
name: Optional[str] = None,
|
|
341
|
+
) -> None:
|
|
342
|
+
"""显式注册 ``@MessageEndpoint`` 类到指定路径。"""
|
|
343
|
+
if not _is_message_endpoint(endpoint_cls):
|
|
344
|
+
raise WebSocketHandlerException(
|
|
345
|
+
f"{endpoint_cls!r} 未标注 @MessageEndpoint"
|
|
346
|
+
)
|
|
347
|
+
if path in self._routes:
|
|
348
|
+
raise WebSocketConnectionException(f"路径 {path!r} 已注册 WebSocket 端点")
|
|
349
|
+
dispatcher = MessageEndpointDispatcher(
|
|
350
|
+
endpoint_cls, instance=instance, configurer=self._configurer,
|
|
351
|
+
session_registry=self._session_registry,
|
|
352
|
+
)
|
|
353
|
+
self._routes[path] = _RouteEntry(path, lambda: dispatcher, name=name)
|
|
354
|
+
|
|
355
|
+
# ==================== 挂载到 ASGI 应用 ====================
|
|
356
|
+
|
|
357
|
+
def install(self, app: Any) -> None:
|
|
358
|
+
"""把所有路由挂载到 Starlette/FastAPI 应用。"""
|
|
359
|
+
for entry in self._routes.values():
|
|
360
|
+
self._install_route(app, entry)
|
|
361
|
+
|
|
362
|
+
def _install_route(self, app: Any, entry: _RouteEntry) -> None:
|
|
363
|
+
"""挂载单个 WebSocket 路由到应用。"""
|
|
364
|
+
# 延迟导入 Starlette WebSocket
|
|
365
|
+
from starlette.websockets import WebSocket, WebSocketState
|
|
366
|
+
|
|
367
|
+
async def endpoint(websocket: WebSocket) -> None:
|
|
368
|
+
# 握手:接受连接
|
|
369
|
+
try:
|
|
370
|
+
await websocket.accept()
|
|
371
|
+
except Exception as exc:
|
|
372
|
+
raise WebSocketConnectionException(f"WebSocket 握手失败: {exc}") from exc
|
|
373
|
+
|
|
374
|
+
session = WebSocketSession(websocket)
|
|
375
|
+
self._session_registry.register(session)
|
|
376
|
+
handler = entry.handler_factory()
|
|
377
|
+
|
|
378
|
+
# 调用 after_connection_established
|
|
379
|
+
try:
|
|
380
|
+
await handler.after_connection_established(session)
|
|
381
|
+
except Exception as exc:
|
|
382
|
+
logger.warning("after_connection_established 抛异常: %s", exc)
|
|
383
|
+
await _safe_close(session, code=1011, reason="server error")
|
|
384
|
+
self._session_registry.unregister(session.id)
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
# 消息循环
|
|
388
|
+
try:
|
|
389
|
+
while True:
|
|
390
|
+
# 检查会话状态
|
|
391
|
+
if session.is_closed:
|
|
392
|
+
break
|
|
393
|
+
# 兼容 Starlette:state 可能是 WebSocketState.CONNECTED 或 CONNECTING
|
|
394
|
+
try:
|
|
395
|
+
state = websocket.state
|
|
396
|
+
except RuntimeError:
|
|
397
|
+
state = None
|
|
398
|
+
if state is not None:
|
|
399
|
+
# Starlette 0.30+: state 在 disconnect 后为 DISCONNECTED
|
|
400
|
+
from starlette.websockets import WebSocketState as _State
|
|
401
|
+
if state == getattr(_State, "DISCONNECTED", None):
|
|
402
|
+
break
|
|
403
|
+
|
|
404
|
+
message = await websocket.receive()
|
|
405
|
+
msg_type = message.get("type", "")
|
|
406
|
+
if msg_type == "websocket.disconnect":
|
|
407
|
+
# 客户端断开
|
|
408
|
+
code = message.get("code", 1000)
|
|
409
|
+
await handler.after_connection_closed(session, f"client closed (code={code})")
|
|
410
|
+
break
|
|
411
|
+
if "text" in message:
|
|
412
|
+
await handler.handle_text_message(session, message["text"])
|
|
413
|
+
elif "bytes" in message:
|
|
414
|
+
await handler.handle_binary_message(session, message["bytes"])
|
|
415
|
+
except Exception as exc:
|
|
416
|
+
# 区分:连接关闭 vs 处理器异常
|
|
417
|
+
if _is_disconnect(exc):
|
|
418
|
+
await _safe_call(handler.after_connection_closed, session, "client disconnected")
|
|
419
|
+
else:
|
|
420
|
+
logger.warning("WebSocket 消息循环异常: %s", exc)
|
|
421
|
+
await _safe_call(handler.handle_transport_error, session, exc)
|
|
422
|
+
await _safe_call(handler.after_connection_closed, session, str(exc))
|
|
423
|
+
await _safe_close(session, code=1011, reason="server error")
|
|
424
|
+
finally:
|
|
425
|
+
self._session_registry.unregister(session.id)
|
|
426
|
+
# 清理该会话的所有 broker 订阅
|
|
427
|
+
self._configurer.broker.unsubscribe_all(session.id)
|
|
428
|
+
|
|
429
|
+
# 注册到应用。Starlette 把 WebSocket 路由 API 挂在 ``app.router`` 上,
|
|
430
|
+
# FastAPI 既暴露 ``app.websocket`` 装饰器又有 ``app.router.add_websocket_route``。
|
|
431
|
+
# 此处按优先级兼容多版本。
|
|
432
|
+
add_websocket_route = (
|
|
433
|
+
getattr(app, "add_websocket_route", None)
|
|
434
|
+
or getattr(getattr(app, "router", None), "add_websocket_route", None)
|
|
435
|
+
)
|
|
436
|
+
app_websocket_decorator = getattr(app, "websocket", None)
|
|
437
|
+
|
|
438
|
+
if callable(add_websocket_route):
|
|
439
|
+
# 标准注册:add_websocket_route(path, endpoint)
|
|
440
|
+
add_websocket_route(entry.path, endpoint)
|
|
441
|
+
elif callable(app_websocket_decorator):
|
|
442
|
+
# FastAPI 风格:app.websocket(path) 是装饰器工厂
|
|
443
|
+
app_websocket_decorator(entry.path)(endpoint)
|
|
444
|
+
else:
|
|
445
|
+
raise WebSocketConnectionException(
|
|
446
|
+
f"应用 {app!r} 不支持 WebSocket 路由(无 websocket/add_websocket_route)"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _is_server_endpoint(cls: Any) -> bool:
|
|
451
|
+
"""类是否标注 ``@ServerEndpoint``。"""
|
|
452
|
+
if not isinstance(cls, type):
|
|
453
|
+
return False
|
|
454
|
+
from .handler import ServerEndpoint
|
|
455
|
+
return any(isinstance(a, ServerEndpoint)
|
|
456
|
+
for a in getattr(cls, "__spring_annotations__", []) or [])
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _is_message_endpoint(cls: Any) -> bool:
|
|
460
|
+
"""类是否标注 ``@MessageEndpoint``。"""
|
|
461
|
+
if not isinstance(cls, type):
|
|
462
|
+
return False
|
|
463
|
+
return any(isinstance(a, MessageEndpoint)
|
|
464
|
+
for a in getattr(cls, "__spring_annotations__", []) or [])
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
async def _safe_close(session: WebSocketSession, code: int, reason: str) -> None:
|
|
468
|
+
try:
|
|
469
|
+
await session.close(code=code, reason=reason)
|
|
470
|
+
except Exception:
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
async def _safe_call(method: Callable, *args) -> None:
|
|
475
|
+
try:
|
|
476
|
+
result = method(*args)
|
|
477
|
+
if inspect.isawaitable(result):
|
|
478
|
+
await result
|
|
479
|
+
except Exception as exc:
|
|
480
|
+
logger.debug("safe_call %s 抛异常: %s", getattr(method, "__name__", method), exc)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _is_disconnect(exc: Exception) -> bool:
|
|
484
|
+
"""判断异常是否是 WebSocket 断开(Starlette 抛 WebSocketDisconnect)。"""
|
|
485
|
+
try:
|
|
486
|
+
from starlette.websockets import WebSocketDisconnect
|
|
487
|
+
if isinstance(exc, WebSocketDisconnect):
|
|
488
|
+
return True
|
|
489
|
+
except ImportError:
|
|
490
|
+
pass
|
|
491
|
+
# 兜底:按异常名/消息判断
|
|
492
|
+
name = type(exc).__name__.lower()
|
|
493
|
+
return "disconnect" in name or "closed" in str(exc).lower()
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def install_websocket_routes(
|
|
497
|
+
app: Any,
|
|
498
|
+
classes: Optional[Iterable[Type]] = None,
|
|
499
|
+
modules: Optional[Iterable[Any]] = None,
|
|
500
|
+
router: Optional[WebSocketRouter] = None,
|
|
501
|
+
) -> WebSocketRouter:
|
|
502
|
+
"""便捷函数:扫描 ``@ServerEndpoint`` 类并挂载到应用。
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
app: Starlette/FastAPI 应用。
|
|
506
|
+
classes: 显式传入的端点类列表。
|
|
507
|
+
modules: 模块列表(扫描其中的 ``@ServerEndpoint`` 类)。
|
|
508
|
+
router: 可选的预构造路由器;为 None 时新建。
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
挂载完成的 ``WebSocketRouter``。
|
|
512
|
+
"""
|
|
513
|
+
r = router or WebSocketRouter()
|
|
514
|
+
endpoints = discover_server_endpoints(classes=classes, modules=modules)
|
|
515
|
+
for path, cls in endpoints.items():
|
|
516
|
+
if path not in r.routes:
|
|
517
|
+
r.add_endpoint(path, cls)
|
|
518
|
+
r.install(app)
|
|
519
|
+
return r
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
__all__ = [
|
|
523
|
+
"WebSocketRouter",
|
|
524
|
+
"MessageEndpointDispatcher",
|
|
525
|
+
"install_websocket_routes",
|
|
526
|
+
]
|