init: chongming 项目模板(init.py 初始化骨架 + 内置插件最小示例)

This commit is contained in:
2026-10-13 17:29:29 +08:00
commit 042ac0e8d5
24 changed files with 1314 additions and 0 deletions
+25
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
3.12
+62
View File
@@ -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 <name> [operation ...] # 生成内置插件骨架(--process 生成进程插件项目)
uv run gen_models config/workers/<name> src/plugins/<name> # 按 TOML 生成 Pydantic 模型
uv run build pyinstaller # 打包可执行文件(Nuitka 用 uv run build nuitka)
uv run build_plugin <name> # 进程插件打包 → dist/<name>.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` 即可。
+108
View File
@@ -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/<type>/ # 插件注册(config.toml)与端点定义(<op>.toml)
├── src/plugins/ # 内置插件源码(inline,网关进程内加载)
│ └── example/ # 最小示例:Plugin 生命周期 + @rpc_method + 模型
├── workers/ # 进程插件本地开发目录(external,不入库;生产独立容器)
└── resources/ # 构建产物(plugins.mbank 等)
```
## 新增插件 / 端点
```bash
uv run gen_plugin <name> [operation ...] # 一键生成内置插件骨架(含 config/ 注册与端点 TOML)
uv run gen_models config/workers/<name> src/plugins/<name> # 改 TOML 后重新生成模型
```
手写流程(参考 `src/plugins/example/` 的四个文件):
1. `config/workers/<name>/config.toml`:`[workers]` 注册(`type` / `module = "plugins.<name>"` /
`factory` / `cleanup` / `count` / `load_mode`)+ `[worker]` 路由前缀与端点默认值
2. `config/workers/<name>/<op>.toml`:端点定义(`[GET]` / `[POST]` / `[MQTT]` 分节 + `params` + `response_model`)
3. `uv run gen_models ...` 生成 `models/pydantic.py`
4. `router/<op>.py`:`@rpc_method(subject="<name>.<op>", method=...)` 实现;
在 `router/__init__.py` 导入
启动时网关会做**契约校验**:TOML 里的 HTTP 路由缺少对应 `@rpc_method` 会直接启动失败。
进程插件(`load_mode = "external"`,独立进程/容器部署):
```bash
uv run gen_plugin <name> --process # 生成自包含插件项目 workers/<name>/
cd workers/<name> && uv run chongming-worker --type <name> # 本地调试
uv run build_plugin <name> # 打包 → dist/<name>.zip(容器入口 start.sh)
```
## 配置
- **应用配置**:根 `config.toml`(开发读 `[development]`;`[default].env` 切换环境)
- **插件注册与端点**:`config/workers/<type>/config.toml`、`config/workers/<type>/<op>.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 协议等用法与之一致。
+155
View File
@@ -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/<type>/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/<type>/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"
+38
View File
@@ -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,
},
},
}
+95
View File
@@ -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 <name> # 生成新插件骨架")
return 0
if __name__ == "__main__":
sys.exit(main())
+14
View File
@@ -0,0 +1,14 @@
# 内置插件专属依赖清单(打包收集用)
#
# 什么时候写这里:插件用了「网关本体不 import」的第三方包(如 openai / httpx / minio)。
# 这些包在 .mbank 内 import,PyInstaller/Nuitka 静态分析看不到,必须显式声明,
# 否则产物运行到加载插件时才 ImportError。
#
# 同时记得在根 pyproject.toml 的 dependencies 里安装(dev 运行 inline 插件与打包共用同一 venv)。
# 插件改为 external 进程插件后不需要本文件:依赖随插件项目 workers/<name>/pyproject.toml 自包含。
#
# 结构(节名 = src/plugins/ 下的插件名;顶层 imports 为多个插件共用):
# imports = ["httpx"]
#
# [doubao]
# imports = ["openai", "volcenginesdkarkruntime"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

+98
View File
@@ -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/<type>/config、/scale)改动跨重启保持;
# toml 仅在缓存缺失时引导。启动日志会提示 toml 与缓存的差异键
# (以缓存为准);如需让 toml 改动生效,先删除 worker:cm:configs:<type>
# (运行时配置)/ worker:cm:counts:<type>(副本数)缓存键再重启。
# "toml" = 以文件为准:启动时用 config/workers/<type>/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=<N>
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/<type>/config.toml → [[workers.mqtt_topics]]
# (如 aibox_sophon_adapter 插件的 /board_ping 心跳主题)
# 全局 [<env>.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"
+192
View File
@@ -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 `[<env>.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:
"""把 `[<env>.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()
+20
View File
@@ -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
View File
+184
View File
@@ -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
+65
View File
@@ -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)
+47
View File
@@ -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"
View File
+37
View File
@@ -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/<op>.toml`
2. `uv run gen_models config/workers/example src/plugins/example`
3. 写 `router/<op>.py`(`@rpc_method(subject="example.<op>", ...)`),引用生成的模型
4. 在 `router/__init__.py` 里导入
## 不要这个示例时
删除 `src/plugins/example/` 与 `config/workers/example/` 两个目录即可(网关支持零插件启动)。
+46
View File
@@ -0,0 +1,46 @@
"""example —— 内置(inline)插件最小示例。
展示三件事:
1. 端点声明:`config/workers/example/*.toml` 定义参数与响应模型(见同目录 hello.toml)
2. 端点实现:`router/<op>.py` 用 `@rpc_method` 注册,与 TOML 的 subject/方法一一对应
3. 生命周期:`Plugin` 子类统一管理初始化与清理,模块级共享状态放 `global_var.py`
新增端点:
1. 写 `config/workers/example/<op>.toml`(按 [GET] / [POST] / [MQTT] 分节)
2. `uv run gen_models config/workers/example src/plugins/example` 生成模型
3. 写 `router/<op>.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
+36
View File
@@ -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"]
+18
View File
@@ -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",
]
+30
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
from .hello import hello_get, hello_post
__all__ = ["hello_get", "hello_post"]
+40
View File
@@ -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"],
)