返回系列

启动与配置:为什么用外部 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 打包后不可灵活调整,设备地址容易泄露到仓库不适合设备联调
.envWeb 项目常见,部署工具支持多exe 场景不直观,Windows 用户不一定熟悉,前端仍要另行注入可用但不是最贴合
命令行参数启动时透明,适合脚本参数多时难维护,用户容易输错只适合少量覆盖
外部 TOML结构清楚,适合复制、备份和手工编辑需要自己实现默认文件生成和校验当前采用

TOML 的优势在于它天然有 section。[mock]、[sensor]、[backend]、[console]、[record] 比一堆扁平环境变量更容易读。对一个面向设备调试的工具来说,可读性比“部署平台标准化”更重要。

当前方案

config.py 定义了 AppConfig dataclass 和默认 TOML 文本。get_config() 会执行四步:

  1. 用 resolve_config_path() 找到实际配置文件。
  2. 如果文件不存在,用 ensure_config_file() 写入默认配置。
  3. 用 tomllib.load() 读取 TOML。
  4. 用 config_bool、config_int、config_str 合并默认值和用户值。
启动命令 uv run RespiraScope 读取 TOML 自动生成默认文件 端口避让 find_available_port 启动后端 FastAPI + Socket.IO 静态 Console runtime-config.js 浏览器页面 连接实际端口

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 配置,避免引入额外依赖
AppConfigconfig.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 也使用它。这个设计让“用户配置”和“实际运行状态”之间有一层明确的转换,不会让前端猜后端端口。