modify: 统一插件风格

This commit is contained in:
2026-08-14 16:03:49 +08:00
parent fe95a67e93
commit f2a3a37389
14 changed files with 306 additions and 53 deletions
+1
View File
@@ -33,6 +33,7 @@ config
# .agents
.github
.zcode
# lib
lib
+34 -11
View File
@@ -2,7 +2,7 @@
基于 **FastAPI + Zenoh + MQTT + Vue 3** 的**配置驱动微服务网关平台**。
通过 TOML 配置文件定义 RPC 端点,自动生成 Pydantic 模型与 FastAPI 路由,同时暴露 **HTTP** 和 **MQTT** 双通道。底层基于 Zenoh 分布式消息协议实现 RPC 通信,Worker 插件可独立开发、热加载、水平扩展。
通过 TOML 配置文件定义 RPC 端点,自动生成 Pydantic 模型与 FastAPI 路由,同时暴露 **HTTP** 和 **MQTT** 双通道。底层基于 Zenoh 分布式消息协议实现 RPC 通信,Worker 插件可独立开发、按副本数水平扩展。
---
@@ -13,8 +13,9 @@
| **FastAPI + Uvicorn** | 高性能异步 Web 框架,自动生成 OpenAPI 文档 |
| **Zenoh 服务发现** | 基于 liveliness 的 Worker 注册与 RPC 调用,负载均衡 + 超时重试 |
| **MQTT 集成** | 内嵌 amqtt Broker,MQTT ↔ Worker 双向桥接 |
| **动态路由生成** | 扫描 `config/workers/*.toml` 自动注册 HTTP 路由 |
| **动态路由生成** | 扫描 `config/workers/*/*.toml` 自动注册 HTTP 路由 |
| **Worker 框架** | `@rpc_method` 装饰器 + `Plugin` 基类统一生命周期 + Pydantic 模型绑定 |
| **启动契约校验** | TOML 路由 ↔ `@rpc_method` ↔ 生成模型三方一致性校验,fail fast |
| **自动化模型生成** | `gen_models` 根据 TOML 配置生成请求/响应模型 |
| **插件脚手架** | `gen_plugin` 一键生成风格统一的 Worker 插件骨架 |
| **JWT 鉴权** | Rust 实现,路由级 `auth_required` 控制 |
@@ -36,15 +37,15 @@ chongming-zenoh/
├── config/ # 配置文件目录
│ ├── zenoh_config.json5 # Zenoh 会话配置
│ └── workers/ # Worker 路由定义(TOML)
│ ├── config.toml # 插件注册列表
│ ├── auth/ # 鉴权插件(login / register / me / …)
│ ├── config.toml # 旧式全局插件注册(仅作兜底兼容)
│ ├── auth/ # 鉴权插件(config.toml = 插件注册 + 运行时配置)
│ ├── doubao/ # 豆包视觉理解插件
│ ├── aibox_sophon_adapter/ # AI Box 设备心跳监控插件
│ └── example/ # 示例插件(四则运算 / MQTT 端点)
├── src/
│ ├── chongming/ # 核心应用
│ │ ├── app/
│ │ │ ├── core/ # WorkerManager, RouteGenerator, MQTTBridge, auth
│ │ │ ├── core/ # WorkerManager, RouteGenerator, MQTTBridge, contracts(契约校验), auth
│ │ │ └── routes/ # 内置路由(文件上传等)
│ │ └── scripts/ # build, gen_models, gen_plugin
│ ├── chongming-web/ # Vue 3 前端
@@ -211,17 +212,34 @@ async def upload(file: UploadFile = File(...)):
```toml
# config/workers/aibox_sophon_adapter/config.toml
[[workers.mqtt_topics]]
topic = "/board_ping" # 支持 +(单级)/ #(多级,须在末尾)通配符
topic = "box/aibox_sophon/board_ping" # 支持 +(单级)/ #(多级,须在末尾)通配符
subject = "aibox_sophon_adapter.board_ping" # 可选,{1} {2}... 对应通配符捕获值
response_topic = "/board_ping/response" # 可选,响应主题;默认 "<实际主题>/response"
```
- `subject` 缺省为 `<插件类型>.<topic 最后一段>`;`response_topic` 缺省为 `<实际匹配主题>/response`。
- 网关发现插件时自动收集所有 `[[workers.mqtt_topics]]`,MQTT Bridge 一并订阅并路由。
- 请求负载同样支持 `_method` / `_timeout` / `_cacheable` 控制字段。
- 多条自定义主题按配置顺序匹配,先匹配者优先;内置 `chongming/...` 接口最后兜底。
- 全局 `[<env>.mqtt].custom_topics` 仍可作为通用兜底(不依赖具体插件时使用,可选)。
### 请求负载格式(信封协议)
MQTT 请求负载为信封结构,控制字段与业务数据分离:
```json
{
"meta": {
"method": "GET", // 可选,默认 GET;MQTT 专用端点需显式 "MQTT"
"timeout": 30, // 可选,RPC 超时(秒),默认 30
"cacheable": false // 可选,是否允许结果缓存,默认 false
},
"data": { "a": 3, "b": 5 } // 业务参数,传给 Worker
}
```
- 非信封格式的负载会被网关丢弃并告警(严格协议,设备侧请按此格式发布)。
- HTTP 通道由网关自动组装信封(注入 method / user_info),无需调用方关心。
### 快速测试
```bash
@@ -243,14 +261,19 @@ python publisher.py example add '{"a":3,"b":5}' # 调用 add
### 添加新 Worker 插件
```text
1. 创建 src/plugins/<name>/ — __init__.py, global_var.py, router/, models/
2. 注册到 config/workers/config.toml 的 [[workers]]
1. 创建 src/plugins/<name>/ — __init__.py(Plugin 子类), global_var.py, router/, models/
2. 创建 config/workers/<name>/config.toml,在 [workers] 节注册插件
(type / module / factory / cleanup / count + [workers.config] 运行时配置)
3. 创建 config/workers/<name>/*.toml 端点定义
4. 运行 uv run python -m chongming.scripts.gen_models 生成模型
5. 实现 router/<op>.py,引用 models/pydantic.py 中的模型
6. 更新 router/__init__.py 导入
```
> 推荐用脚手架一步生成骨架:`uv run gen_plugin <name> [operation ...]`。
> 启动时网关会做**契约校验**:TOML 路由缺少对应 `@rpc_method` 将导致启动失败,
> 模型缺失/孤儿等不一致以警告日志提示。
### 为现有插件添加新端点
```text
@@ -303,7 +326,7 @@ uv pip install -e utils/<name> # 安装/更新工具包
- **应用配置**:根目录 `config.toml`,包含环境、服务器、安全、MQTT 等全局设置
- **Zenoh 配置**:`config/zenoh_config.json5`
- **Worker 注册**:`config/workers/config.toml`,定义加载的插件及其副本数
- **插件注册**:`config/workers/<name>/config.toml` 的 `[workers]` 节,定义加载方式、副本数与运行时配置(旧全局 `config/workers/config.toml` 仅作兜底兼容)
- **路由定义**:`config/workers/<name>/*.toml`,每个端点一个文件
详细配置说明见 [`config/README.md`](config/README.md)。
@@ -339,7 +362,7 @@ uv pip install -e utils/<name> # 安装/更新工具包子包
| 字段 | 位置 | 说明 |
| -------------------- | --------------------- | --------------------------------------- |
| `count` | `workers/config.toml` | Worker 副本数 |
| `count` | `<插件>/config.toml` | Worker 副本数(`[workers]` 节) |
| `router_prefix` | `<插件>/config.toml` | API 路由前缀 |
| `defaults.timeout` | `<插件>/config.toml` | 默认超时(秒) |
| `defaults.cacheable` | `<插件>/config.toml` | 是否缓存 RPC 结果 |
+8 -4
View File
@@ -4,9 +4,10 @@ MQTT Publisher — 向 chongming Worker 发送 MQTT 请求
用法:
python publisher.py <worker_type> <operation> [json_payload]
示例:
python publisher.py example add '{"a": 3, "b": 5, "_method": "GET"}'
python publisher.py doubao analyze '{"text": "hello", "_method": "POST"}'
示例(信封协议,meta 可选):
python publisher.py example add '{"meta": {"method": "GET"}, "data": {"a": 3, "b": 5}}'
python publisher.py example add '{"a": 3, "b": 5}' # 扁平载荷自动包装(method=GET)
python publisher.py example add '{"meta": {"method": "MQTT"}, "data": {"a": 3, "b": 5}}'
"""
import asyncio
@@ -24,7 +25,7 @@ PREFIX = "chongming"
# 默认参数
DEFAULT_WORKER = "example"
DEFAULT_OP = "add"
DEFAULT_PAYLOAD = {"a": 3, "b": 5, "_method": "GET"}
DEFAULT_PAYLOAD = {"meta": {"method": "GET"}, "data": {"a": 3, "b": 5}}
async def main():
worker_type = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_WORKER
@@ -33,6 +34,9 @@ async def main():
topic = f"{PREFIX}/{worker_type}/{operation}"
payload = json.loads(payload_str)
# 扁平载荷自动包装为信封(method 默认 GET),已含 meta/data 则原样使用
if not (isinstance(payload, dict) and ("meta" in payload or "data" in payload)):
payload = {"meta": {"method": "GET"}, "data": payload}
client = MQTTClient()
await client.connect("mqtt://localhost:1883/")
+1 -1
View File
@@ -56,11 +56,11 @@ chongming-request = { git = "http://124.71.68.6:3000/chongming/chongming-utils-r
chongming-jwt = { git = "http://124.71.68.6:3000/chongming/chongming-utils-jwt.git" }
chongming-config = { git = "http://124.71.68.6:3000/chongming/chongming-utils-config.git" }
chongming-password = { git = "http://124.71.68.6:3000/chongming/chongming-utils-password.git" }
chongming-worker = { git = "http://124.71.68.6:3000/chongming/chongming-utils-worker.git" }
bytedance = { git = "http://124.71.68.6:3000/chongming/bytedance.git" }
chongming-stream-manager = { git = "http://124.71.68.6:3000/chongming/chongming-stream-manager.git" }
chongming-database = { git = "http://124.71.68.6:3000/chongming/chongming-utils-database.git" }
chongming-mqtt = { git = "http://124.71.68.6:3000/chongming/chongming-mqtt.git" }
chongming-worker = { git = "http://124.71.68.6:3000/chongming/chongming-utils-worker.git"}
[project.scripts]
build = "chongming.scripts.build:build"
+3
View File
@@ -50,6 +50,9 @@ async def lifespan(app: FastAPI):
# 5. 加载 workers(同时自动发现插件自定义路由)
worker_tasks, custom_routers = await lifespan_setup.load_workers(app, session, manager)
# 5.5 契约校验(TOML 路由 ↔ @rpc_method ↔ 生成模型),失败则启动终止
lifespan_setup.validate_contracts(app)
# 6. 注册路由(含 TOML 动态路由 + 插件自定义路由)
await lifespan_setup.register_routes(app, manager, custom_routers)
+168
View File
@@ -0,0 +1,168 @@
"""
启动契约校验(config/workers TOML ↔ @rpc_method 注册表 ↔ gen_models 生成模型)
挂载于 lifespan 的 load_workers 与 register_routes 之间,此时插件模块已导入
(@rpc_method 注册表就绪)且 TOML 路由尚未注册到 FastAPI。
策略:
- 硬失败(汇总后 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
from typing import Any, Dict, List, Optional, Set
from chongming_worker import get_all_rpc_methods
from .route_generator import load_worker_routes_from_dir
from .logger import get_logger
logger = get_logger("contracts")
# 参与 HTTP 路由注册的方法(与 route_generator 的注册行为一致)
HTTP_METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH")
def _model_name(base: str, method: str, suffix: str) -> str:
"""与 gen_models._model_name 保持一致的模型命名: <Op><Method><Suffix>"""
method_title = "Mqtt" if method == "MQTT" else method.title()
return f"{base.title().replace('-', '').replace('_', '')}{method_title}{suffix}"
def _load_plugin_models(worker_type: str) -> Optional[Set[str]]:
"""
加载插件 models.pydantic 中所有 Pydantic 模型的类名集合。
返回 None 表示模块不存在(插件无生成模型,跳过模型相关校验)。
注: gen_models 生成的 `<Plugin>Response` 联合类型——多模型时为 UnionType
(自动被 isinstance(type) 过滤),单模型时是类的别名(与真实模型同一对象,
按对象去重排除),两者都不会被误报为孤儿模型。
"""
from pydantic import BaseModel
for mod_name in (
f"plugins.{worker_type}.models.pydantic",
f"src.plugins.{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(name)
return names
return None
def validate_contracts(plugins: List[Dict[str, Any]]) -> None:
"""
对每个发现的插件执行三方一致性校验。
Args:
plugins: _discover_plugins() 返回的插件配置列表
Raises:
RuntimeError: 存在硬失败项(HTTP 路由缺失 @rpc_method 注册)时
"""
registry = get_all_rpc_methods()
hard_errors: List[str] = []
warnings: List[str] = []
for wc in plugins:
worker_type = wc.get("type", "")
if not worker_type:
continue
toml_routes = load_worker_routes_from_dir(worker_type)
registered = registry.get(worker_type, {})
toml_keys = {f"{r['subject']}#{r['method']}" for r in toml_routes}
model_names = _load_plugin_models(worker_type)
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 = "(该插件注册表为空,请检查启动日志中的 '加载 Worker 插件失败' 错误)"
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 清理)"
)
# 输出结果
if warnings:
logger.warning("契约校验发现 %d 个警告:", len(warnings))
for w in warnings:
logger.warning(" ⚠ %s", w)
if hard_errors:
logger.error("契约校验失败,发现 %d 个硬性错误:", len(hard_errors))
for e in hard_errors:
logger.error(" ✗ %s", e)
raise RuntimeError(
"启动契约校验失败: "
+ "; ".join(hard_errors)
)
logger.info("契约校验通过(TOML 路由 ↔ @rpc_method 注册表一致)")
+18
View File
@@ -21,6 +21,11 @@ from ..core.mqtt_bridge import MQTTBridge
config = get_config()
logger = get_logger("app")
# 挂载框架/插件父 logger 的 handler:logger 模块只为具名 logger 挂 handler,
# 子 logger(chongming_worker.runtime、plugins.doubao 等)经 propagate 到父级才可见
get_logger("chongming_worker")
get_logger("plugins")
try:
env = config["default"]["env"]
except KeyError: # pragma: no cover
@@ -377,6 +382,19 @@ async def register_routes(app: FastAPI, manager: WorkerManager, custom_routers:
app.include_router(r, prefix=prefix)
logger.info(f"已注册 {len(custom_routers)} 个插件自定义路由(prefix={prefix})")
def validate_contracts(app: FastAPI | None = None):
"""
契约校验(TOML 路由 ↔ @rpc_method 注册表 ↔ gen_models 生成模型)。
在 load_workers 之后、register_routes 之前调用:此时插件模块已导入
(注册表就绪)。HTTP 路由缺失对应 @rpc_method 时抛 RuntimeError 终止启动,
其余不一致以警告日志汇总。
"""
from ..core.contracts import validate_contracts as _validate
_validate(_discover_plugins())
def setup_mqtt_broker(app: FastAPI):
"""启动 MQTT Broker 子进程(原子锁确保多 worker 仅启动一个)"""
env_config = config.get(env, {})
+16 -2
View File
@@ -97,7 +97,19 @@ class MQTTBridge:
t: str = m.topic; p = m.data # type: ignore
try: pl = json.loads(p.decode("utf-8"))
except json.JSONDecodeError: return
pl.setdefault("_method", "GET")
# 信封协议(严格): {"meta": {...}, "data": {...}}
# 非信封格式载荷直接丢弃并告警,请设备侧按新协议发布
if not isinstance(pl, dict) or ("meta" not in pl and "data" not in pl):
logger.warning(
"丢弃非信封格式载荷 [%s]: 请使用 {'meta': {...}, 'data': {...}} 格式", t
)
return
meta = pl.get("meta") or {}
data = pl.get("data") or {}
if not isinstance(data, dict):
data = {}
pl = {"meta": meta, "data": data}
# 1. 优先匹配显式配置的自定义订阅主题(顺序即优先级)
for ct, compiled in self._compiled:
@@ -136,9 +148,11 @@ class MQTTBridge:
async def _route(self, wt: str, subj: str, pl: dict, rt: str):
logger.info(f"MQTT -> {subj}")
meta = pl.get("meta") or {}
try:
r = await self._wm.call(worker_type=wt, subject=subj, payload=pl,
timeout=float(pl.pop("_timeout",30)), cacheable=bool(pl.pop("_cacheable",False)))
timeout=float(meta.get("timeout", 30)),
cacheable=bool(meta.get("cacheable", False)))
if r is not None: await self._pub(rt, r)
except RuntimeError as e: await self._pub(rt, {"error":str(e)})
except Exception as e: await self._pub(rt, {"error":str(e)})
+15 -10
View File
@@ -186,6 +186,8 @@ def load_worker_routes_from_dir(worker_type: str) -> List[Dict[str, Any]]:
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(
@@ -268,11 +270,12 @@ def _make_body_endpoint(worker_manager: WorkerManager, rc: RouteConfig, worker_t
body: RequestModel, # type: ignore
) -> Any:
try:
payload = body.model_dump()
payload.setdefault("_method", http_method)
# 信封协议: {"meta": {...}, "data": {...}}
meta = {"method": http_method}
user_info = getattr(request.state, "user", None)
if user_info:
payload["user_info"] = user_info
meta["user_info"] = user_info
payload = {"meta": meta, "data": body.model_dump()}
result = await worker_manager.call(
worker_type=worker_type,
subject=subject,
@@ -313,11 +316,12 @@ def _make_query_endpoint(worker_manager: WorkerManager, rc: RouteConfig, worker_
# 无参数情况
if not param_spec_combined:
async def _simple_endpoint(request: Request) -> Any:
payload = {}
payload.setdefault("_method", http_method)
# 信封协议: {"meta": {...}, "data": {...}}
meta = {"method": http_method}
user_info = getattr(request.state, "user", None)
if user_info:
payload["user_info"] = user_info
meta["user_info"] = user_info
payload = {"meta": meta, "data": {}}
try:
result = await worker_manager.call(
worker_type=worker_type,
@@ -339,11 +343,12 @@ def _make_query_endpoint(worker_manager: WorkerManager, rc: RouteConfig, worker_
param_names = list(param_spec_combined.keys())
async def endpoint(request: Request, **kwargs: Any) -> Any:
payload = {k: kwargs[k] for k in param_names}
payload.setdefault("_method", http_method)
# 信封协议: {"meta": {...}, "data": {...}}
meta = {"method": http_method}
user_info = getattr(request.state, "user", None)
if user_info:
payload["user_info"] = user_info
meta["user_info"] = user_info
payload = {"meta": meta, "data": {k: kwargs[k] for k in param_names}}
try:
result = await worker_manager.call(
worker_type=worker_type,
@@ -394,7 +399,7 @@ def _register_single_route(
# MQTT 端点:不注册 HTTP 路由,仅通过 MQTT Bridge 调用
if rc.method == "MQTT":
logger.info(
f"MQTT 端点: subject={worker_type}.{rc.subject} (仅 MQTT,不注册 HTTP 路由)"
f"MQTT 端点: subject={rc.subject} (仅 MQTT,不注册 HTTP 路由)"
)
return
+2 -1
View File
@@ -20,7 +20,8 @@ def fake_create_workers(session, config, count=1, worker_manager=None):
async def main():
auth_plugin._create_workers = fake_create_workers
# 替换 Plugin 基类的 worker 创建实现,避免依赖 zenoh 会话
auth_plugin.plugin._create_workers_impl = fake_create_workers
with tempfile.TemporaryDirectory() as tmp:
config = {"type": "auth", "data_dir": tmp}
+26 -11
View File
@@ -99,20 +99,20 @@ async def test_custom_routing():
)
# 1. 默认 subject = <worker>.<最后一段>;响应 = <实际主题>/response
await bridge._handle(make_msg("devices/dev-01/telemetry", {"v": 1}))
await bridge._handle(make_msg("devices/dev-01/telemetry", {"meta": {}, "data": {"v": 1}}))
assert wm.calls[-1]["worker_type"] == "device"
assert wm.calls[-1]["subject"] == "device.telemetry"
assert published[-1][0] == "devices/dev-01/telemetry/response"
# 2. 带占位符 + 自定义响应主题
await bridge._handle(make_msg("foo/a/b", {"v": 2}))
await bridge._handle(make_msg("foo/a/b", {"meta": {}, "data": {"v": 2}}))
assert wm.calls[-1]["worker_type"] == "foo"
assert wm.calls[-1]["subject"] == "foo.handle.a/b"
assert published[-1][0] == "out/a/b/resp"
# 3. 内置 chongming 兜底
await bridge._handle(
make_msg(f"{MQTT_TOPIC_PREFIX}/example/add", {"a": 1, "b": 2})
make_msg(f"{MQTT_TOPIC_PREFIX}/example/add", {"meta": {}, "data": {"a": 1, "b": 2}})
)
assert wm.calls[-1]["worker_type"] == "example"
assert wm.calls[-1]["subject"] == "example.add"
@@ -120,7 +120,7 @@ async def test_custom_routing():
print("✅ 自定义主题路由 + 内置兜底通过")
async def test_custom_routing_control_fields():
async def test_envelope_meta_fields():
wm = FakeWM()
bridge, published = make_bridge(
wm, [{"topic": "devices/+/telemetry", "worker": "device"}]
@@ -128,14 +128,27 @@ async def test_custom_routing_control_fields():
await bridge._handle(
make_msg(
"devices/d1/telemetry",
{"_timeout": 5, "_cacheable": True, "_method": "POST"},
{"meta": {"timeout": 5, "cacheable": True, "method": "POST"}, "data": {}},
)
)
assert wm.calls[-1]["timeout"] == 5.0
assert wm.calls[-1]["cacheable"] is True
assert "_timeout" not in wm.calls[-1]["payload"]
assert wm.calls[-1]["payload"].get("_method") == "POST"
print("✅ 控制字段 (_timeout/_cacheable/_method) 透传通过")
payload = wm.calls[-1]["payload"]
assert payload["meta"]["method"] == "POST"
assert payload["data"] == {}
print("✅ 信封 meta 字段 (method/timeout/cacheable) 透传通过")
async def test_flat_payload_dropped():
"""严格信封协议:扁平载荷(无 meta/data)应被丢弃且不调用 worker"""
wm = FakeWM()
bridge, published = make_bridge(
wm, [{"topic": "devices/+/telemetry", "worker": "device"}]
)
await bridge._handle(make_msg("devices/d1/telemetry", {"_method": "POST", "v": 1}))
assert wm.calls == []
assert published == []
print("✅ 非信封(扁平)载荷被丢弃通过")
async def test_non_json_dropped():
@@ -165,7 +178,7 @@ async def test_board_ping_topic():
}
],
)
await bridge._handle(make_msg("/board_ping", {"BoardId": "B001", "Status": 1}))
await bridge._handle(make_msg("/board_ping", {"meta": {}, "data": {"BoardId": "B001", "Status": 1}}))
assert wm.calls[-1]["worker_type"] == "aibox_sophon_adapter"
assert wm.calls[-1]["subject"] == "aibox_sophon_adapter.board_ping"
assert published[-1][0] == "/board_ping/response"
@@ -182,7 +195,8 @@ def test_plugin_config_driven_collection():
assert "aibox_sophon_adapter" in by_type, "未发现 aibox_sophon_adapter 插件"
topics = by_type["aibox_sophon_adapter"]["mqtt_topics"]
assert any(
t["topic"] == "/board_ping" and t["worker"] == "aibox_sophon_adapter"
t["topic"] == "box/aibox_sophon/board_ping"
and t["worker"] == "aibox_sophon_adapter"
and t["subject"] == "aibox_sophon_adapter.board_ping"
for t in topics
), topics
@@ -194,7 +208,8 @@ def test_plugin_config_driven_collection():
async def main():
test_compile_topic()
await test_custom_routing()
await test_custom_routing_control_fields()
await test_envelope_meta_fields()
await test_flat_payload_dropped()
await test_non_json_dropped()
await test_board_ping_topic()
test_plugin_config_driven_collection()
+5 -5
View File
@@ -30,11 +30,11 @@ REQUEST_TOPIC = f"{PREFIX}/{WORKER}/{OPERATION}"
RESPONSE_TOPIC = f"{PREFIX}/{WORKER}/{OPERATION}/response"
BROKER_URL = "mqtt://localhost:1883/"
# 测试用例
# 测试用例(信封协议: meta.method 控制分派,data 携带业务参数)
TEST_CASES = [
{"a": 3, "b": 5, "_method": "MQTT", "expected": 8},
{"a": -1, "b": 10, "_method": "MQTT", "expected": 9},
{"a": 3.14, "b": 2.86, "_method": "MQTT", "expected": 6.0},
{"a": 3, "b": 5, "expected": 8},
{"a": -1, "b": 10, "expected": 9},
{"a": 3.14, "b": 2.86, "expected": 6.0},
]
PASS = 0
@@ -50,7 +50,7 @@ async def run_test():
for i, case in enumerate(TEST_CASES):
a, b, expected = case["a"], case["b"], case["expected"]
payload = {"a": a, "b": b, "_method": "MQTT"}
payload = {"meta": {"method": "MQTT"}, "data": {"a": a, "b": b}}
payload_bytes = json.dumps(payload).encode("utf-8")
await client.publish(REQUEST_TOPIC, payload_bytes, QOS_1)
Generated
+1 -1
View File
@@ -319,7 +319,7 @@ source = { git = "http://124.71.68.6:3000/chongming/chongming-stream-manager.git
[[package]]
name = "chongming-worker"
version = "0.1.0"
source = { git = "http://124.71.68.6:3000/chongming/chongming-utils-worker.git#4b8985d5bd4fc45894bc3e27ecbd541277b83e2f" }
source = { git = "http://124.71.68.6:3000/chongming/chongming-utils-worker.git#a58e92c21e5bf4b3ada55b2abc894e8ead7d17bc" }
dependencies = [
{ name = "eclipse-zenoh" },
{ name = "tomli" },
+7 -6
View File
@@ -44,18 +44,19 @@ check("set 后 require 返回同一对象", gv.require("db") is marker)
check("模块属性 db 已被更新", gv.db is marker)
# ========== 2. __init__.py 导入链(router + auto_bind_models)==========
# ========== 2. __init__.py 导入链(router + Plugin 基类)==========
import plugins.example as plug
check("plugins.example 导入成功", True)
check("create_workers 存在", callable(getattr(plug, "create_workers", None)))
check("cleanup 存在", callable(getattr(plug, "cleanup", None)))
check("_initialized 默认 False", plug._initialized is False)
check("plugin 实例存在", getattr(plug, "plugin", None) is not None)
check("plugin._initialized 默认 False", plug.plugin._initialized is False)
# ========== 3. create_workers 幂等性(mock session/config)==========
import asyncio
# 用桩替代 _create_workers,避免真实 zenoh 会话
# 用桩替代 Plugin 基类的 worker 创建实现,避免真实 zenoh 会话
calls = []
@@ -64,7 +65,7 @@ def _stub_create_workers(session, config, count=1, worker_manager=None):
return ["task-stub"]
plug._create_workers = _stub_create_workers # type: ignore[attr-defined]
plug.plugin._create_workers_impl = _stub_create_workers # type: ignore[method-assign]
async def main():
@@ -73,7 +74,7 @@ async def main():
# 第一次调用:应初始化 db
t1 = await plug.create_workers(session="s1", config=cfg, count=2)
check("首次 create_workers 调用桩", t1 == ["task-stub"])
check("_initialized 置 True", plug._initialized is True)
check("plugin._initialized 置 True", plug.plugin._initialized is True)
from plugins.example.global_var import db as gv_db
check("global_var.db 已初始化", gv_db is not None)
@@ -86,7 +87,7 @@ async def main():
# cleanup 后复位
await plug.cleanup("worker/example/worker-i-1")
check("cleanup 后 _initialized 复位 False", plug._initialized is False)
check("cleanup 后 plugin._initialized 复位 False", plug.plugin._initialized is False)
from plugins.example.global_var import db as gv_db3
check("cleanup 后 db 为 None", gv_db3 is None)