夜雨聆风学习资料网

ARTICLE · 1355864

FUXA 源码解读 ④|实时通讯

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') 里做完四件事:

  1. 鉴权
    (137-159):从 socket.handshake.query.token 取 JWT,没有就换成 guest token(140 行),然后 jwt.verify;secureEnabled 为假时直接把 isAuthenticated 置真(144 行)。校验失败就 socket.disconnect() 并 return——连不上的客户端连日志都看不到业务错误。
  2. 初始化每个 socket 的订阅数组
    (135 行):socket.tagsClientSubscriptions = []。
  3. 注册上行 handler
    (166-462):device-status、device-values、device-tags-subscribe、daq-query、alarms-status 等。
  4. 登记断开日志
    (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 行)——每次重连都会重报一遍订阅,这是断线恢复能自愈的关键。

把「打开一个画面」按时间轴排一遍,就能看出首屏值是这么来的:

  1. 页面加载,hmi.service 建 socket;此时 socket.connected 还是 false。
  2. connect
     触发 → tagsSubscribe() → 发 device-tags-subscribe,tagsId 是「视图标签 ∪ 首页标签」去重后的数组。
  3. 服务端覆盖该 socket 的订阅数组(418 行),因为带了 sendLastValue,立刻把 devices.getDevicesValues() 的当前值推一遍(421-426)。
  4. 之后的值都靠轮询出口(updateDeviceValues)推送,前端 onDeviceChanged 逐条更新变量表。

如果画面打开后「一直是空、几秒后才出值」,说明第 2 步的 sendLastValue 没生效或订阅数组为空——这时看服务端有没有打出 device-tags-subscribe 的日志,比在浏览器里翻 Network 快。

双向事件表

▲ 上行七个事件、下行七类事件;没有独立的「写值事件」,写与读共用 device-values。

03

核心数据结构

socket 对象上被塞了四个业务字段,它们决定这个连接能做什么:

字段
赋值位置
作用
tagsClientSubscriptions
135 / 418-420
该连接的标签订阅数组,广播过滤用
userId
148
JWT 里的用户 id,日志与权限判断
userGroups
149
用户组,报警裁剪用(getSocketPermission,722-730)
isAuthenticated
144 / 150
写值放行的唯一依据(isSocketWriteAuthorized,40-45)

服务端还有一个全局订阅表 tagsSubscription(runtime/index.js:701-706 的 subscriptionTagChange),键是标签 id、值是 true。它和 socket 上的订阅数组是两套东西:

谁维护
用途
生命周期
socket.tagsClientSubscriptions
前端发 device-tags-subscribe 时整体覆盖
决定「值发给谁」
随连接
tagsSubscription
(进程级 Map)
报警/脚本初始化时报上来
决定「值要不要通知内部消费者」
进程级,只增不减

消息体本身很薄。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 决定谁能写。它够简单,也够脆——订阅是全量覆盖、退订是空实现、心跳只管进程不管设备,这三条决定了排查「页面不刷新」时必须先分清是「没订上」「没发出去」还是「设备没采到」。

一个可执行的排查阶梯,按顺序走:

  1. 服务端日志有没有 socket.io client connected 与订阅记录——排除「根本没连上」;
  2. DevTools 里 socket 帧有没有 device-values 下行——没有就查 broadcastAll 与订阅数组;
  3. device-status
     是不是 connect-error——排除设备侧;
  4. 用 getValues()(REST 或脚本)看缓存值是否在变——排除轮询是否在跑。

这四步分别落在连接、分发、设备、轮询四层,不会互相掩盖。

下一篇进 05 HMI 视图渲染:views 表里那份 JSON 怎么变成一个可运行的画面,fuxa-view 怎么把控件属性和 device + tag 路径绑起来,以及为什么「换一台设备就要重配控件」在这套模型里是必然的。

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

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

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

相关学习资料