启动与配置:为什么用外部 TOML、端口避让和运行时前端配置
复盘 RespiraScope 的启动路径:外部配置、端口选择、FastAPI/Socket.IO 应用创建、静态 Web Console 注入,以及这些设计相比 .env 和硬编码端口的取舍。
背景问题
RespiraScope 既可以源码运行,也可以打成 Windows exe。它还要在开发机、演示机和设备联调环境里切换传感器地址、后端端口、控制台端口、模拟信号开关和记录辅助点。配置如果写死在代码里,打包后改一次就要重新构建;如果分散在 .env、启动参数和前端脚本里,定位问题会很慢。
因此项目把运行时配置集中到外部 TOML 文件,默认路径是 D:/ct/breath-config/breath.toml 或 /ct/breath-config/breath.toml。程序启动时先读取配置,再检查端口,再把实际端口注入到静态前端的 /runtime-config.js。
设计方案对比
| 方案 | 优点 | 问题 | RespiraScope 的判断 |
|---|---|---|---|
| 代码硬编码 | 最简单,变量少 | exe 打包后不可灵活调整,设备地址容易泄露到仓库 | 不适合设备联调 |
.env | Web 项目常见,部署工具支持多 | exe 场景不直观,Windows 用户不一定熟悉,前端仍要另行注入 | 可用但不是最贴合 |
| 命令行参数 | 启动时透明,适合脚本 | 参数多时难维护,用户容易输错 | 只适合少量覆盖 |
| 外部 TOML | 结构清楚,适合复制、备份和手工编辑 | 需要自己实现默认文件生成和校验 | 当前采用 |
TOML 的优势在于它天然有 section。[mock]、[sensor]、[backend]、[console]、[record] 比一堆扁平环境变量更容易读。对一个面向设备调试的工具来说,可读性比“部署平台标准化”更重要。
当前方案
config.py 定义了 AppConfig dataclass 和默认 TOML 文本。get_config() 会执行四步:
- 用
resolve_config_path()找到实际配置文件。 - 如果文件不存在,用
ensure_config_file()写入默认配置。 - 用
tomllib.load()读取 TOML。 - 用
config_bool、config_int、config_str合并默认值和用户值。
main.py 是本地和 exe 的统一入口。它先读取配置,再用 find_available_port() 检查后端端口。如果配置里的 8000 已被占用,就向后找可用端口,并用 with_runtime_ports() 生成新的 AppConfig。随后启动 frontend-console 静态服务,再用 uvicorn.run(create_socket_app(config)) 启动后端。
为什么这样选
端口避让的关键价值是降低演示失败概率。设备联调现场经常已经有旧进程占用端口,如果程序直接崩溃,用户只会看到“启动失败”。现在后端可以自动从 8000 切到 8001,同时前端通过 /runtime-config.js 获取真实后端端口,不需要用户手工改 JS。
统一 Web Console 也有明确取舍。旧式做法可能为 monitor、lab、guide、api-docs 分别启动不同静态站点,但这会产生多个端口和多个访问入口。当前只启动 frontend-console,里面用 iframe/tab 组合其他静态页面。代价是前端页面之间要通过 postMessage 同步语言,但换来的是用户只需要记一个地址。
代价和边界
外部 TOML 也有成本。配置文件自动生成意味着第一次启动会写入磁盘;如果目标环境没有写权限,就需要提前准备配置目录。端口避让虽然方便,但生产系统如果依赖固定端口,就应该关闭冲突进程,而不是默默切换端口。RespiraScope 选择端口避让,是因为它更偏工程验证和演示工具。
运行时前端配置也不是万能的。静态页面只有启动后才知道后端真实端口,所以直接双击 HTML 文件不能获得完整体验。项目的设计前提是通过 uv run RespiraScope 或 exe 启动,让后端和 Console 一起运行。
关键函数表
| 函数或类 | 来源 | 作用 |
|---|---|---|
tomllib.load() | Python 标准库 | 读取 TOML 配置,避免引入额外依赖 |
AppConfig | config.py | 把配置收敛成不可变 dataclass |
ensure_config_file() | config.py | 首次启动自动生成默认配置 |
find_available_port() | ports.py | 检查并寻找可绑定端口 |
start_static_frontend() | frontend_static.py | 用 ThreadingHTTPServer 启动静态 Console |
runtime_config_payload() | frontend_static.py | 把实际端口和开关暴露给前端 |
create_socket_app() | app.py | 把 FastAPI 和 Socket.IO 合成 ASGI app |
读源码建议
读启动链路时按顺序看 config.py、ports.py、frontend_static.py、main.py。注意 AppConfig 不是只给后端用,前端 /runtime-config.js 也使用它。这个设计让“用户配置”和“实际运行状态”之间有一层明确的转换,不会让前端猜后端端口。