commit 042ac0e8d5f27cdb748896fb43a772aa6ffde471 Author: chakcy <947105045@qq.com> Date: Tue Oct 13 17:29:29 2026 +0800 init: chongming 项目模板(init.py 初始化骨架 + 内置插件最小示例) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80da7f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.py[oc] +*.egg-info + +# 依赖与构建产物 +.venv +build/ +build_nuitka/ +dist/ +wheels/ +resources/ +logs/ +*.log +data/ +cache_data/ + +# 进程插件开发目录(生产在独立容器部署,不入库;如需随仓库管理删掉本行) +workers/ + +# 其它 +.vscode +.idea +*.pem +*.key diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e872f5 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# chongming 项目模板 + +以 **chongming 网关宿主项目**(chongming-core / chongming-zenoh)为蓝本的项目模板: +初始化出一个开箱可跑的网关项目——宿主骨架(`public/`)+ 内置插件最小示例 +(`src/plugins/example/`)+ 配置(`config/`)+ 构建配置,**不含 `utils/`**:网关与全部 +`chongming-*` 工具包都通过 uv 从私有源安装。 + +## 初始化新项目 + +```bash +python init.py <目标目录> [--name 项目名] [--app-name 应用名] [--no-sync] + +# 例:目录名即项目名 +python init.py ~/proj/my-gateway + +# 例:指定项目名与应用显示名 +python init.py ../demo-gateway --name demo-gateway --app-name "演示网关" +``` + +脚本会:拒绝覆盖非空目录 → 复制模板(`README.project.md` → `README.md`)→ 替换 +`{{project_name}}` / `{{app_name}}` 占位符 → 执行 `uv sync`。 + +生成后: + +```bash +cd <目标目录> +uv run serve # http://localhost:8000/docs +curl 'http://localhost:8000/api/v1/example/hello?name=world' +``` + +## 模板内容 + +```text +pyproject.toml # 宿主依赖(chongming-gateway + 骨架依赖)与 dev 工具 +config.toml # 应用主配置:环境/服务器/缓存/MQTT/打包收集清单/module_system +plugins_deps.toml # 内置插件专属依赖(打包收集用,空模板) +.python-version # 解释器版本(uv 用) +init.py # 本初始化脚本(不复制进目标项目) +README.project.md # 生成项目里的 README(初始化时重命名为 README.md) +public/ # 可执行文件打包骨架:main.py / server.py / utils / ico / 运行时 config.toml +config/ # 项目配置:zenoh_config.json5 + workers/example(示例插件注册与端点) +src/plugins/example/ # 内置插件最小示例:Plugin 生命周期 + @rpc_method + gen_models 产物 +``` + +## 生成项目的常用命令 + +```bash +uv run serve # 启动(热重载) +uv run gen_plugin [operation ...] # 生成内置插件骨架(--process 生成进程插件项目) +uv run gen_models config/workers/ src/plugins/ # 按 TOML 生成 Pydantic 模型 +uv run build pyinstaller # 打包可执行文件(Nuitka 用 uv run build nuitka) +uv run build_plugin # 进程插件打包 → dist/.zip +``` + +## 维护模板 + +- 骨架(`public/`)、示例插件与配置结构源自 chongming-core 仓库,改动时从那边同步; + 同步后建议用 `init.py` 生成一个临时项目验证 `uv run serve` 可启动。 +- 占位符只有两个:`{{project_name}}`(pyproject 的 name)、`{{app_name}}` + (config.toml 与 public/config.toml 的 `app.name` / `app.description`)。 +- 依赖版本不写死:网关与工具包按私有源最新发布版解析;需要锁定时在生成的项目里 + `uv lock` 提交 `uv.lock` 即可。 diff --git a/README.project.md b/README.project.md new file mode 100644 index 0000000..a1fdd9c --- /dev/null +++ b/README.project.md @@ -0,0 +1,108 @@ +# {{app_name}} + +基于 **chongming 网关**的宿主项目(由 chongming 项目模板初始化): + +- **网关本体**:`chongming-gateway` 包(FastAPI + Zenoh),从私有源按版本依赖,不在本仓库 +- **本仓库负责**:宿主骨架(`public/`,可执行文件入口)、内置插件(`src/plugins/`)、 + 配置(`config/`)、构建与交付产物 + +## 快速开始 + +```bash +uv sync # 同步依赖(网关与 chongming-* 工具包从私有源安装) +uv run serve # 启动服务(开发模式,热重载) +``` + +| 通道 | 地址 | 说明 | +| ---- | ---- | ---- | +| HTTP | `http://localhost:8000` | REST API(`config.toml` 的 `prefix` 为 `/api/v1`) | +| Swagger | `http://localhost:8000/docs` | OpenAPI 交互文档(调试模式开启) | +| MQTT | `mqtt://localhost:1883` | 内嵌 Broker(`use_embedded_broker = true`) | + +开箱示例(内置插件 `example`): + +```bash +curl 'http://localhost:8000/api/v1/example/hello?name=world' +# {"message":"hello, world!","count":1} + +curl -X POST http://localhost:8000/api/v1/example/hello \ + -H 'Content-Type: application/json' -d '{"name":"world"}' +``` + +## 目录结构 + +```text +├── config.toml # 应用主配置(环境、服务器、缓存、MQTT、打包清单) +├── pyproject.toml # 宿主依赖(chongming-gateway 等) +├── plugins_deps.toml # 内置插件专属依赖(打包收集用) +├── public/ # 可执行文件打包骨架:main.py / server.py / utils / 运行时 config.toml +├── config/ # 项目配置 +│ ├── zenoh_config.json5 # Zenoh 会话配置 +│ └── workers// # 插件注册(config.toml)与端点定义(.toml) +├── src/plugins/ # 内置插件源码(inline,网关进程内加载) +│ └── example/ # 最小示例:Plugin 生命周期 + @rpc_method + 模型 +├── workers/ # 进程插件本地开发目录(external,不入库;生产独立容器) +└── resources/ # 构建产物(plugins.mbank 等) +``` + +## 新增插件 / 端点 + +```bash +uv run gen_plugin [operation ...] # 一键生成内置插件骨架(含 config/ 注册与端点 TOML) +uv run gen_models config/workers/ src/plugins/ # 改 TOML 后重新生成模型 +``` + +手写流程(参考 `src/plugins/example/` 的四个文件): + +1. `config/workers//config.toml`:`[workers]` 注册(`type` / `module = "plugins."` / + `factory` / `cleanup` / `count` / `load_mode`)+ `[worker]` 路由前缀与端点默认值 +2. `config/workers//.toml`:端点定义(`[GET]` / `[POST]` / `[MQTT]` 分节 + `params` + `response_model`) +3. `uv run gen_models ...` 生成 `models/pydantic.py` +4. `router/.py`:`@rpc_method(subject=".", method=...)` 实现; + 在 `router/__init__.py` 导入 + +启动时网关会做**契约校验**:TOML 里的 HTTP 路由缺少对应 `@rpc_method` 会直接启动失败。 + +进程插件(`load_mode = "external"`,独立进程/容器部署): + +```bash +uv run gen_plugin --process # 生成自包含插件项目 workers// +cd workers/ && uv run chongming-worker --type # 本地调试 +uv run build_plugin # 打包 → dist/.zip(容器入口 start.sh) +``` + +## 配置 + +- **应用配置**:根 `config.toml`(开发读 `[development]`;`[default].env` 切换环境) +- **插件注册与端点**:`config/workers//config.toml`、`config/workers//.toml` +- **Zenoh**:`config/zenoh_config.json5` + +常用开关: + +| 配置 | 位置 | 说明 | +| ---- | ---- | ---- | +| `cache.backend` | 根 `config.toml` | `sqlite`(单机默认)/ `redis`(多实例共享) | +| `mqtt.use_embedded_broker` | 根 `config.toml` | `true` 自带 Broker;`false` 连外部 Broker | +| `workers.count` | 插件 `config.toml` | 副本数(多网关共享 cache 时按在线网关分摊) | +| `workers.load_mode` | 插件 `config.toml` | `inline`(默认)/ `external` | + +## 打包与部署 + +```bash +uv run build pyinstaller # 打包可执行文件 → build/chongming +uv run build nuitka # Nuitka 打包(Linux 需宿主 patchelf) +uv run build --encrypt --license # 对外分发:加密插件模块 + 注入授权公钥 +uv run build --only mbank # 只重新生成 plugins.mbank(插件热更新只需替换该文件) +``` + +- 无前端时跳过 vue 步骤:`uv run build pyinstaller --only mbank,prepare,pack` +- 插件代码在 `plugins.mbank` 里 → 换文件即可热更新;**网关本体是普通依赖** → + 升级网关需改 `pyproject.toml` 版本(或 `uv lock --upgrade-package chongming-gateway`) + 并重新 `uv run build` +- 新增插件用了新第三方包时:`pyproject.toml` 追加依赖 + `plugins_deps.toml` 声明 + (否则打包产物运行到加载插件时才报 ImportError) + +## 进一步文档 + +网关主体、插件开发、进程插件、Worker 运维等完整文档在 chongming-core(chongming-zenoh)仓库的 +`README.md` 与 `docs/`;本仓库的管理端点、实例定向、MQTT 协议等用法与之一致。 diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..c6a8737 --- /dev/null +++ b/config.toml @@ -0,0 +1,155 @@ +[default] +app.debug = false +app.description = "{{app_name}} — chongming 网关项目" +app.name = "{{app_name}}" +app.version = "0.1.0" +env = "development" +prefix = "/api/v1" +upload_path = "uploads" + +[zenoh] +json5_path = "./config/zenoh_config.json5" + +[cache] +# 单机默认 sqlite(本机共享);多实例部署改 "redis"(或共享卷 sqlite),各实例指向同一后端 +backend = "sqlite" +path = "./cache_data/cache.db" +# 副本数/运行时配置的取值来源: +# "toml"(默认)= 以文件为准(启动用 config/workers//config.toml 覆盖写入缓存) +# "cache" = 以缓存为单一事实来源(管理接口改动跨重启保持) +launch_worker_count = "toml" + +# redis 后端专用(backend = "redis" 时生效) +redis_host = "127.0.0.1" +redis_port = 6379 +redis_db = 0 +redis_password = "" +redis_prefix = "chongming:" + +[security] +# 打包装备(uv run build)会自动注入随机密钥;生产环境请改用环境变量或自行替换 +algorithm = "HS256" +secret_key = "your-secret-key-change-in-production" + +[development] +debug = true +env = "development" + +[development.server] +# 三个启动入口(serve / granian / gunicorn)共用这些参数 +access_log = false +host = "0.0.0.0" +port = 8000 +reload = true + +[development.logging] +file = "logs" +format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +level = "DEBUG" +# access 日志采样率:0.1 = 只记录 10% 的成功请求(错误 >=400 始终全量) +access_sample_rate = 0.1 + +[development.cors] +allow_credentials = true +allow_headers = ["*"] +allow_methods = ["*"] +# allow_credentials=true 时 allow_origins 不能为 ["*"],需显式列出来源 +allow_origins = [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:5174", + "http://127.0.0.1:5174", +] + +[development.mqtt] +enabled = true +host = "127.0.0.1" +port = 1883 +# true(默认)= 网关自带内嵌 amqtt Broker(开箱即用); +# false = 连接外部 Broker,host/port 改为对方地址 +use_embedded_broker = true +# Broker 认证(可选,默认匿名;密码明文存放,生产建议外部 Broker + 环境变量注入) +# username = "chongming" +# password = "change-me" + +[development.mqtt.broker] +# amqtt Broker 可选配置,默认允许匿名连接 + +# 旧式全局插件注册文件(仅作兜底兼容;新插件请在 config/workers//config.toml 各自声明) +[plugins_worker] +config_path = "./config/workers/config.toml" + +[pyinstaller_build_import] +# 打包收集清单(PyInstaller --hidden-import / Nuitka --include-module),`uv run build` 的 +# pack 步骤使用。网关本体依赖随 chongming-gateway 包发布、静态分析可跟到;这里保留: +# - 动态导入、静态分析看不到的(granian 内部子模块、amqtt 插件、sqlite_vfs 等) +# - public 骨架自身(server / utils)与服务器可选实现(uvloop 等) +# 内置插件专属依赖(网关本体不 import 的第三方包)写根目录 plugins_deps.toml,按插件分节声明。 +base = [ + "utils", + "utils.launch", + "utils.config", + "server", + "module_bank", + "granian", + "granian._compat", + "granian._futures", + "granian._granian", + "granian._imports", + "granian._internal", + "granian._loops", + "granian._signals", + "granian._types", + "granian.asgi", + "granian.constants", + "granian.errors", + "granian.http", + "granian.log", + "granian.net", + "granian.rsgi", + "granian.server", + "granian.server.common", + "granian.server.mp", + "granian.server.mt", + "granian.wsgi", + "httptools", + "uvicorn", + "sqlite_vfs", + "sqlite_vfs.core", + "sqlite_vfs.contrib", + "sqlite_vfs.contrib.fastapi_static", + "amqtt", + # amqtt 内嵌 Broker 的插件经 entry points 动态加载,静态分析看不到 + "amqtt.plugins.authentication", + "amqtt.plugins.logging_amqtt", + "amqtt.plugins.sys.broker", + "amqtt.plugins.topic_checking", + # 数据库 / 缓存驱动:SQLAlchemy 与 chongming-cache 按配置动态加载,必须显式声明 + "aiosqlite", # SQLite 异步驱动(网关内置 auth 库用) + "asyncpg", # PostgreSQL 驱动(插件配 database.url 时用) + "sqlmodel", + "chongming_cache.backends", + "chongming_cache.backends.sqlite_", # cache backend = "sqlite" 时使用 + "chongming_worker.contracts", + "chongming_worker.route_config", + "chongming_worker.routes_publisher", + "python_multipart", # multipart 上传端点 + "orjson", + "aiofiles", +] +linux = [ + "uvloop", +] +windows = [] + +[production.module_system] +# 构建用(uv run build 的 mbank 步骤读这里):把插件源码打进 resources/plugins.mbank, +# 交付产物运行时经模块银行加载插件(网关本体是普通依赖包,不进 .mbank) +path = [ + [ + "resources/plugins.mbank", + "src/plugins", + "plugins", + ], +] +type = "module-bank" diff --git a/config/zenoh_config.json5 b/config/zenoh_config.json5 new file mode 100644 index 0000000..b93b688 --- /dev/null +++ b/config/zenoh_config.json5 @@ -0,0 +1,38 @@ +{ + // Zenoh 会话配置 —— 由 config.toml 的 [zenoh].json5_path 指定(见 utils/gateway/src/chongming/app/core/lifespan_setup.py) + // 这是项目的zenoh 客户端会话配置,决定应用如何加入 zenoh 网络。 + // 注意:此文件是应用侧(peer/client)配置,不是独立 router 的配置文件。 + + // mode —— 本节点的运行模式: + // "peer" : 既可作为客户端连接,也能参与对等组网(本项目默认,多进程间直连) + // "client" : 纯客户端,只连接 router,不自建对等网络 + // "router" : 路由节点(仅独立 zenohd 使用,本项目不会以 router 身份启动) + // 本项目用 peer 即可;若已部署独立 Zenoh Router(如 deploy/docker-compose.yml 的 zenoh 服务), + // 也可保持 peer 通过下方 connect.endpoints 连接到 router。 + mode: "peer", + + connect: { + // connect.endpoints —— 本节点要主动连接的远端对等端/router 地址列表。 + // 当前填 127.0.0.1:7447 指向本机(TCP 自连接模式,未接独立 router 时连到其他 peer)。 + // 接入 docker 部署的 Zenoh Router 后: + // - 同机部署:保持 tcp/127.0.0.1:7447 即可(router 容器已把 7447 映射到宿主) + // - 跨机部署:改为 router 机器地址,如 "tcp/192.168.x.x:7447" + endpoints: ["tcp/127.0.0.1:7447"], + }, + + scouting: { + multicast: { + // 多播发现开关。关闭后不通过局域网 224.0.0.224:7446 自动发现对等端, + // 仅通过 connect.endpoints 显式连接。多实例/多机器下建议保持 false 避免互相发现串扰。 + enabled: false, + }, + }, + + transport: { + shared_memory: { + // 共享内存传输开关。开启后同机进程间通过共享内存零拷贝交换,性能更好。 + // 若应用与 router 跨容器/跨机器部署,共享内存不可用,需改为 false(退化为 TCP 传输)。 + enabled: true, + }, + }, +} \ No newline at end of file diff --git a/init.py b/init.py new file mode 100644 index 0000000..6a56d0c --- /dev/null +++ b/init.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""chongming 项目模板初始化。 + +用法: + python init.py <目标目录> [--name 项目名] [--app-name 应用名] [--no-sync] + +行为: + 1. 拒绝覆盖非空目录 + 2. 复制模板内容(不含 init.py 与模板自身 README;README.project.md 重命名为 README.md) + 3. 替换占位符 {{project_name}}(发行名)/ {{app_name}}(应用显示名) + 4. 默认在目标目录执行 uv sync(--no-sync 跳过) + +示例: + python init.py ~/proj/my-gateway + python init.py ../demo --name demo --app-name "演示网关" +""" +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +TEMPLATE_DIR = Path(__file__).resolve().parent + +# 模板自身文件 / 生成物:不复制 +SKIP_NAMES = {"init.py", "README.md", ".git", "__pycache__", ".venv", "dist", "build"} + +# 做占位符替换的文本文件 +TEXT_SUFFIXES = {".py", ".toml", ".md", ".json5", ".json", ".yaml", ".yml", ".sh", ".txt"} +TEXT_NAMES = {".gitignore", ".env.example"} + +# 生成项目里该文件重命名为 README.md +PROJECT_README = "README.project.md" + + +def main() -> int: + parser = argparse.ArgumentParser(description="chongming 项目模板初始化") + parser.add_argument("target", help="目标项目目录(不存在则创建;必须为空)") + parser.add_argument("--name", help="项目名(pyproject 的 name,默认取目标目录名)") + parser.add_argument("--app-name", help="应用显示名(config.toml 的 app.name,默认同项目名)") + parser.add_argument("--no-sync", action="store_true", help="跳过 uv sync") + args = parser.parse_args() + + target = Path(args.target).expanduser().resolve() + project_name = args.name or target.name + app_name = args.app_name or project_name + + if target.exists() and any(target.iterdir()): + print(f"❌ 目标目录非空,拒绝覆盖: {target}") + return 1 + + replacements = {"{{project_name}}": project_name, "{{app_name}}": app_name} + copied = 0 + for src in sorted(TEMPLATE_DIR.rglob("*")): + rel = src.relative_to(TEMPLATE_DIR) + if any(part in SKIP_NAMES for part in rel.parts): + continue + dst = target / ("README.md" if rel.as_posix() == PROJECT_README else rel) + if src.is_dir(): + dst.mkdir(parents=True, exist_ok=True) + continue + dst.parent.mkdir(parents=True, exist_ok=True) + if src.suffix in TEXT_SUFFIXES or src.name in TEXT_NAMES: + text = src.read_text(encoding="utf-8") + for key, value in replacements.items(): + text = text.replace(key, value) + dst.write_text(text, encoding="utf-8") + else: + shutil.copy2(src, dst) + copied += 1 + + print(f"✅ 已初始化: {target}") + print(f" 项目名={project_name} 应用名={app_name} 文件数={copied}") + + if args.no_sync: + print("(--no-sync:跳过 uv sync)") + elif shutil.which("uv") is None: + print("⚠️ 未找到 uv,跳过依赖安装;请在目标目录自行执行 uv sync") + else: + print("→ uv sync(首次安装依赖,可能需要几分钟)") + if subprocess.call(["uv", "sync"], cwd=target) != 0: + print("⚠️ uv sync 未成功,请在目标目录手动重试") + + print("\n下一步:") + print(f" cd {target}") + print(" uv run serve # 启动(http://localhost:8000/docs)") + print(" curl 'http://localhost:8000/api/v1/example/hello?name=world'") + print(" uv run gen_plugin # 生成新插件骨架") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins_deps.toml b/plugins_deps.toml new file mode 100644 index 0000000..eab227c --- /dev/null +++ b/plugins_deps.toml @@ -0,0 +1,14 @@ +# 内置插件专属依赖清单(打包收集用) +# +# 什么时候写这里:插件用了「网关本体不 import」的第三方包(如 openai / httpx / minio)。 +# 这些包在 .mbank 内 import,PyInstaller/Nuitka 静态分析看不到,必须显式声明, +# 否则产物运行到加载插件时才 ImportError。 +# +# 同时记得在根 pyproject.toml 的 dependencies 里安装(dev 运行 inline 插件与打包共用同一 venv)。 +# 插件改为 external 进程插件后不需要本文件:依赖随插件项目 workers//pyproject.toml 自包含。 +# +# 结构(节名 = src/plugins/ 下的插件名;顶层 imports 为多个插件共用): +# imports = ["httpx"] +# +# [doubao] +# imports = ["openai", "volcenginesdkarkruntime"] diff --git a/public/chongming.ico b/public/chongming.ico new file mode 100644 index 0000000..8e36272 Binary files /dev/null and b/public/chongming.ico differ diff --git a/public/config.toml b/public/config.toml new file mode 100644 index 0000000..786dabd --- /dev/null +++ b/public/config.toml @@ -0,0 +1,98 @@ +[default] +app.debug = false +app.description = "{{app_name}} — chongming 网关项目" +app.name = "{{app_name}}" +app.version = "0.1.0" +env = "production" +prefix = "/api/v1" +upload_path = "uploads" + +[zenoh] +json5_path = "./config/zenoh_config.json5" + +[cache] +backend = "sqlite" +path = "./cache_data/cache.db" +# 配置优先来源模式(同时决定 [workers.config] 与 count 在启动时的取舍): +# "cache"(本模板默认)= 以缓存为准:chongming-cache 为单一事实来源,插件初始化即读缓存值, +# 管理接口(/admin/workers//config、/scale)改动跨重启保持; +# toml 仅在缓存缺失时引导。启动日志会提示 toml 与缓存的差异键 +# (以缓存为准);如需让 toml 改动生效,先删除 worker:cm:configs: +# (运行时配置)/ worker:cm:counts:(副本数)缓存键再重启。 +# "toml" = 以文件为准:启动时用 config/workers//config.toml 的 +# [workers.config] 与 count 覆盖写入缓存(前端读缓存 → 显示 toml 值); +# 管理接口改动仅本次运行有效,重启网关即被文件覆盖。 +launch_worker_count = "cache" + +[security] +algorithm = "HS256" +secret_key = "your-secret-key-change-in-production" + +[production] +debug = false +env = "production" + +[production.server] +# 启动参数对各服务器通用(由 main.py 的转换函数映射,无需按服务器改配置); +# 服务器/副本数可用环境变量覆盖:APP_SERVER=granian|uvicorn|gunicorn、APP_WORKERS= +access_log = false +host = "0.0.0.0" +port = 8000 +reload = false +timeout_keep_alive = 65 +workers = 4 + +[production.logging] +backup_count = 5 # 最多保存 10 个日志文件 +file = "logs" +format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +level = "INFO" +max_size = 10485760 # 10MB +access_sample_rate = 0.1 + +[production.cors] +allow_credentials = true +allow_headers = ["Content-Type", "Authorization", "Accept"] +allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"] +allow_origins = ["http://localhost:5173", "http://127.0.0.1:5173"] +max_age = 600 + +[production.mqtt] +enabled = true +host = "0.0.0.0" +port = 1883 +# 是否由网关启动内嵌 amqtt Broker 子进程: +# true (默认)= 网关自带 amqtt Broker,host/port 为 Broker 监听地址 +# false = 使用外部 MQTT Broker,网关仅启动 Bridge 客户端连接 +# host/port 需指向外部 Broker 地址(如 "192.168.1.10") +use_embedded_broker = true +# MQTT Broker 认证(可选,默认匿名): +# - 内嵌 Broker(use_embedded_broker=true):配置后要求所有客户端(含网关 Bridge) +# 用该账号密码连接,未配置则保持匿名 +# - 外部 Broker:网关 Bridge 用该账号密码连接(broker 侧自行配置对应账号) +# 注意:密码为明文存储,生产环境建议使用外部 Broker + 环境变量注入 +# username = "chongming" +# password = "change-me" + +# MQTT Bridge 额外订阅主题推荐声明在各插件自己的配置里: +# config/workers//config.toml → [[workers.mqtt_topics]] +# (如 aibox_sophon_adapter 插件的 /board_ping 心跳主题) +# 全局 [.mqtt].custom_topics 仍可作为通用兜底(可选,不推荐)。 + +[production.mqtt.broker] +# amqtt Broker 可选配置,默认允许匿名连接 +# 详细参数参考: https://github.com/Yakifo/amqtt + +[production.module_system] +# 运行期模块银行加载清单(frozen 产物用):网关本体是普通依赖包(chongming-gateway, +# 已由 PyInstaller/Nuitka 打进产物),只有内置插件经模块银行加载。 +path = [ + "resources/plugins.mbank", +] +type = "module-bank" + +# 静态前端(可选):接入 Vue 前端后由 `uv run build` 的 vue 步骤打包为 static.svfs, +# 再启用下面这段(否则静态文件服务会因文件缺失而启动失败): +# [production.file_system] +# path = "static.svfs" +# type = "svfs" diff --git a/public/main.py b/public/main.py new file mode 100644 index 0000000..1c8861b --- /dev/null +++ b/public/main.py @@ -0,0 +1,192 @@ +# main.py - 修复 reload 问题 +#!/usr/bin/env python3 +""" +FastAPI chongming 示例 +主入口文件,适配 chongming 打包系统 + +服务器选择(APP_SERVER 环境变量,默认见 DEFAULT_SERVER): +- gunicorn:现状保留(Linux 默认,uvloop worker + preload 多进程) +- uvicorn:Windows 默认(gunicorn 依赖 fork,Windows 不可用) +- granian:Rust HTTP 内核,多进程 + +启动参数保持 config.toml `[.server]` 现状(host / port / workers / +reload / access_log / timeout_keep_alive),由 `_to_uvicorn`、`_to_granian`、 +`_to_gunicorn_args` 转换为对应服务器的参数。 +""" +import asyncio # noqa: F401 +import os +import platform +import sys +from multiprocessing import freeze_support + +import server # noqa: F401 # 预加载:master 内完成插件模块银行装载,fork 出的 worker 共享 +import uvloop # noqa: F401 # 显式导入保证打包时收集 uvloop(uvicorn/granian loop 依赖) +from chongming import RELOAD_PATHS # 热重载监听目录(网关包内定义) +from chongming.app.core.logger import get_logger +from chongming.app.core.parent_death import guard_parent_death +from utils.launch import launch +from uvicorn.workers import UvicornWorker + +use_shell = platform.system() == "Windows" + +# 未设置 APP_SERVER 时的平台默认(保持现状) +# DEFAULT_SERVER = "uvicorn" if use_shell else "gunicorn" +DEFAULT_SERVER = "granian" +SUPPORTED_SERVERS = ("gunicorn", "uvicorn", "granian") + + +class UvloopUvicornWorker(UvicornWorker): + CONFIG_KWARGS = {"loop": "uvloop"} + + +def _server_params(app_config: dict) -> dict: + """把 `[.server]` 的现状参数整理为各服务器共用的启动参数。 + + 只做缺省兜底与运行环境覆盖(打包环境强制禁 reload、APP_WORKERS 覆盖副本数), + 键名保持 config.toml 现状,改名交给下面的转换函数。 + """ + server_config = app_config.get("server", {}) + # 打包环境强制禁用重载 + is_frozen = getattr(sys, "frozen", False) + # 支持通过环境变量 APP_WORKERS 覆盖 worker 数量(方便嵌入式设备调优) + default_workers = server_config.get("workers", 4) + workers = int(os.environ.get("APP_WORKERS", str(default_workers))) + return { + "host": server_config.get("host", "0.0.0.0"), + "port": server_config.get("port", 8000), + "workers": workers, + "reload": False if is_frozen else server_config.get("reload", False), + "access_log": server_config.get("access_log", False), + "timeout_keep_alive": server_config.get("timeout_keep_alive", 5), + "log_level": "debug" if app_config.get("debug", False) else "info", + } + + +def _to_uvicorn(params: dict) -> dict: + """共用参数 → uvicorn.run 关键字参数(键名一致)。""" + return { + "host": params["host"], + "port": params["port"], + "reload": params["reload"], + "workers": params["workers"], + "log_level": params["log_level"], + "access_log": params["access_log"], + "timeout_keep_alive": params["timeout_keep_alive"], + } + + +def _to_granian(params: dict) -> dict: + """共用参数 → granian.Granian 关键字参数。 + + 仅两处改名:host → address、access_log → log_access; + timeout_keep_alive 在 granian 无对应项(HTTP/1 keep-alive 只有开关、无空闲 + 超时配置),忽略。 + """ + return { + "address": params["host"], + "port": params["port"], + "interface": "asgi", + "workers": params["workers"], + "reload": params["reload"], + "log_level": params["log_level"], + "log_access": params["access_log"], + } + + +def _to_gunicorn_args(params: dict) -> list: + """共用参数 → gunicorn 命令行参数(保持现状:uvloop worker + preload)。""" + return [ + "gunicorn", + "server:app", + "-k", + "__main__.UvloopUvicornWorker", + "--preload", + "-w", + str(params["workers"]), + "-b", + f"{params['host']}:{params['port']}", + ] + + +def _select_server(logger) -> str: + """按 APP_SERVER 环境变量选择服务器,非法值/平台不兼容时回退默认。""" + server_kind = (os.environ.get("APP_SERVER") or DEFAULT_SERVER).strip().lower() + if server_kind not in SUPPORTED_SERVERS: + logger.warning( + f"未知 APP_SERVER={server_kind!r}(可选: {', '.join(SUPPORTED_SERVERS)})," + f"回退 {DEFAULT_SERVER}" + ) + server_kind = DEFAULT_SERVER + if server_kind == "gunicorn" and use_shell: + logger.warning("Windows 不支持 gunicorn(依赖 fork),回退 uvicorn") + server_kind = "uvicorn" + return server_kind + + +def app_run(app_config: dict, default_config: dict): + """ + 应用运行函数 - 适配模块银行环境 + + Args: + app_config: 应用配置字典,包含服务器配置、环境设置等 + default_config: 默认配置字典,包含应用名称、版本等基础信息 + + Returns: + None: 此函数不返回值,直接启动服务器并阻塞运行 + """ + logger = get_logger("app") + + params = _server_params(app_config) + server_kind = _select_server(logger) + + # 显示启动信息 + env = app_config.get("env", "development") + logger.info("=" * 50) + logger.info(f"🚀 启动 {default_config.get('app.name', 'FastAPI 应用')}") + logger.info(f"📦 版本: {default_config.get('app.version', '1.0.0')}") + logger.info(f"🌍 环境: {env}") + logger.info(f"🖥️ 服务器: {server_kind}") + logger.info(f"📍 地址: http://{params['host']}:{params['port']}") + logger.info(f"📚 文档: http://{params['host']}:{params['port']}/docs") + logger.info(f"🔧 调试: {'启用' if app_config.get('debug', False) else '禁用'}") + logger.info(f"🔄 重载: {'启用' if params['reload'] else '禁用'}") + logger.info(f"👥 Workers: {params['workers']}") + logger.info("=" * 50) + + # 根据所选服务器启动(默认平台分支保持现状) + if server_kind == "granian": + import granian + + granian.Granian( + target="server:app", + **_to_granian(params), + reload_paths=RELOAD_PATHS, + ).serve() + elif server_kind == "uvicorn": + import uvicorn + + uvicorn.run("server:app", **_to_uvicorn(params)) + else: + from gunicorn.app.wsgiapp import run + + sys.argv = _to_gunicorn_args(params) + run() + + +def main(): + """ + 主函数入口 - 初始化多进程支持并启动应用 + + Returns: + None: 此函数不返回值,调用launch函数启动应用 + """ + # 父进程死亡防护(master 侧):引导进程(onefile 引导/终端 shell)死亡时 + # ——含被 SIGKILL——内核给本进程发 SIGTERM,granian 走优雅停机收掉全部 worker; + # worker 侧在 app 的 lifespan 里各自设置(PR_SET_PDEATHSIG 不跨 fork 继承)。 + guard_parent_death(get_logger("app")) + freeze_support() + launch(app_run, "production") + + +if __name__ == "__main__": + main() diff --git a/public/server.py b/public/server.py new file mode 100644 index 0000000..6d43907 --- /dev/null +++ b/public/server.py @@ -0,0 +1,20 @@ +# server.py +import sys +from utils.launch import init_module_bank +from utils.config import ConfigLoader + +# 加载配置文件并初始化模块银行(当前只剩内置插件 plugins.mbank) +config = ConfigLoader("config.toml") +init_module_bank(config) + +# 网关本体是普通依赖包(chongming-gateway),直接导入即可——打包时 +# PyInstaller/Nuitka 也沿这行静态收集网关及其依赖。 +# 插件(plugins 包)由上面的模块银行提供,在 lifespan 启动时被网关按配置加载。 +try: + from chongming.app import app as app_instance +except ImportError as e: + print(f"无法导入网关(chongming.app): {e}") + sys.exit(1) + +# 暴露 ASGI 应用实例,供 uvicorn 服务器使用 +app = app_instance diff --git a/public/utils/__init__.py b/public/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/public/utils/config.py b/public/utils/config.py new file mode 100644 index 0000000..bef1306 --- /dev/null +++ b/public/utils/config.py @@ -0,0 +1,184 @@ +# config_loader.py - 读取config.toml的示例代码 + + +import os +import sys +from pathlib import Path +from typing import Any, Dict, Optional + +# 根据Python版本选择合适的TOML解析库 +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + +class ConfigLoader: + """TOML配置文件加载器 + + 提供配置文件的加载、访问和管理功能。支持自动查找配置文件、 + 点号分隔的嵌套键访问、配置节获取以及配置重载等功能。 + + Attributes: + config_path: 配置文件的路径 + _config: 存储解析后的配置数据 + """ + + def __init__(self, config_path: Optional[str] = None): + """ + 初始化配置加载器 + + Args: + config_path: 配置文件路径,如果为None则自动查找 + + Raises: + FileNotFoundError: 当无法找到配置文件时抛出异常 + """ + if config_path is None: + # 自动查找配置文件 + config_path = self._find_config_file() + + self.config_path = Path(config_path) + self._config: Dict[str, Any] = {} + self.load() + + def _find_config_file(self) -> str: + """ + 自动查找配置文件 + + 按照预定义的优先级顺序搜索配置文件: + 1. 当前目录下的config.toml + 2. config子目录下的config.toml + 3. 用户配置目录~/.config/myapp/config.toml + 4. 系统配置目录/etc/myapp/config.toml + + Returns: + str: 找到的第一个配置文件路径 + + Raises: + FileNotFoundError: 当所有可能的位置都未找到配置文件时抛出异常 + """ + possible_paths = [ + "config.toml", + "config/config.toml", + "~/.config/myapp/config.toml", + "/etc/myapp/config.toml", + ] + + for path in possible_paths: + expanded_path = os.path.expanduser(path) + if os.path.exists(expanded_path): + return expanded_path + + raise FileNotFoundError("未找到配置文件") + + def load(self) -> None: + """ + 加载配置文件 + + 读取并解析TOML格式的配置文件,将配置数据存储到内部字典中。 + + Raises: + FileNotFoundError: 当配置文件不存在时抛出异常 + tomllib.TOMLDecodeError: 当配置文件格式不正确时抛出异常 + """ + if not self.config_path.exists(): + raise FileNotFoundError(f"配置文件不存在: {self.config_path}") + + with open(self.config_path, "r", encoding="utf-8") as f: + self._config = tomllib.loads(f.read()) + + def get(self, key: str, default: Any = None) -> Any: + """ + 获取配置值,支持点号分隔的路径 + + 通过点号分隔的键路径访问嵌套的配置值。例如: + - "database.host" 访问 {"database": {"host": "localhost"}} + - "server.port" 访问 {"server": {"port": 8080}} + + Args: + key: 配置键,支持点号分隔的嵌套路径 + default: 当键不存在时返回的默认值,默认为None + + Returns: + Any: 配置值,如果键不存在则返回默认值 + """ + keys = key.split(".") + value = self._config + + try: + for k in keys: + value = value[k] + return value + except (KeyError, TypeError): + return default + + def get_section(self, section: str) -> Dict[str, Any]: + """ + 获取整个配置节 + + 返回指定配置节的完整字典内容,适用于需要访问某个配置节下 + 所有配置项的场景。 + + Args: + section: 配置节名称 + + Returns: + Dict[str, Any]: 配置节的内容字典,如果节不存在则返回空字典 + """ + return self._config.get(section, {}) + + def reload(self) -> None: + """ + 重新加载配置文件 + + 从磁盘重新读取配置文件并更新内部配置数据。 + 适用于配置文件在运行时被修改的场景。 + + Raises: + FileNotFoundError: 当配置文件不存在时抛出异常 + """ + self.load() + + def __getitem__(self, key: str) -> Any: + """ + 支持字典式访问 + + 允许使用方括号语法访问配置值,如:config["database.host"] + + Args: + key: 配置键 + + Returns: + Any: 配置值 + + Raises: + KeyError: 当键不存在且无默认值时抛出异常(通过get方法处理) + """ + return self.get(key) + + def __contains__(self, key: str) -> bool: + """ + 检查配置是否存在 + + 支持使用in操作符检查配置键是否存在,如:"database.host" in config + + Args: + key: 要检查的配置键 + + Returns: + bool: 如果配置键存在且值不为None则返回True,否则返回False + """ + return self.get(key) is not None + + @property + def all(self) -> Dict[str, Any]: + """ + 获取所有配置 + + 返回完整的配置字典,适用于需要遍历或导出所有配置的场景。 + + Returns: + Dict[str, Any]: 包含所有配置的字典 + """ + return self._config diff --git a/public/utils/launch.py b/public/utils/launch.py new file mode 100644 index 0000000..6db5547 --- /dev/null +++ b/public/utils/launch.py @@ -0,0 +1,65 @@ +from .config import ConfigLoader +import os +from module_bank import PythonToSQLite +from typing import Callable + +# module_bank 依赖在每次加载 PyArmor 加密模块时都会 print 一行 +# "已部署 PyArmor 运行时",启动期会刷屏(约 50 行)。依赖包不便改动, +# 这里用模块级 print 遮蔽(模块内名字解析 globals 优先于 builtins)静默掉。 +import module_bank.sqlite_module_loader as _sqlite_module_loader + +_sqlite_module_loader.print = lambda *args, **kwargs: None + + +def init_module_bank(config): + """ + 初始化模块银行系统,将指定路径下的Python模块打包并安装导入器。 + + 该函数会验证所有模块路径的有效性,创建PythonToSQLite打包器实例, + 并为每个打包器安装导入器以支持模块的动态加载。 + + Args: + config (dict): 配置字典,必须包含 'production' -> 'module_system' -> 'path' 键, + 其值为模块路径列表 + + Raises: + ValueError: 当配置的模块路径中有任何一个不存在时抛出异常 + """ + MODULE_BANK_PATH_LIST = config["production"]["module_system"]["path"] + for module_path in MODULE_BANK_PATH_LIST: + if not os.path.exists(module_path): + raise ValueError(f"模块路径不存在: {module_path}") + + # 为每个模块路径创建PythonToSQLite打包器实例 + packer_list = [ + PythonToSQLite(module_path) for module_path in MODULE_BANK_PATH_LIST + ] + # 遍历所有打包器,安装导入器(模块清单不再打印,避免启动刷屏) + for packer in packer_list: + key = packer.get_key() + packer.install_importer(key=key) + + +def launch(run_fun: Callable[[dict, dict], None], run_mode: str): + """ + 启动应用程序,加载配置并执行指定的运行函数。 + + 该函数从config.toml文件中加载配置,根据运行模式获取对应的应用配置和默认配置, + 然后调用传入的运行函数执行应用程序逻辑。 + + Args: + run_fun (Callable[[dict, dict], None]): 运行函数,接收两个参数: + - app_config (dict): 当前运行模式的配置 + - default_config (dict): 默认配置 + run_mode (str): 运行模式,用于从配置文件中获取对应模式的配置项 + + Returns: + None + """ + config = ConfigLoader("config.toml") + + # 获取对应运行模式的配置 + default_config = config.get("default", {}) + app_config = config.get(run_mode, {}) + + run_fun(app_config, default_config) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5be8e3b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[project] +# 宿主项目:骨架(public/)+ 内置插件(src/plugins/)+ 配置(config/)+ 构建配置。 +# 网关本体与 chongming-* 工具包都是独立发布的依赖,本文件只声明宿主自己需要的依赖。 +name = "{{project_name}}" +version = "0.1.0" +description = "{{app_name}} — chongming 网关项目" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + # —— 网关本体(含 fastapi / granian / zenoh / chongming-worker 等传递依赖)—— + "chongming-gateway", + # —— public 骨架(exe 入口)自己的依赖 —— + "granian[reload,uvloop]>=2.8.4", + "gunicorn", + "httptools", + "module-bank", + "uvicorn", + "uvloop", + # 内置插件(src/plugins/)专属依赖写在这下面,并同步 plugins_deps.toml(打包收集清单); + # 示例插件无需额外包 +] + +[dependency-groups] +dev = [ + "chongming-cli", # build / gen_plugin / gen_models / build_plugin / migrate_plugin + "greenlet>=3.5.6", # SQLAlchemy 异步(sqlite/postgres 插件)需要 + "nuitka[onefile]", # Nuitka 打包(onefile extra 带 zstandard 压缩) + "patchelf; sys_platform == 'linux'", # Nuitka standalone/onefile 在 Linux 必需 + "pyarmor", # --encrypt 时的混淆 + "pyinstaller", + "ruff", +] + +[tool.uv] +# 宿主项目自身不出包(无 Python 包),uv sync 只装依赖与 dev 工具 +package = false + +# 需要本地联调 chongming-gateway 源码时取消注释(路径指向网关源码目录), +# 联调结束恢复按发布版依赖并 uv lock --upgrade-package chongming-gateway: +# [tool.uv.sources] +# chongming-gateway = { path = "../chongming-zenoh/utils/gateway", editable = true } + +[[tool.uv.index]] +url = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" + +[[tool.uv.index]] +url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple" diff --git a/src/plugins/__init__.py b/src/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/plugins/example/README.md b/src/plugins/example/README.md new file mode 100644 index 0000000..b59fa47 --- /dev/null +++ b/src/plugins/example/README.md @@ -0,0 +1,37 @@ +# example 插件(内置 / inline 最小示例) + +一个可删除、可改名的最小插件:一个 `hello` 端点(GET + POST),演示内置插件的标准结构。 + +## 文件职责 + +| 文件 | 作用 | +| ---- | ---- | +| `../../config/workers/example/config.toml` | 插件注册(`[workers]`)+ 路由前缀与端点默认值(`[worker]`)+ 运行时配置(`[workers.config]`) | +| `../../config/workers/example/hello.toml` | 端点定义:`[GET]` / `[POST]` 分节,`params` 声明入参,`response_model` 声明出参 | +| `models/pydantic.py` | 由 `uv run gen_models` 生成的请求/响应模型(勿手改) | +| `router/hello.py` | `@rpc_method` 端点实现,引用生成的模型 | +| `router/__init__.py` | 导入所有端点模块(触发注册) | +| `__init__.py` | `Plugin` 子类统一生命周期(`on_init` / `on_cleanup`)+ 模块级入口 | +| `global_var.py` | 模块级共享状态(示例为内存计数;真实插件放 Database / API client) | + +## 调用示例 + +```bash +curl 'http://localhost:8000/api/v1/example/hello?name=world' # GET(query 参数) +curl -X POST http://localhost:8000/api/v1/example/hello \ + -H 'Content-Type: application/json' -d '{"name":"world"}' # POST(JSON body) +``` + +MQTT 通道同理:topic `chongming/example/hello`,载荷为信封格式 +(`{"meta": {"method": "GET"}, "data": {"name": "world"}}`)。 + +## 新增端点 + +1. 写 `config/workers/example/.toml` +2. `uv run gen_models config/workers/example src/plugins/example` +3. 写 `router/.py`(`@rpc_method(subject="example.", ...)`),引用生成的模型 +4. 在 `router/__init__.py` 里导入 + +## 不要这个示例时 + +删除 `src/plugins/example/` 与 `config/workers/example/` 两个目录即可(网关支持零插件启动)。 diff --git a/src/plugins/example/__init__.py b/src/plugins/example/__init__.py new file mode 100644 index 0000000..6731dd5 --- /dev/null +++ b/src/plugins/example/__init__.py @@ -0,0 +1,46 @@ +"""example —— 内置(inline)插件最小示例。 + +展示三件事: + 1. 端点声明:`config/workers/example/*.toml` 定义参数与响应模型(见同目录 hello.toml) + 2. 端点实现:`router/.py` 用 `@rpc_method` 注册,与 TOML 的 subject/方法一一对应 + 3. 生命周期:`Plugin` 子类统一管理初始化与清理,模块级共享状态放 `global_var.py` + +新增端点: + 1. 写 `config/workers/example/.toml`(按 [GET] / [POST] / [MQTT] 分节) + 2. `uv run gen_models config/workers/example src/plugins/example` 生成模型 + 3. 写 `router/.py` 实现 `@rpc_method`,引用生成的模型 + 4. 在 `router/__init__.py` 中导入(触发注册) +""" +import logging + +from chongming_worker.plugin import Plugin + +from .router import * # noqa: F401,F403 触发 @rpc_method 注册 + +logger = logging.getLogger(__name__) + + +class ExamplePlugin(Plugin): + """example 插件:on_init 准备共享状态,on_cleanup 释放资源。""" + + type = "example" + auto_bind = True # 首次初始化时自动绑定本插件 models 目录中的 Pydantic 模型 + + async def on_init(self): + """首次 create_workers 时调用一次:初始化资源。""" + # 模块级共享状态:router 用 global_var.require("state") 读取 + self.set_var("state", { + "count": 0, + "greeting": self.config.get("greeting", "hello"), + }) + logger.info("[example] 初始化完成") + + async def on_cleanup(self): + """插件停止 / 配置热更新时调用:释放自定义客户端等(数据库由基类处理)。""" + logger.info("[example] 清理完成") + + +# 暴露给网关 lifespan 的模块级入口(bound method) +plugin = ExamplePlugin() +create_workers = plugin.create_workers +cleanup = plugin.cleanup diff --git a/src/plugins/example/global_var.py b/src/plugins/example/global_var.py new file mode 100644 index 0000000..99dab10 --- /dev/null +++ b/src/plugins/example/global_var.py @@ -0,0 +1,36 @@ +"""example 插件全局运行时状态(模块级)。 + +约定: + - 模块只声明默认值,不产生导入期副作用 + - 由 `Plugin.set_var` 写入(白名单见 `_ALLOWED`),路由里用 `require()` 读取 + - `require()` 取"必须已初始化"的值,未初始化时给出明确错误 +""" +from typing import Any + +# 允许写入的全局变量白名单(拼写错误会立即抛错,而不是静默创建无用变量) +_ALLOWED = frozenset({"state"}) + +# 运行时状态:示例用内存计数;需要持久化时改为 Database / API client 等 +state: dict[str, Any] | None = None + + +def set_global_var(name: str, value: Any) -> None: + """白名单校验版 setter(`Plugin.set_var` 会调用它)。""" + if name not in _ALLOWED: + raise KeyError(f"global_var: 未知变量 '{name}'(允许: {sorted(_ALLOWED)})") + globals()[name] = value + + +def require(name: str) -> Any: + """取必须已初始化的全局变量,未初始化时抛清晰错误。""" + if name not in _ALLOWED: + raise KeyError(f"global_var: 未知变量 '{name}'(允许: {sorted(_ALLOWED)})") + value = globals().get(name) + if value is None: + raise RuntimeError( + f"global_var: '{name}' 未初始化 —— 请确认插件 create_workers 已执行且配置正确" + ) + return value + + +__all__ = sorted(_ALLOWED) + ["set_global_var", "require"] diff --git a/src/plugins/example/models/__init__.py b/src/plugins/example/models/__init__.py new file mode 100644 index 0000000..421d625 --- /dev/null +++ b/src/plugins/example/models/__init__.py @@ -0,0 +1,18 @@ +""" +模型导出 — example +自动生成,请勿手动修改 +""" + +from .pydantic import ExampleResponse +from .pydantic import HelloGetRequest +from .pydantic import HelloGetResponse +from .pydantic import HelloPostRequest +from .pydantic import HelloPostResponse + +__all__ = [ + "ExampleResponse", + "HelloGetRequest", + "HelloGetResponse", + "HelloPostRequest", + "HelloPostResponse", +] diff --git a/src/plugins/example/models/pydantic.py b/src/plugins/example/models/pydantic.py new file mode 100644 index 0000000..d352c0e --- /dev/null +++ b/src/plugins/example/models/pydantic.py @@ -0,0 +1,30 @@ +""" +自动生成的 Pydantic 模型 — example +来源: config/workers/example/ +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +class HelloGetRequest(BaseModel): + """打招呼(GET,query 参数) — 请求体""" + name: str = Field(...) + +class HelloGetResponse(BaseModel): + """打招呼(GET,query 参数) — 响应体""" + message: str = Field(...) + count: int = Field(...) + +class HelloPostRequest(BaseModel): + """打招呼(POST,JSON body) — 请求体""" + name: str = Field(...) + +class HelloPostResponse(BaseModel): + """打招呼(POST,JSON body) — 响应体""" + message: str = Field(...) + count: int = Field(...) + + +# 所有响应模型的联合类型 +ExampleResponse = HelloGetResponse | HelloPostResponse diff --git a/src/plugins/example/router/__init__.py b/src/plugins/example/router/__init__.py new file mode 100644 index 0000000..61894b0 --- /dev/null +++ b/src/plugins/example/router/__init__.py @@ -0,0 +1,3 @@ +from .hello import hello_get, hello_post + +__all__ = ["hello_get", "hello_post"] diff --git a/src/plugins/example/router/hello.py b/src/plugins/example/router/hello.py new file mode 100644 index 0000000..3aac9f8 --- /dev/null +++ b/src/plugins/example/router/hello.py @@ -0,0 +1,40 @@ +"""hello 端点 — 演示 @rpc_method 的 GET(query 参数)与 POST(JSON body)两种形态。""" +from chongming_worker import rpc_method + +from .. import global_var +from ..models.pydantic import ( + HelloGetRequest, + HelloGetResponse, + HelloPostRequest, + HelloPostResponse, +) + + +@rpc_method( + subject="example.hello", + method="GET", + summary="打招呼(GET)", + response_model=HelloGetResponse, +) +async def hello_get(request: HelloGetRequest) -> HelloGetResponse: + state = global_var.require("state") + state["count"] += 1 + return HelloGetResponse( + message=f"{state['greeting']}, {request.name}!", + count=state["count"], + ) + + +@rpc_method( + subject="example.hello", + method="POST", + summary="打招呼(POST)", + response_model=HelloPostResponse, +) +async def hello_post(request: HelloPostRequest) -> HelloPostResponse: + state = global_var.require("state") + state["count"] += 1 + return HelloPostResponse( + message=f"{state['greeting']}, {request.name}!", + count=state["count"], + )