小Cの已经记不起来的博客

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() 过了,不然容易写出半个文件。

几个额外要留意的点

整体下来,WebSocket 这套东西上手不难,难的都是这些边边角角的细节。上面的代码直接抄过去就能跑,等真遇到更复杂的场景(比如要房间广播、自动重连带退避),再考虑上 Socket.IO 也不迟。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放