ARTICLE · 1357237
FUXA 源码解读 ⑧|脚本与调度
01
一句话结论
FUXA 里有两个「调度」:用户脚本在主进程用 Module._compile 直接执行,定时靠 1 秒 tick 与 node-schedule;设备排程是独立的一套,用 node-schedule 把时间段映射成标签的 0/1,并对标签做主控回写。脚本没有沙箱,异常被函数体内的 catch 变成返回值。
02
入口与调用链
服务启动时三件事按顺序发生:runtime/index.js:122 建 scriptsMgr,:495 调 scriptsMgr.start(),:77-83 初始化 schedulerStorage 与 schedulerService。脚本管理器自己是一个三段状态机(scripts/index.js:19-23):INIT 编译、LOAD 读工程、IDLE 每秒扫一遍 schedulingMap(:137-166)。
执行入口有三类,最后都落到同一个 runScript:
前端测试与手动运行:POST /api/runscript(api/scripts/index.js:36-62)→ scriptsMgr.runScript(scripts/index.js:76-101)→ msm.runScript(msm.js:58-70); 报警动作:alarms/index.js 的 RUN_SCRIPT 分支直接调 runtime.scriptsMgr.runScript; 设备排程动作:handleRunScript(scheduler-service.js:1553-1571)构造一个只有 id 与 parameters 的临时脚本对象再调同一个入口。
定时入口在 start() 的 setInterval(_checkStatus, 1000)(scripts/index.js:29-40)。LOAD 阶段(:226-245)把 interval 型脚本放进 schedulingMap,把 cron 型脚本转成 node-schedule job 存进 scheduledJobs。设备排程则完全不经过这个 tick,由 schedulerService.init 注册两类回调:tag-value:changed → onTagChanged(:33)和 io.connection → setAllInitialStates(:36-38)。
到前端有两条 socket:SCRIPT_CONSOLE(hmi.service.ts:334-336)与 SCRIPT_COMMAND(:337-339)。$setView/$openCard 在 script.service.ts:208-220 里只是构造一条 ScriptCommandMessage 交给 hmiService.onScriptCommand,服务端 scriptSendCommand 用 io.emit 发给所有客户端(runtime/index.js:774-779)。
03
核心数据结构

两套调度
▲ 用户脚本用 1 秒 tick + node-schedule,设备排程用 node-schedule + 标签主控,两者的失败可见性完全相反。
脚本侧(scripts/index.js):schedulingMap 以脚本名为键(:22),值是 ScriptSchedule(:327-380),字段只有 id / name / scheduling / lastRun / created。它的两个方法就是全部调度语义:isToRun(time) 处理 start(!lastRun && elapsed > interval)与 interval(elapsed > interval);getScheduleRules() 把 schedules[] 转成 node-schedule 的 RecurrenceRule(时/分/星期几)或一个绝对 Date(月份模式)。
msm.js 侧维护三样:scriptsMap(脚本名 → 原始脚本对象)、systemFunctions(系统函数表)、scriptsModule(一次编译出来的大模块)。测试模式还会注入 initEvents(:14-16:events = _events; id = _id),让脚本里的 console.log 能通过 events.emit('script-console') 回传浏览器。
排程侧(scheduler-storage.js):独立 SQLite 库 scheduler.db,一张 schedulers(id TEXT PRIMARY KEY, data TEXT, created_at, updated_at)(:47-54)。data 是整包 JSON,里面 settings.devices[] 给出设备名与 variableId,schedules[设备名][] 给事件:startTime / endTime / days / months / daysOfMonth / duration / eventMode / recurring / disabled。客户端页面只读这份数据,服务端据此建 job。
04
关键流程

脚本执行
▲ 编译一次、全局可调;异常在函数体内部就被吃掉,所以外层永远看不到失败。
1. 编译。_scriptsToModule(msm.js:99-137)把每个脚本拼成 async function name(params) { try { <code> } catch (e) { console.log(e); return JSON.stringify(e); } }(:113),最后加一行 module.exports = {...},用 Module._compile 编译成内存模块(:139-145)。脚本里的 require 是 Node 原生 require,path 还被额外注入成常量(:13),所以脚本能加载任意包。
2. 执行。runScript(msm.js:58-70)先把参数按 p.value 优先展开(:60),若带 id 则用已加载的最新版本覆盖(:61-63),再调用。runTestScript(:43-56)不复用这份模块,而是复制 scriptsMap、替换目标脚本、带 eventsIncludes 重新编译,所以测试模式的 console.log 才有效。
3. 定时脚本。 interval 型每秒判一次(scripts/index.js:158-164),命中就 _runScheduledScript 并把 lastRun 写回内存;start 型因为 !lastRun 只成立一次,实际语义是「启动后延迟 interval 秒执行一次」。cron 型交给 node-schedule(:236-238),进程重启后 job 由 LOAD 重建,lastRun 不落库,所以重启会丢掉「上次跑到哪」。
4. 设备排程。createSchedulerJobs(scheduler-service.js:185-206)先对每个设备的 variableId 发 tag-change:subscription(:194-196),再为每个事件建 start/end 两个 job(createEventJob,:211+)。到点后 writeTagFromEvent(:775-802)写标签、补发 device-value:changed,并用 schedulerWriting 集合挡 1 秒,防止自己写的值再触发主控回写。
5. 主控回写。onTagChanged(:822-871)拿到变更标签后先看 schedulerWriting 是否包含它,再判断当前时段是否应有事件生效;如果外部写入(包括操作员手动置位)与期望值不符,立刻按期望值回写。这就是「排程设备独占」的实现:谁改都会被改回去。
6. 开关量与事件态。 普通时段用 startTime/endTime 两个 cron;eventMode 事件在开始时刻写 1,再起一个 duration 秒的 setTimeout 写 0(scheduler-service.js:374-458),所以事件型排程只依赖开始时刻,结束时刻由时长推出来。
7. 热更新是整表重载。updateScript 与 removeScript 都只是 reset()(scripts/index.js:59-70),把状态打回 LOAD;下一 tick 的 _loadProperty 先 scheduledJobs.forEach(cancel) 再清空重建(:215-247),全部脚本重新编译。改一个脚本会顺带让所有 cron job 断一次,重载完成前的那一拍不会有定时脚本运行。
05
设计取舍与坑
1. 脚本等于服务进程。Module._compile 在主进程执行,require 未被拦截,系统函数又被写到 global(msm.js:29-34)。脚本能读写文件、起子进程、装包、改任何全局状态;isAuthorised 只回答「谁能跑脚本」,不回答「跑的时候能碰什么」。要隔离只能在进程/容器层面做,代码里没有第二道门。
2. 异常被吞,定时失败不可见。 包装层的 catch 把异常 console.log 到 stdout 并 return JSON.stringify(err)(msm.js:113),于是 runScript 的 Promise 永远正常 resolve。_runScheduledScript 的 .catch 因此永不触发,scheduledScriptFailures 永远是空集(scripts/index.js:168-187),「首次失败记 error、恢复记 info」这条日志链路形同虚设。现场表现是脚本停跑而服务端日志干净。此条记为 D-14。
3. CLIENT 模式只挡了 interval,没挡 cron。LOAD 里 interval 分支有条件 script.mode != 'CLIENT'(:230),cron 分支没有(:232-240)。所以一个标成客户端执行的脚本,只要排程方式是「时/分/星期几」,仍会在服务端被调度、在服务进程里执行;浏览器那侧同时也会跑一遍。此条记为 D-13。
4. 每个客户端连上都会重写排程标签。io.connection 不分角色直接 setAllInitialStates()(:36-38)→ setInitialStates(:163-180)→ 对每个设备 writeTagFromEvent。这里没有比较当前值,setTagValue 直接下发到设备(devices/index.js:296-306);也就是说每来一个客户端(含手机重连、刷新、看板轮询),所有排程设备都会收到一遍 0/1 写命令。对写寿命有限的寄存器、或带副作用的线圈(复位、启停),这是实打实的多写。此条记为 D-12。更麻烦的是事件型排程:连上客户端时若当前不在任何事件窗口,expectedValue 为 0,writeTagFromEvent 照样下发(:171-175),等于对本来就没开的设备再补一次「关」。schedulerWriting 只防回环,不防重复写。
5. lastRun 从不落盘,每次重载都会「补跑」。ScriptSchedule 构造时固定 this.lastRun = 0; this.created = now(scripts/index.js:331-332),而 isToRun 对 interval 型的判断是 time - lastRun > interval(:338)。lastRun 为 0 时这个条件必然成立,所以启动、重启、以及任何一次 updateScript/reset 之后,所有 interval 脚本会在第一个 tick 立即执行一次;start 型则退化成「启动后延迟 interval 秒执行」,每次重载都重新计。编辑任意一个脚本都会让全部定时脚本重跑一拍,做初始化写入或报工的脚本最容易暴露这个问题。此条记为 D-15。
6. 排程动作传参可能为 null。handleRunScript 用 action.actoptions?.params || null 当参数(scheduler-service.js:1565),而 msm.runScript 第一行就是 _script.parameters.map(...)(msm.js:60),对 null 直接抛 TypeError。异常发生在 try 之外、落在 ScriptsManager.runScript 的 Promise 里,最终表现为一条 runtime.scriptsMgr 错误日志、动作不执行。旧工程或手工构造的排程最容易踩到。
7. 排程命令是广播,不是定向。scriptSendCommand 用 io.emit(runtime/index.js:774-779),$setView 会让所有在线客户端一起跳页;对报警弹窗是想要的效果,对「只给某个工位看」的脚本就是越权。要定向得自己在脚本里按客户端筛选,运行时没有提供通道。
8. 两套存储、两种恢复。 脚本随工程走(SetScript 触发 scriptsMgr.updateScript,runtime/index.js:555-558),排程单独进 scheduler.db。备份工程时如果只拷工程文件,排程会丢;反过来只拷 scheduler.db,排程里的设备名对不上新工程也恢复不了。迁移时两边都要带上。
9. 系统函数是一等 API,脚本只是调用方之一。POST /api/runSysFunction(api/scripts/index.js:68-90)允许管理员按名字直接调 global 上的系统函数,白名单由 sysFunctionExist 校验(scripts/index.js:125-128),最终落到 runSysFunction 的 global[functionName](...params)(msm.js:87-89)。换句话说 $getTag、$setTag、$getHistoricalTags 这些能力本身不区分调用者是脚本还是 HTTP,权限只拦在接口入口。理解这一点,09 篇看鉴权时会顺很多。
10. toLogEvent 是个没接线的参数。runScript(script, toLogEvent = true) 在签名里保留了这个开关(scripts/index.js:76),但 77-91 的函数体里从未读取它;注释还写着「Removed logging for script execution」。调用方(报警、排程、REST)传 true 或 false 都没有区别,别指望靠它打开/关闭脚本执行日志。
06
小结 + 下一篇
脚本与调度这一篇要带走四件事:编译一次、全局共享(Module._compile + global);定时靠 tick 与 cron 两条腿(isToRun 与 getScheduleRules);排程设备靠主控独占(onTagChanged 回写);失败被吞(异常变返回值,scheduledScriptFailures 失效)。这四点决定了脚本层「好用但不可信、出问题很难查」的性格。
排查顺序也据此定:先看服务端 stdout(异常只在那里),再看 /api/runscript 的返回字符串(错误会伪装成正常结果),最后才去查排程数据。反过来,如果设备侧出现「莫名多写一次」,先怀疑客户端连接触发的 setAllInitialStates,而不是脚本本身。
下一篇进入 REST API 与鉴权:脚本用的 /api/runscript、命令接口的 sourceScriptName 白名单、JWT 与 secureFnc 中间件到底在哪一层生效,以及为什么同一条接口对管理员和普通用户返回不同的字段。














































































