简介:electron-udp 是一款基于 Electron 框架与 JavaScript 语言实现的 UDP 通信调试工具源码,面向网络编程初学者和有 UDP 服务验证需求的开发者,提供图形界面下的数据报收发测试方案,适用于局域网联调、实验教学和快速验证等场景。它支持在桌面应用中同时添加多个 UDP 客户端与服务器,任意指定目标主机和端口,并配备随机数据生成功能和定时发送能力,用来检测远程或本地 UDP 服务是否按照预期运行,也可用于功能演示和简单压力验证。压缩包共包含 30 个文件,以 JavaScript 脚本作为核心模块,覆盖客户端管理、服务器管理、数据生成、界面渲染等功能;CSS 文件负责布局与样式美化,PNG 图片记录界面截图,JSON 保存依赖与项目配置,整体体积仅为 301KB,轻量易读、便于二次开发。项目附带完整的运行与构建说明,既可通过 npm 安装依赖后启动运行,也能打包为 EXE 发行版;同时拥有 README 和模块化源码,适合作为学习 Electron 桌面应用开发与 UDP 协议实现的实战参考。该资源已有 1300 人学习浏览,实用性与参考价值已经获得验证。
1. 一个能跑在桌面上的 UDP 调试工具:Electron 与 dgram 的组合
做网络协议调试的人,大概率经历过这样的场景:想在局域网里发一个自定义 UDP 数据报,手边只有命令行工具,要么是nc -u敲起来不直观,要么是抓包软件看半天不知道自己的报文格式对不对。这个electron-udp项目就是为解决这个问题来的——它把 UDP 客户端和服务器都塞进了一个 Electron 桌面应用里,通过界面就能启动监听、指定目标端口、收发数据报,所有操作都有即时反馈。底层用的是 Node.js 内置的dgram模块,不依赖第三方库,代码量很小,适合有 JavaScript 基础的开发者照着改、学着用,也适合在网络课上做演示工具。
2. 为什么选 Electron 做 UDP 工具:桌面 UI 与 Node dgram 的配合
2.1 dgram 模块:UDP 的底层语义与生命周期
UDP 和 TCP 最大的区别在于连接性:TCP 要先握手建立连接,UDP 不需要,直接往ip:port扔数据报就行。dgram模块封装了这套逻辑,核心接口是dgram.createSocket(type),其中type可以是'udp4'或'udp6'。创建出来的 socket 有两种典型用法:绑定本地端口,或者不绑定直接发送。绑定端口后,socket 可以调用socket.on('message')监听收到的数据报;不绑定也能用socket.send()指定目标地址和端口把数据发出去,系统会临时分配一个随机源端口。
这个项目的实用之处在于,它把这两种用法同时暴露在界面上:服务器区负责绑定端口、接收数据、可选地回发数据;客户端区负责从指定地址和端口发送数据。你可以先启动服务器,监听127.0.0.1:9000,然后用客户端往同一个端口发消息,看到服务器区实时显示接收到的内容,整个过程完全在本地完成,不需要局域网内的第二台机器。
生命周期上,dgram.Socket是一个 EventEmitter,关键事件有message、listening、error和close。调用socket.bind(port, address)后,会触发listening事件,之后才能接收数据。项目里如果用户点了「启动服务器」但没有任何反应,多半是listening事件没触发而error事件先到了。后面避坑章节会专门讲这个问题。
2.2 主进程与渲染进程的职责划分
Electron 应用分为主进程和渲染进程。Node.js 的dgram模块只能在主进程里用,因为渲染进程默认没有 Node 环境,就算打开了nodeIntegration,网络 socket 这种底层资源也不应该在 UI 线程里直接操作。所以这个项目的架构很标准:主进程持有 socket 实例,负责收发数据;渲染进程只负责画界面、收集用户输入、显示数据。
两者之间通过ipcRenderer.invoke和ipcMain.handle通信。比如用户点击「发送」按钮,渲染进程会调用window.udpApi.sendData(...),这个 API 由 preload 脚本通过contextBridge暴露,底层是ipcRenderer.invoke('udp:sendData', ...)。主进程收到后,拿到 socket 的指针执行socket.send,然后把结果返回给渲染进程。数据到达时,主进程通过webContents.send('udp:onData', data)主动推到渲染进程,渲染进程再用回调更新界面。
这种分工也意味着,你改 UI 时不会碰到 socket 相关的逻辑,想改收发规则时也不用去翻 HTML 文件。项目把每一条 IPC 通道都用统一的前缀udp:命名,后续添加新功能(比如清空日志、切换 Hex 显示)时,照着这个模式加通道就行。
2.3 数据流与事件驱动的收发模型
UDP 收数据是一次性的:每收到一个数据报,message事件回调里会拿到两个参数——msg是 Buffer 对象,rinfo包含发送方的地址、端口和报文大小。项目把rinfo里的信息拼成一行日志,再配合msg.toString('utf8')显示内容。这里有个初学者容易踩的坑:msg是 Buffer,直接String(msg)会得到[object Object],必须显式调用toString()并指定编码。
发送数据走的是一条独立的路径。渲染进程传入目标 IP、端口和字符串内容,主进程里用Buffer.from(message)转成 Buffer,再调用socket.send(buffer, 0, buffer.length, port, address, callback)。回调里能拿到error对象,如果发送失败(比如目标端口没有服务在监听,UDP 通常不会报错,但如果本机 socket 已经关闭会报ERR_SOCKET_DGRAM_NOT_RUNNING),项目会把这个错误原样回传给界面,而不是让它静默吞掉。
事件驱动的核心在于,所有操作都是异步回调或事件监听,没有阻塞式的等待。这意味着你可以连续快速点击「发送」按钮,不会卡死界面;但如果连续发送时没有做节流,也可能出现消息乱序的问题——UDP 本身不保证顺序,项目里也没有做重排序,这是符合 UDP 语义的,使用时要心里有数。
3. 复现这个项目:从零搭建 electron-udp 的完整步骤
3.1 初始化项目与依赖安装
先建一个项目目录,用 npm 初始化,然后装 Electron 作为开发依赖。我一般会先建好目录结构,再写package.json。目录结构如下:
electron-udp/ ├── package.json ├── src/ │ ├── main.js │ ├── preload.js │ └── renderer/ │ ├── index.html │ └── renderer.js先执行初始化命令:
mkdir electron-udp && cd electron-udp npm init -y npm install --save-dev electron这里把 Electron 装到devDependencies是因为最终打包用electron-builder时,它会把 Electron 运行时打进去,不需要单独作为运行时依赖。初始化出来的package.json里main字段默认指向index.js,要改成src/main.js,否则启动时会找不到入口。同时加上start脚本:
{ "name": "electron-udp", "version": "1.0.0", "description": "UDP client and server built with Electron", "main": "src/main.js", "scripts": { "start": "electron ." }, "devDependencies": { "electron": "^30.0.0" } }npm start会自动查找当前目录下的package.json,读取main字段来定位主进程文件。如果你把main写错成index.js,终端会提示找不到模块,这时别急着装electron重来,先检查这个字段。
3.2 主进程代码:创建 UDP 服务器与客户端
主进程文件src/main.js承担所有 dgram 逻辑。先写整体骨架:
// src/main.js const { app, BrowserWindow, ipcMain } = require('electron'); const dgram = require('dgram'); const path = require('path'); let mainWindow; let serverSocket = null; // 已绑定的服务器 socket let clientSocket = null; // 未绑定的发送 socket function createWindow() { mainWindow = new BrowserWindow({ width: 960, height: 720, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false, }, }); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); mainWindow.on('closed', () => { mainWindow = null; cleanupSockets(); }); } function cleanupSockets() { if (serverSocket) { serverSocket.close(); serverSocket = null; } if (clientSocket) { clientSocket.close(); clientSocket = null; } } app.whenReady().then(() => { registerIpcHandlers(); createWindow(); }); app.on('window-all-closed', () => { cleanupSockets(); if (process.platform !== 'darwin') { app.quit(); } });关键在于registerIpcHandlers里的三个 handler:启动服务器、停止服务器、发送数据。我拆开写:
function registerIpcHandlers() { ipcMain.handle('udp:startServer', async (event, config) => { const { port, address } = config; if (serverSocket) { serverSocket.close(); serverSocket = null; } serverSocket = dgram.createSocket('udp4'); serverSocket.on('message', (msg, rinfo) => { const data = { message: msg.toString('utf8'), raw: msg.toString('hex'), from: rinfo.address, port: rinfo.port, size: rinfo.size, time: new Date().toISOString(), }; mainWindow.webContents.send('udp:onData', data); }); serverSocket.on('error', (err) => { mainWindow.webContents.send('udp:serverError', err.message); serverSocket.close(); serverSocket = null; }); serverSocket.on('listening', () => { const addr = serverSocket.address(); mainWindow.webContents.send('udp:serverListening', { address: addr.address, port: addr.port, }); }); serverSocket.bind(port, address); return { ok: true }; }); ipcMain.handle('udp:stopServer', async () => { if (!serverSocket) return { ok: false, message: '服务器未启动' }; serverSocket.close(); serverSocket = null; return { ok: true }; }); ipcMain.handle('udp:sendData', async (event, payload) => { const { message, targetPort, targetAddress } = payload; if (!clientSocket) { clientSocket = dgram.createSocket('udp4'); } const buffer = Buffer.from(message, 'utf8'); return new Promise((resolve, reject) => { clientSocket.send(buffer, 0, buffer.length, targetPort, targetAddress, (err) => { if (err) { reject(err); } else { resolve({ ok: true, bytes: buffer.length }); } }); }); }); }代码逻辑说明:启动服务器时,先关闭已有 socket 避免端口冲突,然后新建udp4socket。监听message事件后,把 Buffer 同时转成 UTF-8 字符串和十六进制字符串,连同发送方信息一起推给渲染进程。这里mainWindow.webContents.send可能发生在页面加载完成之前,所以需要在createWindow里等到did-finish-load再让界面可操作。我一般在index.html里放一个禁用状态的按钮,收到udp:serverListening后才启用。
发送数据时复用同一个clientSocket,因为它从未绑定端口,所以每次send系统会分配随机源端口。这样做的好处是连续发送时源端口不变,对端回包时能准确送到;坏处是如果你同时开了两个实例,它们的客户端随机端口可能相同,但 UDP 报错概率极低,一般不用管。
3.3 预加载脚本与渲染进程通信
预加载脚本通过contextBridge暴露白名单 API,渲染进程只能调用这些方法,不能直接访问ipcRenderer。这是 Electron 官方推荐的安全做法。
// src/preload.js const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('udpApi', { startServer: (config) => ipcRenderer.invoke('udp:startServer', config), stopServer: () => ipcRenderer.invoke('udp:stopServer'), sendData: (payload) => ipcRenderer.invoke('udp:sendData', payload), onData: (callback) => { const listener = (_event, data) => callback(data); ipcRenderer.on('udp:onData', listener); return () => ipcRenderer.removeListener('udp:onData', listener); }, onServerError: (callback) => { const listener = (_event, message) => callback(message); ipcRenderer.on('udp:serverError', listener); return () => ipcRenderer.removeListener('udp:serverError', listener); }, onServerListening: (callback) => { const listener = (_event, info) => callback(info); ipcRenderer.on('udp:serverListening', listener); return () => ipcRenderer.removeListener('udp:serverListening', listener); }, });onData等方法的返回值是一个取消订阅函数,这在 Vue 或 React 里清理副作用很有用。这个项目用的是原生 HTML + JS,没有生命周期钩子,所以我把取消函数挂在全局,窗口关闭时不需要手动调用,因为主进程会销毁整个 WebContents。
参数说明:startServer的config对象包含port和address。address如果不传,bind会绑到0.0.0.0,就是所有网卡都能收到数据;如果传127.0.0.1,只有本机回环能收到。这是很多人的困惑点——局域网另一台机器发数据能收到,本机自己发的反而收不到,可能就是绑到了回环地址。
3.4 界面逻辑:绑定端口、发送数据报
渲染进程的 HTML 只需要一个简单的布局,两个输入区域加两个按钮,再加上一个日志列表。这里给出关键 JS 逻辑:
// src/renderer/renderer.js const serverPortInput = document.getElementById('serverPort'); const serverAddressInput = document.getElementById('serverAddress'); const startServerBtn = document.getElementById('startServerBtn'); const stopServerBtn = document.getElementById('stopServerBtn'); const logList = document.getElementById('logList'); window.udpApi.onData((data) => { const item = document.createElement('li'); item.textContent = `[${data.time}] 来自 ${data.from}:${data.port} (${data.size} bytes): ${data.message}`; logList.prepend(item); }); window.udpApi.onServerListening((info) => { startServerBtn.disabled = true; stopServerBtn.disabled = false; logList.prepend(`${new Date().toISOString()} 服务器已监听 ${info.address}:${info.port}`); }); startServerBtn.addEventListener('click', async () => { const port = parseInt(serverPortInput.value, 10) || 8000; const address = serverAddressInput.value.trim() || '0.0.0.0'; await window.udpApi.startServer({ port, address }); }); stopServerBtn.addEventListener('click', async () => { await window.udpApi.stopServer(); startServerBtn.disabled = false; stopServerBtn.disabled = true; }); // 发送区处理 const messageInput = document.getElementById('messageInput'); const targetPortInput = document.getElementById('targetPort'); const targetAddressInput = document.getElementById('targetAddress'); const sendBtn = document.getElementById('sendBtn'); sendBtn.addEventListener('click', async () => { const message = messageInput.value; const targetPort = parseInt(targetPortInput.value, 10) || 9000; const targetAddress = targetAddressInput.value.trim() || '127.0.0.1'; try { const result = await window.udpApi.sendData({ message, targetPort, targetAddress }); logList.prepend(`[${new Date().toISOString()}] 发送 ${result.bytes} bytes 到 ${targetAddress}:${targetPort}`); } catch (err) { logList.prepend(`[${new Date().toISOString()}] 发送失败: ${err.message}`); } });这段代码的逻辑很清楚:点击「启动服务器」时,读取端口和地址,调用 preload 暴露的 API。主进程完成bind后,会通过onServerListening回调更新按钮状态。发送区则更简单,直接拼参数调用sendData,主进程返回发送字节数后显示在日志顶部。
注意logList.prepend把新日志放在最上方,这样消息多的时候不需要滚动就能看到最新内容。如果你习惯把新消息加在底部,注意调整,否则排查时会看漏。
4. 参数配置与边界场景:端口、缓冲区、消息分割
4.1 参数表:端口、地址、缓冲区、广播标志
用这个项目做实际调试前,先把关键参数摸清。下表整理了dgram模块涉及到的常用配置项,以及在这个项目界面里对应的输入框。
| 参数 | 位置 | 默认值 | 说明 | 典型坑 |
|---|---|---|---|---|
port | 服务器监听端口 | 8000 | UDP 端口范围 0-65535,0 表示随机分配 | 端口被占用时bind不报错,但要到error事件才提示 |
address | 服务器绑定地址 | 0.0.0.0 | 所有网卡;127.0.0.1仅回环 | 绑错地址会导致收不到局域网消息 |
targetPort | 客户端目标端口 | 9000 | 发送目标端口 | 目标端口没有服务时,UDP 不报错,消息直接丢弃 |
targetAddress | 客户端目标地址 | 127.0.0.1 | IPv4 地址或主机名 | 写localhost也行,但解析可能走 IPv6 |
message | 发送内容 | 空字符串 | 会按 UTF-8 编码 | 中文字符串长度按字节算,界面显示可能不一致 |
| 缓冲区 | 内置SO_RCVBUF | 由系统决定 | 可通过socket.setRecvBufferSize()调整 | 大消息超过接收缓冲区会被截断 |
这个项目没有暴露缓冲区调整接口,但你在二次开发时可以直接在主进程serverSocket.bind之后调用serverSocket.setRecvBufferSize(64 * 1024)。UDP 数据报最大理论长度是 65507 字节(IPv4 payload),但实际能接收多少取决于系统缓冲区。如果调试时发现大报文丢了,先别怀疑代码逻辑,用netstat -u看看接收队列是不是溢出。
4.2 多消息与粘包处理
UDP 是有边界的:每次send对应一次message事件,即使发送方连续发两条,接收方也会分别收到两次回调。所以不存在 TCP 里那种「粘包」问题。但这里有个反直觉的坑:如果你连续发送太快,接收方可能看到message事件触发的顺序和发送顺序不一致,这是因为 UDP 不保证顺序。局域网内延迟极低,乱序概率很小,但不能完全排除。
我在实际测试时习惯给每条消息加上序号前缀,比如[1] first、[2] second。接收端日志里如果序号颠倒,说明网络确实乱序了,这时候要调整发送频率,或者接受 UD P 的语义不做重排。这个项目本身没有重排逻辑,所以在界面里看到乱序不要以为是 bug。
还有一个数据分割的问题:当消息内容超过 MTU(通常 1500 字节)时,IP 层会分片,但dgram会在接收时重组成一个完整的 Buffer,所以应用层感知不到分片。但如果超过接收缓冲区或超过 65507 字节,数据就会丢。项目界面上我建议加一个字符计数提示,超过 60000 字节就弹警告,避免用户输入超长内容后以为发成功了。
4.3 本地回环与网络接口选择
0.0.0.0监听所有接口意味着任何网卡上的数据都能到达这个 socket。如果你的机器同时连着 Wi-Fi 和有线网,0.0.0.0能收到两个网卡的数据。这在调试时很方便,但也会带来安全风险:局域网内其他机器知道端口后,可以往你这个端口发任意数据。所以如果你只需要本机调试,建议地址填127.0.0.1,这样外部机器无法访问。
发送数据时的目标地址则不受服务器绑定地址影响。客户端发送时,操作系统根据目标地址自动选择合适的网卡走路由。比如目标地址是192.168.1.100,数据会从 Wi-Fi 网卡出去;如果是127.0.0.1,则只走回环接口。有一个常见现象:服务器绑定了0.0.0.0,客户端往本机公网 IP 发送,也能收到,因为数据先走了物理网卡,再被回环到监听 socket。这个行为在不同操作系统上略有差异,Windows 和 macOS 都支持,但 Linux 的rp_filter可能会丢弃这种包,所以遇到收不到的情况,先试着把目标地址改成127.0.0.1。
5. 常见问题与避坑:UDP 调试中容易翻车的四个细节
5.1 端口被占用却没有任何报错
现象:启动服务器后,界面提示「服务器已监听」的日志迟迟不来,也没有任何错误提示,但 netstat 发现端口被其他进程占着。
原因:dgram.Socket的bind方法是异步的,如果端口被占用,它不会立即抛出同步异常,而是把错误派发到error事件。项目代码里虽然监听了error,但如果error事件触发的时机早于listening事件,用户界面可能只收到udp:serverError而没有收到udp:serverListening,但错误提示被渲染进程的onServerError回调处理了,如果回调里只是console.log,界面上自然看不到。
解决:在渲染进程里把onServerError的回调也写到日志区域,而不是只放 console。另外在启动服务器前主动检测端口占用,常见做法是在主进程里先尝试创建一个临时 socket 绑定同端口,绑定失败就提示端口被占用。我一般直接依赖error事件,但确保 UI 上能看到它。这里给出修改建议:
window.udpApi.onServerError((message) => { logList.prepend(`[${new Date().toISOString()}] 服务器错误: ${message}`); startServerBtn.disabled = false; stopServerBtn.disabled = true; });5.2 收到数据但界面不刷新
现象:服务器明明收到了数据,主进程message回调里的console.log能看到输出,但界面日志区域一直空白。
原因:主进程通过webContents.send('udp:onData', data)推送数据,但渲染进程里的onData注册时机太晚。如果服务器在渲染进程还没执行到window.udpApi.onData(...)之前就收到了第一条消息,这条消息的事件就被错过了。另一种可能是preload.js里有多个onData监听器,旧监听器虽然被创建但从未移除,新监听器加了以后,前面的旧回调抛异常导致后续回调不执行。
解决:确保在页面加载完成后再启动服务器,或者把onData的注册代码放到 HTML 文件的<script>末尾,并且不要用window.onload包裹。更稳妥的方式是在主进程发数据前判断mainWindow.webContents.isLoading(),如果还在加载就缓存消息,等did-finish-load后再统一发送。我在项目里用的办法是,在渲染进程中先注册好所有监听器,再允许用户点击启动按钮,按钮初始是禁用状态,页面就绪后才启用。
5.3 客户端发送成功但服务器收不到
现象:点击发送按钮后,界面提示「发送 N bytes 到 192.168.1.5:8000」,但服务器那边一点动静没有。
原因:最常见是目标地址写错了,看起来像是同一网段,实际配置了多个虚拟网卡,数据走了另一个路由。另一个可能是服务器的绑定地址不是0.0.0.0,而是127.0.0.1,这样局域网内的数据包到达机器后,不会再回环到这个 socket 上。还有一种隐蔽情况:本机开了防火墙,出站 UDP 被拦截,但发送方不知道,因为 UDP 既不会报错,也没有 ACK。
解决:先做回环测试,把目标地址改成127.0.0.1,如果通了说明代码没问题。如果回环能通,局域网不通,检查服务器的绑定地址和防火墙。在 Linux 上可以用sudo iptables -L看 OUTPUT 链;在 Windows 上检查「允许应用通过防火墙」。我自己的排查顺序是:先回环,再换目标 IP,再绑定0.0.0.0,最后抓包。
5.4 关闭窗口后进程还在后台跑
现象:点掉窗口,应用窗口没了,但任务管理器里 node 进程还在,占着 UDP 端口。
原因:主进程的window-all-closed事件里虽然有app.quit(),但如果 socket 没有被正确关闭,dgram的句柄会阻止进程退出。尤其当serverSocket仍处于监听状态时,Node 事件循环里还有活跃的 socket 对象,即使窗口关闭了,进程也不会自动结束。
解决:在cleanupSockets里对每个 socket 调用close(),并且close()接受一个回调,可以在回调里确认退出。更保险的做法是在before-quit事件里强制清理:
app.on('before-quit', () => { cleanupSockets(); });另外注意socket.close()是幂等的,重复调用不会报错,但在一个 socket 已经触发error后,调用close可能会再次触发error,建议在close前先移除所有事件监听器,或者用socket.unref()让 socket 不阻止进程退出。对于临时发送用的clientSocket,unref()是安全的。
6. 再进一步:把 electron-udp 扩展成协议测试平台
这个项目目前的定位是「能跑起来的 UDP 调试工具」,但它很容易扩展成更实用的协议测试平台。我在实际工作中,给它加过三个功能,这里分享一个最简单也最有用的:自动回显。
在服务器模式下,把收到的数据原样发回给发送方,就能省去手动来回打字的麻烦。实现方式很简单,在主进程的message回调里增加一个回显开关:
serverSocket.send(msg, 0, msg.length, rinfo.port, rinfo.address);注意这里的msg是 Buffer,复用之前收到的原始数据,不需要再转字符串。回显的地址和端口取自rinfo,而rinfo.port是发送方的源端口,所以回显一定能到达。
第二个值得加的功能是 Hex 显示切换。UDP 报文经常是二进制格式,UTF-8 解码出来全是乱码。我在日志区域加了一个 checkbox,选中时显示raw字段(十六进制字符串),不选中时显示message字段。主进程在message事件里已经把两种格式都计算好了,界面上只是切换显示,不增加任何额外开销。
第三个功能是发送历史记录。把每次发送的目标地址、端口、内容和时间存成一个数组,渲染进程里做简单渲染,方便比对前后两次报文的差异。这个功能对协议联调特别有用,因为 UDP 没有会话状态,你很难记住之前发了什么。
最后的进阶技巧是组播支持。如果你要调试局域网内多台设备之间的组播通信,可以把dgram.createSocket('udp4')改成dgram.createSocket({ type: 'udp4', reuseAddr: true }),然后调用socket.addMembership('239.0.0.1')。这样 socket 就能收到组播地址上的数据报。但要注意,组播默认只在特定网卡生效,需要先socket.bind(port, '0.0.0.0')拿到一个随机端口,再addMembership。我在这上面吃过亏,以为绑定了组播地址就行,结果收不到,后来才明白组播地址只能用于加入,不能用于绑定。
从那以后,我每次拿到一个网络工具,都会先检查它的错误处理路径:UDP 这种无连接协议,表面安静,实际可能到处都是洞。这个项目把客户端和服务器放在一个界面里,回环测试变得非常方便,也让我养成了「每改一个参数先看日志」的习惯。希望这篇拆解能让你少走弯路,真正把这个小工具用起来。
本文还有配套的精品资源,点击获取