简介:这是一款基于Electron和Node.js net模块实现的UDP调试工具,面向需要测试和验证UDP通信的桌面端开发者。程序支持在任意主机和端口上添加多个UDP客户端与服务器,并内置随机数据生成器,可按固定间隔发送数据报,简单实用。资源共30个文件,以JavaScript逻辑、CSS样式、HTML页面和JSON配置为主,附带图标与字体等静态资源,压缩包仅301KB,结构简洁明了。项目提供完整源码、package.json依赖清单及打包脚本,读者可通过npm install安装依赖,使用npm start本地调试,执行npm run build生成Windows可执行文件。已有1299人学习下载,适合初学者理解Electron多窗口与Node.js网络编程的配合方式,也可直接改造用于自定义UDP测试场景。
1. 一个临时凑合的 UDP 调试工具,怎么就值得用 Electron 专门做一遍
做嵌入式或者网络调试的人,几乎都经历过这种场景:设备连上了局域网,固件里 UDP 上报的数据死活不对,你想在电脑上赶紧开个端口看一眼数据包,结果翻遍系统自带的工具,要么只能单向发、要么收包不显示时间戳、要么界面丑到没法长时间盯着看。最后往往是现写一个几十行的 Python 脚本顶上去,可脚本一多,参数、端口、数据格式全得改代码,调试一次改一次,越弄越乱。electron-udp 这个方向解决的正是这类问题:把 UDP 客户端和服务器做进一个跨平台桌面应用里,用界面管端口、管收发、管数据格式,底层还是 Node.js 原生的 dgram 模块,不引入额外依赖。
这个方案适合谁?适合那些需要频繁调试局域网设备、写传感器数据采集 Demo、验证自定义二进制协议的开发者。Electron 在这里不是用来做重型业务系统的,它只是把一个“本该是命令行工具”的东西,包上了一层看得见、点得动的界面。本文会从主进程架构讲起,给出最小可跑的客户端和服务端代码,再把收发数据报时那些隐蔽的坑一个个挑出来。
2. 先把架构理清楚:为什么 UDP 逻辑必须住在 Electron 主进程
2.1 渲染进程碰不到 dgram,这是 Electron 的安全模型决定的
Electron 应用由主进程和渲染进程组成,渲染进程跑的是网页逻辑,默认情况下require('dgram')根本不可用,因为渲染进程的 Node.js 集成在安全配置下是关闭的。即便你为了省事打开了nodeIntegration,UDP socket 的事件循环和渲染进程的 UI 线程混在一起,也会出现收包时界面掉帧、状态不同步的奇怪问题。常见做法是让主进程独占所有 UDP 操作,渲染进程只负责展示和转发用户意图。
两者之间通过 IPC 通信:渲染进程调用ipcRenderer.invoke或send把“我要发这些字节”传给主进程,主进程用 dgram 的 socket 真正把数据报发出去;主进程收到 UDP 消息后,再通过webContents.send把数据推给渲染进程,由界面完成展示。这个单向数据流的模型非常好排查:UDP 收不到就查主进程,界面不刷新就查事件名是否匹配,两个问题互不干扰。
2.2 preload 脚本:把暴露给渲染进程的 API 收窄到只剩几个方法
主进程和渲染进程之间如果裸用ipcRenderer,渲染进程就有了任意发送 IPC 事件的能力,这在安全上不可取。更好的做法是在 preload 脚本里用contextBridge暴露一个受控的 API 对象,比如window.udp.send(port, host, buffer)、window.udp.startServer(port)、window.udp.onMessage(callback)。这样渲染进程永远不知道ipcRenderer长什么样,它只认识那三四个方法。
preload 脚本的配置在 BrowserWindow 的webPreferences里完成,相关参数是preload、contextIsolation: true、nodeIntegration: false。这三个参数是 Electron 安全基线,没有特殊情况不要改。主进程里注册对应的ipcMain.handle来接收渲染进程的请求,消息通道名建议统一前缀,比如udp:send、udp:start、udp:stop、udp:message,前缀命名在多个模块共存时能有效避免事件名冲突。
3. 把最小客户端和服务端跑起来:主进程里的完整代码
3.1 主进程 UDP 服务端:listen + message 事件是最短路径
下面这段代码是 UDP 服务端的最小实现,放在 Electron 主进程文件里,作用是监听本机指定端口并把收到的数据广播给渲染进程:
const { app, BrowserWindow, ipcMain, webContents } = require('electron'); const dgram = require('dgram'); let mainWindow = null; let udpServer = null; function sendToRenderer(channel, payload) { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, payload); } } ipcMain.handle('udp:start-server', (event, port) => { return new Promise((resolve, reject) => { if (udpServer) { udpServer.close(); udpServer = null; } const server = dgram.createSocket({ type: 'udp4', reuseAddr: true }); udpServer = server; server.on('error', (err) => { console.error('udp server error:', err.message); if (err.code === 'EADDRINUSE') { reject(new Error('端口 ' + port + ' 被占用,换个端口再试')); } else { reject(err); } }); server.on('message', (msg, rinfo) => { const data = { time: Date.now(), remoteAddress: rinfo.address, remotePort: rinfo.port, hex: msg.toString('hex'), text: msg.toString('utf8') }; sendToRenderer('udp:message', data); }); server.bind(port, () => { const address = server.address(); resolve({ port: address.port, address: address.address }); }); }); });这段代码的逻辑很直接:createSocket创建 UDP socket,bind开始监听,message事件在每收到一个数据报时触发。rinfo里带的是发送方地址和端口,这是调试时最需要的信息,所以我把时间戳、来源地址、HEX 字符串、UTF-8 文本四样东西一起打包发给渲染进程。reuseAddr: true是为了避免频繁重启服务时端口进入 TIME_WAIT 状态导致绑定失败,后面避坑章节会展开说。
参数说明:udp:start-server这个通道是异步的,所以用ipcMain.handle配合 Promise 返回结果,渲染进程可以用await等待“端口绑定成功”的确认。createSocket的第一个参数是 socket 类型,udp4表示走 IPv4,IPv6 环境改成udp6即可,但本机调试场景里 udp4 覆盖绝大多数设备。
3.2 主进程 UDP 客户端:send 之前别忘了一件事
客户端比服务端简单,核心就是创建 socket 然后往指定地址和端口发数据,但很多第一次写的人会漏掉socket.bind()这一步。不 bind 直接 send 在 Node.js 里也能工作,系统会自动分配一个临时端口,但某些网络环境下设备端会校验源端口,导致回包发不回来。所以我习惯在发送前显式绑定一个本地端口:
let udpClient = null; let clientBoundPort = 41234; ipcMain.handle('udp:send', async (event, { host, port, hexString }) => { if (!udpClient) { udpClient = dgram.createSocket({ type: 'udp4', reuseAddr: true }); udpClient.on('error', (err) => { console.error('udp client error:', err.message); }); await new Promise((resolve, reject) => { udpClient.bind(clientBoundPort, '0.0.0.0', () => resolve()); }); } const buf = Buffer.from(hexString.replace(/\s+/g, ''), 'hex'); await new Promise((resolve, reject) => { udpClient.send(buf, 0, buf.length, port, host, (err) => { if (err) reject(err); else resolve(); }); }); return { ok: true, bytes: buf.length, boundPort: clientBoundPort }; });这段代码里我把输入设计成十六进制字符串,因为调试网络协议时最容易看懂、最不容易被文本编码干扰的格式就是 HEX。Buffer.from(hexString, 'hex')会把aa bb 0d 0a这样的字符串转成字节流,注意我先把所有空白字符去掉了,这样用户复制串口工具里的 HEX 数据直接粘贴就能发,不用手工清理。
参数说明:send函数的签名是socket.send(buffer, offset, length, port, host, callback),其中offset和length控制从 buffer 的哪个位置取多少字节。我写的是0和buf.length,也就是整包发送。host支持 IP 地址和域名,但在局域网调试场景里最好直接用 IP,避免 DNS 解析引入不必要的延迟和不确定性。
3.3 preload 桥接:渲染进程怎么安全拿到这两个能力
主进程的handle注册好了,接下来要写 preload 脚本把 API 暴露给渲染进程:
const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('udp', { startServer: (port) => ipcRenderer.invoke('udp:start-server', port), send: (host, port, hexString) => ipcRenderer.invoke('udp:send', { host, port, hexString }), onMessage: (callback) => { const listener = (_event, data) => callback(data); ipcRenderer.on('udp:message', listener); return () => ipcRenderer.removeListener('udp:message', listener); } });onMessage的处理有个细节:它返回了一个取消订阅函数,渲染进程在组件卸载或页面切换时必须调用它,否则会重复注册监听。Electron 里ipcRenderer.on每次调用都会新增一个监听函数,一个页面被打开关闭几次之后,旧监听还挂在 IPC 通道上,就会收到重复消息,界面出现一堆重复数据。
参数说明:onMessage没有直接用ipcRenderer.on把事件对象_event传给回调,而是只传data。这是故意的——渲染进程不需要关心 Electron 内部的事件结构,它只需要拿到数据。preload 在这里扮演了隔离层的角色,让渲染进程的代码保持干净。
4. 数据收发要做到能干活:Buffer 处理、广播、HEX 转换这些细节别跳过
4.1 收到的字节别急着转字符串:先存 HEX,文本按需解码
调试 UDP 时最常见的翻车点是用msg.toString()去读收到的数据,然后打印出来是一堆乱码,于是怀疑设备端格式错了。实际上问题出在编码上:UDP 数据报是二进制字节流,设备可能发的是 GBK、UTF-16 甚至裸的浮点数结构体,toString('utf8')只是其中一种解释方式。
更稳妥的做法是像前一节代码那样,把msg.toString('hex')作为主数据保存,文本只作为辅助展示字段。对十六进制再按固定协议解析出具体字段,是后续的事,但至少界面上不会出现乱码。如果你要解析自定义协议,传 HE 给一个协议解析函数即可:
function parseSensorPayload(hexStr) { // 示例:前 2 字节是设备 ID,第 3-4 字节是温度值(小端) const buf = Buffer.from(hexStr, 'hex'); if (buf.length < 5) return null; return { deviceId: buf.readUInt16BE(0), temperature: buf.readUInt16LE(2) / 100.0 }; }4.2 IPv4 广播:同一局域网内给所有设备发同一份数据
做设备发现功能时经常要用到广播。UDP 广播就是把数据包发送到子网的广播地址,比如192.168.1.255端口 6000,这样同网段所有设备都能收到。Node.js 的 dgram 默认不允许发送广播包,需要显式开启soBroadcast:
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); socket.setBroadcast(true); const buf = Buffer.from('discover', 'utf8'); await new Promise((resolve, reject) => { socket.send(buf, 0, buf.length, 6000, '192.168.1.255', (err) => { if (err) reject(err); else resolve(); }); });这里的坑在于广播地址不是固定的255.255.255.255,它取决于你所在子网。常见做法是拿本机 IP 和子网掩码算出来,或者直接给界面加一个“广播地址”输入框让用户自己填。对调试工具来说,让用户填更可靠,因为工具的使用者通常都知道自己设备的网段。
setBroadcast(true)必须在send之前调用,否则发送广播包会报EACCES权限错误。另外 Windows 防火墙会对 UDP 广播弹授权窗口,第一次运行时如果没有弹出来,去防火墙设置里给应用放行 UDP 规则,否则广播包发出去了但收不到回包。
4.3 收发线程模型:dgram 的消息回调里不要做耗时操作
dgram 的message事件回调运行在 Node.js 的事件循环线程里,不是独立的接收线程。这意味着如果你在回调里做同步的磁盘写入、复杂的 JSON 解析、或者调用阻塞的第三方库方法,整个应用的 UDP 收发都会被卡住,表现为收包延迟飙升、界面数据断断续续。
正确的做法是回调里只做“拷贝数据 + 转发给渲染进程”这两件事,把耗时操作放到队列里异步执行:
server.on('message', (msg, rinfo) => { pendingPackets.push({ time: Date.now(), rinfo: { address: rinfo.address, port: rinfo.port }, hex: msg.toString('hex') }); // 立即返回,不在这里解析协议 });然后让一个定时器每 100ms 取一批数据送去做协议解析和界面刷新。这个批处理模式在高频率数据上报场景下特别重要,它能平滑掉突发流量导致的界面卡顿。渲染进程那边也不要每条消息都触发一次 DOM 更新,可以每批消息合成一次udp:message-batch事件推送过去。
4.4 从数据报里取字节:大小端别默认用大端
Node.js 的Buffer既有readUInt16BE也有readUInt16LE,选错大小端在调试时相当坑。很多单片机设备上报数据时用的是小端序,比如温度传感器把 25.5 度编码成两个字节0x33 0x09,按小端读是 0x0933 = 2355,再除以 100 得到 23.55 度;如果按大端读就是 0x3309 = 13065,除以 100 变成 130.65 度,差了五倍多。
我的建议是在界面加一个大小端切换下拉框,默认小端。做法是解析函数接收一个endian参数:
function readInt16(buf, offset, endian) { return endian === 'little' ? buf.readInt16LE(offset) : buf.readInt16BE(offset); }这个选项在调试阶段非常实用,因为你往往不确定设备手册里写的大小端是真是假(手册和固件不一致的情况真不少)。有这个切换功能,至少能在界面上立刻验证哪个解析结果符合物理常识。
5. 从能跑到跑稳,UDP 调试工具避坑清单
5.1 防火墙拦截:程序没写错,但包就是进不来
现象:代码在本地回环地址127.0.0.1收发正常,改成局域网 IP 后只能发不能收,服务端message事件一次都不触发。
原因:Windows 和 macOS 的防火墙默认拦截入站 UDP 数据报。UDP 没有连接概念,防火墙无法像 TCP 那样通过“主动发起的连接”来自动放行回包。
解决:Windows 上打开“允许应用通过防火墙”,把应用的专用和公用网络复选框都勾上。如果应用还在开发阶段,快捷方式指向的是 Electron 的二进制入口,需要放行的是实际的 exe 文件。macOS 则在系统设置-隐私与安全性-防火墙里手动允许。
5.2 端口绑定冲突:上次退出没释放,重启直接报错
现象:开发环境下频繁Ctrl+C杀掉应用再重启,第二次启动时bind报EADDRINUSE。
原因:UDP socket 关闭后端口不会立即释放,尤其是还有待处理数据报时,端口会停留在某种占用状态持续一小段时间。另外如果你开了两个应用实例,第二个实例绑定同一端口也会冲突。
解决:双重保险。第一,socket 创建时设置reuseAddr: true允许地址复用;第二,退出应用时在主进程before-quit事件里显式关闭所有 socket:
app.on('before-quit', () => { if (udpServer) { try { udpServer.close(); } catch (e) {} } if (udpClient) { try { udpClient.close(); } catch (e) {} } });注意socket.close()之后要把变量置为null,否则下次startServer时会因为udpServer非空而先执行一个已经关闭的 socket 的close(),那不会报错,但也不会释放对应的资源。
5.3 收包收到一半的 Buffer:没有粘包,但 Buffer 会被复用
现象:连续收到的两个数据报,第一个的尾部字节出现在第二个的开头,或者第二个数据比实际发送的多出几个字节。
原因:Node.js 的 dgram 传给message回调的msg是一个 Buffer,它在回调执行完毕后可能被内部复用。如果你把msg存到数组里、或者异步操作里引用它,等真正去读的时候 Buffer 内容已经被下一次收包覆盖了。
解决:在回调里立刻拷贝:
server.on('message', (msg, rinfo) => { const copy = Buffer.from(msg); // 拷贝,不再引用原 Buffer pendingPackets.push(copy); });Buffer.from(msg)会分配一块新内存,把数据完整复制过去。这样原 buffer 被复用也不影响。这是 Node.js dgram 那些年最隐蔽的坑之一,尤其在高频收包时表现得神出鬼没。
5.4 界面收不到推送:mainWindow 变量没赋值或已被销毁
现象:主进程message回调里明明打印了数据,但渲染进程的onMessage回调就是不执行。
原因:mainWindow.webContents.send发送的时机,mainWindow 可能还是null(应用还没创建窗口)或者已经被销毁(窗口关闭了但主进程还活着)。webContents.send在目标不存在时会安静地失败,不报任何错误。
解决:封装一个发送函数,发送前做判空:
function sendToRenderer(channel, payload) { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, payload); } }如果窗口可以被关闭后重新创建,还要确保创建新窗口时重新给mainWindow赋值,否则旧的已销毁引用还留在变量里。
5.5 UDP 丢包:不要拿 UDP 当 TCP 用,数据量大要自己想办法
现象:设备端每 10ms 发一包,电脑端界面显示每秒只能收到几十包,其余的全丢了。
原因:UDP 本身不保证送达,任何环节都可能丢包:设备发送缓冲区溢出、路由器丢包、电脑接收缓冲区溢出、应用处理速度跟不上。在事件循环里做耗时操作是应用侧最常见的原因。
解决:先把耗时操作从message回调中移走,再看操作系统层面。Linux 可以调net.core.rmem_max和net.core.rmem_default增大接收缓冲区,Windows 则可以在代码里设置 socket 的接收缓冲区大小:
const server = dgram.createSocket({ type: 'udp4', reuseAddr: true, recvBufferSize: 1024 * 1024 });recvBufferSize的单位是字节,设为 1MB 对大多数调试场景足够。但也要明白,UDP 丢包在极端流量下无法完全避免,如果业务要求不丢,该上 TCP 或 RUDP 就上,别在 UDP 上硬扛。
6. 把调试工具做得像样:三个让收包界面好用到哭的小技巧
第一个技巧是收包计数和速率统计。在渲染进程里用一个累加器,每秒算一次包数和字节数,放界面顶部的状态栏。这个数据在判断设备是否正常上报时特别直观——如果设备说在发,但速率是 0,问题就在链路;如果速率跳来跳去但不稳定,问题多半在设备端发包节奏。实现上不要每个包都更新 DOM,用setInterval每秒更新一次即可。
第二个技巧是协议模板保存。调试一个固定设备时,设备序列号、上报端口、HEX 结构体是固定的,每次打开工具重新输入一遍太折磨。把一组调试参数存成 JSON 文件,界面提供“加载模板”按钮。模板里存发送内容、解析规则、大小端设置。这样换设备调试时,点两下就能切到对应的参数环境。
第三个技巧是收包暂停功能。高频率收包时界面刷得太快,肉眼根本来不及看其中某一包的内容。加一个“暂停滚动”开关,暂停后新包照常计数,但列表不再滚动、选中行的数据固定住,方便仔细分析。实现上用一个paused布尔值控制列表是否执行滚动和追加,数据仍然追加到内存数组里,取消暂停后一次性补齐显示。
最后一个习惯是保留“原始 HEX 复制”按钮。选中任意一条收包记录,点一下就把完整的十六进制字符串复制到剪贴板,格式带空格,正好能粘回发送框里回发给设备。这个动作在回环测试和协议复现时每天都在用。把这些小功能加起来,这个工具就不再是一个“简单到不好用”的 Demo,而是能真正跟着你进实验室干活的趁手家伙。我当年就是靠这几个小功能,把一个临时凑合的调试界面,一路打磨成了团队里每个人电脑上都装着的小工具。希望帮到你。
本文还有配套的精品资源,点击获取