Files
chongming-core/docs/process-plugin.md
T

503 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 进程插件开发指南(external 模式)
> 进程插件(Process Plugin)是运行在**独立进程**中的 Worker 插件(`load_mode = "external"`),
> 与网关零导入关系、仅经 Zenoh 通讯,可独立打包部署到不同容器。
> 本文档覆盖从**创建代码 → 配置 → 调试 → 验证 → 打包 → 部署**的完整开发流程。
---
## 1. 什么是进程插件
网关支持两种插件运行模式,**按插件粒度共存**(混合部署):
| 模式 | 运行位置 | 目录 | 路由来源 | 契约校验位置 |
| ---- | -------- | ---- | -------- | ------------ |
| **inline**(默认) | 网关进程内 import | `src/plugins/<name>/`(chongming-plugins 仓库) | 网关盘扫 TOML 生成 | 网关启动时 |
| **external(进程插件)** | 独立进程 | `workers/<name>/`(本地开发目录,**自包含项目**,不入库) | Worker 经 Zenoh 发布路由描述符,网关动态挂载 | **Worker 进程内** |
进程插件的特点:
- **零导入关系**:只依赖 `chongming-worker` SDK + zenoh,禁止 import `chongming`(网关包)
- **独立生命周期**:由 Docker/K8s 编排拉起,进程入口 `worker_serve --type <name>`(或 `chongming-worker --type <name>`)
- **动态路由**:启动时把路由描述符发布到 Zenoh `chongming/routes/<type>/...`,网关 RouteRegistry 常驻订阅后动态挂载 HTTP 端点;退出时摘除
- **独立打包**:`uv run build_plugin <name>` 一键打包为可部署 zip,与网关分别部署到不同容器
- **代码结构一致**:与 inline 插件完全相同(Plugin 基类 + router/ + models/)
**适用场景**:需要独立扩缩容、强隔离(第三方依赖冲突、资源占用大)、或与网关发布节奏解耦的业务。
---
## 2. 运行原理
### 2.1 架构
```mermaid
graph TB
HTTP[HTTP 客户端] -->|REST API| API[FastAPI 动态路由]
REG[网关 RouteRegistry<br/>订阅 chongming/routes/**] -->|动态挂载/摘除| API
API --> WM[WorkerManager<br/>负载均衡 + 超时重试]
subgraph GW[网关容器 chongming]
API
REG
WM
end
subgraph PW[进程插件容器 workers/demo]
WS[worker_serve 进程]
WS -->|① put 路由描述符<br/>chongming/routes/demo/**| REG
WS -->|② liveliness 注册<br/>worker/demo/<id>| WM
end
WM -->|Zenoh Query| WS
WS -.->|每 10s 重发布(自愈)| REG
WS -.->|SIGTERM 时 delete 摘除| REG
```
### 2.2 Worker 进程启动流程(`chongming_worker.process.worker_serve`)
```text
worker_serve --type demo
1. 读全局 config.toml(仅 [zenoh] 节)→ 打开 Zenoh 会话
2. 自读 config/workers/demo/config.toml 的 [workers] 节(load_plugin_config 展平)
3. import 插件模块(plugins.<name> / workers.<name>)→ 触发 @rpc_method 注册
4. 进程内契约校验(TOML ↔ 注册表 ↔ 生成模型),硬失败则退出(退出码 1)
5. create_workers 创建 Worker 副本(默认 count 个,--count 可覆盖)
6. RoutePublisher 发布路由描述符 → 网关动态挂载 HTTP 路由
7. 等待退出信号;期间每 10s 周期性重发布(网关后启动 / 样本丢失时自愈)
8. SIGTERM/SIGINT → 摘除路由 → cleanup → 取消副本任务 → 关闭会话
```
### 2.3 路由注册与摘除协议
- **发布**:路由描述符 put 到 `chongming/routes/<type>/<route_id>`,其中
`route_id = <subject 的 . 替换为 _>__<METHOD>`(如 `demo_hello__GET`),
描述符为 TOML 兼容 dict + `schema_version=1` + `type=<插件名>`
- **元信息**:插件声明了 `[[workers.mqtt_topics]]` 时,额外发布
`chongming/routes/<type>/__meta__`(含 mqtt_topics),网关据此动态订阅外部主题
- **自愈**:每 10s 重发布一次全部描述符(`ANNOUNCE_INTERVAL`),网关后启动或样本丢失可恢复
- **摘除**:优雅退出时 delete 全部已发布 key,网关 2s 防抖后摘除 HTTP 端点(请求变 404)
- **网关侧**:RouteRegistry 常驻订阅 `chongming/routes/**`,与盘扫解耦——
**网关容器无需放置 external 插件的配置文件**
### 2.4 服务发现
Worker 副本经 liveliness 注册 `worker/<type>/<id>`(与 inline 副本同一机制),
WorkerManager 据此做负载均衡,网关侧无需任何改动。
---
## 3. 目录结构约定
进程插件是**自包含项目**:`workers/<name>/` 即项目根,同时也是打包 zip 的根
(**gitignore,不入库**)。开发目录与容器工作目录完全一致,开发即产物:
```text
workers/<name>/ # 项目根 = zip 根 = 容器工作目录
├── workers/<name>/ # 插件代码(模块 workers.<name>)
│ ├── __init__.py # Plugin 子类 + create_workers / cleanup 模块级入口
│ ├── global_var.py # 模块级共享状态(Database、API client 等)
│ ├── router/ # RPC 端点实现
│ │ ├── __init__.py # 导入所有端点模块
│ │ └── <操作名>.py # 每个端点一个文件,@rpc_method 装饰器
│ ├── models/ # 数据模型(gen_models 自动生成,勿手动修改)
│ │ ├── __init__.py
│ │ └── pydantic.py # 请求/响应 Pydantic 模型
│ └── README.md # 插件说明(可选)
├── config/
│ ├── zenoh_config.json5 # Zenoh 会话配置
│ └── workers/<name>/ # 插件注册 + 端点配置
│ ├── config.toml # [workers] 节注册 + [worker] 元信息 + [workers.config]
│ └── <操作名>.toml # 每个端点一个定义文件
├── config.toml # 最小根配置(仅 [zenoh] 节,worker 启动必需)
├── pyproject.toml # uv 依赖清单(chongming-worker + 扩展依赖)
└── start.sh # 容器入口(uv sync + uv run worker_serve --type <name>)
```
> 参考示例:`workers/demo/`(完整项目,含 5 个端点,配合 `test_worker_isolation.py` /
> `test_process_mode_e2e.py` 使用)。
---
## 4. 开发流程
### 第 1 步:生成项目骨架
```bash
uv run gen_plugin demo --process # 生成完整进程插件项目(含示例操作)
uv run gen_plugin demo hello storage --process # 指定初始操作
uv run gen_plugin demo add --process # 为已有项目增量添加操作
```
生成的代码位于 `workers/demo/workers/demo/`,其中 `__init__.py`(与 inline 插件
完全一致,推荐 Plugin 基类风格):
```python
import logging
from chongming_worker.plugin import Plugin
from . import router # noqa: F401 触发 @rpc_method 注册
logger = logging.getLogger(__name__)
class DemoPlugin(Plugin):
type = "demo"
db_name = None # 不需要数据库则置 None
auto_bind = True
async def on_init(self):
"""首次 create_workers 时调用一次:初始化资源。"""
# self.set_var("db", self.db)
pass
async def on_cleanup(self):
"""cleanup 时调用:关闭自定义客户端等。"""
_plugin = DemoPlugin()
create_workers = _plugin.create_workers
cleanup = _plugin.cleanup
```
> 注意:`module` 注册为 `workers.<name>` 时,`sys.path` 需包含项目根目录——
> `worker_serve` 已自动处理(console script 启动时会显式把 CWD 加入 `sys.path`),
> 只需在项目根 `workers/<name>/` 下运行。
### 第 2 步:插件注册配置
创建 `workers/<name>/config/workers/<name>/config.toml`,`[workers]` 节注册插件
(**关键:`load_mode = "external"`**):
```toml
# workers/demo/config/workers/demo/config.toml
[workers]
type = "demo" # 插件类型(worker type)
module = "workers.demo" # Python 模块路径(进程插件用 workers.<name>)
factory = "create_workers" # 工厂函数名(Plugin 基类绑定的方法)
cleanup = "cleanup" # 清理函数名
count = 2 # Worker 副本数
load_mode = "external" # ★ 进程插件:独立进程运行,经 Zenoh 注册路由
[worker]
name = "demo"
version = "0.1.0"
description = "进程插件示例"
defaults = { method = "GET", timeout = 30.0, auth_required = false, cacheable = false, internal = false }
[worker.registration]
router_prefix = "/demo" # HTTP 路由前缀
tags = ["demo", "process-plugin"]
# Worker 运行时配置(展平后传给 create_workers 的 config 参数)
[workers.config]
greeting_prefix = "Hello"
```
### 第 3 步:端点 TOML 定义
每个端点一个文件,如 `workers/<name>/config/workers/<name>/hello.toml`:
```toml
[GET]
summary = "进程插件示例:问候"
params = ["name: str"]
auth_required = false
[GET.response_model]
greeting = { type = "str", required = true }
worker_id = { type = "str", required = false, default = "" }
timestamp = { type = "float", required = true }
```
流式端点(SSE)在方法节追加 `stream = true` 并配大 `timeout`,详见主 README「流式端点」。
### 第 4 步:生成 Pydantic 模型
```bash
uv run gen_models workers/demo/config/workers/demo workers/demo/workers/demo
```
在 `workers/<name>/workers/<name>/models/` 下生成 `pydantic.py` 与 `__init__.py`。
### 第 5 步:实现 RPC 端点
`workers/<name>/workers/<name>/router/hello.py`:
```python
import time
from chongming_worker.decorators import rpc_method
from ..models.pydantic import HelloGetRequest, HelloGetResponse
@rpc_method(subject="demo.hello", method="GET", response_model=HelloGetResponse)
async def hello(request: HelloGetRequest) -> HelloGetResponse:
return HelloGetResponse(
greeting=f"Hello, {request.name}!",
timestamp=time.time(),
)
```
`workers/<name>/workers/<name>/router/__init__.py`:
```python
from . import hello
__all__ = ["hello"]
```
**异常语义**(插件作者只需 return / raise,通道由 SDK 处理):
| 形态 | 插件写法 | 网关行为 |
| ---- | -------- | -------- |
| 业务错误 | `return {"code": 1, "error": "..."}` | HTTP 200 透传(业务语义,网关不解析) |
| 框架异常(非流式) | `raise ValueError(...)` | SDK 经 `reply_err` 通道回复 → 网关重试后 **HTTP 503** |
| 流式异常 | 生成器内 raise | SDK 发送 `{"error": ...}` 收尾块 → SSE error 事件终止 |
即「**返回即业务、抛出即框架**」。注意:框架异常从历史行为「HTTP 200 + 错误信封」
修正为 **503**(`reply_err` 通道,见 wire 协议 §4.3 兼容矩阵)。
### 第 6 步:本地调试
在**插件项目根** `workers/<name>/` 下运行(worker 以 CWD 为工作目录读配置/import 代码):
```bash
cd workers/demo
uv sync # 首次:安装依赖(项目独立 venv,与容器一致)
uv run worker_serve --type demo # 本地调试
uv run worker_serve --type demo --count 1 # 覆盖副本数
./start.sh # 与容器一致的启动方式
# 开发期复用仓库 venv(已装好 SDK)时,用 --project 指向仓库根:
uv run --project .. worker_serve --type demo
```
启动后预期日志:
```text
[demo] 路由发布完成: 5 端点 + 无元信息
[demo] Worker 进程就绪,等待退出信号...
```
> 若配置了 `load_mode` 非 `"external"`,会输出告警:网关也会在进程内加载该插件,
> 外部进程将与网关内副本**重复服务**。
### 第 7 步:验证
```bash
# 1. 进程隔离性(阻断 chongming 导入 + AST 检查 import 语句;自动切到 demo 项目根)
uv run python test_worker_isolation.py
# 2. 端到端(需先启动网关 uv run serve):从 demo 项目根拉起 worker 进程,
# 路由动态注册 → 跨进程 RPC → 退出摘除(404) → 重启恢复
uv run python test_process_mode_e2e.py
```
### 第 8 步:打包
```bash
uv run build_plugin demo # → dist/demo.zip
uv run build_plugin demo --output /tmp/pkg # 指定输出目录
```
打包 = 项目根直接压缩(开发目录即产物,zip 根即容器工作目录),自动排除
`__pycache__` / `*.pyc` / `.venv` / `*.egg-info` / `.git`,`start.sh` 权限强制 0o755。
产物布局与 §3 完全一致(**zip 根即容器工作目录**):
```text
workers/<name>/ 插件代码(模块 workers.<name>)
config/workers/<name>/ 插件注册 + 端点配置
config/zenoh_config.json5 Zenoh 会话配置
config.toml 最小根配置(仅 [zenoh] 节,worker 启动必需)
pyproject.toml uv 依赖清单(chongming-worker 内网 git 源 + orjson + 扩展依赖)
start.sh 容器入口:uv sync --no-dev + uv run worker_serve --type <name>
```
打包前校验(任一不满足即失败退出):
- `workers/<name>/` 项目根存在
- `workers/<name>/config/workers/<name>/config.toml` 存在且含 `[workers]` 节
- `workers/<name>/config/zenoh_config.json5` 与 `workers/<name>/config.toml`(含 `[zenoh]`)存在
- `load_mode` 非 `"external"` 时**告警**(不阻断,但会造成重复服务)
扩展依赖:直接在项目根 `pyproject.toml` 的 `dependencies` 中追加(如 `"minio"`)。
### 第 9 步:容器部署
```bash
# 镜像需预装 uv(https://docs.astral.sh/uv/)
unzip demo.zip -d /app
cd /app
./start.sh
```
- `start.sh` 自动执行 `uv sync --no-dev`(按 pyproject 从内网 git 源拉取 chongming-worker)并启动 worker
- 生产部署时进程插件与网关**分别打包到不同容器**:
- 网关容器:`chongming` + 依赖
- 进程插件容器:插件代码 + `chongming-worker` + 依赖
- 插件配置随 zip 部署在**插件容器**(worker 自读);网关侧无需放置
(RouteRegistry 常驻订阅 Zenoh 路由描述符,与盘扫解耦)
---
## 5. 文件传输:直连 S3/Minio 对象存储(不依赖网关)
进程插件可直接对接 S3/Minio 对象存储做文件传输——上传、下载、删除、预签名 URL
均由**插件自己的配置**(`[workers.config.storage]`)驱动,**数据路径不经过网关**
(预签名 URL 模式下网关甚至不参与数据传输:设备/浏览器拿到 URL 后直传/直取 Minio)。
### 5.1 配置
```toml
# workers/<name>/config/workers/<name>/config.toml
[workers.config.storage]
endpoint = "192.168.1.10:9000" # Minio 地址 host:port(不带协议前缀)
access_key = "minioadmin"
secret_key = "minioadmin"
bucket = "chongming-files"
secure = false # false=http(默认),true=https
region = "" # 可选
auto_create_bucket = true # 可选,默认 true:启动时确保 bucket 存在
```
语义(`chongming_worker.storage.StorageClient.from_config`):
| 情形 | 行为 |
| ---- | ---- |
| 未配置 / `enabled = false` | 存储关闭(`self.storage = None`),端点自行降级 |
| 配置段存在但缺必填键(endpoint/access_key/secret_key/bucket) | **启动硬失败**(ValueError),快速反馈配置错误 |
| 连接失败 / bucket 不可用 | 告警降级为 None(不影响其他端点) |
### 5.2 SDK 用法
基类在 `_bootstrap` 阶段检测到 `config["storage"]` 段即自动初始化
`self.storage`(无需任何额外代码);插件在 `on_init` 发布到 global_var 供路由读取:
```python
class DemoPlugin(Plugin):
async def on_init(self):
if self.storage is not None: # 配置了 storage 段才初始化
self.set_var("storage", self.storage)
```
路由内使用(同步核心 + async 包装,RPC handler 直接 await):
```python
from ..global_var import require
storage = require("storage")
await storage.aput_bytes("dir/file.txt", data) # 上传字节
await storage.aput_file("dir/file.txt", "/tmp/a.txt") # 上传本地文件
data = await storage.aget_bytes("dir/file.txt") # 下载字节
await storage.aget_file("dir/file.txt", "/tmp/b.txt") # 下载到本地文件
url = await storage.apresigned_put_url("dir/file.txt") # 预签名 PUT(设备直传)
url = await storage.apresigned_get_url("dir/file.txt") # 预签名 GET(设备直取)
await storage.adelete("dir/file.txt") # 删除
```
API 一览(`chongming_worker.storage.StorageClient`):
| 方法(async 同名加 `a` 前缀) | 说明 |
| ---- | ---- |
| `put_file(object_key, local_path, content_type=None)` | 上传本地文件(fput_object) |
| `put_bytes(object_key, data, content_type=None)` | 上传内存字节 |
| `get_file(object_key, local_path)` | 下载到本地文件 |
| `get_bytes(object_key)` | 下载为字节 |
| `delete(object_key)` | 删除对象 |
| `presigned_put_url(object_key, expires=3600)` | 预签名 PUT URL(默认 1 小时) |
| `presigned_get_url(object_key, expires=3600)` | 预签名 GET URL(默认 1 小时) |
### 5.3 参考示例
`workers/demo` 演示了 4 个文件传输端点(项目内 `config/workers/demo/` 下对应 TOML):
- `POST /demo/presign_upload` — filename → object_key + 预签名 PUT URL(设备直传)
- `GET /demo/presign_download` — object_key → 预签名 GET URL(设备直取)
- `POST /demo/upload_bytes` — base64 小文件经 Zenoh RPC 上传(大文件请用预签名直传)
- `GET /demo/download_bytes` — base64 小文件经 Zenoh RPC 下载
配置后重启 worker 即可用;未配置时这些端点返回「storage 未配置」错误(降级演示)。
> 依赖:minio SDK 随 `chongming-worker` 一起安装(utils/worker pyproject 已声明),
> 进程插件无需额外依赖。SDK 侧 `chongming_worker.storage` 不依赖网关包,
> 隔离性约束与打包流程均不受影响。
---
## 6. 隔离性约束
进程插件**禁止 import `chongming`(网关包)**,只允许依赖:
- `chongming-worker`(worker SDK:`chongming_worker.*`)
- `zenoh` / `pydantic` 等第三方库
- 插件自身的扩展依赖(项目根 `pyproject.toml` 中声明)
由 `test_worker_isolation.py` 双重保证:
1. **运行时阻断**:`sys.modules["chongming"] = None` 后走完整链路
(SDK 导入 → 插件配置自读 → 插件 import → 契约校验),任何网关依赖立即 ImportError
2. **静态检查**:AST 解析 SDK 各模块源码,确认无 `chongming` / `chongming.*` import 语句
---
## 7. 进程内契约校验
Worker 进程启动时自读 TOML 并**在自己进程内**做三方一致性校验
(`chongming_worker.contracts.validate_worker_contracts`),校验失败直接退出:
| 检查项 | 策略 |
| ---------------------------- | --------------- |
| TOML HTTP 路由缺 `@rpc_method` | **硬失败,进程退出** |
| `[MQTT]` / `internal` 路由缺注册 | 警告日志 |
| 已注册方法无 TOML 路由 | 警告日志(反向漂移检测) |
| params/response_model 缺生成模型 | 警告日志(gen_models 过期) |
| 生成模型未被任何路由引用 | 警告日志(孤儿模型) |
---
## 8. 常见问题排查
| 现象 | 原因 / 处理 |
| ---- | ----------- |
| 启动告警「load_mode 不是 external」 | 进程插件必须在 `workers/<name>/config/workers/<name>/config.toml` `[workers]` 节设置 `load_mode = "external"`,否则网关内副本与外部进程重复服务 |
| 契约校验失败、进程退出 | 检查 TOML 端点(subject/方法节)与 `@rpc_method(subject=..., method=...)` 是否一致;模型缺失时重跑 `uv run gen_models workers/<name>/config/workers/<name> workers/<name>/workers/<name>` |
| `TypeError: rpc_method() got an unexpected keyword argument 'stream'` | venv 里是旧版 chongming-worker(git 源拉取),本地未 push 的框架改动不生效;联调时 `uv pip install -e utils/worker` |
| 路由没出现 | worker 是否就绪(日志「路由发布完成」);网关 RouteRegistry 是否订阅(启动流程 5.1);每 10s 自愈重发布,稍等或重启 worker |
| 路由一直 404 | worker 是否已退出(优雅退出会摘除路由);进程被 SIGKILL 时描述符残留,重启 worker 后重发布自愈覆盖 |
| 找不到插件配置 | `worker_serve` 须在**插件项目根** `workers/<name>/` 下运行(读 `config/workers/<name>/config.toml`,`--config` 可指定全局配置路径) |
| storage.* 端点报「storage 未配置」 | 插件 config.toml 未配置 `[workers.config.storage]`(或 `enabled = false` / 连接失败降级)。补齐配置后重启 worker;启动日志可见「对象存储已就绪」或「storage 连接失败」告警 |
| 容器内 import `workers.demo` 失败 | 确认在容器工作目录(zip 根)启动,`start.sh` 内含 `cd "$(dirname "$0")"`,worker_serve 会把 CWD 加入 sys.path |
---
## 9. 相关命令速查
```bash
uv run gen_plugin <name> --process # 生成进程插件项目骨架
cd workers/<name> # 以下均在插件项目根下执行
uv run worker_serve --type <name> # 本地调试独立 Worker 进程
./start.sh # 与容器一致的启动方式(uv sync + 启动)
cd .. && uv run build_plugin <name> # 进程插件打包 → dist/<name>.zip
uv run gen_models workers/<name>/config/workers/<name> workers/<name>/workers/<name> # 生成模型
uv run python test_worker_isolation.py # 进程隔离性测试
uv run python test_process_mode_e2e.py # 端到端测试(先启动网关)
```
---
## 10. 参考
- [主 README:进程插件与混合部署](../README.md)
- [Worker 插件开发指南(inline,结构约定与装饰器细节)](../src/plugins/README.md)
- [Worker 框架(chongming-worker)](../utils/worker/README.md)
- [Worker 框架(Rust:chongming-worker-rs,多语言参考实现)](../utils/worker-rs/README.md)
- [chongming-cli(build_plugin / gen_models 等命令)](../utils/cli/README.md)
- [进程插件线协议规范(语言无关)](./plugin-wire-protocol.md)——网关与插件的唯一契约,其他语言实现(Rust/Go 等)据此开发
- [多语言插件演进统一规划(Rust 优先)](./multi-language-plugin-plan.md)
- 代码:`utils/worker/src/chongming_worker/process.py`(进程入口)、
`routes_publisher.py`(路由发布协议)、`contracts.py`(契约校验)、
`utils/cli/src/chongming_cli/build_plugin.py`(打包器)