掘金 后端 ( ) • 2024-04-28 18:28

在前面的章节中,我们已经讨论过作为Web应用开发平台,nodejs中最重要的模块:HTTP/S协议。逻辑上来看,它们作为网络协议是比较高级的应用层的协议,其实是基于TCP这样一个更加底层和基础协议的。这就将是我们准备在本文中要研究的问题。除此之外,其他的一些基础和扩展的网络协议相关的内容包括UDP、DNS等,我们也会在本文内一并探讨。由于相关技术在实际开发工作中,实际接触和使用这些底层协议的机会并不多,所以这里更多是概念性和了解性的探讨。

由于篇幅的限制,上面的内容,笔者需要划分为两个部分进行阐述,本文的将重点讨论TCP,后面的内容才会涉及UDP和DNS。

net模块

nodejs中,TCP协议是使用其net模块来实现的。在其官方文档中,如此描述这个模块:

node:net module 提供了用于创建基于信息流的TCP和IPC服务器(net.createServer())和客户端(net.createConnection())软件的异步网络API。

我们可以将其作为nodejs http模块的基础和底层协议模块,它的本质就是TCP网络协议的实现。和HTTP协议实现类型,它使用同一个主模块来支持客户端和服务端的应用模型。此外,有趣的是,除了TCP,它还支持可以使用基本相同的方式,来支持IPC,这个我们后面有简单的讨论。

和几乎所有的网络协议和应用的模式一样,TCP协议也是一种典型的客户端服务器(请求/响应),就是在逻辑上将通信的双方分为客户端和服务端;服务端启动后,侦听网络端口等待连接;客户端通过服务端的地址和端口发起连接请求;连接建立后就可以传输网络数据包来进行通信了。所以这里的几个核心的要素和概念就是服务端、客户端、连接通道、数据包等等。

在实际的Web应用开发场景中,直接使用net模块的机会其实不是很多。但如果要深入基于TCP协议的开发,比如要对接一些物联网(Internet Of Things, IOT)系统,由于它们在硬件和网络方面的一些限制,可能会直接使用TCP协议,或者基于TCP协议开发上层的应用协议。还有很多应用,它们也是基于累述的考虑,需要使用自己的私有协议,或者应用层语义,一般也是在TCP协议上进行扩展的。这时,对于TCP协议的了解和开发,就可能就需要了。这种开发模式,也经常被称为“Socket编程”。

示例代码

为了方便分析和表述,笔者参照了nodejs官方文档,并进行了简单的修改和合成,将本章节相关操作的示例代码,写在一个单一文件当中,然后再对应的组成步骤中具体分析,代码内容如下:

const
PORT = 8124,
net = require('node:net');

//start server 
const startNet = ()=>{
    const server = net.createServer((c) => {
        // 'connection' listener.
        console.log("Server", "Client Connected");

        c
        .on('data', (data) => {
          console.log("Server","Receive:" + data);
          c.write("ServerData-" + Date.now());
        })
        .on('end', () => console.log("Server", "Client Disconnected"));
    })
    .on('error', (err) => { throw err; });

    server.listen(PORT, () => console.log('Server bound', PORT));      
}; startNet();

/// client 
let client;
const send = ()=>{
    if (!client) {
        client = net.createConnection({ port: PORT }, () => {
            // 'connect' listener.
            console.log('Client Connected to server OK!');
        });

        client
        .on('data', (data) => console.log("Client","Receive:" + data))
        .on('end', () => console.log("Client", "Disconnect From Server")); 

    } else { // send data
        client.write("ClientData-"+ Date.now());
    }

}; setInterval(send, 3000);

简单而言,这段代码分为两个大的部分。第一个部分创建了一个TCP服务器,它可以侦听一个TCP端口,在接收数据后进行相关的处理;第二个服务其实是一个TCP客户端,它连接服务器,并发送一些数据,并在收到服务端消息后,进行相关处理。

服务端

在服务端,通常的应用场景就是使用net模块来创建一个TCP服务器。参照示例代码,我们会发现,这几乎和HTTP模块是一模一样的,这里的基本操作和业务流程是:

  • 先调用createServer方法,创建一个Server实例
  • 创建方法的参数,是一个回调方法
  • 回调方法的参数,是一个connection对象,它将在有客户端连接时,触发并回调
  • 通过设置和重写connection对象的onData和onError事件,可以进行具体业务操作,特别是使用onData接收和处理客户端发送的数据
  • 设置连接方法之后,就可以调用server.listen方法,启动TCP服务的网络侦听
  • 要向客户端发送消息,可以调用连接对象的write方法
  • 侦听启动过程中,如果网络端口被占用,会抛出冲突错误,这是最常见的一个错误情况

从这个实现,我们也能够更容易理解HTTP协议和TCP协议之间的关系。它就是在TCP协议基础上,加入了应用层处理数据的机制,以遵守HTTP协议规范和约定而已。

客户端

net模块中,客户端使用的概念,不是一个简单的客户端或者请求对象,而是一个“连接”的对象。这里的标准业务流程是:

  • 通过调用net.createConnection方法,可以创建一个连接对象,这里我们也可以将其看作tcp客户端
  • 创建连接的参数是一个回调方法,名为connectListener,在此处可以设置在连接成功后进行的一些操作
  • 设置和重新连接对象的onData和onError事件,可以进行相关的业务操作,特别是在onData中,接收和处理服务器发送的数据
  • 要使用连接,向服务器发送数据,需要调用write方法

net对象

net对象是net模块的默认和根类。它的主要方法和属性包括:

  • createServer(): 创建服务器
  • connect()、createConnection() 连接服务器并创建连接对象,可选的设置参数包括服务端地址、端口、路径(IPC)、keepAlive等等,这两个方法是等效的(connect是createConnection的别名)
  • isIP()、isIPv6()、isIPv4(): 可以用于判断一个字符串的是否是有效的IP和类型

基于模块架构设计的考虑,net模块还提供了net.Blocklist(黑名单)和net.SocketAddress(插座地址)等类,它们基本上是比较简单的数据结构,这里不再赘述。

net模块的核心其实是net.Server对象和net.Socket对象,我们可以将它们理解称为TCP服务端和客户端的抽象实现。Server我们前面已经了解到,下面我们再来了解一下Socket。

socket对象(net.Socket)

在net模块中,TCP的网络连接,被抽象成为socket对象(net.Socket)。在客户端,它就是客户端本身; 在服务端,它是在客户端连接成功后,回调的对象;在这两个场景中,它们是一致的。使用时,它的主要属性和方法包括:

  • address() 获得绑定地址
  • localAddress、localPort、localFamily: 作为客户端连接的ip地址和相关信息
  • remoteAddress、remotePort、
  • write() 写入和发送数据
  • pause()、resume() 暂停和恢复读取数据
  • end() 结束写入,被称为“半关闭”连接
  • setTimemout()、timeout: 设置超时选项和属性
  • distroy() 关闭和销毁连接
  • bytesRead、bytesWritten: 作为连接对象读写的字节数量
  • pending、connecting、distroyed等: 各种连接状态
  • readyState: 就绪状态,包括opening、open、readyOnly、writeOnly等

socket使用回调事件来控制工作状态和进行操作,其主要回调事件包括:

  • connect 连接成功
  • ready 就绪
  • close 关闭
  • data 读取和接收数据
  • end 数据传输结束
  • timeout 超时
  • error 错误

blockList 黑名单

在阅读nodejs net模块文档的时候,笔者发现了一个有趣的功能: blocklist,应该就是黑名单的功能。笔者觉得,这个功能的立意和构思很好,但好像实现有一点问题(也可能是笔者的理解有问题)。

为什么这么说?我们先来看看相关的示例代码:


const blockList = new net.BlockList();
blockList.addAddress('123.123.123.123');
blockList.addRange('10.0.0.1', '10.0.0.10');
blockList.addSubnet('8592:757c:efae:4e45::', 64, 'ipv6');

console.log(blockList.rules());  // Prints: rules list
console.log(blockList.check('10.0.0.3'));  // Prints: true
console.log(blockList.check('222.111.111.222'));  // Prints: false

从代码中可以看到,blockList的用法是,先创建黑名单实例,然后可以其包含添加地址、范围和子网。然后就可以用这个黑名单检查一个网络地址是否在黑名单内了。

笔者的疑问在于,这好像其实只是一个规则定义和检查程序,并没有真正的和TCP协议的运行结合起来,比如服务器可以应用这个规则,并在客户端连接时进行检查,如果不匹配,可以抛出如“forbiden”这种错误并拒绝连接。起码,从它的示例代码中,笔者尚未看到相关的内容。所以,这个特性,给笔者的感觉,只是做了一些基础的工作,尚未完善。

还有,既然有黑名单,也应该设计类似的白名单的机制,起码在规则和检查层面上,并不难实现。根据“默认安全”的信息系统安全原则,白名单对于很多应用场景如系统间互联,其实必要性更大,是一个很重要的基础安全策略。

IPC

从文档和实现来看,nodejs的net模块,不仅能够支持标准的TCP协议,还能够支持IPC协议。这样,理论上而言,我们就可以使用标准的网络通信的模式,来处理进程之间通信的问题。

IPC协议全称是进程间通信(Inter-Process Communication)协议,它定义了在操作系统中,不同进程之间传递数据或信号的一种机制和规则。 严格说来,IPC并非像TCP一样的标准协议,而是由各个操作系统定义和实现的一种或者多种进程间通信的范式,在不同的操作系统中,常见的实现方式包括管道(Pipe)、命名管道(Named Pipe)、信号(Signal)、消息队列(Message Queue)、共享内存(Shared Memory)、信号量(Semaphore)、套接字(Socket)等等。

本文中讨论的IPC,主要是指使用命名管道(Windows)和套件字(Socket)方式,就是类似网络端口通信实现的进程间通信机制。因为这种方式的工作模式和网络基本上一样的,无需在应用和业务层面进行调整和修改,通过简单的连接参数调整,就可以适应网络和IPC等不同的应用场景。

有了这样一个机制,就可以大大提升应用程序开发和架构时的灵活性。现代化的应用程序结构越来越复杂,开发者希望通过模块化,来降低开发的复杂性,并提高架构的灵活性,一个常见的方式就是将应用程序划分为后端服务(也经常被称为Core)和前端界面,它们在操作系统层面上,就是两个应用程序和进程,但业务逻辑

我们可以将前面TCP程序的案例,改造成为IPC的方式,可以使用的示例代码如下:


const
net = require('node:net'),
path = require("path"),
PORT = 8124,
SCKFILE = path.join('\\\\?\\pipe', process.cwd(), 'SCK'+PORT);

// in linux could use:  '/tmp/echo.sock' as socket file 

//start server 
net.createServer((c) => {
    // 'connection' listener.
    console.log("Server", "Client Connected");

    c
    .on('data', (data) => {
        console.log("Server","Receive:" + data);
        c.write("ServerData-" + Date.now());
    })
    .on('end', () => console.log("Server", "Client Disconnected"));
})
.on('error', (err) => { throw err; })
.listen(SCKFILE, () => console.log('Server bound', SCKFILE));      

/// client 
let client;
const send = ()=>{
    if (!client) {
        client = net.createConnection({ path: SCKFILE }, () => {
            // 'connect' listener.
            console.log('Client Connected to server OK!');
        });

        client
        .on('data', (data) => console.log("Client","Receive:" + data))
        .on('end', () => console.log("Client", "Disconnect From Server")); 

    } else { // send data
        client.write("ClientData-"+ Date.now());
    }

}; setInterval(send, 3000);

这个程序的执行的效果,在本机上,是和前面TCP示例是完全一样的,只不过是使用了IPC的机制而已。但要注意,这一段代码,只能运行在Windows系统当中。其实,在标准的Unix或者Linux系统中,可以直接使用文件作为Socket,这是因为在这样的操作系统中,所有的输入输出接口都可以被抽象成为文件来进行操作(万物皆文件),文件本身也只是一个抽象的数据接口而非数据存储结构。但在Windows系统中,情况稍微有点复杂,所以它采用了一个“命名管道”的技术(使用\pipe作为开头的特殊路径),来模拟文件接口的方式。

理解了这个原理,我们将会认识到,使用IPC来实现应用程序进程模块之间的通信,比在本机使用网络堆栈,是有一定的优势的。首先只是使用一个文件描述符,不需要启用一个TCP网络协议模块,可以减少系统特别是网络相关资源的占用;另外不暴露网络端口,也不需要配置防火墙等,比较简单和安全;最后就是这个结构没有网络协议的解析和转换,理论传输性能也应该比较高。当然,这种方式会将应用程序模块绑定在同一个操作系统环境中运行,稍微牺牲了一点配置和运行的灵活性。

小结

本文探讨了在nodejs中,对于HTTP协议的底层基础协议-TCP的应用和实现方式。包括了模块定义、基本工作原理、主要类和方法、程序框架和一些典型的需求和使用场景。