add: worker 侧运行时下沉(route_config/contracts/routes_publisher/process 入口,零网关依赖)
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Worker 侧契约校验(网关与 worker 进程共享,唯一实现)
|
||||
|
||||
校验三方一致性:config/workers TOML 路由 ↔ @rpc_method 注册表 ↔ gen_models 模型。
|
||||
网关 inline 模式启动时对 inline 插件批量校验(见网关 contracts.py 封装);
|
||||
worker 进程 external 模式在自己进程内对自身校验(validate_worker_contracts)。
|
||||
|
||||
策略:
|
||||
- 硬失败(汇总后 raise):
|
||||
HTTP 路由(GET/POST/PUT/DELETE/PATCH 且 internal != true)在注册表中
|
||||
缺少对应 subject#METHOD 的 @rpc_method → 该路由运行时必然 503。
|
||||
- 软警告(仅日志汇总,不阻断):
|
||||
[MQTT] / internal 路由缺少注册、已注册方法无 TOML 路由、
|
||||
TOML 声明 params/response_model 但生成模型缺失(gen_models 过期)、孤儿模型。
|
||||
"""
|
||||
import importlib
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from chongming_worker.decorators import get_all_rpc_methods
|
||||
from chongming_worker.route_config import load_worker_routes_from_dir, model_name
|
||||
|
||||
logger = logging.getLogger("chongming_worker.contracts")
|
||||
|
||||
# 参与 HTTP 路由注册的方法(与网关 route_generator 的注册行为一致)
|
||||
HTTP_METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH")
|
||||
|
||||
|
||||
def load_plugin_models(worker_type: str) -> Optional[Set[str]]:
|
||||
"""
|
||||
加载插件 models.pydantic 中所有 Pydantic 模型的类名集合。
|
||||
|
||||
按序尝试 inline 插件(src/plugins/)与进程插件(workers/)路径;
|
||||
返回 None 表示模块不存在(插件无生成模型,跳过模型相关校验)。
|
||||
注: gen_models 生成的 `<Plugin>Response` 联合类型——多模型时为 UnionType
|
||||
(自动被 isinstance(type) 过滤),单模型时是类的别名(与真实模型同一对象,
|
||||
用 obj.__name__ 收敛到真实模型名,别名不会抢占排序位)。
|
||||
"""
|
||||
from pydantic import BaseModel
|
||||
|
||||
for mod_name in (
|
||||
f"plugins.{worker_type}.models.pydantic",
|
||||
f"src.plugins.{worker_type}.models.pydantic",
|
||||
f"workers.{worker_type}.models.pydantic",
|
||||
):
|
||||
try:
|
||||
mod = importlib.import_module(mod_name)
|
||||
except ImportError:
|
||||
continue
|
||||
names: Set[str] = set()
|
||||
seen_objs: Set[int] = set()
|
||||
for name in dir(mod):
|
||||
obj = getattr(mod, name)
|
||||
if (
|
||||
isinstance(obj, type)
|
||||
and issubclass(obj, BaseModel)
|
||||
and obj is not BaseModel
|
||||
and id(obj) not in seen_objs
|
||||
):
|
||||
seen_objs.add(id(obj))
|
||||
names.add(obj.__name__)
|
||||
return names
|
||||
return None
|
||||
|
||||
|
||||
def check_plugin(
|
||||
worker_type: str,
|
||||
toml_routes: List[Dict[str, Any]],
|
||||
registered: Dict[str, Any],
|
||||
model_names: Optional[Set[str]],
|
||||
) -> tuple[List[str], List[str]]:
|
||||
"""
|
||||
对单个插件执行 TOML ↔ 注册表 ↔ 生成模型一致性校验。
|
||||
|
||||
Args:
|
||||
worker_type: 插件类型
|
||||
toml_routes: load_worker_routes_from_dir() 输出的路由列表
|
||||
registered: get_all_rpc_methods().get(worker_type, {}) 注册表
|
||||
model_names: 插件 models.pydantic 的模型名集合(None 表示无生成模型)
|
||||
|
||||
Returns:
|
||||
(hard_errors, warnings): 硬错误与软警告消息列表
|
||||
"""
|
||||
hard_errors: List[str] = []
|
||||
warnings: List[str] = []
|
||||
toml_keys = {f"{r['subject']}#{r['method']}" for r in toml_routes}
|
||||
|
||||
for r in toml_routes:
|
||||
key = f"{r['subject']}#{r['method']}"
|
||||
route_desc = f"{r['method']} {r['path']} -> subject '{r['subject']}'"
|
||||
|
||||
if key in registered:
|
||||
continue
|
||||
|
||||
if r["method"] in HTTP_METHODS and not r.get("internal", False):
|
||||
# 硬失败:运行时必然 503
|
||||
hint = ""
|
||||
if not registered:
|
||||
hint = "(该插件注册表为空,请检查插件导入日志)"
|
||||
hard_errors.append(
|
||||
f"[{worker_type}] {route_desc} 缺少对应的 @rpc_method 注册{hint}"
|
||||
)
|
||||
else:
|
||||
# 软警告:MQTT / internal 端点缺失注册
|
||||
warnings.append(
|
||||
f"[{worker_type}] {route_desc} 无对应的 @rpc_method(method='{r['method']}') 注册"
|
||||
)
|
||||
|
||||
# 已注册方法无 TOML 路由(反向漂移检测)
|
||||
for key in registered:
|
||||
if key not in toml_keys:
|
||||
warnings.append(
|
||||
f"[{worker_type}] 已注册 '{key}' 但无对应 TOML 路由定义"
|
||||
)
|
||||
|
||||
# 模型一致性(仅当插件存在生成模型时校验)
|
||||
if model_names is not None:
|
||||
referenced: Set[str] = set()
|
||||
for r in toml_routes:
|
||||
route_name = r.get("route_name", "")
|
||||
if not route_name:
|
||||
continue
|
||||
method = r["method"]
|
||||
if r.get("params"):
|
||||
expected = model_name(route_name, method, "Request")
|
||||
referenced.add(expected)
|
||||
if expected not in model_names:
|
||||
warnings.append(
|
||||
f"[{worker_type}] '{r['subject']}' [{method}] 声明了 params,"
|
||||
f"但模型 {expected} 不存在(可能未运行 gen_models)"
|
||||
)
|
||||
if r.get("response_model"):
|
||||
expected = model_name(route_name, method, "Response")
|
||||
referenced.add(expected)
|
||||
if expected not in model_names:
|
||||
warnings.append(
|
||||
f"[{worker_type}] '{r['subject']}' [{method}] 声明了 response_model,"
|
||||
f"但模型 {expected} 不存在(可能未运行 gen_models)"
|
||||
)
|
||||
|
||||
for name in sorted(model_names - referenced):
|
||||
warnings.append(
|
||||
f"[{worker_type}] 模型 {name} 未被任何 TOML 路由引用"
|
||||
f"(孤儿模型,可重新运行 gen_models 清理)"
|
||||
)
|
||||
|
||||
return hard_errors, warnings
|
||||
|
||||
|
||||
def validate_worker_contracts(worker_type: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Worker 进程侧契约校验(external 模式):校验单个插件并返回可发布的路由描述符。
|
||||
|
||||
调用时机:worker 进程 import 插件模块后(@rpc_method 注册表就绪)、
|
||||
发布路由描述符之前。
|
||||
|
||||
Args:
|
||||
worker_type: 插件类型(与 config/workers/<type> 目录一致)
|
||||
|
||||
Returns:
|
||||
通过校验的 TOML 路由描述符列表(直接用于 RoutePublisher 发布)
|
||||
|
||||
Raises:
|
||||
RuntimeError: 存在硬失败项(HTTP 路由缺失 @rpc_method 注册)时,
|
||||
调用方应终止 worker 进程,避免发布残缺路由。
|
||||
"""
|
||||
registered = get_all_rpc_methods().get(worker_type, {})
|
||||
toml_routes = load_worker_routes_from_dir(worker_type)
|
||||
model_names = load_plugin_models(worker_type)
|
||||
|
||||
hard_errors, warnings = check_plugin(
|
||||
worker_type, toml_routes, registered, model_names
|
||||
)
|
||||
|
||||
for w in warnings:
|
||||
logger.warning(" ⚠ %s", w)
|
||||
|
||||
if hard_errors:
|
||||
logger.error("Worker 契约校验失败,发现 %d 个硬性错误:", len(hard_errors))
|
||||
for e in hard_errors:
|
||||
logger.error(" ✗ %s", e)
|
||||
raise RuntimeError(
|
||||
f"[{worker_type}] 契约校验失败: " + "; ".join(hard_errors)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[%s] 契约校验通过,可发布 %d 个路由描述符", worker_type, len(toml_routes)
|
||||
)
|
||||
return toml_routes
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
独立 Worker 进程运行时(与网关完全隔离)
|
||||
|
||||
进程插件与网关零导入关系:本模块只依赖 chongming_worker 自身、zenoh 与
|
||||
标准库,不 import chongming(网关包)。进程插件容器 = 插件代码 + chongming-worker。
|
||||
|
||||
进程入口(console script):
|
||||
chongming-worker --type <name> [--count N] [--config config.toml]
|
||||
|
||||
流程:
|
||||
1. 读全局 config.toml(仅 [zenoh].json5_path)→ 打开 Zenoh 会话
|
||||
2. 自读 config/workers/<type>/config.toml 的 [workers] 节(插件注册 + 运行时配置)
|
||||
3. import 插件模块(plugins.<name> / workers.<name>)→ @rpc_method 注册
|
||||
4. 进程内契约校验(TOML ↔ 注册表 ↔ 生成模型),硬失败则退出
|
||||
5. create_workers 创建副本(count 进程内保留)
|
||||
6. 经 Zenoh 发布路由描述符(chongming/routes/<type>/...),每 10s 周期重发布自愈
|
||||
7. SIGTERM/SIGINT → 摘除路由 + cleanup + 关闭会话
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
import zenoh
|
||||
|
||||
from chongming_worker.contracts import validate_worker_contracts
|
||||
from chongming_worker.routes_publisher import RoutePublisher
|
||||
|
||||
logger = logging.getLogger("chongming_worker.process")
|
||||
|
||||
# 路由描述符周期性重发布间隔(秒):网关后启动/样本丢失时自愈
|
||||
ANNOUNCE_INTERVAL = 10.0
|
||||
|
||||
|
||||
def load_plugin_config(worker_type: str, config_dir: str = "config/workers") -> dict:
|
||||
"""
|
||||
读取单个插件的 config.toml [workers] 节并展平(与网关 _discover_plugins
|
||||
的展平规则一致,worker 侧独立实现,不依赖网关)。
|
||||
|
||||
Returns:
|
||||
{"type", "module", "factory", "cleanup", "count", "load_mode",
|
||||
"mqtt_topics", "config": 展平后的运行时配置}
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
path = pathlib.Path(config_dir) / worker_type / "config.toml"
|
||||
with open(path, "rb") as f:
|
||||
plugin_conf = tomllib.load(f)
|
||||
|
||||
workers_section = plugin_conf.get("workers", {})
|
||||
if not workers_section:
|
||||
raise ValueError(f"{path} 缺少 [workers] 节")
|
||||
|
||||
plugin_type = workers_section.get("type", worker_type)
|
||||
load_mode = workers_section.get("load_mode", "inline")
|
||||
|
||||
mqtt_topics = []
|
||||
for t in workers_section.get("mqtt_topics", []) or []:
|
||||
if not isinstance(t, dict) or not t.get("topic"):
|
||||
continue
|
||||
t = dict(t)
|
||||
t.setdefault("worker", plugin_type)
|
||||
mqtt_topics.append(t)
|
||||
|
||||
workers_flat = {k: v for k, v in workers_section.items()
|
||||
if k not in ("config", "mqtt_topics", "load_mode")}
|
||||
runtime_config = {
|
||||
**plugin_conf,
|
||||
**workers_flat,
|
||||
**workers_section.get("config", {}),
|
||||
}
|
||||
|
||||
return {
|
||||
"type": plugin_type,
|
||||
"module": workers_section.get("module", f"plugins.{plugin_type}"),
|
||||
"factory": workers_section.get("factory", "create_workers"),
|
||||
"cleanup": workers_section.get("cleanup", "cleanup"),
|
||||
"count": workers_section.get("count", 1),
|
||||
"load_mode": load_mode,
|
||||
"mqtt_topics": mqtt_topics,
|
||||
"config": runtime_config,
|
||||
}
|
||||
|
||||
|
||||
def worker_serve():
|
||||
"""独立 Worker 进程入口(console script: chongming-worker / worker_serve)。"""
|
||||
parser = argparse.ArgumentParser(description="chongming 独立 Worker 进程")
|
||||
parser.add_argument("--type", required=True, help="插件类型(如 demo / example)")
|
||||
parser.add_argument("--count", type=int, default=None, help="副本数(覆盖配置中的 count)")
|
||||
parser.add_argument("--config", default="config.toml", help="全局配置文件路径(读取 [zenoh] 节)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# console script 启动时 sys.path 不含当前目录,显式加入以保证
|
||||
# plugins.<name>(inline 插件)与 workers.<name>(进程插件)均可导入
|
||||
project_root = os.path.abspath(os.getcwd())
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
try:
|
||||
wc = load_plugin_config(args.type)
|
||||
except FileNotFoundError as e:
|
||||
logger.error("未找到插件配置: %s", e)
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
logger.error("%s", e)
|
||||
sys.exit(1)
|
||||
|
||||
if wc.get("load_mode", "inline") != "external":
|
||||
logger.warning(
|
||||
"[%s] 插件配置 load_mode 不是 \"external\":网关也会在进程内加载该插件,"
|
||||
"外部进程将与网关内副本重复服务。若以独立进程运行,请在插件 "
|
||||
"config.toml 的 [workers] 节设置 load_mode = \"external\"。",
|
||||
args.type,
|
||||
)
|
||||
|
||||
# 全局配置只读 [zenoh] 节(worker 不依赖网关的配置结构)
|
||||
with open(args.config, "rb") as f:
|
||||
global_config = tomllib.load(f)
|
||||
json5_path = global_config.get("zenoh", {}).get("json5_path", "config/zenoh_config.json5")
|
||||
|
||||
async def _run():
|
||||
# 1. Zenoh 会话
|
||||
zconfig = zenoh.Config()
|
||||
zconfig.from_file(json5_path)
|
||||
session = zenoh.open(zconfig)
|
||||
logger.info("[%s] Zenoh 会话已建立", args.type)
|
||||
|
||||
try:
|
||||
# 2. import 插件(触发 @rpc_method 注册),然后契约校验
|
||||
module = __import__(wc["module"], fromlist=[wc["factory"]])
|
||||
routes = validate_worker_contracts(args.type)
|
||||
|
||||
# 3. 创建 Worker 副本(跨进程经 liveliness 发现,无需本地注册)
|
||||
factory = getattr(module, wc["factory"])
|
||||
count = args.count or wc.get("count", 1)
|
||||
if inspect.iscoroutinefunction(factory):
|
||||
tasks = await factory(session, wc["config"], count, worker_manager=None)
|
||||
else:
|
||||
tasks = factory(session, wc["config"], count, worker_manager=None)
|
||||
|
||||
# 4. 发布路由描述符(网关 RouteRegistry 订阅后动态挂载 HTTP 路由)
|
||||
publisher = RoutePublisher(session, args.type)
|
||||
mqtt_topics = wc.get("mqtt_topics") or []
|
||||
publisher.publish_routes(routes, mqtt_topics)
|
||||
|
||||
# 5. 等待退出信号;期间周期性重发布(网关后启动/样本丢失时自愈)
|
||||
stop_event = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, stop_event.set)
|
||||
|
||||
async def _announce():
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=ANNOUNCE_INTERVAL)
|
||||
return
|
||||
except asyncio.TimeoutError:
|
||||
publisher.publish_routes(routes, mqtt_topics)
|
||||
|
||||
announce_task = asyncio.create_task(_announce())
|
||||
logger.info("[%s] Worker 进程就绪,等待退出信号...", args.type)
|
||||
await stop_event.wait()
|
||||
announce_task.cancel()
|
||||
await asyncio.gather(announce_task, return_exceptions=True)
|
||||
|
||||
# 6. 优雅退出:摘除路由 → cleanup → 取消副本任务 → 关闭会话
|
||||
publisher.unpublish()
|
||||
cleanup = getattr(module, wc.get("cleanup", ""), None)
|
||||
if cleanup is not None:
|
||||
worker_id = f"worker/{args.type}/worker-i-{os.getpid()}"
|
||||
if inspect.iscoroutinefunction(cleanup):
|
||||
await cleanup(worker_id)
|
||||
else:
|
||||
cleanup(worker_id)
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
logger.info("[%s] Worker 进程已退出", args.type)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
try:
|
||||
asyncio.run(_run())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Worker 路由 TOML 配置解析(网关与 worker 进程共享,唯一实现)
|
||||
|
||||
从 config/workers/<type>/ 目录解析路由描述符(TOML 兼容格式),
|
||||
网关 inline 模式用于盘扫注册 FastAPI 路由,worker 进程 external 模式用于
|
||||
自读配置 + 契约校验 + 发布描述符。两边共用本模块保证格式一致。
|
||||
"""
|
||||
import logging
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
logger = logging.getLogger("chongming_worker.route_config")
|
||||
|
||||
# 默认的 worker 配置目录
|
||||
WORKER_CONFIG_DIR = "config/workers"
|
||||
|
||||
|
||||
def model_name(base: str, method: str, suffix: str) -> str:
|
||||
"""gen_models 模型命名约定: <Op><Method><Suffix>"""
|
||||
method_title = "Mqtt" if method == "MQTT" else method.title()
|
||||
return f"{base.title().replace('-', '').replace('_', '')}{method_title}{suffix}"
|
||||
|
||||
|
||||
def load_worker_routes_from_dir(worker_type: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
从 config/workers/{worker_type}/ 目录加载路由配置。
|
||||
|
||||
目录结构:
|
||||
config/workers/{worker_type}/
|
||||
config.toml # [worker] 分组信息 + registration + defaults
|
||||
add.toml # 接口名 = 文件名(不含扩展名)
|
||||
[GET]
|
||||
summary = ...
|
||||
params = [...]
|
||||
response_model = { ... }
|
||||
[POST]
|
||||
... # 同一个接口支持多个请求方法
|
||||
[MQTT]
|
||||
... # MQTT 专用端点(不注册 HTTP 路由)
|
||||
|
||||
返回: List[Dict], 每个 dict 包含 flatten 后的路由配置,
|
||||
每个方法节(如 GET/POST/MQTT) 生成一个独立的路由条目。
|
||||
"""
|
||||
base_dir = Path(WORKER_CONFIG_DIR) / worker_type
|
||||
if not base_dir.is_dir():
|
||||
logger.warning(f"Worker 配置目录不存在: {base_dir}")
|
||||
return []
|
||||
|
||||
# 1. 读取分组配置
|
||||
group_cfg_path = base_dir / "config.toml"
|
||||
group_defaults: Dict[str, Any] = {}
|
||||
registration: Dict[str, Any] = {}
|
||||
router_prefix: str = ""
|
||||
if group_cfg_path.exists():
|
||||
with open(group_cfg_path, "rb") as f:
|
||||
cfg = tomllib.load(f)
|
||||
w = cfg.get("worker", {})
|
||||
group_defaults = w.get("defaults", {})
|
||||
registration = w.get("registration", {})
|
||||
router_prefix = registration.get("router_prefix", "")
|
||||
|
||||
# 2. 遍历所有接口 toml 文件
|
||||
routes: List[Dict[str, Any]] = []
|
||||
for route_file in sorted(base_dir.glob("*.toml")):
|
||||
if route_file.name == "config.toml":
|
||||
continue
|
||||
|
||||
# 文件名(不含 .toml)作为路径名
|
||||
route_name = route_file.stem # 例如 "add"
|
||||
base_path = router_prefix + "/" + route_name # 例如 "/calc/add"
|
||||
|
||||
with open(route_file, "rb") as f:
|
||||
route_data = tomllib.load(f)
|
||||
|
||||
# 每个方法节(GET/POST/PUT/DELETE)生成一个独立路由
|
||||
for method, spec in route_data.items():
|
||||
if not isinstance(spec, dict):
|
||||
logger.warning(f"忽略非 dict 的方法节: {route_file.name}[{method}]")
|
||||
continue
|
||||
|
||||
# 合并: spec 显式字段优先,然后 group_defaults 兜底
|
||||
route = dict(spec)
|
||||
for key, default_val in group_defaults.items():
|
||||
if key not in route:
|
||||
route[key] = default_val
|
||||
|
||||
# 覆盖关键字段 - method 始终从 TOML 节键获取,不被 defaults 覆盖
|
||||
route["method"] = method.upper()
|
||||
route["path"] = route.get("path", base_path)
|
||||
if "subject" not in route:
|
||||
# 自动生成 subject: worker_type.route_name (如 "calc.add")
|
||||
route["subject"] = f"{worker_type}.{route_name}"
|
||||
# 记录来源文件名(契约校验用其推断 gen_models 模型命名)
|
||||
route["route_name"] = route_name
|
||||
|
||||
routes.append(route)
|
||||
logger.debug(
|
||||
f" 已加载: {route['method']} {route['path']} -> {route['subject']}"
|
||||
)
|
||||
|
||||
logger.info(f"从 {base_dir} 加载了 {len(routes)} 个路由")
|
||||
return routes
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
路由描述符发布器(worker 进程侧,协议唯一实现)
|
||||
|
||||
独立 worker 进程在完成契约校验后,将路由描述符 put 到 Zenoh 的
|
||||
`chongming/routes/<type>/...` 命名空间;进程优雅退出时 delete 摘除。
|
||||
|
||||
网关 RouteRegistry 订阅同名空间并动态挂载 HTTP 路由。
|
||||
描述符 schema_version=1,字段与网关 RouteConfig 完全对位(TOML 兼容 dict)。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import zenoh
|
||||
|
||||
logger = logging.getLogger("chongming_worker.routes_publisher")
|
||||
|
||||
ROUTES_PREFIX = "chongming/routes"
|
||||
META_KEY = "__meta__"
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def route_id(subject: str, method: str) -> str:
|
||||
"""route_id = subject 与 method 的编码(避免 keyexpr 特殊字符歧义)"""
|
||||
return f"{subject.replace('.', '_')}__{method.upper()}"
|
||||
|
||||
|
||||
class RoutePublisher:
|
||||
"""发布/摘除某 worker_type 的路由描述符。"""
|
||||
|
||||
def __init__(self, session: zenoh.Session, worker_type: str):
|
||||
self._session = session
|
||||
self._worker_type = worker_type
|
||||
self._keys: List[str] = []
|
||||
|
||||
def publish_routes(
|
||||
self,
|
||||
routes: List[Dict[str, Any]],
|
||||
mqtt_topics: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
"""
|
||||
发布路由描述符。
|
||||
|
||||
Args:
|
||||
routes: 契约校验通过的路由描述符列表(TOML 兼容格式)
|
||||
mqtt_topics: 插件声明的 [[workers.mqtt_topics]](可空)
|
||||
"""
|
||||
for r in routes:
|
||||
subject = r.get("subject", "")
|
||||
method = r.get("method", "GET")
|
||||
if not subject:
|
||||
logger.warning("跳过无 subject 的路由描述符: %s", r)
|
||||
continue
|
||||
descriptor = dict(r)
|
||||
descriptor["schema_version"] = SCHEMA_VERSION
|
||||
descriptor["type"] = self._worker_type
|
||||
key = f"{ROUTES_PREFIX}/{self._worker_type}/{route_id(subject, method)}"
|
||||
self._session.put(key, json.dumps(descriptor, ensure_ascii=False).encode("utf-8"))
|
||||
self._keys.append(key)
|
||||
logger.info(
|
||||
"已发布路由: %s %s (subject=%s)",
|
||||
method.upper(), descriptor.get("path", "?"), subject,
|
||||
)
|
||||
|
||||
if mqtt_topics:
|
||||
meta_key = f"{ROUTES_PREFIX}/{self._worker_type}/{META_KEY}"
|
||||
meta = {"schema_version": SCHEMA_VERSION, "mqtt_topics": mqtt_topics}
|
||||
self._session.put(
|
||||
meta_key, json.dumps(meta, ensure_ascii=False).encode("utf-8")
|
||||
)
|
||||
self._keys.append(meta_key)
|
||||
logger.info("已发布插件元信息: %d 个自定义 MQTT 主题", len(mqtt_topics))
|
||||
|
||||
# 去重(周期性重发布会重复调用本方法)
|
||||
self._keys = list(dict.fromkeys(self._keys))
|
||||
logger.info(
|
||||
"[%s] 路由发布完成: %d 端点 + %s", self._worker_type,
|
||||
len(self._keys), "元信息" if mqtt_topics else "无元信息",
|
||||
)
|
||||
|
||||
def unpublish(self):
|
||||
"""摘除全部已发布的路由描述符(优雅退出时调用)。"""
|
||||
for key in self._keys:
|
||||
try:
|
||||
self._session.delete(key)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning("摘除路由失败 [%s]: %s", key, e)
|
||||
logger.info("[%s] 已摘除 %d 个路由描述符", self._worker_type, len(self._keys))
|
||||
self._keys.clear()
|
||||
Reference in New Issue
Block a user