阅读原文
前言
TCP 为传输层协议,在 NodeJS 中,基于 TCP 的核心模块为 net
,http
和 https
模块都是基于 net
实现的,我们先简单介绍 net
的用法,再根据 net
实现一个简易的聊天室。
net 模块的基本用法
1、使用 net 创建一个网络服务
方式 1:
const net = require("net");
// 创建 TCP 服务
const server = net.createServer(function(socket) {
// ......
});
server.listen(3000);
方式 2:
const net = require("net");
// 创建 TCP 服务
const server = net.createServer();
// 监听连接
server.on("connection", function(socket) {
// ......
});
server.listen(3000);
上面两种创建网络服务的方式第二种更常用,回调函数的参数都为 socket
(套接字),在产生连接时执行,每产生一个连接就会产生一个 socket
,我们也可以将 socket
理解为客户端。
如果现在使用浏览器连接这个服务可以成功接收到请求,但浏览器是 http
协议,不识别,所以不会有任何响应。
2、使用 TCP 模拟 http
const net = require("net");
// 创建 TCP 服务
const server = net.createServer();
// 监听连接
server.on("connection", function(socket) {
// 设置编码
socket.setEncoding("utf8");
// 读取请求报文
socket.on("data", function(data) {
console.log(data);
});
// 给浏览器返回响应报文
socket.write(`
HTTP/1.1 200 ok
Content-Length: 5
hello
`);
});
server.listen(3000);
// GET /favicon.ico HTTP/1.1
// Host: localhost:3000
// Connection: keep-alive
// Pragma: no-cache
// Cache-Control: no-cache
// ...... 后面省略
soket
是一个可读可写流 Duplex(双工流),所以既可以读取来自浏览器的请求信息,又可以写入响应信息,在模拟 http
时需遵循 http
协议规则,每行前面不允许有空格或制表符,响应头与响应正文之间需空一行。
此时启动服务,使用浏览器访问 localhost:3000 可以在控制台打印请求报文,并在浏览器中显示 hello
。
http
的头部信息可以通过命令窗口中使用 curl
发送请求进行查看,输入命令为 curl -v http://.....
,默认命令行窗口是不支持 curl
命令的,请在 curl 官网 下载系统对应的版本,在 Windows 系统中,下载后的压缩包解压后将 curl.exe
和 ca-bundle.crt
拷贝至 C:\Windows\System32
或将所在文件夹添加至系统环境变量。
3、server、socket 的属性和方法
在 TCP 创建的服务 server
和连接中的 socket
本身具有一些属性、方法和事件,我们通过下面这个例子来介绍。
const net = require("net");
// 创建 TCP 服务器
const server = net.createServer();
server.on("connection", function(socket) {
// 客户端的 ip + 端口号
let key = socket.remoteAddress + socket.remotePort;
server.getConnetions(function(err, count) {
socket.write(`当前有${count}人,总人数为${server.maxConnections}人。`);
});
socket.on("data", function(data) {
// 设置编码
socket.setEncoding("utf8");
// 关闭客户端
// socket.end();
// 关闭服务器
// server.close();
server.unref();
});
});
// 最大连接数
server.maxConnections = 3;
server.on("close", function() {
console.log("服务端关闭");
});
server.on("error", function(err) {
if (err.code === "EADDRINUSE") {
server.listen(err.port + 1);
}
});
server.listen(3000, function() {
console.log("server start 3000");
});
socket.remoteAddress
属性,获取客户端的 IP 地址。
socket.remotePort
属性,获取客户端的端口号。
socket.setEncoding
方法,设置编码格式。
socket.write
方法,向客户端写入内容,写入内容的值只能为字符串或 Buffer。
socket.end
方法,断开对应客户端的连接,并返回信息,返回内容的值只能为字符串或 Buffer,soket
可以监听 end
事件,当关闭客户端时触发并执行回调。
socket.destroy
方法,用于销毁当前客户端对应的 socket
对象。
server.maxConnections
属性,是当前服务器允许的最大连接数,数值类型,当连接数超过设定值时,新的客户端将无法连接服务器。
server.getConnetions
方法,获取当前的连接数,参数为回调函数,回调函数有两个参数 err
(错误)和 count
(当前连接数),异步执行。
server.close
方法,关闭服务器,并没有真的关闭服务器,而是不允许新的连接,直到所有连接都断开后自动关闭服务器。
server.unref
方法,关闭服务器的另一种形式,不阻止新的连接,当所有连接都断开时自动关闭服务器。
server.listen
方法,监听端口号,支持传入回调,在启动服务后执行。
server
的 close
事件,参数为回调函数,异步执行,当服务器关闭时触发。
server
的 error
事件,参数为回调函数,回调函数的参数为 err
(错误对象),异步执行,当启动服务器或服务器运行时出现错误触发。
在 Webpack 中如果启动 webpack-dev-server
在端口号被占用时,端口号会自动 +1
,我们可以利用 err
错误对象来模拟,在 err
事件对象上有很多属性,其中的 code
属性值为 EADDRINUSE
时代表端口号被占用,所以在判断 code
值后,重新调用了 server.listen
并传入重新计算后的端口号。
想看一看上面代码的效果需要客户端的支持,本文中模拟客户端访问服务器有三种方式,使用一种即可。
创建客户端
验证我们自己实现的 TCP 服务器需要客户端访问,在本文的主题简易聊天室当中也需要用户和客户端,所以介绍一下创建客户端的方式。
- 可以使用
net
模块创建客户端,并启访问服务器; - Mac 中可以直接在命令窗口执行
brew install telnet
安装telnet
,安装后输入telnet localhost 3000
即可以访问上面的服务器; - Windows 中
telnet
接收到的服务器响应会变成乱码,所以可以使用 Xshell 和 PuTTY 等客户端工具。
使用 net
创建客户端代码如下:
// 客户端:client.js
const net = require("net");
// 创建客户端
let client = net.createConnection({ port: 3000 });
// 给服务器发送消息
client.write("s:username:message");
为了方便本文中使用 PuTTY 工具,Windows 系统在使用之前需打开 Telnet 服务端和客户端,通过控制面板 → 打开或关闭 Windows 功能 → 勾选 Telnet 服务端和客户端。
PuTTY 界面如下,在 Connection type
(连接类型)中默认为 SSH
,我们之所以使用 Raw
而不使用其他类型是因为其他的方式在连接服务器时会发送窗口信息,我们不需要这些数据。
点击界面下面的 Open
按钮就可以创建一个客户端连接,客户端窗口如下,可以通过输入并回车确定的方式向服务端发送消息。
目前所有的准备工作已经就绪,下面就是我们的正题,用 net
模块实现一个 TCP 服务,并使用 PuTTY 作为客户端,实现一个简易的聊天室。
实现简易聊天室
1、定义聊天室规则
聊天室主要有四个功能,都需要输入对应的命令。
- 显示在线用户:命令为
l
; - 改名:聊天室默认用户名为匿名,重命名的命令为
r:newname
; - 私聊:私聊的参数为聊天对象的名字和消息内容,命令为
s:username:message
; - 广播:发送的消息除自己以外的所有人都能接收到,命令为
b:message
。
在存储所有的客户端时,都使用客户端的 ip + port
作为用户的唯一标识。
2、服务搭建
// 服务器:server.js
const net = require("net");
// 处理输入命令模块
const processInstructs = require("./process-instructs");
const server = net.createServer(); // 创建服务
let client = {}; // 客户端
let port = 3000; // 端口号
// 监听连接
server.on("connection", socket => {
// 客户端的 ip + 端口号 作为存储客户端的唯一标识
let key = socket.remoteAddress + socket.remotePort;
// 将客户端添加到 client 存储中
client[key] = { username: "匿名", socket };
// 欢迎功能
server.getConnections((err, count) => {
socket.write(`欢迎加入!目前有 ${count} 人。\r\n`);
});
// 设置编码
socket.setEncoding("utf8");
// 监听用户输入
socket.on("data", data => {
// 由于输入消息按回车键确认,所以需处理消息中的回车
data = data.replace(/\r\n/, "");
// 处理输入并做出响应
processInstructs(client, key, data);
});
// 客户端主动关闭后在服务器客户端存储中清除客户端,并销毁对应的 socket
socket.on("end", () => {
socket.destroy();
delete client[key];
});
});
// 监听端口号
server.listen(port, () => {
console.log(`server start ${port}`);
});
在上面的服务搭建当中,创建了 client
对象,专门存储聊天室内的客户端及信息,客户端使用 ip + port
作为存储的唯一标识,用户名默认为 “匿名”,设置了欢迎功能,并显示当前在线人数,监听用户的输入,并处理了消息中的回车,引入 process-instructs
对指令进行处理,最后处理了离开的用户,目的是防止有离开后,其他的人使用了私聊或广播功能通知这个人,因为找不到对应的 socket
而出现错误。
3、处理指令模块 process-instructs
// 文件:process-instructs.js
// 引入处理不同指令的功能函数
const { list, rename, private, broadcast } = require("./instructs");
module.exports = function(client, key, data) {
let dataArr = data.split(":");
// 针对不同的指令调用不同的处理方法
switch (dataArr[0]) {
case "l":
list(client, key);
break;
case "r":
rename(client, key, dataArr);
break;
case "s":
private(client, key, dataArr);
break;
case "b":
broadcast(client, key, dataArr);
break;
default:
socket.write("命令有误\r\n");
}
};
在上面对指令的处理中针对不同的指令引入了 instructs
模块对应的处理方法。
4、指令处理方法模块 instructs
// 文件:instructs.js
// 处理 l 指令,显示在线用户
exports.list = function(client, key) {
// 获取当前 socket
let socket = client[key].socket;
// 写入信息
soket.write("当前用户列表:\r\n");
Object.values(client).forEach(p => {
socket.write(`${p.username}\r\n`);
});
};
// 处理 r 指令,用户重命名
exports.rename = function(client, key, dataArr) {
let newName = dataArr[1];
// 更新对应 socket 的新用户名并通知
client[key].username = newName;
client[key].socket.write(`新用户名是: ${newName}\r\n`);
};
// 处理 s 指令,私聊
exports.private = function() {
Object.keys(client).forEach(c => {
if (client[c].username === dataArr[1]) {
client[c].socket.write(
`${client[key].username}: ${dataArr[2]}\r\n`
);
}
});
};
// 处理 b 指令,广播
exports.broadcast = function() {
Object.keys(client).forEach(c => {
if (c !== key) {
client[c].socket.write(
`${client[key].username}: ${dataArr[1]}\r\n`
);
}
});
};
显示在线用户功能的思路是将 client
内部所有在线用户的用户名循环写入到当前 socket
中,重命名功能的思路是获取输入的新用户名替换掉 client
中对应的 username
并将当前新用户名设置成功的消息返回当前 socket
,私聊功能的思路是循环 client
内的所有客户端,当 username
和发送的用户名相同时,将消息写入这个用户名对应的 socket
,广播功能思路是循环 client
,将消息写入给出自己以外的所有客户端。
总结
本文重点在于理解多人聊天功能的开发思路,及 NodeJS 中关于 TCP 传输对应的 net
模块的使用,实际上本文中聊天室的代码在用户重名的情况下并没有做任何处理,正常情况应该使用 id
作为唯一标识,而不是指定用户名,在 NodeJS 开发中其实很少直接使用 net
大多情况下使用 http
和 https
来替代,但是我们应该知道他们都是基于 net
封装的,了解 net
会在使用 http
和 https
时更得心应手。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。