ARTICLE · 1355864
FUXA 源码解读 ④|实时通讯
01
一句话结论
FUXA 的实时通道只有一条 socket.io 连接,和后端 REST 共用同一个 http 端口;下行主要就一个 device-values 事件,上行复用同一个事件名、靠 cmd:'set' 反向。服务端收发都收口在 runtime/index.js 的两个函数里——updateDeviceValues 负责「发给谁」,socket.on('device-values') 负责「谁在写」——所以读实时层只需要盯住这一对函数,不必满仓库找 emit。
02
入口与调用链
连接从 main.js:278-288 建立:socketIO(server, {...}) 把 socket.io 挂到已经建好的 http server 上,路径 BASE_PATH + '/socket.io',pingInterval 60 秒、pingTimeout 120 秒,并开启 allowEIO3 让 v2 客户端也能连。注意这里没有单独监听端口,前端访问页面和连 socket 打的是同一个地址。
服务端的连接处理在 runtime/index.js:133-462,一个 io.on('connection') 里做完四件事:
- 鉴权
(137-159):从 socket.handshake.query.token 取 JWT,没有就换成 guest token(140 行),然后 jwt.verify;secureEnabled 为假时直接把 isAuthenticated 置真(144 行)。校验失败就 socket.disconnect() 并 return——连不上的客户端连日志都看不到业务错误。 - 初始化每个 socket 的订阅数组
(135 行):socket.tagsClientSubscriptions = []。 - 注册上行 handler
(166-462):device-status、device-values、device-tags-subscribe、daq-query、alarms-status 等。 - 登记断开日志
(161-163)。
下行方向不是在这里发的,而是靠事件总线接出来:runtime/index.js:127 把 events.on('device-value:changed', updateDeviceValues) 挂上,驱动层只管抛事件,转发逻辑与 socket 实例解耦。同一组挂载还有 device-status:changed(128 行)和 tag-change:subscription(130 行)。
前端侧,hmi.service.ts:206-212 用 io(socketUrl.origin, { path, query }) 建连接,query 里就一个 token;connect 回调里立刻 tagsSubscribe()(215 行)——每次重连都会重报一遍订阅,这是断线恢复能自愈的关键。
把「打开一个画面」按时间轴排一遍,就能看出首屏值是这么来的:
页面加载,hmi.service 建 socket;此时 socket.connected 还是 false。 - connect
触发 → tagsSubscribe() → 发 device-tags-subscribe,tagsId 是「视图标签 ∪ 首页标签」去重后的数组。 服务端覆盖该 socket 的订阅数组(418 行),因为带了 sendLastValue,立刻把 devices.getDevicesValues() 的当前值推一遍(421-426)。 之后的值都靠轮询出口(updateDeviceValues)推送,前端 onDeviceChanged 逐条更新变量表。
如果画面打开后「一直是空、几秒后才出值」,说明第 2 步的 sendLastValue 没生效或订阅数组为空——这时看服务端有没有打出 device-tags-subscribe 的日志,比在浏览器里翻 Network 快。

双向事件表
▲ 上行七个事件、下行七类事件;没有独立的「写值事件」,写与读共用 device-values。
03
核心数据结构
socket 对象上被塞了四个业务字段,它们决定这个连接能做什么:
| tagsClientSubscriptions | ||
| userId | ||
| userGroups | ||
| isAuthenticated |
服务端还有一个全局订阅表 tagsSubscription(runtime/index.js:701-706 的 subscriptionTagChange),键是标签 id、值是 true。它和 socket 上的订阅数组是两套东西:
| socket.tagsClientSubscriptions | |||
| tagsSubscription |
消息体本身很薄。tagsToSend(692-699)只保留四个字段:
{ id: tag.id, value: tag.value, timestamp: tag.timestamp, quality: tag.quality }
rawValue、changed、daq 这些内部字段一律不出网。前端拿到的 quality 是设备侧给的连接质量,不是采集质量;判断「值可不可信」要看 device-status,这一点在第 5 段会再提一次。
事件名本身没有共享的协议包:服务端一份枚举在 server/runtime/events.js:7-36,前端一份同名枚举在 hmi.service.ts:798-828。两边都是手写的字符串字面量,改一个不改另一个,编译期不会报错,运行时表现为「消息发出去没人响应」——排查这类问题时,先把两边的事件名并排抄一遍。
04
关键流程
一次值变化,三条去向:

值分发路径
▲ 同一个出口函数里做三种分发;只有前两条会到浏览器。
具体分支在 runtime/index.js:654-690:settings.broadcastAll === false 时逐 socket 过滤(657-675),否则 io.emit 全量广播(676-681);无论哪条,最后都会给 tagsSubscription 里的标签发一次 tag-value:changed(682-686),报警评估和脚本触发都挂在这条内部事件上。
订阅是「全量替换」,不是增量。 前端每次订阅都把「视图标签 + 首页标签」合并成数组、去重后再发(hmi.service.ts:481-484):
const mergedArray = this.viewsTagsSubscription.concat(this.homeTagsSubscription);let msg = { tagsId: [...new Set(mergedArrayResolvedAdapter)], sendLastValue };this.socket.emit(IoEventTypes.DEVICE_TAGS_SUBSCRIBE, msg);
服务端收到后直接赋值覆盖(418-420),并且先做类型防御:不是数组就清空。带 sendLastValue: true 时,服务端立刻把当前所有设备的值推一遍(421-426)。
写值是「乐观更新 + 失败回滚」。 前端发 device-values 时 cmd:'set',服务端在 203-221 校验 isSocketWriteAuthorized;不通过就回一条 cmd:'set-unauthorized',前端 hmi.service.ts:263-265 收到后重新 askDeviceValues(),把界面上提前改掉的值拉回真实值。
心跳是单向的。runtime/index.js:464-466 每 heartbeatIntervalSec(默认 10 秒)io.emit('heartbeat', {message: 'FUXA server is alive!'})。它只证明「服务端进程在跑」,不证明设备在连——设备连接状态走独立的 device-status 事件(166-175 与 712-717)。
断线之后怎么回来。前端没有自己写重连循环,全交给 socket.io 客户端的内置重连(hmi.service.ts:209-212 只传了 path 与 query,没关 reconnection,也没设重试上限)。代码只挂了三处观察点:reconnect_attempt 只打日志(221-223)、reconnect_failed 与 error 调 safeReloadIfServerAlive()(224-232)、connect_error 里只有错误信息含 CORS 才走刷新(233-239)。重连成功 → 触发 connect → 重报订阅 → 服务端补推当前值,这条闭环是「网络闪断后画面自动恢复」的全部机制。
safeReloadIfServerAlive()(736-748)值得单独看一眼:它先 GET /api/version 探活,探通了才 window.location.reload(),所以不会在服务端还活着但 socket 断了的情况下盲目刷新。这个探活用的正是第 01 篇提到的那个免鉴权接口。
历史查询也在这条通道上。 画面里的曲线走 daq-query / daq-result 这组消息(events.js:19-20),不是逐点去开 REST 请求。好处是复用已经建立的身份,不必再给历史查询补一套鉴权;代价是「一次拉几千个点」的批量查询会挤在实时通道里,和值推送抢同一根管子。
05
设计取舍与坑
1)device-tags-unsubscribe 的服务端 handler 是空的。runtime/index.js:431-436:
socket.on(Events.IoEventTypes.DEVICE_TAGS_UNSUBSCRIBE, (message) => { try { } catch (err) { ... }});
try 块里一行代码都没有。前端确实会发这个事件(hmi.service.ts:507-512),但服务端不做任何处理——退订的唯一方式是下一次「全量订阅」把旧的覆盖掉。如果某个页面切走后不再有任何订阅动作,那个 socket 会一直保留旧数组,继续收到它不再需要的值。
2)broadcastAll 默认是全开。 判断是 === false 才进订阅模式(657 行),而 settings.default.js 里没有这一项,默认 undefined → 走 io.emit。标签几千个、客户端几十个的工程里,每条变化都是一次全量 emit 加一次 JSON 序列化。要省带宽就得显式写 broadcastAll: false,但那时过滤成本转到服务端:每个 socket 一次 subscriptions.includes(tag.id),O(socket 数 × 订阅数 × 变化标签数)。
3)tagsSubscription 只增不减。subscriptionTagChange(701-706)是 set,全进程没有任何 delete。报警和脚本一旦订阅过某个标签,这条记录就永久留着,tag-value:changed 会对每一个命中标签都发一次。工程里删掉大量标签后,这个 Map 不会自己收缩。
4)secureOnlyEditor 会把写值鉴权整体关掉。isSocketWriteAuthorized(40-45)第一个判断就是:
if (!settings || !settings.secureEnabled || settings.secureOnlyEditor) return true;
也就是说开了 secureOnlyEditor(只保护编辑器)之后,任何能连上 socket 的人都能写设备值,包括 guest。这个开关的本意是「画面只读、编辑器要登录」,但实现上放行的是写值通道,不是「禁止改工程」。现场要开放画面给非授权人员时,务必确认这一项为 false。
5)token 只在握手时校验一次。jwt.verify 出现在 147 行,之后整条连接的生命周期内不再校验。所以token 过期不会让已建立的连接失效,一个长期开着的大屏页面可以一直持有当初那份权限。反过来,改权限、禁用用户也不会立刻踢掉在线连接——除非服务端重启(那时 secretCode 若为随机生成,所有连接一起失效,见第 01 篇)。
6)sendLastValue 是一颗广播炸弹。 421-426 行直接复用 updateDeviceValues 推全量,而这个函数是给所有 socket 发消息的。一个客户端刚打开页面请求「最后值」,全体在线客户端都会收到一轮全量推送。人少时无感,几十个大屏同时刷新页面时会看到一波突发流量。
7)内部订阅和前端订阅互不影响。 682-686 那条分支不看 socket.tagsClientSubscriptions,只要标签在 tagsSubscription 里就发。好处是「没人看页面时报警照常工作」;代价是无法通过「关掉页面」来降低报警侧的开销。
8)device-status 只接受 'get',其它一律拒绝。 166-175 行收到非 'get' 的消息只打一条 rejected client status update 警告。前端无法把设备的连接状态改成任何值,状态唯一来源是驱动自己的 _emitStatus。在浏览器控制台手工 emit 一条 device-status 只会换来一条 warn,界面不会有任何变化——这里是刻意的单向性,别拿它当调试手段。
06
小结 + 下一篇
实时层的设计是「一个出口、两种分发、一套鉴权」:驱动只负责抛事件,updateDeviceValues 决定广播还是定向,isSocketWriteAuthorized 决定谁能写。它够简单,也够脆——订阅是全量覆盖、退订是空实现、心跳只管进程不管设备,这三条决定了排查「页面不刷新」时必须先分清是「没订上」「没发出去」还是「设备没采到」。
一个可执行的排查阶梯,按顺序走:
服务端日志有没有 socket.io client connected 与订阅记录——排除「根本没连上」; DevTools 里 socket 帧有没有 device-values 下行——没有就查 broadcastAll 与订阅数组; - device-status
是不是 connect-error——排除设备侧; 用 getValues()(REST 或脚本)看缓存值是否在变——排除轮询是否在跑。
这四步分别落在连接、分发、设备、轮询四层,不会互相掩盖。
下一篇进 05 HMI 视图渲染:views 表里那份 JSON 怎么变成一个可运行的画面,fuxa-view 怎么把控件属性和 device + tag 路径绑起来,以及为什么「换一台设备就要重配控件」在这套模型里是必然的。













































































