WebSocket 实时通信与 文件上传 应用
前阵子给一个内部小工具加需求:任务在后台跑,跑完了页面要能立刻收到通知,顺便还得支持把生成的日志文件传回服务器。一开始我图省事用的轮询,前端每两秒请求一次接口,结果多开几个页面后端 CPU 就开始飘红了... 于是老老实实换成了 WebSocket,文件上传也顺手走了同一条连接,中间踩了不少坑,这里把实测能跑的方案记一下,免得下次又从头摸一遍。
为什么不用轮询了
轮询的问题很直白:绝大部分请求其实是空转,什么数据都没拿到,但连接建立、断开的开销一次没少。页面一多,后端就先扛不住了。也考虑过 SSE,单向推送确实够用,但后面要把文件上传也塞进同一条通道,双向通信还是得靠 WebSocket,就一步到位了。
先把 WebSocket 跑起来
后端我用的是 Node.js 的 ws 这个库,够轻量,没有 Socket.IO 那堆自动降级的花活儿,现在浏览器原生都支持 WebSocket,基本不需要降级了。
npm install ws 装好之后,服务端最简代码大概长这样:
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 3000 });
wss.on('connection', (ws) => {
console.log('有客户端连进来了');
ws.on('message', (data, isBinary) => {
// isBinary 用来区分文本帧和二进制帧,后面传文件会用到
if (isBinary) return;
// 简单广播给所有客户端,实现实时通知
wss.clients.forEach((client) => {
if (client.readyState === client.OPEN) client.send(data.toString());
});
});
});
前端更简单:
const ws = new WebSocket('ws://localhost:3000');
ws.onmessage = (e) => console.log('收到消息:', e.data);
ws.send(JSON.stringify({ type: 'hello' }));
跑起来之后你会发现,消息确实是毫秒级到的,和轮询那种"隔一秒才看到"的体感完全不一样。调试的时候可以先用 wscat -c ws://localhost:3000 在命令行里把服务端验通,再去看前端,能省不少来回切的时间。
一定要加心跳,不然连接会悄悄断掉
这是我踩的第一个坑。本地调试一切正常,一挂到 nginx 后面,页面放个几分钟再操作,消息就收不到了——连接早被 nginx 默认的 60 秒超时给掐了,而且浏览器那边还不一定触发 onclose,你就干等着。
解决方案就是心跳:客户端每隔一段时间发个 ping,服务端回个 pong,让这条连接一直"活着"。
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
}, 30000);
服务端收到 ping 原样回个 pong 就行。另外 nginx 那边记得加上这几行,少了 Upgrade 头的话连握手都过不去:
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
文件上传:切片 + 二进制帧
本来文件上传我可以直接走 HTTP 的 multipart/form-data,但实时通道都有了,进度条又想做得丝滑一点,就干脆试试直接用 WebSocket 传。思路很朴素:前端把文件切成小块一块一块发,服务端按顺序拼回去。TCP 本身保证顺序,所以不用操心乱序,只需要约定一个极简协议:开始时先发一条 JSON 说明文件名和大小,中间纯二进制帧一路发,发完再发一条 JSON 表示结束。
前端切片发送:
async function uploadFile(file) {
const CHUNK = 64 * 1024; // 64KB 一片
ws.send(JSON.stringify({ type: 'upload-start', name: file.name, size: file.size }));
for (let offset = 0; offset < file.size; offset += CHUNK) {
const chunk = await file.slice(offset, offset + CHUNK).arrayBuffer();
ws.send(chunk);
setProgress(Math.min(100, Math.round(((offset + CHUNK) / file.size) * 100)));
}
ws.send(JSON.stringify({ type: 'upload-end', name: file.name }));
}
服务端接收并落盘:
const fs = require('fs');
const uploads = new Map(); // 每个连接挂一个写入流
ws.on('message', (data, isBinary) => {
if (isBinary) {
uploads.get(ws)?.write(data);
return;
}
const msg = JSON.parse(data.toString());
if (msg.type === 'upload-start') {
// 图省事加了个时间戳前缀,严格点还应该过滤文件名里的特殊字符
uploads.set(ws, fs.createWriteStream(`/data/uploads/${Date.now()}-${msg.name}`));
}
if (msg.type === 'upload-end') {
uploads.get(ws)?.end();
uploads.delete(ws);
ws.send(JSON.stringify({ type: 'done', name: msg.name }));
}
});
ws.on('close', () => {
uploads.get(ws)?.end();
uploads.delete(ws);
});
不出问题的话,前端进度条会一路平滑跑到 100%,服务端目录下就能看到完整的文件了。重启服务端之前记得确认流都 end() 过了,不然容易写出半个文件。
几个额外要留意的点
ws默认maxPayload是 100MB,单帧别超过这个数,这也是为什么要切片,而不是把整个文件一口气send过去。- 一个连接同时只传一个文件最省心,想并发传多个的话,得在协议里加个 taskId 把二进制帧和文件对应起来,复杂度会上一截。
- 断线重连记得做,最简单的就是
onclose里setTimeout几秒后重建连接,没传完的文件从头再传一遍。文件不大的话完全可接受,别学我一开始非要折腾断点续传,白熬了一晚上。 - 服务端日志记得把连接数打出来,
wss.clients.size一行的事,排查"到底连上没有"的时候特别好用。
整体下来,WebSocket 这套东西上手不难,难的都是这些边边角角的细节。上面的代码直接抄过去就能跑,等真遇到更复杂的场景(比如要房间广播、自动重连带退避),再考虑上 Socket.IO 也不迟。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。