前言

TCP 为传输层协议,在 Node.js 中,基于 TCP 的核心模块为 nethttphttps 模块都是基于 net 实现的,我们先简单介绍 net 的用法,再根据 net 实现一个简易的聊天室。

net 模块的基本用法

使用 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 协议,不识别,所以不会有任何响应。

使用 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://.....

Windows 系统中默认命令行窗口不支持 curl 命令,请在 curl 官网 下载系统对应的版本,下载后的压缩包解压后将 curl.execa-bundle.crt 拷贝至 C:\Windows\System32 或将所在文件夹添加至系统环境变量。

server、socket 的属性和方法

在 TCP 创建的服务 server 和连接中的 socket 本身具有一些属性、方法和事件,我们通过下面这个例子来介绍。

const net = require('net');

// 创建 TCP 服务器
const server = net.createServer();

server.on('connection', function (socket) {
  // 客户端的 ip + 端口号
  const 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 方法,监听端口号,支持传入回调,在启动服务后执行。

serverclose 事件,参数为回调函数,异步执行,当服务器关闭时触发。

servererror 事件,参数为回调函数,回调函数的参数为 err(错误对象),异步执行,当启动服务器或服务器运行时出现错误触发。

Webpack 中如果启动 webpack-dev-server 在端口号被占用时,端口号会自动 +1,我们可以利用 err 错误对象来模拟,在 err 事件对象上有很多属性,其中的 code 属性值为 EADDRINUSE 时代表端口号被占用,所以在判断 code 值后,重新调用了 server.listen 并传入重新计算后的端口号。

想看一看上面代码的效果需要客户端的支持,本文中模拟客户端访问服务器有三种方式,使用一种即可。

创建客户端

验证我们自己实现的 TCP 服务器需要客户端访问,在本文的主题简易聊天室当中也需要用户和客户端,所以介绍一下创建客户端的方式。

  • 可以使用 net 模块创建客户端,并启访问服务器;
  • Mac 中可以直接在命令窗口执行 brew install telnet 安装 telnet,安装后输入 telnet localhost 3000 即可以访问上面的服务器;
  • Windowstelnet 接收到的服务器响应会变成乱码,所以可以使用 Xshell PuTTY 等客户端工具。

使用 net 创建客户端代码如下:

/* 客户端:client.js */
const net = require('net');

// 创建客户端
const client = net.createConnection({ port: 3000 });

// 给服务器发送消息
client.write('s:username:message');

由于本人目前使用 Windows 电脑,文中使用 PuTTY 工具,在使用之前需打开 Telnet 服务端和客户端,步骤如下:

  • 打开控制面板;
  • 打开或关闭 Windows 功能;
  • 勾选 Telnet 服务端和客户端。

PuTTY 界面如下,在 Connection type(连接类型)中默认为 SSH,我们之所以使用 Raw 而不使用其他类型是因为其他的方式在连接服务器时会发送窗口信息,我们不需要这些数据。


PuTTY 界面
PuTTY 界面


点击界面下面的 Open 按钮就可以创建一个客户端连接,客户端窗口如下,可以通过输入并回车确定的方式向服务端发送消息。


PuTTY 客户端窗口
PuTTY 客户端窗口


目前所有的准备工作已经就绪,下面就是我们的正题,用 net 模块实现一个 TCP 服务,并使用 PuTTY 作为客户端,实现一个简易的聊天室。

实现简易聊天室

定义聊天室规则

聊天室主要有四个功能,都需要输入对应的命令:

  • 显示在线用户:命令为 l
  • 改名:聊天室默认用户名为匿名,重命名的命令为 r:newname
  • 私聊:私聊的参数为聊天对象的名字和消息内容,命令为 s:username:message
  • 广播:发送的消息除自己以外的所有人都能接收到,命令为 b:message

在存储所有的客户端时,都使用客户端的 ip + port 作为用户的唯一标识。

服务搭建

/* 服务器:server.js */
const net = require('net');

// 处理输入命令模块
const processInstructs = require('./process-instructs');

const server = net.createServer(); // 创建服务
const client = {}; // 客户端
const port = 3000; // 端口号

// 监听连接
server.on('connection', socket => {
  // 客户端的 ip + 端口号 作为存储客户端的唯一标识
  const 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 而出现错误。

处理指令模块 process-instructs

/* 文件:process-instructs.js */
// 引入处理不同指令的功能函数
const { list, rename, privateChat, broadcast } = require('./instructs');

module.exports = function (client, key, data) {
  const dataArr = data.split(':');

  // 针对不同的指令调用不同的处理方法
  switch (dataArr[0]) {
    case 'l':
      list(client, key);
      break;
    case 'r':
      rename(client, key, dataArr);
      break;
    case 's':
      privateChat(client, key, dataArr);
      break;
    case 'b':
      broadcast(client, key, dataArr);
      break;
    default:
      socket.write('命令有误\r\n');
  }
};

在上面对指令的处理中针对不同的指令引入了 instructs 模块对应的处理方法。

指令处理方法模块 instructs

/* 文件:instructs.js */
// 处理 l 指令,显示在线用户
exports.list = function (client, key) {
  // 获取当前 socket
  const socket = client[key].socket;

  // 写入信息
  soket.write('当前用户列表:\r\n');
  Object.values(client).forEach(path => {
    socket.write(path.username + '\r\n');
  });
};

// 处理 r 指令,用户重命名
exports.rename = function (client, key, dataArr) {
  const newName = dataArr[1];

  // 更新对应 socket 的新用户名并通知
  client[key].username = newName;
  client[key].socket.write('新用户名是: ' + newName + '\r\n');
};

// 处理 s 指令,私聊
exports.privateChat = function (client, key, dataArr) {
  Object.keys(client).forEach(path => {
    if (client[path].username === dataArr[1]) {
      client[path].socket.write(client[key].username + ': ' + dataArr[2] + '\r\n');
    }
  });
};

// 处理 b 指令,广播
exports.broadcast = function (client, key, dataArr) {
  Object.keys(client).forEach(path => {
    if (path !== key) {
      client[path].socket.write(client[key].username + ': ' + dataArr[1] + '\r\n');
    }
  });
};

显示在线用户功能的思路是将 client 内部所有在线用户的用户名循环写入到当前 socket 中。

重命名功能的思路是获取输入的新用户名替换掉 client 中对应的 username 并将当前新用户名设置成功的消息返回当前 socket

私聊功能的思路是循环 client 内的所有客户端,当 username 和发送的用户名相同时,将消息写入这个用户名对应的 socket

广播功能思路是循环 client,将消息写入给出自己以外的所有客户端。

总结

本文重点在于理解多人聊天功能的基本开发思路,及 Node.jsTCP 传输对应的 net 模块的应用,实际上本文中聊天室的代码在用户重名的情况下并没有做任何处理,正常情况应该使用 id 作为唯一标识,而不是指定用户名,在 Node.js 开发中其实很少直接使用 net 大多情况下使用 httphttps 来替代,但是我们应该知道他们都是基于 net 封装的,了解 net 会在使用 httphttps 时更得心应手。