Node.js 网络编程之 TCP

我们时时刻刻都在与网络打交道,但对于网络协议往往一知半解,遇到相关问题时也常常抓耳挠腮,茫然不知所措;为此,从本文开始笔者将为大家带来 Node.js 网络编程系列,希望能够通过理论到实践的方式让大家真正掌握 Node.js 网络编程。

下面我们就从 TCP 协议以及 TCP 协议在 Node.js 中的应用开始这段 Node.js 网络编程的旅程。

协议

TCP 协议(传输控制协议)是运行在运输层之上,能够为调用它的应用程序提供一种可靠的、面向连接的服务的网络传输协议。它主要有以下特点:

  • 面向连接:客户端与服务端传输数据之前需要经过三次握手来建立连接;
  • 可靠的数据传输:通过重传、校验和等机制来保证数据能够被通信双方正确接收;
  • 流量控制:通过滑动窗口等机制来平衡数据发送方的发送速率与数据接收方的接收速率,以便高效地利用各种资源。

本节剩余部分我们将简单介绍下 TCP 的连接/分手过程、TCP 连接队列、TCP 的半开/半关闭连接等内容。

连接过程

我们知道 TCP 是面向连接的协议,大家也都知道 TCP 连接需要经过三次握手(见下图),本小节我们就聊聊相关内容。

connect.jpg

如图所示,TCP 的连接过程为:

  • 客户端通过 connect() 调用向服务端发送 SYN 以希望建立连接,此刻客户端进入 SYN_SENT 状态;
  • 服务端收到客户端的 SYN 请求后,会给客户端发送 SYN + ACK,此刻服务端进入 SYN_RCVD 状态;
  • 客户端收到服务端的 SYN + ACK 响应后,会给服务端发送 ACK,此刻客户端与服务端(在收到客户端的 ACK 确认后)分别进入 ESTABLISH 状态。

一旦完成上述的三个步骤,客户端和服务端之间就可以相互发送包括数据的报文段了。

分手过程

所谓合久必分,我们已经知道了 TCP 的连接过程,那么 TCP 的分手过程又是怎么样的呢?为什么 TCP 的分手要比连接多一次握手操作呢?这正是本小节要聊的内容。

close.jpg 如图所示,TCP 的分手过程为:

  • 客户端通过 close() 调用向服务端发送 FIN 以希望断开请求,此刻客户端进入 FIN_WAIT_1 状态;
  • 服务端收到客户端的 FIN 请求后,会立刻给客户端发送 ACK,此刻服务端进入 CLOSE_WAIT 状态,客户端收到 ACK 响应后进入 FIN_WAIT_2 状态;
  • 服务端在合适的时机调用 close(),并向客户端发送 FIN,此刻服务端进入 LAST_ACK 状态;
  • 客户端收到服务端的 FIN 响应后,会给服务端发送 ACK,此刻客户端进入 TIME_WAIT 状态,客户端会在超时时间(具体时间依赖于具体实现,常见值有 30 秒、1 分钟、2 分钟)到期后关闭连接,服务端也会在收到客户端的 ACK 确认后关闭连接。

对于上述的分手过程,大家经常的疑问是,服务端在接收到客户端的 FIN 请求后,为什么不直接给客户端发送 FIN + ACK 响应?这是因为,虽然客户端通过 FIN 表示自己不会再给服务端发送数据了,但服务端可能会有数据发送给客户端,因此服务端只能在没有数据要发送给客户端的时候才能给客户端发送 FIN 响应,而立刻发送 ACK 响应是要告诉客户端它已经收到了客户端的 FIN 请求。

连接队列

TCP 使用连接队列来应对并发问题,根据连接的状态,TCP 连接队列可分为以下两种:

  • 半连接队列(SYN Queue):当服务端收到客户端的 SYN 请求后,在给客户端发送 SYN + ACK 的同时会将相关连接信息存入到一个队列中,由于此类连接尚未完成 TCP 的握手过程,故此我们将存储此类连接信息的队列称之为半连接队列(SYN Queue);
  • 全连接队列(Accept Queue):当服务端与客户端完成了整个握手过程,但却未被 accept 调用消费的连接会被存入到一个队列中,该队列称之为全连接队列(Accept Queue)。

如果服务器需要应对大量并发,那么就要适当调大相关队列的长度,这是因为队列空间一旦用完后,很容易造成丢包或连接复位,从而导致客户端无法连接到服务端的现象。这两个队列的长度依赖于以下几个变量:

  • 方法 server.listen(见下文 net.Server 模块使用)backlog 参数的值,在 Node.js 中,默认值为 511
  • 系统参数 tcp_max_syn_backlog(半连接队列长度的最大值)的值,默认值为 128
  • 系统参数 somaxconn(全连接队列长度的最大值)的值,默认值为 1024

相关对列长度的计算规则为:

  • 半连接队列(SYN Queue):取 backlogtcp_max_syn_backlogsomaxconn 三者中的最小值;
  • 全连接队列(Accept Queue):取 backlogsomaxconn 两者中的最小值。

根据上面的计算规则可知,半连接队列的长度一定不能大于全连接队列的长度。

半开连接

当客户端与服务端建立起正常连接后,如果某一方因进程崩溃、电源断电等原因导致连接断开,而另一端对此却毫不知情(傻傻地等待着对方的回应),我们将这样的 TCP 连接称为半开连接。

由于客户端与服务端之间通信线路的不确定性,半开连接出现的几率非常大,如果不对这种情况进行处理,那么没有断开的一端会一直维持着相关连接,这样将造成系统资源的极度浪费;为了规避这种问题,连接的一方(以下称 A 端)会在某段时间后向连接的另一方(以下称 B 端)发送一个心跳检测,然后根据不同的响应进行处理:

  • 如果收到的响应标识为 RST,则表明 B 端已断开连接并且已重新启动,此时 A 端会将待处理的错误设置为 ECONNRESET 并关闭链接;
  • 如果没收到任何响应,则表明 B 端已断开,此时 A 端会将待处理的错误设置为 ETIMEOUT 并关闭连接。

半关闭连接

在客户端与服务端建立起正常连接外,如果连接的一方(以下称 A 端)向另一方(以下称 B 端)发送 FIN 请求关闭连接,B 端返回 ACK 后,却并没有立刻发送 FINA 端,这种情况下 A 端不能再向 B 端发送数据,但可以接收来自 B 端的数据,我们将这样的 TCP 连接称为半关闭连接。

应用

在 Node.js 中,我们可通过 net 模块创建 TCP 或 IPC(即进程间通信,关于其实现,Windows 多采用命名管道,其它系统多采用 Unix 域套接字)服务和相关客户端,比如下面的例子:

// server.js
const { createServer } = require('net');

const server = createServer((socket) => {
  socket.on('data', (data) => {
    console.log(`Server: ${data}!`);
    socket.write(data);
  });

  socket.on('close', () => {
    console.log('Server: connection is closed!');
  });
})

server.listen(3000, 'localhost', () => {
  console.log('Server is ready!');
});

// client.js
const { createConnection } = require('net');

const client = createConnection(3000, 'localhost');

client.on('connect', () => {
  console.log('Client: connection is established!');
});

client.on('data', (data) => {
  console.log(`Client: ${data}!`);
});

client.on('close', () => {
  console.log('Client: connection is closed!');
});

client.write('Tom');
client.end();
复制代码

上例实现了一个简单的 TCP 服务,主要涉及了 net.createServernet.Servernet.createConnectionnet.socket 的使用,下面我们对其进行一一介绍。

net.createServer

net.createServer 主要用来创建 net.Server 实例;参数如下:

  • options:属性设置(可不指定),相关属性如下:

    • allowHalfOpen:是否允许半关闭连接(为什么不是半开连接请看下文解释);默认值为 false
    • pauseOnConnect:是否暂停数据的读取操作(即暂停 data 事件的触发,直至调用 socket.resume());默认值为 false
    • noDelay:是否禁用 Nagle 算法,在 Nagle 算法下,调用 socket.write() 后会在发送数据前对数据进行缓冲,禁用该算法后,则直接发送数据;默认值为 false
    • keepAlive:是否启用长连接;默认值为 false
    • keepAliveInitialDelay:设置空闲 socket 接收到最后一个数据包与发送第一个长连接探测之间的延迟时间(单位毫秒);默认值为 0
  • connectionListener:客户端连接成功时的回调函数,可在该函数中与客户端进行通信。

net.Server

net.Server 主要用于创建 TCP 或 IPC 服务,并对服务进行管理。

常用方法

  • constructor:创建新的实例,参数等同于 net.createServer 方法,此处不再重述;

  • getConnections:异步获取当前活跃连接的数量,只有当 socket 传递给子进程后才有效,其回调函数中包含 errcount 两个参数;

  • listen:启动服务并监听来自客户端的连接,该方法有以下四种签名:

    • server.listen(handle[, backlog][, callback])

      • handle:已绑定到某端口、Unix 域套接字或 Windows 命名管道的句柄;
      • backlog:限制连接队列的最大值,默认值 511
      • callback:服务启动成功后的回调函数。
    • server.listen(path[, backlog][, callback])

      • path连接路径,仅用于 IPC 服务;
      • backlog:限制连接队列的最大值,默认值 511
      • callback:服务启动成功后的回调函数。
    • server.listen([port[, host[, backlog]]][, callback])

      • port:服务监听端口号;
      • host:服务监听主机名;
      • backlog:限制连接队列的最大值,默认值 511
      • callback:服务启动成功后的回调函数。
    • server.listen(options[, callback])

      • options:属性设置,相关属性如下:

        • port:服务监听端口号;
        • host:服务监听主机名;
        • path连接路径,仅用于 IPC 服务;如指定了 port,将忽略此值;
        • backlog:限制连接队列的最大值,默认值 511
        • exclusive:在 cluster 中是否允许共享服务监听句柄;默认值为 false
        • readableAll:是否允许所有用户对 IPC 管道进行读操作,仅用于 IPC 服务;默认值为 false
        • writableAll:是否允许所有用户对 IPC 管道进行写操作,仅用于 IPC 服务;默认值为 false
        • ipv6Only:是否禁用双协议栈;默认值为 false
        • signal:使用指定的 AbortSignal 来关闭监听服务。
      • callback:服务启动成功后的回调函数。

  • close:调用该方法后,将不再接收新的连接请求,等到所有已有连接结束并触发 close 事件后,服务将完全关闭,相关参数如下:

    • callback:事件 close 触发后执行的回调函数。

相关事件

  • listening:服务通过调用 server.listen() 进行监听绑定后触发;

  • connection:新连接创建后触发;回调函数参数 socketnet.Socket 实例;

  • drop:当连接数达到服务的阈值(通过 server.maxConnections 获得)时,服务将拒绝新连接并触发该事件;如果为 TCP 服务,回调函数参数 data 的属性如下所示,否则 data 值为 undefined

    • localAddress:网络连接绑定的本地 IP 地址;
    • localPort:网络连接绑定的本地端口号;
    • remoteAddress:网络连接绑定的远程 IP 地址;
    • remotePort:网络连接绑定的远程端口号;
    • remoteFamily:远程 IP 所用协议,值为 IPv4IPv6
  • error:发生异常时触发;该事件触发不会触发 close 事件,需要手动调用 server.close()

  • close:服务完全关闭时触发。

net.createConnection

net.createConnection 主要用来创建客户端连接;有以下三种签名:

  • net.createConnection(options[, connectListener])

    • options:属性设置,相关属性如下:

      • port:远程服务的端口号;必填项;

      • host:远程服务的主机名;默认值为 localhost

      • localAddress:网络连接绑定的本地 IP 地址;

      • localPort:网络连接绑定的本地端口号;

      • family:IP 协议的版本,可用值为 460;值为 0 时表示同时支持 IPv4IPv6;默认值为 0

      • hints:DNS 查询时的标识设置,点击查看可用标识

      • lookup:自定义 DNS 查询逻辑,默认调用 dns.lookup()

      • onread:如果指定了该属性,会将数据存储到指定的 buffer 中,并在数据到达 socket 时传递给指定的回调函数;如果指定了该属性,data 事件将不会被触发;比如下面的例子:

        const { createConnection } = require('net');
        
        const client = createConnection({
          port:  3000,
          onread: {
            // 每次从 socket 读取数据时都会重用 4KB 缓冲区
            buffer: Buffer.alloc(4 * 1024),
            callback: function(nread, buf) {
              // 从 0 到 nread 之间的缓存区数据可用
              console.log(buf.toString('utf8', 0, nread));
            }
          }
        });
        
        client.on('data', (data) => {}); // 永远不会被触发
        复制代码
      • path连接路径,仅用于 IPC 服务;如果指定了该属性,所有与 TCP 有关的配置都将被忽略;

      • 属性 noDelaykeepAlivekeepAliveInitialDelay 在上文中已介绍,此处不再重述;

    • connectListener:连接创建成功后的回调。

  • net.createConnection(path[, connectListener])

    • path连接路径,仅用于 IPC 服务;
    • connectListener:连接创建成功后的回调。
  • net.createConnection(port[, host][, connectListener])

    • port:远程服务的端口号;必填项;
    • host:远程服务的主机名;默认值为 localhost
    • connectListener:连接创建成功后的回调。

net.socket

net.Socket 主要用来创建套接字实例,并以此作为服务端与客户端之间通信的桥梁。

常用方法

  • constructor:创建新的实例,参数为 Object 对象,相关属性如下:

    • fd:已存在 socket 相关文件描述符,如指定则使用该 socket,否则将创建一个新的 socket;
    • allowHalfOpen:是否允许半关闭连接(为什么不是半开连接请看下文解释);默认值为 false
    • readable:如果指定了 fd,用于设置是否允许对相关 socket 进行读取操作,如未指定 fd,则忽略该属性;默认值为 false
    • writable:如果指定了 fd,用于设置是否允许对相关 socket 进行写操作,如未指定 fd,则忽略该属性;默认值为 false
    • signal:用于销毁 socket 对象的终止信号。
  • connect:创建连接,该方法有以下三种签名:

    • socket.connect(options[, connectListener]):参数等同于 net.createConnection(options[, connectListener]),此处不再重述;
    • socket.connect(path[, connectListener]):参数等同于 net.createConnection(path[, connectListener]),此处不再重述;
    • socket.connect(port[, host][, connectListener]):参数等同于 net.createConnection(port[, host][, connectListener]),此处不再重述。
  • write:发送数据给连接的另一方,相关参数如下:

    • data:要发送的数据;类型是 stringBufferUint8Array
    • encoding:当 data 类型为 string 时的字符编码;默认值为 utf8
    • callback:数据发送成功后的回调。
  • end:对连接进行半关闭操作,相关参数如下:

    • data:在发送 FIN 包前发送的最后一个数据;类型是 stringBufferUint8Array
    • encoding:当 data 类型为 string 时的字符编码;默认值为 utf8
    • callbackdataFIN 包发送成功后的回调。

相关事件

  • lookup:在域名解析后,socket 连接之前触发,不适用于 Unix 套接字;回调函数的参数如下:

    • err:域名解析异常;
    • address:解析后的 IP 地址;
    • family:IP 地址协议版本,可用值为 4IPv4)、6IPv6);
    • host:主机名。
  • connect:socket 连接成功后触发;

  • ready:一般在 connect 事件触发后立刻触发;

  • data:接收到数据时触发;回调函数的参数 data 类型是 stringBuffer

  • end:连接的另一端调用 socket.end() 时触发;

  • timeout:当 socket 因空闲而超时时触发;

  • drain:当写入缓冲区为空时触发;

  • error:发生异常时触发;该事件触发后会立即触发 close 事件;

  • close:当 socket 完全关闭时触发;回调函数的参数 hadErrorboolean 类型,表示是否因传输错误导致 socket 关闭。

特别说明

上文我们对 allowHalfOpen 的注释为是否允许半关闭连接,可能会有人会说这明显是半开连接嘛,不急着辩论,我们看下官方的解释:

By default (allowHalfOpen is false) the socket will send an end of transmission packet back and destroy its file descriptor once it has written out its pending write queue. However, if allowHalfOpen is set to true, the socket will not automatically end() its writable side, allowing the user to write arbitrary amounts of data. The user must call end() explicitly to close the connection (i.e. sending a FIN packet back).

上文的大概意思是:

  • allowHalfOpenfalse 的情况下,客户端在调用 socket.end() 方法后,客户端与服务端的连接会自动关闭;
  • allowHalfOpentrue 的情况下,客户端在调用 socket.end() 方法后,客户端与服务端的连接不会自动关闭,而只能等待服务端手动调用 socket.end() 关闭相关连接。

从上面的解释来看,其行为更加符合半关闭连接,故本文笔者对 allowHalfOpen 的注释为是否允许半关闭连接。

总结

本文我们首先对 TCP 协议的主要内容进行了介绍,其中包括 TCP 的特性、TCP 的连接/分手过程、TCP 连接队列、TCP 的半开/半关闭连接等内容;在了解了相关协议的基本运行原理后,我们接着介绍了 Node.js 中 net 模块的使用;希望能够通过这种从原理到实践的方式让大家真正掌握 Node.js 网络编程。

由于篇幅限制,本文对网络协议的介绍仅限于表面,如想继续深入,大家可自行查阅相关书籍(比如:UNIX 网络编程卷1);同时由于笔者认知水平有限,文中若有纰漏之处,还望大家批评指正。

最后,祝大家快乐编码每一天。

参考链接

猜你喜欢

转载自juejin.im/post/7130431011946496013