夜雨聆风学习资料网

ARTICLE · 1357234

FUXA 源码解读 ⑩|集成与部署

FUXA 源码解读 ⑩|集成与部署

01

一句话结论

FUXA 的三种交付形态(容器、桌面版、单 EXE)跑的是同一个 server/main.js,差别不在代码分支,而在进程外面套的那层壳:谁 fork 它、userDir 指到哪、端口从哪来。部署翻车的地方也集中在三处 —— _appdata/settings.js 生成后不再被覆盖、Electron 把端口写死成 1881、Node-RED 的兜底路由只在它自己启用时才补上。

02

入口与调用链

main.js 的前 100 行基本就是在回答"我在哪、我的数据放哪":

  1. main.js:59-79
    :rootDir = __dirname、workDir = cwd/_appdata;如果 process.env.userDir 存在,两者都改到 userDir 下(:63-66),命令行 --userDir 优先级更高(:68-71);最后 mkdirSync(workDir)(:77-79)。
  2. main.js:85-99
    :配置文件固定叫 <userDir>/_appdata/settings.js;不存在时才从 server/settings.default.js 复制一份(COPYFILE_EXCL,只复制一次)。
  3. main.js:271-286
    :有 settings.https 就 https.createServer,否则 http.createServer;同一个 server 交给 socket.io(pingInterval 60s / pingTimeout 120s / allowEIO3)。
  4. main.js:343-352
    :端口优先级是 -p 参数 > PORT 环境变量 > settings.uiPort > 1881;uiHost 缺省 0.0.0.0。
  5. main.js:355-363
    :等 init-runtime-ok 事件才调 startFuxa(),然后 FUXA.init(server, io, settings, logger, events)。
  6. main.js:607-657
    :startFuxa 里先挂 EADDRINUSE 处理(:610-622),再尝试挂 Node-RED(失败只 warn,:625-633),再按 disableServer 决定是否挂 FUXA.httpApi(:635-637),最后 server.listen(:639-643)。

settings.httpStatic 为空时走 :644-646 的 server.headless-mode —— 这是第三种形态的开关:只跑设备驱动、脚本、排程,不建 Web 服务。

03

核心数据结构

部署相关的东西其实只有"目录"和"配置文件"两样。

路径
内容
定义位置
<userDir>/_appdata/settings.js
唯一运行时配置,首次从默认文件复制
main.js:85-99
_appdata/_dbusers.fuxap.db
、apikeys.fuxap.db、daq-map_*.db、daq-data_*.db、*_chronicle
settings.default.js:43
、usrstorage.js:59、sqlite/index.js
_appdata/_logs
应用日志与 api.log
settings.default.js:22
、main.js:522
_appdata/_images
、_widgets
用户上传的图片与控件
main.js:258-264
_appdata/_webcam_snapshots
摄像头抓拍,默认 01:00 清理
settings.default.js:128
、main.js:664
<userDir>
 根
brand
 品牌资源从这里读
main.js:82

容器的四个卷就是把 _appdata、_db、_logs、_images 各挂出来(compose.yml:5-9),所以升级镜像不丢数据,前提是你挂对了目录层级 —— 它们都挂在 /usr/src/app/FUXA/server/ 下,而不是 /usr/src/app/FUXA/。

settings.js 里跟部署强相关的几项:uiPort(默认 1881)、uiHost、httpStatic(空则 headless)、disableServer(true 则不挂 REST,仅编辑器)、nodeRedEnabled / nodeRedAuthMode、logApiLevel(默认 tiny,settings.default.js:39)。

04

关键流程

4.1 容器:四阶段构建

Dockerfile 分了四个构建阶段,每个都在解决一个具体问题:

  • client-builder
    (:9-14):固定在 $BUILDPLATFORM 上只构建一次 Angular 产物,多架构构建时共享,避免在 QEMU 下重复跑 ng build;
  • server-builder
    (:17-56):装 python3/build-essential/libsqlite3-dev,npm prune --production 之后强制 sqlite3 从源码重编(:42),这是容器里 sqlite ABI 不匹配的经典解法;ODBC 与 Snap7 都是可选参数(INSTALL_ODBC / NODE_SNAP,:19-20、:38-50);
  • plugins-builder
    (:71-97):把 11 个通讯包(OPC UA、EtherNet/IP、ADS、MC Protocol、BACnet、PCCC、Redis、ODBC、chartjs、webcam)预装到 /plugins_pkg,再由 runner 阶段整体拷进 server/_pkg(:143)。这样容器首启就带全部协议,不用在 UI 里一个个装;
  • runner
    (:100-150):只留运行时库(libcairo2/libpango 是 canvas 的原生依赖,:105-114),ENV NODE_ENV=production、EXPOSE 1881、CMD node main.js。

4.2 桌面版:主进程只负责"拉起来再装窗口"

app/electron/main.js 的流程很短:fork 一个 server/main.js,env 里塞 userDir 和 PORT: 1881(:512-515);然后 waitForServer('http://localhost:1881', 30000, 1000) 轮询(:537),就绪后 win.loadURL('http://localhost:1881')(:542)。项目选择、最近项目、全屏、自动启动这些都在 Electron 侧,配置写在 %APPDATA%/fuxa-app/config.json(:52-54)。

也就是说桌面版并没有把服务端"嵌入式化",它就是个带窗口的进程管理器 —— 窗口崩了不影响服务,服务挂了窗口白屏。

4.3 单 EXE:同一份二进制,两种模式

headless-guardian.js 用环境变量 FUXA_SERVER 切换角色:

  • FUXA_SERVER=1
    (:33-64):解析 -p 写进 PORT,把数据目录定为 EXE 同级 data(:43-53),然后 require('./server/main.js');
  • 默认(守护模式):spawn(process.execPath, []) 拉起自己(:118-122),子进程退出就 2 秒后重启,最多 1000 次;10 秒内连续重启超过 5 次会强制冷却 10 秒(:75-80、:95-101)。

这里有两个很"实战"的细节。第一,pkg 不认命令行参数(会被当 JS 文件路径),所以端口靠环境变量传给子进程(:117);第二,pkg 快照下 spawn 的子进程句柄不保持父进程事件循环,父进程会"空闲退出"并连带杀掉子进程,于是用一个绝不 unref 的 15 秒 setInterval 强行保活(:157-167),注释把原因写得很清楚。

headless-entry.js 是同一思路的精简版:没有守护,直接 fork 并转发 SIGINT/SIGTERM(:44-65)。

三种壳子,一个内核

▲ 三条交付线都收敛到 server/main.js,差别是 userDir、端口来源和"崩了谁来拉起"。

4.4 Node-RED:挂在同一个 server 上

mountNodeRedIfInstalled(integrations/node-red/index.js:365 起)的关键顺序,main.js:625-627 在 server.listen 之前调用它:

  1. 先 installNodeRedWebSocketAuth(server, ...)(:470)—— 因为 Express 中间件对 HTTP upgrade 不生效,所以用 server.prependListener('upgrade') 抢在 Node-RED 自己的 upgrade 监听前面验签,失败直接写 401 并 socket.destroy()(:273-280);
  2. RED.init(server, redSettings)
    (:471)复用同一个 server;
  3. /nodered
     挂 RED.httpAdmin、/dashboard 挂 RED.httpNode,两者前面都套同一个中间件(:477-478);
  4. 注册 /fuxa/devices、/fuxa/scripts、/fuxa/views、/fuxa/alarms 四个只读端点给编辑器下拉框用(:482-526);
  5. 最后补 SPA 的 app.get('*')(:532-538)。

中间件的判定(:299-363):baseUrl === '/dashboard' 直接放行;legacy-open 模式整段跳过;否则 API Key 或"已登录且非 guest 且管理员",否则 401/403。注意它比 FUXA 自己的 REST 更严格 —— FUXA 会把坏 token 降级成 guest,Node-RED 直接 401。

flow 里能碰到的东西都挂在 functionGlobalContext.fuxa 上(:401-440):getTag / setTag / getDaq / getAlarms / getHistoryAlarms / ackAlarm / emit / on / sendMessage。另外顺手给了 fs、http、https、dgram、dns、crypto、sqlite3、serialport(:443-460),child_process 和 net 要显式打开 nodeRedUnsafeModules 才注入(:462-465)。

Node-RED 挂进同一个 HTTP 服务

▲ 一条 listen 上两套路由,危险点全在"Express 不管 upgrade"这一条上。

4.5 Swagger 是可选件

mountSwaggerIfEnabled(main.js:542-561)读 settings.swagger || settings.swaggerEnabled,缺 swagger-ui-express 或 yamljs 就只 warn 不报错,文档来自 server/docs/openapi.yaml,挂在 /api-docs。容器镜像里默认没装这两个包,所以"镜像里看不到 /api-docs"是预期的,不是坏了。

05

设计取舍与坑

配置只生成一次,之后不会跟着镜像升级变。settings.js 用 COPYFILE_EXCL 复制(main.js:93),已经有了就跳过。这解释了最常见的困惑:升级到新版本后,"默认值明明改了,现场还是老样子"。要改默认行为,必须手工编辑 _appdata/settings.js,或删掉它重启。

index.html 是启动时缓存进内存的。main.js:338 把整个 index.html 读成字符串,:500-505 用它直接响应所有 shell 路由。前端重新构建后不重启服务,浏览器拿到的仍是旧壳 —— 只有静态资源走 express.static(:513-515)会更新。这是取舍:省了每次读盘,代价是"改了没生效"。

Electron 的 1881 是硬编码的。:513 直接 PORT: 1881,没有回退逻辑。本机已有实例占着这个端口时,server.listen 触发 EADDRINUSE → process.exit(1)(main.js:610-621),桌面版只会表现为"加载 30 秒后报错",日志在子进程里,用户在窗口里看不到。

Node-RED 与 SPA 兜底是耦合的。app.get('*') 只写在 mountNodeRedIfInstalled 里(index.js:532-539),且外面套了 disableServer !== false。如果 Node-RED 挂载失败,main.js:628-630 只 warn 并继续 —— 那么这条兜底路由也不会被注册,"深链接直接访问 /view/xxx"就可能 404。这是顺序依赖,不是 bug,但排查时要先看日志里有没有 [Node-RED] Failed to initialize。

legacy-open 等于把编辑器交出去。nodeRedAuthMode === 'legacy-open' 时中间件在 index.js:306-308 直接 next(),/nodered 上的 admin、scripts、devices 全公开。这个模式存在是为了兼容老部署,新装环境不要选。

容器预装插件,非容器要自己装。server/_pkg 里的 11 个包只存在于镜像(Dockerfile:143)。用 EXE 或裸 node main.js 部署时,第一次连 OPC UA / ODBC 会提示缺模块,需要在 UI 里逐个安装,且需要编译工具链。

headless 模式没有 Web,也就没有 REST。httpStatic 为空时根本不 listen(main.js:644-646),disableServer=true 时连 FUXA.httpApi 都不挂(:635-637)。这时"接口调不通"不是权限问题,是服务压根没起。

06

小结

三句话:

  1. 部署的三个旋钮是 userDir、端口、httpStatic,代码本身在三种形态下完全一致;
  2. 真正会咬人的是"配置只生成一次"和"index.html 内存缓存",它们让"改了但没生效"变成默认体验;
  3. Node-RED 是可选外挂,挂在同一个 server 上,成功与否都不该阻塞主服务 —— 但它失败时 SPA 兜底会一起消失。

07

上线前必查 5 项

这五项都能在十分钟内查完,且都是"查得到证据"的,不是靠猜。

① 报警历史是否按 ontime 过滤(D-07)

怎么查:造一条跨零点的报警(23:50 触发、次日 00:10 恢复),用 /api/getAlarmsHistory 以"次日 00:00–01:00"为窗口查询;没有环境就直接看 _db 里 chronicle 表的 ontime 与恢复时间字段,再对照 alarmstorage.js:158 的 SQL 条件。

查到什么算有问题:该条在次日窗口里查不到,但活动报警表里它明明是当天恢复的。这说明历史查询只认开始时间,跨天报警会漏。

② 重启后活动报警的 value 是否还在(D-09)

怎么查:让一个数值报警处于 ON 状态,记下界面显示的具体数值,重启服务,再看报警看板和详情;同时 SELECT * FROM alarms(表结构见 alarmstorage.js:56)。

查到什么算有问题:重启后报警文字里的 %d 原样显示、没有被替换成数值。这说明活动表的持久化里没有 value 字段,重启只能恢复状态、恢复不了数值。

③ 新建标签的首个样本是否入库(D-11)

怎么查:新建一个数值标签并开启 DAQ,写入一次值,等一个采集周期,然后用 /api/getDaqValue 取当天数据,看第一条记录的时间戳;也可以直接查 daq-map_*.db 与 daq-data_*.db。

查到什么算有问题:map 表里已有这个点位,但 data 表的第一条记录明显晚于第一次写入(晚一个轮询周期以上)。对应 sqlite/index.js:201-205:map 是异步登记的,第一批值到达时点位还不在内存映射里。

④ DAQ 切卷周期是否真是配置值(D-10)

怎么查:打开 _appdata/settings.js 搜 daqTokenizer(默认 24),再看 _db 下 daq-data_<id>_<时间后缀>.db 的文件名,算相邻两卷的时间差。

查到什么算有问题:第一卷是 24 小时,第二卷起变成 1 小时。原因是初始化读的是 settings.daqTokenizer(sqlite/index.js:34),而切卷时读的是 settings.daqTimeToken(:147、:245),后者在默认配置里不存在,回退成 db_daqtoken(1 小时,:17)。

⑤ 定时脚本失败是否会被计数(D-14)

怎么查:建一个定时脚本,正文只写 throw new Error('boom'),触发一次后看脚本管理页的失败计数与日志。

查到什么算有问题:失败计数仍是 0,日志里只有一条普通输出。因为 msm.js:113 把异常包成了返回值,scripts/index.js:168-187 的失败分支永远进不去 —— 监控面板会"全绿",而脚本其实全挂。

系列到此收尾。十篇覆盖了从启动链路到部署形态的完整地图;疑似缺陷清单.md 已按 P0–P3 重排,可直接当工单池用。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料