WebSocket 服务端
HTTP 是"一问一答",服务器无法主动推送数据。WebSocket 建立一条全双工长连接,让聊天、实时通知、在线协作成为可能。Node 生态最常用的是轻量的 ws 和功能全面的 Socket.IO,本文围绕服务端视角讲透两者的使用与细节。
一、WebSocket 服务端方案
| 方案 | 特点 | 适用场景 |
|---|---|---|
ws | 轻量、底层、标准 WebSocket 协议实现 | 简单聊天、低延迟推送、自己封装逻辑 |
Socket.IO | 自动重连、房间、命名空间、HTTP 长轮询降级 | 功能需求多、追求开箱即用 |
uWebSockets.js | 极高吞吐,C++ 内核 | 高并发推送网关 |
bash
npm install ws
npm install socket.ioWebSocket 握手基于 HTTP:客户端发起 Upgrade: websocket 请求,服务端返回 101 后连接升级为双向通道。
二、ws 库使用
2.1 创建服务与连接事件
javascript
const { WebSocketServer } = require("ws");
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws, req) => {
console.log("新客户端接入,来源:", req.url);
// 收到消息
ws.on("message", (data) => {
console.log("收到:", data.toString());
});
// 连接关闭
ws.on("close", (code, reason) => {
console.log("连接关闭:", code, reason.toString());
});
// 出错
ws.on("error", (error) => console.error("连接错误:", error.message));
});2.2 消息收发与广播
javascript
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws) => {
// 发给当前客户端
ws.send(JSON.stringify({ type: "welcome", text: "欢迎加入" }));
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
// 广播:遍历所有客户端发送
wss.clients.forEach((client) => {
if (client.readyState === 1) { // 1 = OPEN
client.send(JSON.stringify({ ...msg, from: "server" }));
}
});
});
});
// 服务端主动推送(如定时心跳、业务事件)
setInterval(() => {
wss.clients.forEach((client) => client.send(JSON.stringify({ type: "ping" })));
}, 30000);2.3 心跳检测
代理、防火墙会静默回收空闲连接,需要应用层心跳保活并清理假死连接:
javascript
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws) => {
ws.isAlive = true;
ws.on("pong", () => { ws.isAlive = true; });
});
// 每 30 秒检查一次
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate(); // 没回 pong,判定死连接
ws.isAlive = false;
ws.ping(); // 发 ping 等 pong
});
}, 30000);
wss.on("close", () => clearInterval(interval));三、Socket.IO 特点
| 特性 | 说明 |
|---|---|
| 自动重连 | 断线后按退避策略自动重连,客户端无需自己写逻辑 |
| 房间(Room) | 按房间分组广播,join/leave 管理 |
| 命名空间(Namespace) | 用 /chat、/news 隔离业务,互不干扰 |
| 事件机制 | socket.emit 发送自定义事件,比裸消息更语义化 |
| Fallback | 不支持 WebSocket 时自动降级为 HTTP 长轮询 |
| 确认回调 | 发送事件可带回调,类似 RPC |
javascript
const { Server } = require("socket.io");
const http = require("http");
const server = http.createServer();
const io = new Server(server, { cors: { origin: "http://localhost:5173" } });
io.on("connection", (socket) => {
console.log("连接:", socket.id);
socket.on("chat message", (msg) => {
io.emit("chat message", { from: socket.id, text: msg }); // 全量广播
});
socket.on("disconnect", (reason) => {
console.log("断开:", socket.id, reason);
});
});
server.listen(3000);四、服务端广播实现
| 方式 | API | 范围 |
|---|---|---|
| 全局广播 | io.emit(event, data) | 所有命名空间的所有客户端 |
| 当前命名空间广播 | io.of("/chat").emit(...) | 某命名空间内全部客户端 |
| 指定客户端 | io.to(socketId).emit(...) | 精确推送给某人 |
| 房间广播 | io.to(room).emit(...) | 房间内所有成员 |
| 排除自己 | socket.broadcast.emit(...) | 广播给除发送者外的人 |
用 ws 手动实现时,遍历 wss.clients 并过滤 readyState 即可;Socket.IO 则把广播、房间全部内置。
五、房间机制
房间让"群聊/私聊"变得简单:客户端加入房间,服务端向房间发消息,只有房间成员能收到。
javascript
io.on("connection", (socket) => {
// 加入房间
socket.on("join room", (room) => {
socket.join(room);
io.to(room).emit("system", `${socket.id} 加入了 ${room}`);
});
// 离开房间
socket.on("leave room", (room) => {
socket.leave(room);
io.to(room).emit("system", `${socket.id} 离开了 ${room}`);
});
// 向房间发消息
socket.on("room message", ({ room, text }) => {
io.to(room).emit("room message", { from: socket.id, text });
});
});
// 服务端也可主动查询房间成员
const members = await io.in("room-1").fetchSockets();
console.log("房间人数:", members.length);六、断线处理与心跳检测
| 阶段 | 处理 |
|---|---|
正常断开(disconnect) | 清理房间、广播下线通知 |
| 非正常断开(网络抖动) | 保存断线前状态,等待自动重连后恢复 |
| 心跳超时 | 服务端定时 ping,客户端回 pong,超时判定死亡并清理 |
javascript
// Socket.IO 内置心跳,但仍需业务级检测
io.on("connection", (socket) => {
socket.conn.on("ping", () => console.log("客户端存活"));
socket.on("disconnect", (reason) => {
// transport close:网络断开,可等待重连
// server namespace disconnect:服务端主动断开
if (reason !== "transport close") {
cleanupSocket(socket);
}
});
});
// 服务端主动踢人
socket.disconnect(true);七、WebSocket 鉴权
WebSocket 握手就是一次 HTTP 请求,可在握手中校验 token,失败则拒绝连接:
javascript
const jwt = require("jsonwebtoken");
const wss = new WebSocketServer({
port: 8080,
// 握手时校验:通过才允许连接
verifyClient: (info, done) => {
const token = new URLSearchParams(info.req.url.split("?")[1]).get("token");
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
info.req.user = user; // 挂载到 req,后续使用
done(true);
} catch {
done(false, 401, "未授权");
}
},
});
wss.on("connection", (ws, req) => {
console.log("认证用户接入:", req.user.userId);
});Socket.IO 通过中间件鉴权:
javascript
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
socket.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
next(new Error("未授权"));
}
});| 方式 | 说明 |
|---|---|
| 查询参数携带 token | ws://host?token=xxx,简单但 token 会进日志,建议短 token |
Sec-WebSocket-Protocol 头 | 握手头携带,客户端需配合 |
Socket.IO auth 字段 | socket.handshake.auth.token,官方推荐 |
八、与 Express 集成
ws 和 Socket.IO 都能挂在同一个 HTTP 服务上,实现"HTTP 接口 + WebSocket 共存":
javascript
const express = require("express");
const http = require("http");
const { WebSocketServer } = require("ws");
const app = express();
const server = http.createServer(app);
// REST 接口
app.get("/api/health", (req, res) => res.json({ ok: true }));
// WebSocket 挂到同一服务
const wss = new WebSocketServer({ server, path: "/ws" });
wss.on("connection", (ws) => {
ws.send("websocket 已连接");
});
server.listen(3000);Socket.IO 同理:new Server(server) 后 HTTP 与 WebSocket 共用 3000 端口,无需额外开放端口。
九、聊天室完整示例
9.1 服务端
javascript
const { Server } = require("socket.io");
const http = require("http");
const server = http.createServer();
const io = new Server(server, {
cors: { origin: ["http://localhost:5173"] },
});
io.use((socket, next) => {
const token = socket.handshake.auth.token;
if (token === "demo-token") return next();
next(new Error("未授权"));
});
io.on("connection", (socket) => {
// 加入公共聊天室
socket.join("lobby");
io.to("lobby").emit("system", `${socket.id} 进入聊天室,当前 ${members()} 人`);
socket.on("message", (text) => {
const msg = { id: Date.now(), from: socket.id, text, time: new Date().toISOString() };
io.to("lobby").emit("message", msg);
});
socket.on("disconnect", () => {
io.to("lobby").emit("system", `${socket.id} 离开了聊天室`);
});
});
function members() {
return io.of("/").adapter.rooms.get("lobby")?.size ?? 0;
}
server.listen(3000, () => console.log("聊天服务已启动:3000"));9.2 客户端
html
<script src="https://cdn.bootcdn.net/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>
<script>
const socket = io("http://localhost:3000", {
auth: { token: "demo-token" },
});
socket.on("connect", () => console.log("已连接,id =", socket.id));
socket.on("system", (text) => render("系统:", text));
socket.on("message", (msg) => render(msg.from, msg.text));
// 发送消息
document.querySelector("#send").onclick = () => {
socket.emit("message", document.querySelector("#input").value);
};
</script>提示:示例中引用了 BootCDN 的 Socket.IO 客户端脚本,国内环境可正常访问。
十、横向扩展注意
WebSocket 是长连接,一台机器只能维持有限连接数,多实例部署时会出现消息不在同一进程的问题:
| 问题 | 场景 | 解决方案 |
|---|---|---|
| 连接分散 | 用户 A 连实例 1,用户 B 连实例 2 | 消息必须跨实例转发 |
| 房间信息不共享 | io.to(room) 只在本进程有效 | 借助 Redis Adapter 同步 |
| 广播风暴 | 全量广播在每台机器重复 | 由消息中间件分发一次 |
javascript
// Socket.IO + Redis Adapter:房间、广播跨实例生效
const { createAdapter } = require("@socket.io/redis-adapter");
const { createClient } = require("redis");
const pubClient = createClient({ url: "redis://localhost:6379" });
const subClient = pubClient.duplicate();
io.adapter(createAdapter(pubClient, subClient));
// 之后 io.to(room).emit(...) 在所有实例上都生效连接归属记录:把 socket.id → 用户 映射存进 Redis,便于多实例间精确推送。连接数无法无限横向扩展的,还可考虑把 WebSocket 网关独立部署,与业务服务通过消息队列解耦。