NodeJS+TS手写websocket库(上)
网络协议是一组规则和标准,用于定义计算机网络中的通信方式。这些规则确保不同设备之间能够有效地交换数据。网络协议可以分为多个层次,包括应用层、传输层、网络层和数据链路层等,每一层都有不同的协议来处理不同类型的通信任务。
网络协议的层次和示例
-
应用层:
- HTTP(超文本传输协议):用于Web浏览器和服务器之间的通信。
- HTTPS(HTTP安全协议):HTTP的加密版本,使用TLS/SSL。
- FTP(文件传输协议):用于在网络上传输文件。
- SMTP(简单邮件传输协议):用于发送电子邮件。
- DNS(域名系统):将域名转换为IP地址。
- SSH(安全外壳):用于安全地登录到远程计算机。
- WebSocket:用于在客户端和服务器之间建立全双工的实时通信通道。
-
传输层:
- TCP(传输控制协议):提供可靠的、面向连接的传输服务。
- UDP(用户数据报协议):提供无连接的、不可靠的传输服务。
- SCTP(流控制传输协议):提供多流的、可靠的传输服务。
-
网络层:
- IP(互联网协议):用于在网络之间传输数据包。
- ICMP(互联网控制消息协议):用于发送错误消息和操作信息。
-
数据链路层:
- Ethernet(以太网):用于有线局域网(LAN)。
- Wi-Fi(无线保真):用于无线局域网(WLAN)。
HTTP和WebSocket
HTTP(超文本传输协议)
HTTP(HyperText Transfer Protocol)是用于在万维网上传输超文本的应用层协议。它是Web浏览器和Web服务器之间通信的基础。HTTP协议定义了客户端(通常是浏览器)如何向服务器发送请求以及服务器如何响应这些请求。
HTTP的主要特点包括:
- 无状态:每个HTTP请求都是独立的,服务器不会保留前一个请求的信息。
- 请求-响应模型:客户端发送请求,服务器返回响应。
- 基于文本:HTTP消息是可读的文本格式,便于调试和理解。
HTTP有多个版本,目前常用的包括HTTP/1.1和HTTP/2,HTTP/3也在逐渐普及中。
WebSocket
WebSocket是一种全双工通信协议,旨在解决HTTP协议的局限性。它允许在客户端和服务器之间建立持久连接,并在该连接上进行双向数据传输。这使得WebSocket特别适合需要实时通信的应用,如在线聊天、实时游戏和股票行情推送等。
WebSocket的主要特点包括:
- 全双工通信:客户端和服务器可以同时发送和接收消息。
- 低延迟:由于连接是持久的,省去了每次通信都要建立和关闭连接的开销,减少了延迟。
- 基于事件:WebSocket使用事件驱动模型,客户端和服务器可以在任何时候发送数据。
WebSocket连接的建立过程如下:
- 握手阶段:客户端通过HTTP请求向服务器发起WebSocket握手请求。
- 协议升级:如果服务器同意升级协议,会返回一个101状态码,表示协议从HTTP升级为WebSocket。
- 数据传输:握手成功后,客户端和服务器之间就可以通过WebSocket连接进行数据传输。
HTTP 和 WebSocket 的优缺点对比
- HTTP
- 优点:简单、易于实现、广泛支持、适合请求-响应模型
- 缺点:无状态、每次请求都需建立连接,开销较大
- WebSocket
- 优点:持久连接、低延迟、全双工通信
- 缺点:实现较复杂、需要处理连接管理和安全问题
总结来说,HTTP适用于传统的请求-响应模型,而WebSocket则更适合需要实时、低延迟通信的应用场景。
WebSocket与HTTP的关系
-
连接建立:
- WebSocket连接的建立需要通过HTTP/HTTPS协议进行初始握手。客户端向服务器发送一个HTTP请求,这个请求包含一些特定的头部字段,表明客户端希望升级连接到WebSocket协议。
- 服务器接收到请求后,如果同意升级,会返回一个101状态码的响应,表示协议切换成功。
-
连接升级:
- 在HTTP握手阶段,使用的头部字段包括
Upgrade
和Connection
,表明HTTP协议将被升级为WebSocket协议。 - 一旦握手完成,连接就从HTTP切换到WebSocket,后续的通信不再使用HTTP,而是使用WebSocket协议。
- 在HTTP握手阶段,使用的头部字段包括
-
后续通信:
- 握手完成后,WebSocket连接使用一个全双工的通信通道,允许客户端和服务器之间实时地双向传输数据。这与HTTP的请求-响应模型不同。
WebSocket握手示例
以下是一个WebSocket握手的示例:
客户端请求
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
服务器响应
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
在这个握手过程中:
Upgrade
头部字段表明客户端希望将协议升级到WebSocket。Connection
头部字段包含Upgrade
,表示连接将被升级。Sec-WebSocket-Key
是一个随机生成的Base64编码字符串,用于安全验证。Sec-WebSocket-Version
表明WebSocket协议的版本。
服务器通过计算Sec-WebSocket-Key
的哈希值并返回Sec-WebSocket-Accept
头部字段来验证握手请求的有效性。
node中的HTTP服务和WebSocket服务
Node.js 自带了 http
模块,用于实现 HTTP 协议。我们可以使用这个模块来创建 HTTP 服务器和客户端,处理 HTTP 请求和响应。
以下是一个简单的示例,展示如何使用 Node.js 内置的 http
模块创建一个 HTTP 服务器:
const http = require('http');
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello, World!\n');
});
server.listen(3000, '127.0.0.1', () => {
console.log('Server running at http://127.0.0.1:3000/');
});
和HTTP服务不同,Node.js 并没有自带 WebSocket 的实现。如果需要在 Node.js 中使用 WebSocket,需要使用第三方包。我最常用的 WebSocket 包是 ws
以下是一个使用 ws
包创建 WebSocket 服务器的示例:
- 首先,需要安装
ws
包:
npm install ws
- 然后,可以使用以下代码创建一个简单的 WebSocket 服务器:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
console.log('New client connected');
ws.on('message', (message) => {
console.log(`Received message => ${message}`);
});
ws.send('Hello! Message from server...');
});
console.log('WebSocket server is running on ws://localhost:8080');
这段代码创建了一个 WebSocket 服务器,监听在端口 8080。每当有客户端连接时,服务器会发送一条消息给客户端,并打印接收到的任何消息。
使用node自己实现一个websocket服务器
尽管 Node.js 没有内置的 WebSocket 模块,但仍然可以使用原生 Node.js 实现一个简单的 WebSocket 服务器。实现 WebSocket 协议需要处理一些特定的协议细节,如握手、数据帧的格式和掩码处理等。 以下是一个使用原生 Node.js 实现简单 WebSocket 服务器的示例:
- 创建一个 HTTP 服务器来接受 WebSocket 握手请求。
- 处理 WebSocket 握手。
- 处理 WebSocket 数据帧。
服务端代码
const http = require('http');
const crypto = require('crypto');
// 创建一个 HTTP 服务器
const server = http.createServer((req, res) => {
res.writeHead(404);
res.end();
});
// 监听 'upgrade' 事件来处理 WebSocket 握手请求
server.on('upgrade', (req, socket, head) => {
const key = req.headers['sec-websocket-key'];
const acceptKey = generateAcceptValue(key);
const responseHeaders = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${acceptKey}`
];
// 发送 WebSocket 握手响应
socket.write(responseHeaders.join('\r\n') + '\r\n\r\n');
// 监听 'data' 事件来处理 WebSocket 消息
socket.on('data', (buffer) => {
const message = parseMessage(buffer);
if (message) {
console.log('Received message:', message);
socket.write(constructReply(message));
}
});
// 错误处理
socket.on('error', (err) => {
console.error('Socket error:', err);
});
// 连接关闭处理
socket.on('close', () => {
console.log('Client disconnected');
});
});
// 监听端口 8080
server.listen(8080, () => {
console.log('WebSocket server is running on ws://localhost:8080');
});
// 生成 Sec-WebSocket-Accept 值
function generateAcceptValue(key) {
return crypto
.createHash('sha1')
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', 'binary')
.digest('base64');
}
// 解析 WebSocket 消息
function parseMessage(buffer) {
const firstByte = buffer.readUInt8(0);
const secondByte = buffer.readUInt8(1);
// 检查是否是最终帧
const isFinalFrame = Boolean((firstByte >>> 7) & 0x1);
// 保留位(通常为0)
const [reserved1, reserved2, reserved3] = [
Boolean((firstByte >>> 6) & 0x1),
Boolean((firstByte >>> 5) & 0x1),
Boolean((firstByte >>> 4) & 0x1)
];
// 操作码(0x1 表示文本帧)
const opCode = firstByte & 0xf;
// 检查是否有掩码
const isMasked = Boolean((secondByte >>> 7) & 0x1);
let payloadLength = secondByte & 0x7f;
let currentOffset = 2;
// 处理延长的 payload 长度
if (payloadLength > 125) {
if (payloadLength === 126) {
payloadLength = buffer.readUInt16BE(currentOffset);
currentOffset += 2;
} else {
payloadLength = buffer.readUInt32BE(currentOffset) * 2 ** 32 + buffer.readUInt32BE(currentOffset + 4);
currentOffset += 8;
}
}
// 读取掩码键
const maskingKey = buffer.slice(currentOffset, currentOffset + 4);
currentOffset += 4;
// 读取并解码数据
const data = buffer.slice(currentOffset, currentOffset + payloadLength).map((byte, idx) => byte ^ maskingKey[idx % 4]);
return data.toString('utf8');
}
// 构建 WebSocket 回复
function constructReply(message) {
const messageBuffer = Buffer.from(message);
const messageLength = messageBuffer.length;
const buffer = Buffer.alloc(2 + messageLength);
// 设置 FIN 位和文本帧操作码
buffer.writeUInt8(0b10000001, 0);
// 设置 payload 长度
buffer.writeUInt8(messageLength, 1);
// 复制消息内容到缓冲区
messageBuffer.copy(buffer, 2);
return buffer;
}
代码解释:
- 创建 HTTP 服务器:使用
http.createServer
创建一个 HTTP 服务器,并监听upgrade
事件以处理 WebSocket 握手请求。 - WebSocket 握手:在
upgrade
事件中,生成Sec-WebSocket-Accept
头部,并返回 101 Switching Protocols 响应,完成 WebSocket 握手。 - 解析 WebSocket 消息:在
socket.on('data')
事件中,解析 WebSocket 数据帧,提取消息内容。parseMessage
函数解析 WebSocket 数据帧,包括处理掩码和解码消息内容。
- 构建 WebSocket 回复:构建一个简单的 WebSocket 回复帧,并发送回客户端。
constructReply
函数构建一个 WebSocket 回复帧,设置 FIN 位和操作码,并将消息内容复制到缓冲区。
- 错误处理和连接关闭:在
socket.on('error')
和socket.on('close')
中处理错误和连接关闭事件。
客户端代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebSocket 客户端</title>
</head>
<body>
<h1>WebSocket 客户端</h1>
<div>
<label for="messageInput">消息:</label>
<input type="text" id="messageInput" placeholder="输入你的消息" />
<button onclick="sendMessage()">发送</button>
</div>
<div>
<h2>消息记录:</h2>
<ul id="messages"></ul>
</div>
<script>
const ws = new WebSocket("ws://localhost:8080");
ws.onopen = () => {
console.log("已连接到 WebSocket 服务器");
};
ws.onmessage = (event) => {
const messagesList = document.getElementById("messages");
const newMessage = document.createElement("li");
newMessage.textContent = `服务器: ${event.data}`;
messagesList.appendChild(newMessage);
};
ws.onclose = () => {
console.log("已断开与 WebSocket 服务器的连接");
};
ws.onerror = (error) => {
console.error("WebSocket 错误:", error);
};
function sendMessage() {
const input = document.getElementById("messageInput");
const message = input.value;
ws.send(message);
const messagesList = document.getElementById("messages");
const newMessage = document.createElement("li");
newMessage.textContent = `客户端: ${message}`;
messagesList.appendChild(newMessage);
input.value = "";
}
</script>
</body>
</html>
代码解释
- HTML结构:
<head>
部分包含了页面的元数据,如字符编码和视口设置。<body>
部分包含了页面的主要内容,包括一个标题、一个输入框、一个按钮和一个消息记录列表。
- WebSocket连接:
- 在
<script>
标签内,创建了一个新的 WebSocket 对象ws
,连接到ws://localhost:8080
。 ws.onopen
:当 WebSocket 连接成功时触发,输出连接成功的日志。ws.onmessage
:当收到服务器发送的消息时触发,将消息显示在消息记录列表中。ws.onclose
:当 WebSocket 连接关闭时触发,输出连接关闭的日志。ws.onerror
:当 WebSocket 发生错误时触发,输出错误信息。
- 在
- 发送消息:
sendMessage
函数在点击“发送”按钮时调用。- 从输入框中获取消息内容,并通过
ws.send(message)
发送给服务器。 - 将发送的消息添加到消息记录列表中,并清空输入框。
第一版成品图
参考资料和进一步阅读
精品调研
有同学把上面的代码运行起来的话就能发现,这个demo是一个十分十分简陋的产品。一个正常ws库该有的他一样也没有,那说回来一个完整的ws库都应该有什么呢?
我在MDN
上看到了这个
下面,我们挑几个顺眼的对比一下
Git 仓库信息
仓库 | 最近更新 | Star 数量 | 实现语言 |
---|---|---|---|
Socket.IO | 2024-06-18 | 60.4k | Ts/Js |
ws | 2024-06-17 | 21.2k | JavaScript |
µWebSockets | 2024-05-22 | 17k | C++/Node.js |
SocketCluster | 2024-03-28 | 6.1k | JavaScript |
表格数据时间:2024年06月19日12:16:59
我只用过
ws
和一点点Socket.IO
功能对比表
功能/库 | Socket.IO | ws | µWebSockets | SocketCluster |
---|---|---|---|---|
协议支持 | WebSocket, 长轮询 | WebSocket | WebSocket | WebSocket |
自动重连 | 是 | 否 | 否 | 是 |
消息广播 | 是 | 否 | 否 | 是 |
命名空间支持 | 是 | 否 | 否 | 是 |
房间/频道支持 | 是 | 否 | 否 | 是 |
二进制数据传输 | 是 | 是 | 是 | 是 |
消息压缩 | 是 | 是 | 是 | 是 |
心跳机制 | 是 | 否 | 是 | 是 |
身份验证 | 是 | 否 | 否 | 是 |
负载均衡 | 是 | 否 | 否 | 是 |
集群支持 | 是 | 否 | 否 | 是 |
跨平台支持 | 是 | 是 | 是 | 是 |
社区支持 | 高 | 高 | 高 | 中 |
文档和教程 | 丰富 | 中等 | 中等 | 中等 |
性能 | 中等 | 高 | 高 | 高 |
详细功能解释:
- 协议支持:指库支持的通信协议类型。Socket.IO 除了 WebSocket 外,还支持长轮询等其他协议。
- 自动重连:是否支持客户端自动重连机制。
- 消息广播:是否支持将消息广播到所有连接的客户端。
- 命名空间支持:是否支持将连接划分到不同的命名空间以隔离通信。
- 房间/频道支持:是否支持将连接分配到不同的房间或频道进行分组通信。
- 二进制数据传输:是否支持传输二进制数据。
- 消息压缩:是否支持消息的压缩传输。
- 心跳机制:是否支持定期发送心跳包以保持连接活跃。
- 身份验证:是否支持在连接时进行身份验证。
- 负载均衡:是否支持负载均衡机制。
- 集群支持:是否支持在多台服务器上运行以实现高可用性。
- 跨平台支持:是否支持在不同操作系统和平台上运行。
- 社区支持:社区的活跃度和支持度。
- 文档和教程:官方文档和教程的丰富程度。
- 性能:库的性能表现。
v0.0.2功能设计与实现
在第一版的基础上,我们将实现以下功能:
- 频道管理:允许用户加入和离开不同的频道。
- 消息广播:在同一频道内广播消息。
- 在线状态:显示当前在线用户及其状态。
频道管理
频道管理的目的是将用户划分到不同的组中,以便在同一频道内进行消息广播。我们可以使用一个简单的对象来存储每个频道中的用户列表。
消息广播
在实现频道管理后,当用户发送消息时,只需将消息广播到同一频道内的所有用户即可。
在线状态
我们可以维护一个在线用户列表,当用户连接或断开连接时更新该列表,并将更新后的列表广播给所有用户。
服务端代码
以下是更新后的服务端代码:
const http = require("http");
const crypto = require("crypto");
// 存储所有客户端连接和频道信息
const clients = new Map();
const channels = new Map();
// 创建一个 HTTP 服务器
const server = http.createServer((req, res) => {
res.writeHead(404);
res.end();
});
// 监听 'upgrade' 事件来处理 WebSocket 握手请求
server.on("upgrade", (req, socket, head) => {
const key = req.headers["sec-websocket-key"];
const acceptKey = generateAcceptValue(key);
const responseHeaders = [
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${acceptKey}`,
];
// 发送 WebSocket 握手响应
socket.write(responseHeaders.join("\r\n") + "\r\n\r\n");
console.log("WebSocket 握手成功");
// 为每个连接生成唯一的ID
const clientId = crypto.randomBytes(16).toString("hex");
clients.set(clientId, { socket, channels: new Set(), userId: null });
console.log(`客户端连接成功: ${clientId}`);
// 监听 'data' 事件来处理 WebSocket 消息
socket.on("data", (buffer) => {
try {
const message = parseMessage(buffer);
if (message.opCode === 0x8) {
// 处理关闭帧
handleDisconnect(clientId);
} else {
const parsedData = JSON.parse(message.data);
console.log(
`收到消息: ${JSON.stringify(parsedData)} 来自客户端: ${clientId}`
);
handleMessage(clientId, parsedData);
}
} catch (error) {
console.error("解析消息时出错:", error);
}
});
// 错误处理
socket.on("error", (err) => {
console.error("Socket 错误:", err);
});
// 连接关闭处理
socket.on("close", () => {
handleDisconnect(clientId);
});
});
// 监听端口 8080
server.listen(8080, () => {
console.log("WebSocket 服务器运行在 ws://localhost:8080");
});
// 生成 Sec-WebSocket-Accept 值
function generateAcceptValue(key) {
return crypto
.createHash("sha1")
.update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", "binary")
.digest("base64");
}
// 解析 WebSocket 消息
function parseMessage(buffer) {
const firstByte = buffer.readUInt8(0);
const secondByte = buffer.readUInt8(1);
const isFinalFrame = Boolean((firstByte >>> 7) & 0x1);
const opCode = firstByte & 0xf;
const isMasked = Boolean((secondByte >>> 7) & 0x1);
let payloadLength = secondByte & 0x7f;
let currentOffset = 2;
if (payloadLength > 125) {
if (payloadLength === 126) {
payloadLength = buffer.readUInt16BE(currentOffset);
currentOffset += 2;
} else {
payloadLength =
buffer.readUInt32BE(currentOffset) * 2 ** 32 +
buffer.readUInt32BE(currentOffset + 4);
currentOffset += 8;
}
}
const maskingKey = buffer.slice(currentOffset, currentOffset + 4);
currentOffset += 4;
const data = buffer
.slice(currentOffset, currentOffset + payloadLength)
.map((byte, idx) => byte ^ maskingKey[idx % 4]);
return {
isFinalFrame,
opCode,
isMasked,
payloadLength,
data: data.toString("utf8"),
};
}
// 构建 WebSocket 回复
function constructReply(message) {
const messageBuffer = Buffer.from(JSON.stringify(message));
const messageLength = messageBuffer.length;
const buffer = Buffer.alloc(2 + messageLength);
buffer.writeUInt8(0b10000001, 0);
buffer.writeUInt8(messageLength, 1);
messageBuffer.copy(buffer, 2);
return buffer;
}
// 处理 WebSocket 消息
function handleMessage(clientId, message) {
const client = clients.get(clientId);
switch (message.type) {
case "join":
client.userId = message.userId; // 保存用户ID
console.log(`用户 ${client.userId} 请求加入频道 ${message.channel}`);
joinChannel(clientId, message.channel);
break;
case "leave":
console.log(`用户 ${client.userId} 请求离开频道 ${message.channel}`);
leaveChannel(clientId, message.channel);
break;
case "broadcast":
console.log(
`用户 ${client.userId} 在频道 ${message.channel} 广播消息: ${message.content}`
);
broadcastMessage(clientId, message.channel, message.content);
break;
case "status":
console.log(`用户 ${client.userId} 请求在线状态`);
sendStatus(clientId);
break;
default:
console.error("未知消息类型:", message.type);
}
}
// 加入频道
function joinChannel(clientId, channel) {
const client = clients.get(clientId);
if (!channels.has(channel)) {
channels.set(channel, new Set());
}
channels.get(channel).add(clientId);
client.channels.add(channel);
const message = {
type: "system",
content: `用户 ${client.userId} 加入频道 ${channel}`,
};
console.log(`用户 ${client.userId} 加入频道 ${channel}`);
broadcastToChannel(channel, message);
}
// 离开频道
function leaveChannel(clientId, channel) {
const client = clients.get(clientId);
if (channels.has(channel)) {
channels.get(channel).delete(clientId);
if (channels.get(channel).size === 0) {
channels.delete(channel);
}
}
client.channels.delete(channel);
const message = {
type: "system",
content: `用户 ${client.userId} 离开频道 ${channel}`,
};
console.log(`用户 ${client.userId} 离开频道 ${channel}`);
broadcastToChannel(channel, message);
}
// 广播消息到频道
function broadcastToChannel(channel, message) {
if (channels.has(channel)) {
const messageBuffer = constructReply(message);
for (const id of channels.get(channel)) {
const recipient = clients.get(id);
if (recipient) {
recipient.socket.write(messageBuffer);
}
}
console.log(`消息广播到频道 ${channel}: ${JSON.stringify(message)}`);
}
}
// 广播消息
function broadcastMessage(clientId, channel, content) {
const client = clients.get(clientId);
if (channel && channels.has(channel)) {
const message = {
type: "broadcast",
channel,
userId: client.userId,
content,
};
broadcastToChannel(channel, message);
} else {
console.error("广播消息失败: 频道不存在或未提供频道名称");
}
}
// 发送在线状态
function sendStatus(clientId) {
const client = clients.get(clientId);
const onlineClients = Array.from(clients.keys());
const message = { type: "status", online: onlineClients };
client.socket.write(constructReply(message));
console.log(
`发送在线状态给客户端 ${clientId}: ${JSON.stringify(onlineClients)}`
);
}
// 处理断开连接
function handleDisconnect(clientId) {
const client = clients.get(clientId);
if (client) {
for (const channel of client.channels) {
leaveChannel(clientId, channel);
}
clients.delete(clientId);
console.log(`客户端断开连接: ${clientId}`);
}
}
客户端代码
以下是更新后的客户端代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebSocket客户端</title>
<script>
document.addEventListener("DOMContentLoaded", () => {
// 创建WebSocket连接到本地服务器
const socket = new WebSocket("ws://localhost:8080");
// 设置默认房间号为1001
document.getElementById("room").value = "1001";
// 生成随机用户ID
function generateUserId() {
return "user_" + Math.random().toString(36).substr(2, 9);
}
// 加入房间按钮点击事件
document.getElementById("joinRoom").addEventListener("click", () => {
let room = document.getElementById("room").value; // 获取房间号
let userId = document.getElementById("userId").value; // 获取用户ID
// 如果用户ID未填写,则生成随机用户ID
if (!userId) {
userId = generateUserId();
document.getElementById("userId").value = userId; // 显示生成的用户ID
}
// 发送加入房间的消息到服务器
socket.send(JSON.stringify({ type: "join", channel: room, userId }));
});
// 发送消息按钮点击事件
document.getElementById("sendMessage").addEventListener("click", () => {
const message = document.getElementById("message").value; // 获取消息内容
const room = document.getElementById("room").value; // 获取房间号
let userId = document.getElementById("userId").value; // 获取用户ID
// 如果用户ID未填写,则生成随机用户ID
if (!userId) {
userId = generateUserId();
document.getElementById("userId").value = userId; // 显示生成的用户ID
}
// 发送消息到服务器
socket.send(
JSON.stringify({
type: "broadcast",
channel: room,
userId,
content: message,
})
);
// 清空消息输入框
document.getElementById("message").value = "";
});
// 接收到服务器消息时的处理
socket.onmessage = function (event) {
const msg = JSON.parse(event.data);
let displayMessage = "";
if (msg.type === "broadcast") {
displayMessage = `用户 ${msg.userId} 在频道 ${msg.channel} 中说: ${msg.content}`;
} else if (msg.type === "system") {
displayMessage = `系统消息: ${msg.content}`;
}
// 将接收到的消息显示在页面上
document.getElementById("serverMessages").textContent +=
displayMessage + "\n";
};
});
</script>
<style type="text/css">
main {
display: flex;
justify-content: space-between;
width: 100vw;
height: 100vh;
}
.message {
flex: 0 0 75vw;
height: 100vh;
position: relative;
}
.message-main {
flex: 0 0 75vw;
height: 70vh;
min-height: 100px;
}
.send-message {
flex: 0 0 75vw;
height: 300px;
position: absolute;
bottom: 0;
}
.send-message input {
width: calc(75vw - 100px);
}
.login {
flex: 0 0 20vw;
height: 100vh;
}
</style>
</head>
<body>
<h1>WebSocket客户端</h1>
<main>
<div class="message">
<div
class="message-main"
id="serverMessages"
style="white-space: pre"
></div>
<div class="send-message">
<input type="text" id="message" />
<button id="sendMessage">发送消息</button>
</div>
</div>
<div class="login">
<p>
<label for="userId">用户ID:</label>
<input type="text" id="userId" placeholder="留空将自动生成" />
</p>
<p>
<label for="room">房间号:</label>
<input type="text" id="room" />
</p>
<p><button id="joinRoom">加入房间</button></p>
</div>
</main>
</body>
</html>
代码解释
服务端代码
-
创建 HTTP 服务器:使用
http.createServer
创建一个 HTTP 服务器,并监听upgrade
事件以处理 WebSocket 握手请求。 -
WebSocket 握手:在
upgrade
事件中,生成Sec-WebSocket-Accept
头部,并返回 101 Switching Protocols 响应,完成 WebSocket 握手。 -
生成唯一客户端 ID:为每个连接生成一个唯一的客户端 ID,并将其与连接的 socket 以及频道信息一起存储在
clients
Map 中。 -
处理 WebSocket 消息:在
socket.on('data')
事件中,解析 WebSocket 数据帧,提取消息内容,并根据消息类型进行处理。handleMessage
函数根据消息类型(如 join、leave、broadcast、status)调用相应的处理函数。
-
加入频道:
joinChannel
函数将客户端加入指定的频道,并广播系统消息通知其他用户。 -
离开频道:
leaveChannel
函数将客户端从指定频道移除,并广播系统消息通知其他用户。 -
广播消息:
broadcastMessage
函数将消息广播到指定频道内的所有用户。 -
发送在线状态:
sendStatus
函数将当前在线用户列表发送给请求的客户端。 -
处理断开连接:
handleDisconnect
函数在客户端断开连接时,将其从所有频道中移除,并删除客户端记录。
客户端代码
-
HTML 结构:页面包含一个标题、一个消息输入框、一个发送按钮、一个消息记录区域、用户 ID 输入框、房间号输入框和加入房间按钮。
-
WebSocket 连接:在页面加载完成后,创建一个 WebSocket 连接到服务器。
-
生成随机用户 ID:
generateUserId
函数生成一个随机用户 ID。 -
加入房间:点击“加入房间”按钮时,获取房间号和用户 ID(如果未填写则生成随机用户 ID),并发送加入房间的消息到服务器。
-
发送消息:点击“发送消息”按钮时,获取消息内容和房间号,发送消息到服务器,并清空消息输入框。
-
接收服务器消息:在
socket.onmessage
事件中,处理服务器发送的消息,根据消息类型(如 broadcast、system)显示在页面上。 -
消息显示:将接收到的消息显示在消息记录区域中。
运行方法
-
启动服务端:将服务端代码保存为
server.js
文件,并在终端运行以下命令启动服务器:node server.js
-
启动客户端:将客户端代码保存为
index.html
文件,使用浏览器打开该文件。 -
测试功能:
- 在浏览器中打开
index.html
文件,输入用户 ID(可选)和房间号,然后点击“加入房间”按钮。 - 在消息输入框中输入消息,点击“发送消息”按钮,消息将发送到服务器,并广播到同一房间内的所有用户。
- 在多个浏览器窗口或标签页中重复上述步骤,可以测试消息广播和在线状态功能。
- 在浏览器中打开
总结
通过上述代码示例,我们实现了一个简单的基于 WebSocket 的聊天室应用(V0.0.2),包括频道管理、消息广播和在线状态显示等功能。这个示例展示了如何使用 Node.js 和原生 WebSocket 协议实现实时通信,并为进一步扩展和优化提供了基础。
后面把Fastify+ws的im基础服务做完,我们将从业务出发继续实现后续的功能
转载自:https://juejin.cn/post/7381787510073278473