15 KiB
Worker 插件开发指南
每个 src/plugins/<name>/ 子目录是一个独立的 Worker 插件,封装特定领域的业务逻辑。插件通过 Zenoh RPC 对外提供服务,HTTP 路由由框架根据 TOML 配置自动生成。
目录结构约定
<plugin_name>/
├── __init__.py # 入口:Plugin 子类 + create_workers/cleanup 绑定
├── global_var.py # 模块级全局状态(Database、API client 等)
├── router/ # RPC 端点实现
│ ├── __init__.py # 导入所有端点模块(`from . import *` 或显式导入)
│ └── <操作名>.py # 每个端点一个文件,使用 @rpc_method 装饰器
├── models/ # 数据模型
│ ├── __init__.py # 模型导出(由 gen_models 自动生成,勿手动修改)
│ ├── pydantic.py # Pydantic 请求/响应模型(由 gen_models 自动生成)
│ └── <表名>.py # SQLModel ORM 模型(如需要数据库)
├── routes.py # 自定义 FastAPI 路由(可选,用于特殊端点)
└── README.md # 插件说明文档(可选)
已有插件概览
| 插件 | 特点 | 适合参考 |
|---|---|---|
| example | 最简示例,Plugin 基类 + Database + 生命周期 | 入门学习 |
| auth | SQLModel ORM + 管理员初始化 + 多进程幂等 | 数据库操作 |
| doubao | 外部 API 客户端(Ark/OpenAI)+ AICC 安全通道 | 外部集成 |
| aibox_sophon_adapter | 自定义 APIRouter + MQTT 客户端组件(mqtt_clients.py)+ 后台任务 |
高级模式 |
开发新插件:完整流程
第一步:创建目录结构
推荐:直接用脚手架生成统一风格的插件骨架,无需手动创建目录:
uv run gen_plugin <name> [operation ...]以下为手动创建方式(了解结构时参考)。
mkdir -p src/plugins/<name>/router
mkdir -p src/plugins/<name>/models
touch src/plugins/<name>/__init__.py
touch src/plugins/<name>/global_var.py
touch src/plugins/<name>/router/__init__.py
第二步:注册插件
在 config/workers/<name>/config.toml 中通过 [workers] 节注册(网关启动时自动扫描发现):
# config/workers/<name>/config.toml
[workers]
type = "<name>" # 插件类型(worker type)
module = "plugins.<name>" # Python 模块路径
factory = "create_workers" # 工厂函数名(Plugin 基类绑定的方法)
cleanup = "cleanup" # 清理函数名
count = 3 # Worker 副本数
# 运行时配置(展平后传给 create_workers 的 config 参数)
[workers.config]
data_dir = "./data"
旧式全局注册文件
config/workers/config.toml([[workers]]数组)仅作兜底兼容,新插件请使用各插件自己的config.toml。
第三步:编写端点 TOML 配置
创建 config/workers/<name>/config.toml(分组配置):
[worker]
defaults = { timeout = 3.0, auth_required = true, cacheable = false, internal = false }
[worker.registration]
router_prefix = "/<prefix>"
tags = ["<name>"]
创建每个操作的 .toml 文件,如 config/workers/<name>/my_action.toml:
[POST]
summary = "我的操作"
auth_required = true
[POST.response_model]
result = { type = "str", required = true }
message = { type = "str", default = "" }
第四步:生成 Pydantic 模型
uv run gen_models
自动在 src/plugins/<name>/models/ 下生成 pydantic.py 和 __init__.py。
第五步:实现 RPC 端点
src/plugins/<name>/router/my_action.py:
from chongming_worker.decorators import rpc_method
from ..models.pydantic import MyActionPostRequest, MyActionPostResponse
@rpc_method(
subject="<name>.my_action",
method="POST",
response_model=MyActionPostResponse,
)
async def my_action(request: MyActionPostRequest) -> MyActionPostResponse:
"""业务逻辑"""
result = await do_something(request)
return MyActionPostResponse(result="success", message="done")
第六步:更新 router 导出
src/plugins/<name>/router/__init__.py:
from . import my_action
__all__ = ["my_action"]
第七步:实现插件入口(推荐 Plugin 基类)
src/plugins/<name>/__init__.py(由 gen_plugin 脚手架生成):
import logging
from chongming_worker.plugin import Plugin
from . import router # noqa: F401 触发 @rpc_method 注册
# from .routes import router # 可选:自定义 FastAPI 路由(网关自动发现)
logger = logging.getLogger(__name__)
class MyPlugin(Plugin):
type = "<name>"
db_name = "<name>.db" # 自动在 data_dir 下建 SQLite 库(不需要则置 None)
auto_bind = True # 首次初始化时自动 auto_bind_models("<name>")
async def on_init(self):
"""首次 create_workers 时调用一次:初始化资源、启动后台任务。"""
self.set_var("db", self.db) # 发布到 global_var 供业务代码读取
# self.spawn_task(self._background_loop())
async def on_cleanup(self):
"""cleanup 时调用:关闭自定义客户端等(后台任务与数据库由基类处理)。"""
# 暴露给 app lifespan 的模块级入口(bound method,签名与旧写法完全一致)
_plugin = MyPlugin()
create_workers = _plugin.create_workers
cleanup = _plugin.cleanup
基类自动处理:幂等(初始化锁)、统一日志、数据库建库/释放、后台任务取消、清理钩子、模型自动绑定、cleanup 时重置已发布的 global_var。详见 utils/worker/README.md 的「Plugin 基类」章节。
旧写法已废弃:不依赖基类的传统
create_workers/cleanup模块级函数写法仅框架层面保留兼容,现有插件(auth / doubao / aibox_sophon_adapter / example)均已迁移到 Plugin 基类,新插件请勿采用旧写法。
自定义 FastAPI 路由(高级)
当端点无法用 TOML 描述时(如文件上传),插件可暴露自定义 APIRouter。
src/plugins/<name>/routes.py:
from fastapi import APIRouter, UploadFile, File, Form
router = APIRouter(prefix="/<prefix>", tags=["<name>"])
@router.post("/upload")
async def upload(
file: UploadFile = File(...),
description: str = Form(...),
):
# 处理文件上传
return {"status": "ok"}
在 __init__.py 中暴露 router:
from .routes import router # 网关通过 getattr 自动发现
网关在 load_workers() 阶段自动检测并注册该 router,无需修改网关代码。
为现有插件添加新端点
- 创建
config/workers/<name>/<new_op>.toml - 运行
uv run gen_models - 创建
router/<new_op>.py,引用生成的模型 - 更新
router/__init__.py - 重启服务
共享对象(global_var)在两种路由中的使用
数据流
共享对象(Database、API client 等)在 Plugin.on_init 中初始化,通过 set_var 发布到 global_var 模块;两种路由的业务代码(模块级函数)在函数体内 from .global_var import ... 读取。
flowchart LR
P["Plugin.on_init<br/>self.db = ..."] -->|"self.set_var('db', self.db)"| GV["global_var 模块<br/>db / require()"]
R1["router/*.py<br/>@rpc_method 函数"] -->|"from ..global_var import require"| GV
R2["routes.py<br/>APIRouter 处理器"] -->|"from .global_var import db"| GV
为什么需要
global_var.py:@rpc_method函数和 APIRouter 处理器都是模块级函数,拿不到 Plugin 实例,只能通过模块级容器读取;global_var.py独立成模块,可避免与__init__.py循环导入。set_var()内部会调用global_var.set_global_var()(白名单校验)完成发布。
注入:on_init → global_var
src/plugins/<name>/__init__.py:
class MyPlugin(Plugin):
type = "<name>"
db_name = "<name>.db"
async def on_init(self):
self.set_var("db", self.db) # 发布到 global_var 模块
路由一:TOML 驱动标准路由(@rpc_method)
router/<op>.py(请求经 Zenoh → Worker Queryable → 调用函数):
from chongming_worker.decorators import rpc_method
from ..models.pydantic import OpGetRequest, OpGetResponse
@rpc_method(subject="<name>.op", method="GET", response_model=OpGetResponse)
async def op(request: OpGetRequest) -> OpGetResponse:
from ..global_var import require
db = require("db") # 未初始化立即抛清晰错误
async with db.get_session() as session:
...
return OpGetResponse(...)
路由二:插件自定义路由(APIRouter)
routes.py(FastAPI 直接调用,适合 multipart 上传等 TOML 无法描述的端点):
from fastapi import APIRouter, UploadFile, File
router = APIRouter(prefix="/<name>", tags=["<name>"])
@router.post("/upload")
async def upload(file: UploadFile = File(...)):
from .global_var import db
if db is None: # 容忍未初始化,优雅降级
return {"error": "db 未初始化"}
async with db.get_session() as session:
...
return {"ok": True}
在 __init__.py 顶部暴露给网关:from .routes import router(网关通过 getattr(module, "router") 自动发现)。
怎么选:标准 JSON / query 端点(参数与响应可被 Pydantic 模型描述)用路由一
@rpc_method;multipart 上传、需手动解析报文等无法用 TOML 描述的端点用路由二 APIRouter。
经验
- 在函数体内 import(不要放文件顶部):模块 import 时
create_workers尚未执行、global_var还是空;请求到达时变量才注入完成。 - 两种读取风格:
require("db")(必须有,未初始化立即报错)vsfrom .global_var import db+if db is None(允许优雅降级)。 - 参考实现:
aibox_sophon_adapter/routes.py的_save_alarm_record(自定义路由中from .global_var import db+async with db.get_session())。
@rpc_method 装饰器
@rpc_method(
subject="<plugin>.<operation>", # Zenoh 主题(必需)
method="GET", # HTTP 方法:GET / POST / MQTT
response_model=ResponseCls, # Pydantic 响应模型类
)
async def handler(request: RequestCls) -> ResponseCls:
...
| method | 参数来源 | 请求绑定 |
|---|---|---|
GET |
URL query string | TOML params 定义 |
POST |
JSON body | 自动绑定到 Pydantic 请求模型 |
MQTT |
MQTT payload JSON | 同 POST |
请求信封协议:网关统一把请求组装为 {"meta": {"method", "user_info"}, "data": {...}}
发给 Worker;运行时按 meta.method 分派到对应 @rpc_method,data 经请求模型校验后
传给处理器。MQTT 客户端发布消息时需按信封格式(见主 README「请求负载格式」),
MQTT 专用端点要显式 "meta": {"method": "MQTT"}。
- 设置
internal = true后,端点不注册 HTTP 路由,仅可通过 Zenoh RPC 或 MQTT 内部调用 - MQTT 方法返回
None时,Bridge 自动跳过响应发布
插件专属依赖(第三方包)
插件用了网关本体不 import 的第三方包(如 doubao 的 openai / volcenginesdkarkruntime),
除在根 pyproject.toml 的 dependencies 里安装外,还要在根目录 plugins_deps.toml 里
按插件分节声明,打包收集清单(--hidden-import / --include-module)才会带上它:
# 多个插件共用(顶层 imports)
imports = ["httpx"]
[<插件名>]
imports = ["openai", "volcenginesdkarkruntime"]
- 声明位置:inline 插件在
.mbank内 import,打包静态分析看不到,因此除根pyproject.toml(venv 安装)外,插件专属依赖还必须写进根目录plugins_deps.toml, 打包收集清单(--hidden-import/--include-module)才会带上它 - 改为 external 进程插件后不需要本文件:依赖随插件项目
workers/<name>/pyproject.toml自包含 - 一键迁移:
uv run migrate_plugin <name>把src/plugins/<name>/迁移为workers/<name>/进程插件项目(代码/配置/依赖一并迁移,plugins_deps.toml对应节自动注释)。注意:挂载了自定义 FastAPI 路由(APIRouter)或导入了网关包的 插件会被拦截拒绝迁移(--force可跳过),前者需先改成 TOML@rpc_method端点
启动契约校验
服务启动时(load_workers 之后、注册路由之前),网关自动核对三方一致性:
| 检查项 | 策略 |
|---|---|
TOML HTTP 路由缺 @rpc_method |
硬失败,启动直接终止 |
[MQTT] / internal 路由缺注册 |
警告日志 |
| 已注册方法无 TOML 路由 | 警告日志(反向漂移检测) |
| params/response_model 缺生成模型 | 警告日志(gen_models 过期) |
| 生成模型未被任何路由引用 | 警告日志(孤儿模型) |
新增端点后如果启动报错「缺少对应的 @rpc_method 注册」,检查 TOML 的 subject/方法节
与 router/<op>.py 中的 @rpc_method(subject=..., method=...) 是否一致。
命名约定
| 概念 | 约定 | 示例 |
|---|---|---|
| 插件名 | 小写下划线 | aibox_sophon_adapter |
| 操作名 | 小写下划线 | board_ping, list_devices |
| Zenoh subject | <插件>.<操作> |
example.add |
| TOML 文件名 | 与操作名一致 | add.toml |
| Python 文件名 | 与操作名一致 | add.py |
| 模型类名 | <操作><方法>Request/Response |
AddGetRequest |
现有插件
| 插件 | 说明 | 文档 |
|---|---|---|
| auth | 用户注册、登录、JWT 鉴权、用户信息管理 | 含 SQLite 数据库 |
| doubao | 火山引擎豆包视觉理解模型集成 | 第三方 API 调用 |
| example | 四则运算示例 + 访问计数器 | 入门参考 |