aury-boot 0.0.40__py3-none-any.whl → 0.0.42__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.
- aury/boot/_version.py +2 -2
- aury/boot/application/app/components.py +12 -1
- aury/boot/application/config/settings.py +7 -2
- aury/boot/infrastructure/cache/redis.py +83 -15
- aury/boot/infrastructure/channel/__init__.py +2 -1
- aury/boot/infrastructure/channel/backends/__init__.py +2 -1
- aury/boot/infrastructure/channel/backends/redis_cluster.py +124 -0
- aury/boot/infrastructure/channel/backends/redis_cluster_channel.py +139 -0
- aury/boot/infrastructure/channel/base.py +2 -0
- aury/boot/infrastructure/channel/manager.py +9 -1
- aury/boot/infrastructure/clients/redis/manager.py +94 -19
- aury/boot/infrastructure/monitoring/alerting/notifiers/feishu.py +2 -1
- aury/boot/infrastructure/monitoring/profiling/__init__.py +135 -44
- aury/boot/infrastructure/scheduler/__init__.py +2 -0
- aury/boot/infrastructure/scheduler/jobstores/__init__.py +10 -0
- aury/boot/infrastructure/scheduler/jobstores/redis_cluster.py +255 -0
- {aury_boot-0.0.40.dist-info → aury_boot-0.0.42.dist-info}/METADATA +5 -1
- {aury_boot-0.0.40.dist-info → aury_boot-0.0.42.dist-info}/RECORD +20 -16
- {aury_boot-0.0.40.dist-info → aury_boot-0.0.42.dist-info}/WHEEL +0 -0
- {aury_boot-0.0.40.dist-info → aury_boot-0.0.42.dist-info}/entry_points.txt +0 -0
aury/boot/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
31
|
+
__version__ = version = '0.0.42'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 42)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -318,7 +318,18 @@ class SchedulerComponent(Component):
|
|
|
318
318
|
# jobstores: 根据 URL 自动选择存储后端
|
|
319
319
|
if scheduler_config.jobstore_url:
|
|
320
320
|
url = scheduler_config.jobstore_url
|
|
321
|
-
if url.startswith("redis://"):
|
|
321
|
+
if url.startswith("redis-cluster://"):
|
|
322
|
+
# Redis Cluster 模式
|
|
323
|
+
try:
|
|
324
|
+
from aury.boot.infrastructure.scheduler.jobstores import RedisClusterJobStore
|
|
325
|
+
|
|
326
|
+
scheduler_kwargs["jobstores"] = {
|
|
327
|
+
"default": RedisClusterJobStore(url=url)
|
|
328
|
+
}
|
|
329
|
+
logger.info(f"调度器使用 Redis Cluster 存储: {url.split('@')[-1].split('/')[0]}")
|
|
330
|
+
except ImportError:
|
|
331
|
+
logger.warning("Redis Cluster jobstore 需要安装 redis[cluster]: pip install 'redis[cluster]'")
|
|
332
|
+
elif url.startswith("redis://"):
|
|
322
333
|
try:
|
|
323
334
|
from urllib.parse import urlparse
|
|
324
335
|
from apscheduler.jobstores.redis import RedisJobStore
|
|
@@ -263,15 +263,19 @@ class ChannelSettings(BaseModel):
|
|
|
263
263
|
支持的后端类型:
|
|
264
264
|
- memory: 内存后端(默认,单进程)
|
|
265
265
|
- redis: Redis Pub/Sub(多进程/分布式)
|
|
266
|
+
- redis_cluster: Redis Cluster Sharded Pub/Sub(Redis 7.0+)
|
|
267
|
+
|
|
268
|
+
注意:URL 的 scheme 会自动决定后端类型:
|
|
269
|
+
- redis-cluster://... 自动使用 redis_cluster 后端
|
|
266
270
|
"""
|
|
267
271
|
|
|
268
272
|
backend: str = Field(
|
|
269
273
|
default="",
|
|
270
|
-
description="通道后端 (memory/redis),空字符串表示不启用"
|
|
274
|
+
description="通道后端 (memory/redis/redis_cluster),空字符串表示不启用"
|
|
271
275
|
)
|
|
272
276
|
url: str | None = Field(
|
|
273
277
|
default=None,
|
|
274
|
-
description="
|
|
278
|
+
description="连接 URL(redis://... 或 redis-cluster://...)"
|
|
275
279
|
)
|
|
276
280
|
|
|
277
281
|
|
|
@@ -472,6 +476,7 @@ class SchedulerSettings(BaseModel):
|
|
|
472
476
|
default=None,
|
|
473
477
|
description=(
|
|
474
478
|
"任务存储 URL。支持:\n"
|
|
479
|
+
"- redis-cluster://password@host:port(Redis Cluster 存储)\n"
|
|
475
480
|
"- redis://localhost:6379/0(Redis 存储)\n"
|
|
476
481
|
"- sqlite:///jobs.db(SQLite 存储)\n"
|
|
477
482
|
"- postgresql://user:pass@host/db(PostgreSQL 存储)\n"
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""Redis 缓存后端实现。
|
|
1
|
+
"""Redis 缓存后端实现。
|
|
2
|
+
|
|
3
|
+
支持普通 Redis 和 Redis Cluster:
|
|
4
|
+
- redis://... - 普通 Redis
|
|
5
|
+
- redis-cluster://... - Redis Cluster
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
from __future__ import annotations
|
|
4
9
|
|
|
@@ -9,6 +14,7 @@ import time
|
|
|
9
14
|
from collections.abc import Callable
|
|
10
15
|
from datetime import timedelta
|
|
11
16
|
from typing import TYPE_CHECKING, Any
|
|
17
|
+
from urllib.parse import urlparse
|
|
12
18
|
|
|
13
19
|
from redis.asyncio import Redis
|
|
14
20
|
|
|
@@ -17,6 +23,7 @@ from aury.boot.common.logging import logger
|
|
|
17
23
|
from .base import ICache
|
|
18
24
|
|
|
19
25
|
if TYPE_CHECKING:
|
|
26
|
+
from redis.asyncio.cluster import RedisCluster
|
|
20
27
|
from aury.boot.infrastructure.clients.redis import RedisClient
|
|
21
28
|
|
|
22
29
|
|
|
@@ -24,7 +31,7 @@ class RedisCache(ICache):
|
|
|
24
31
|
"""Redis缓存实现。
|
|
25
32
|
|
|
26
33
|
支持两种初始化方式:
|
|
27
|
-
1. 传入 URL
|
|
34
|
+
1. 传入 URL 自行创建连接(支持 redis:// 和 redis-cluster://)
|
|
28
35
|
2. 传入 RedisClient 实例(推荐)
|
|
29
36
|
"""
|
|
30
37
|
|
|
@@ -38,44 +45,99 @@ class RedisCache(ICache):
|
|
|
38
45
|
"""初始化Redis缓存。
|
|
39
46
|
|
|
40
47
|
Args:
|
|
41
|
-
url: Redis连接URL
|
|
48
|
+
url: Redis连接URL(支持 redis:// 和 redis-cluster://)
|
|
42
49
|
redis_client: RedisClient 实例(推荐)
|
|
43
50
|
serializer: 序列化方式(json/pickle)
|
|
44
51
|
"""
|
|
45
52
|
self._url = url
|
|
46
53
|
self._redis_client = redis_client
|
|
47
54
|
self._serializer = serializer
|
|
48
|
-
self._redis: Redis | None = None
|
|
49
|
-
self._owns_connection = False
|
|
55
|
+
self._redis: Redis | RedisCluster | None = None
|
|
56
|
+
self._owns_connection = False
|
|
57
|
+
self._is_cluster = False
|
|
50
58
|
|
|
51
59
|
async def initialize(self) -> None:
|
|
52
60
|
"""初始化连接。"""
|
|
53
61
|
# 优先使用 RedisClient
|
|
54
62
|
if self._redis_client is not None:
|
|
55
63
|
self._redis = self._redis_client.connection
|
|
64
|
+
self._is_cluster = getattr(self._redis_client, "is_cluster", False)
|
|
56
65
|
self._owns_connection = False
|
|
57
|
-
|
|
66
|
+
mode = "Cluster" if self._is_cluster else "Standalone"
|
|
67
|
+
logger.info(f"Redis缓存初始化成功(使用 RedisClient, {mode})")
|
|
58
68
|
return
|
|
59
69
|
|
|
60
70
|
# 使用 URL 创建连接
|
|
61
71
|
if self._url:
|
|
62
72
|
try:
|
|
63
|
-
self.
|
|
64
|
-
self.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
socket_connect_timeout=5,
|
|
68
|
-
socket_timeout=5,
|
|
69
|
-
)
|
|
73
|
+
if self._url.startswith("redis-cluster://"):
|
|
74
|
+
await self._init_cluster()
|
|
75
|
+
else:
|
|
76
|
+
await self._init_standalone()
|
|
70
77
|
await self._redis.ping()
|
|
71
78
|
self._owns_connection = True
|
|
72
|
-
|
|
79
|
+
mode = "Cluster" if self._is_cluster else "Standalone"
|
|
80
|
+
logger.info(f"Redis缓存初始化成功 ({mode})")
|
|
73
81
|
except Exception as exc:
|
|
74
82
|
logger.error(f"Redis连接失败: {exc}")
|
|
75
83
|
raise
|
|
76
84
|
else:
|
|
77
85
|
raise ValueError("Redis缓存需要提供 url 或 redis_client 参数")
|
|
78
86
|
|
|
87
|
+
async def _init_standalone(self) -> None:
|
|
88
|
+
"""初始化普通 Redis。"""
|
|
89
|
+
self._redis = Redis.from_url(
|
|
90
|
+
self._url,
|
|
91
|
+
encoding="utf-8",
|
|
92
|
+
decode_responses=False,
|
|
93
|
+
socket_connect_timeout=5,
|
|
94
|
+
socket_timeout=5,
|
|
95
|
+
)
|
|
96
|
+
self._is_cluster = False
|
|
97
|
+
|
|
98
|
+
async def _init_cluster(self) -> None:
|
|
99
|
+
"""初始化 Redis Cluster(使用 coredis)。
|
|
100
|
+
|
|
101
|
+
支持 URL 格式:
|
|
102
|
+
- redis-cluster://password@host:port (密码在用户名位置)
|
|
103
|
+
- redis-cluster://:password@host:port (标准格式)
|
|
104
|
+
- redis-cluster://username:password@host:port (ACL 模式)
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
from coredis import RedisCluster
|
|
108
|
+
except ImportError as exc:
|
|
109
|
+
raise ImportError(
|
|
110
|
+
"Redis Cluster 需要安装 coredis: pip install coredis"
|
|
111
|
+
) from exc
|
|
112
|
+
|
|
113
|
+
# 解析 URL
|
|
114
|
+
parsed_url = self._url.replace("redis-cluster://", "redis://")
|
|
115
|
+
parsed = urlparse(parsed_url)
|
|
116
|
+
|
|
117
|
+
# 提取认证信息
|
|
118
|
+
username = parsed.username
|
|
119
|
+
password = parsed.password
|
|
120
|
+
|
|
121
|
+
# 处理 password@host 格式
|
|
122
|
+
if username and not password:
|
|
123
|
+
password = username
|
|
124
|
+
username = None
|
|
125
|
+
|
|
126
|
+
# 构建连接参数
|
|
127
|
+
cluster_kwargs: dict = {
|
|
128
|
+
"host": parsed.hostname or "localhost",
|
|
129
|
+
"port": parsed.port or 6379,
|
|
130
|
+
"decode_responses": False,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if username:
|
|
134
|
+
cluster_kwargs["username"] = username
|
|
135
|
+
if password:
|
|
136
|
+
cluster_kwargs["password"] = password
|
|
137
|
+
|
|
138
|
+
self._redis = RedisCluster(**cluster_kwargs)
|
|
139
|
+
self._is_cluster = True
|
|
140
|
+
|
|
79
141
|
async def get(self, key: str, default: Any = None) -> Any:
|
|
80
142
|
"""获取缓存。"""
|
|
81
143
|
if not self._redis:
|
|
@@ -192,15 +254,21 @@ class RedisCache(ICache):
|
|
|
192
254
|
async def close(self) -> None:
|
|
193
255
|
"""关闭连接(仅当自己拥有连接时)。"""
|
|
194
256
|
if self._redis and self._owns_connection:
|
|
257
|
+
# coredis 和 redis-py 都使用 close() 方法
|
|
195
258
|
await self._redis.close()
|
|
196
259
|
logger.info("Redis连接已关闭")
|
|
197
260
|
self._redis = None
|
|
198
261
|
|
|
199
262
|
@property
|
|
200
|
-
def redis(self) -> Redis | None:
|
|
263
|
+
def redis(self) -> Redis | RedisCluster | None:
|
|
201
264
|
"""获取Redis客户端。"""
|
|
202
265
|
return self._redis
|
|
203
266
|
|
|
267
|
+
@property
|
|
268
|
+
def is_cluster(self) -> bool:
|
|
269
|
+
"""检查是否为集群模式。"""
|
|
270
|
+
return self._is_cluster
|
|
271
|
+
|
|
204
272
|
# ==================== 分布式锁 ====================
|
|
205
273
|
# TODO: 后续优化考虑:
|
|
206
274
|
# - 看门狗(Watchdog)机制:自动续期,防止业务执行超过锁超时导致提前释放
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- postgres:// - PostgreSQL LISTEN/NOTIFY
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
-
from .backends import BroadcasterChannel
|
|
12
|
+
from .backends import BroadcasterChannel, RedisClusterChannel
|
|
13
13
|
from .base import ChannelBackend, ChannelMessage, IChannel
|
|
14
14
|
from .manager import ChannelManager
|
|
15
15
|
|
|
@@ -22,4 +22,5 @@ __all__ = [
|
|
|
22
22
|
"ChannelManager",
|
|
23
23
|
# 后端实现
|
|
24
24
|
"BroadcasterChannel",
|
|
25
|
+
"RedisClusterChannel",
|
|
25
26
|
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Redis Cluster 通道后端。
|
|
2
|
+
|
|
3
|
+
使用 coredis 库支持异步 Redis Cluster Sharded Pub/Sub (Redis 7.0+)。
|
|
4
|
+
|
|
5
|
+
URL 格式:
|
|
6
|
+
redis-cluster://password@host:port
|
|
7
|
+
redis-cluster://host:port
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
from aury.boot.common.logging import logger
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from coredis import RedisCluster
|
|
21
|
+
except ImportError as exc:
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"Redis Cluster Channel 需要安装 coredis: pip install coredis"
|
|
24
|
+
) from exc
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Event:
|
|
29
|
+
"""与 broadcaster._base.Event 兼容的事件类。"""
|
|
30
|
+
channel: str
|
|
31
|
+
message: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RedisClusterBackend:
|
|
35
|
+
"""Redis Cluster Pub/Sub 后端。
|
|
36
|
+
|
|
37
|
+
使用 coredis 库的 sharded_pubsub() 支持 Sharded Pub/Sub。
|
|
38
|
+
与 broadcaster.RedisBackend 接口兼容。
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, url: str) -> None:
|
|
42
|
+
host, port, password = self._parse_url(url)
|
|
43
|
+
|
|
44
|
+
self._client: RedisCluster = RedisCluster(
|
|
45
|
+
host=host,
|
|
46
|
+
port=port,
|
|
47
|
+
password=password,
|
|
48
|
+
decode_responses=True,
|
|
49
|
+
)
|
|
50
|
+
self._pubsub: Any = None
|
|
51
|
+
self._queue: asyncio.Queue[Event] = asyncio.Queue()
|
|
52
|
+
self._listener_task: asyncio.Task | None = None
|
|
53
|
+
self._subscribed_channels: set[str] = set()
|
|
54
|
+
|
|
55
|
+
def _parse_url(self, url: str) -> tuple[str, int, str | None]:
|
|
56
|
+
"""解析 URL。"""
|
|
57
|
+
url = url.replace("redis-cluster://", "redis://")
|
|
58
|
+
parsed = urlparse(url)
|
|
59
|
+
password = parsed.password
|
|
60
|
+
# 支持 password@host 格式
|
|
61
|
+
if not password and parsed.username:
|
|
62
|
+
password = parsed.username
|
|
63
|
+
return parsed.hostname or "localhost", parsed.port or 6379, password
|
|
64
|
+
|
|
65
|
+
async def connect(self) -> None:
|
|
66
|
+
"""连接并初始化 pubsub。"""
|
|
67
|
+
# coredis 的 sharded_pubsub() 支持 Redis 7.0+ Sharded Pub/Sub
|
|
68
|
+
self._pubsub = self._client.sharded_pubsub()
|
|
69
|
+
self._listener_task = asyncio.create_task(self._listen_loop())
|
|
70
|
+
logger.debug("Redis Cluster Channel 已连接")
|
|
71
|
+
|
|
72
|
+
async def disconnect(self) -> None:
|
|
73
|
+
"""断开连接。"""
|
|
74
|
+
if self._listener_task:
|
|
75
|
+
self._listener_task.cancel()
|
|
76
|
+
try:
|
|
77
|
+
await self._listener_task
|
|
78
|
+
except asyncio.CancelledError:
|
|
79
|
+
pass
|
|
80
|
+
if self._pubsub:
|
|
81
|
+
await self._pubsub.sunsubscribe()
|
|
82
|
+
await self._client.close()
|
|
83
|
+
logger.debug("Redis Cluster Channel 已断开")
|
|
84
|
+
|
|
85
|
+
async def subscribe(self, channel: str) -> None:
|
|
86
|
+
"""订阅频道。"""
|
|
87
|
+
if self._pubsub:
|
|
88
|
+
await self._pubsub.ssubscribe(channel)
|
|
89
|
+
self._subscribed_channels.add(channel)
|
|
90
|
+
|
|
91
|
+
async def unsubscribe(self, channel: str) -> None:
|
|
92
|
+
"""取消订阅。"""
|
|
93
|
+
if self._pubsub and channel in self._subscribed_channels:
|
|
94
|
+
await self._pubsub.sunsubscribe(channel)
|
|
95
|
+
self._subscribed_channels.discard(channel)
|
|
96
|
+
|
|
97
|
+
async def publish(self, channel: str, message: str) -> None:
|
|
98
|
+
"""发布消息(使用 SPUBLISH)。"""
|
|
99
|
+
await self._client.spublish(channel, message)
|
|
100
|
+
|
|
101
|
+
async def next_published(self) -> Event:
|
|
102
|
+
"""获取下一条消息。"""
|
|
103
|
+
return await self._queue.get()
|
|
104
|
+
|
|
105
|
+
async def _listen_loop(self) -> None:
|
|
106
|
+
"""监听消息循环。"""
|
|
107
|
+
while True:
|
|
108
|
+
try:
|
|
109
|
+
if self._pubsub:
|
|
110
|
+
async for message in self._pubsub:
|
|
111
|
+
if message and message.get("type") in ("message", "smessage"):
|
|
112
|
+
event = Event(
|
|
113
|
+
channel=message.get("channel", ""),
|
|
114
|
+
message=message.get("data", ""),
|
|
115
|
+
)
|
|
116
|
+
await self._queue.put(event)
|
|
117
|
+
except asyncio.CancelledError:
|
|
118
|
+
break
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.warning(f"Redis Cluster pubsub error: {e}")
|
|
121
|
+
await asyncio.sleep(1)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = ["RedisClusterBackend"]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Redis Cluster 通道封装。
|
|
2
|
+
|
|
3
|
+
使用 RedisClusterBackend,提供与 BroadcasterChannel 一致的接口。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
from aury.boot.common.logging import logger
|
|
13
|
+
|
|
14
|
+
from ..base import ChannelMessage, IChannel
|
|
15
|
+
from .redis_cluster import RedisClusterBackend
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RedisClusterChannel(IChannel):
|
|
19
|
+
"""Redis Cluster 通道实现。
|
|
20
|
+
|
|
21
|
+
使用 broadcaster 架构:共享连接 + Queue 分发。
|
|
22
|
+
支持 Sharded Pub/Sub (Redis 7.0+)。
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, url: str) -> None:
|
|
26
|
+
"""初始化 Redis Cluster 通道。
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
url: redis-cluster://[password@]host:port
|
|
30
|
+
"""
|
|
31
|
+
self._url = url
|
|
32
|
+
self._backend = RedisClusterBackend(url)
|
|
33
|
+
self._connected = False
|
|
34
|
+
# 订阅者管理(与 broadcaster 相同的模式)
|
|
35
|
+
self._subscribers: dict[str, set] = {}
|
|
36
|
+
self._listener_task = None
|
|
37
|
+
|
|
38
|
+
async def _ensure_connected(self) -> None:
|
|
39
|
+
if not self._connected:
|
|
40
|
+
await self._backend.connect()
|
|
41
|
+
self._listener_task = __import__("asyncio").create_task(self._listener())
|
|
42
|
+
self._connected = True
|
|
43
|
+
logger.debug(f"Redis Cluster 通道已连接: {self._mask_url(self._url)}")
|
|
44
|
+
|
|
45
|
+
def _mask_url(self, url: str) -> str:
|
|
46
|
+
if "@" in url:
|
|
47
|
+
parts = url.split("@")
|
|
48
|
+
prefix = parts[0]
|
|
49
|
+
suffix = parts[1]
|
|
50
|
+
if "://" in prefix:
|
|
51
|
+
scheme = prefix.split("://")[0]
|
|
52
|
+
return f"{scheme}://***@{suffix}"
|
|
53
|
+
return url
|
|
54
|
+
|
|
55
|
+
async def _listener(self) -> None:
|
|
56
|
+
"""监听后端消息,分发到订阅者。"""
|
|
57
|
+
import asyncio
|
|
58
|
+
while True:
|
|
59
|
+
try:
|
|
60
|
+
event = await self._backend.next_published()
|
|
61
|
+
channel = event.channel
|
|
62
|
+
if channel in self._subscribers:
|
|
63
|
+
for queue in list(self._subscribers[channel]):
|
|
64
|
+
await queue.put(event)
|
|
65
|
+
except asyncio.CancelledError:
|
|
66
|
+
break
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.warning(f"Redis Cluster listener error: {e}")
|
|
69
|
+
|
|
70
|
+
async def publish(self, channel: str, message: ChannelMessage) -> None:
|
|
71
|
+
await self._ensure_connected()
|
|
72
|
+
message.channel = channel
|
|
73
|
+
data = {
|
|
74
|
+
"data": message.data,
|
|
75
|
+
"event": message.event,
|
|
76
|
+
"id": message.id,
|
|
77
|
+
"channel": message.channel,
|
|
78
|
+
"timestamp": message.timestamp.isoformat(),
|
|
79
|
+
}
|
|
80
|
+
await self._backend.publish(channel, json.dumps(data))
|
|
81
|
+
|
|
82
|
+
async def subscribe(self, channel: str) -> AsyncIterator[ChannelMessage]:
|
|
83
|
+
import asyncio
|
|
84
|
+
await self._ensure_connected()
|
|
85
|
+
|
|
86
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
# 首个订阅者时订阅 Redis
|
|
90
|
+
if channel not in self._subscribers:
|
|
91
|
+
await self._backend.subscribe(channel)
|
|
92
|
+
self._subscribers[channel] = set()
|
|
93
|
+
self._subscribers[channel].add(queue)
|
|
94
|
+
|
|
95
|
+
while True:
|
|
96
|
+
event = await queue.get()
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(event.message)
|
|
99
|
+
yield ChannelMessage(
|
|
100
|
+
data=data.get("data"),
|
|
101
|
+
event=data.get("event"),
|
|
102
|
+
id=data.get("id"),
|
|
103
|
+
channel=data.get("channel") or channel,
|
|
104
|
+
timestamp=datetime.fromisoformat(data["timestamp"])
|
|
105
|
+
if data.get("timestamp")
|
|
106
|
+
else datetime.now(),
|
|
107
|
+
)
|
|
108
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
109
|
+
logger.warning(f"解析通道消息失败: {e}")
|
|
110
|
+
finally:
|
|
111
|
+
if channel in self._subscribers:
|
|
112
|
+
self._subscribers[channel].discard(queue)
|
|
113
|
+
if not self._subscribers[channel]:
|
|
114
|
+
del self._subscribers[channel]
|
|
115
|
+
try:
|
|
116
|
+
await self._backend.unsubscribe(channel)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
async def psubscribe(self, pattern: str) -> AsyncIterator[ChannelMessage]:
|
|
121
|
+
raise NotImplementedError(
|
|
122
|
+
"Redis Cluster Sharded Pub/Sub 不支持模式订阅。"
|
|
123
|
+
"请使用具体的 channel 名称。"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async def unsubscribe(self, channel: str) -> None:
|
|
127
|
+
pass # subscribe() 的 finally 块自动处理
|
|
128
|
+
|
|
129
|
+
async def close(self) -> None:
|
|
130
|
+
if self._connected:
|
|
131
|
+
if self._listener_task:
|
|
132
|
+
self._listener_task.cancel()
|
|
133
|
+
await self._backend.disconnect()
|
|
134
|
+
self._connected = False
|
|
135
|
+
self._subscribers.clear()
|
|
136
|
+
logger.debug("Redis Cluster 通道已关闭")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = ["RedisClusterChannel"]
|
|
@@ -18,6 +18,8 @@ class ChannelBackend(Enum):
|
|
|
18
18
|
|
|
19
19
|
# Broadcaster 统一后端(支持 memory/redis/kafka/postgres,通过 URL scheme 区分)
|
|
20
20
|
BROADCASTER = "broadcaster"
|
|
21
|
+
# Redis Cluster + Sharded Pub/Sub (Redis 7.0+),使用 coredis 库
|
|
22
|
+
REDIS_CLUSTER = "redis_cluster"
|
|
21
23
|
# 未来扩展
|
|
22
24
|
RABBITMQ = "rabbitmq"
|
|
23
25
|
ROCKETMQ = "rocketmq"
|
|
@@ -10,6 +10,7 @@ from collections.abc import AsyncIterator
|
|
|
10
10
|
from aury.boot.common.logging import logger
|
|
11
11
|
|
|
12
12
|
from .backends.broadcaster import BroadcasterChannel
|
|
13
|
+
from .backends.redis_cluster_channel import RedisClusterChannel
|
|
13
14
|
from .base import ChannelBackend, ChannelMessage, IChannel
|
|
14
15
|
|
|
15
16
|
|
|
@@ -93,6 +94,7 @@ class ChannelManager:
|
|
|
93
94
|
url: 连接 URL,支持:
|
|
94
95
|
- memory:// - 内存后端(单进程,默认)
|
|
95
96
|
- redis://host:port/db - Redis Pub/Sub
|
|
97
|
+
- redis-cluster://[password@]host:port - Redis Cluster (Sharded Pub/Sub)
|
|
96
98
|
- kafka://host:port - Apache Kafka
|
|
97
99
|
- postgres://user:pass@host/db - PostgreSQL
|
|
98
100
|
|
|
@@ -103,15 +105,21 @@ class ChannelManager:
|
|
|
103
105
|
logger.warning(f"通道管理器 [{self.name}] 已初始化,跳过")
|
|
104
106
|
return self
|
|
105
107
|
|
|
106
|
-
#
|
|
108
|
+
# 根据 URL scheme 自动选择后端
|
|
107
109
|
if isinstance(backend, str):
|
|
108
110
|
backend = ChannelBackend(backend.lower())
|
|
111
|
+
|
|
112
|
+
# 自动检测 redis-cluster:// scheme
|
|
113
|
+
if url.startswith("redis-cluster://"):
|
|
114
|
+
backend = ChannelBackend.REDIS_CLUSTER
|
|
109
115
|
|
|
110
116
|
self._backend_type = backend
|
|
111
117
|
self._url = url
|
|
112
118
|
|
|
113
119
|
if backend == ChannelBackend.BROADCASTER:
|
|
114
120
|
self._backend = BroadcasterChannel(url)
|
|
121
|
+
elif backend == ChannelBackend.REDIS_CLUSTER:
|
|
122
|
+
self._backend = RedisClusterChannel(url)
|
|
115
123
|
elif backend in (ChannelBackend.RABBITMQ, ChannelBackend.ROCKETMQ):
|
|
116
124
|
raise NotImplementedError(f"{backend.value} 后端暂未实现")
|
|
117
125
|
else:
|