MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Node.js TCP/IP 基础与 Socket 编程入门

2023-09-073.5k 阅读

1. TCP/IP 基础概念

1.1 TCP/IP 协议族

TCP/IP(Transmission Control Protocol/Internet Protocol)协议族是一组用于实现网络通信的协议集合,它是互联网的基础。这个协议族包含了多个层次,每个层次负责不同的功能,共同协作完成数据的传输。

  • 应用层:这是最靠近用户的一层,负责处理应用程序之间的通信。常见的应用层协议有 HTTP(用于网页浏览)、SMTP(用于邮件发送)、FTP(用于文件传输)等。在 Node.js 开发中,我们经常使用 HTTP 模块来创建 Web 服务器,这就是在应用层进行的操作。例如,通过 Node.js 的 http 模块创建一个简单的 HTTP 服务器:
const http = require('http');

const server = http.createServer((req, res) => {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Hello, World!');
});

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});
  • 传输层:主要负责端到端的数据传输,确保数据的可靠传输或快速传输。传输层有两个重要的协议,即 TCP(Transmission Control Protocol)和 UDP(User Datagram Protocol)。TCP 提供可靠的、面向连接的数据传输,它通过三次握手建立连接,传输过程中会对数据进行确认、重传等机制,保证数据的完整性。UDP 则提供不可靠的、无连接的数据传输,它速度快,但不保证数据一定能到达目的地或按顺序到达。在 Node.js 中,net 模块主要用于基于 TCP 和 UDP 的网络编程。
  • 网络层:负责将数据分组从源节点发送到目的节点,主要协议是 IP(Internet Protocol)。IP 协议为每个网络设备分配一个唯一的 IP 地址,通过路由算法决定数据分组的传输路径。例如,我们在访问网站时,浏览器会向 DNS(域名系统,工作在应用层,但依赖网络层的 IP 协议)服务器查询网站的 IP 地址,然后通过 IP 协议将请求发送到对应的服务器。
  • 数据链路层:负责将网络层传来的数据帧转换为物理层可以传输的信号,同时处理物理层传来的信号并转换为数据帧。常见的数据链路层协议有以太网协议,它定义了数据帧的格式、MAC 地址等内容。MAC 地址是网络设备在数据链路层的唯一标识。

1.2 IP 地址与端口号

  • IP 地址:是网络设备在网络中的标识,类似于我们现实生活中的家庭住址。IP 地址分为 IPv4 和 IPv6 两种版本。IPv4 是目前广泛使用的版本,它由 32 位二进制数组成,通常以点分十进制的形式表示,如 192.168.1.1。由于 IPv4 地址数量有限,逐渐被 128 位的 IPv6 所取代,IPv6 的表示形式如 2001:0db8:85a3:0000:0000:8a2e:0370:7334。在 Node.js 开发网络应用时,我们需要知道目标服务器的 IP 地址来建立连接。
  • 端口号:端口号用于标识应用程序在计算机中的特定进程,它是一个 16 位的无符号整数,范围从 0 到 65535。其中,0 到 1023 为系统保留端口,通常用于特定的系统服务,如 HTTP 协议默认使用 80 端口,HTTPS 使用 443 端口,FTP 使用 21 端口等。在 Node.js 中创建服务器时,我们需要指定一个端口号来监听客户端的请求,如上面 HTTP 服务器示例中使用了 3000 端口。

1.3 TCP 连接过程 - 三次握手与四次挥手

  • 三次握手:这是 TCP 建立连接的过程。假设客户端为 A,服务器为 B。
    • 第一次握手:A 向 B 发送一个 SYN(同步序列号)包,其中包含一个初始序列号(seq=x),表示 A 想要与 B 建立连接。此时 A 进入 SYN_SENT 状态。
    • 第二次握手:B 收到 A 的 SYN 包后,向 A 发送一个 SYN + ACK 包。这个包中,SYN 部分表示 B 也同意建立连接,ACK 部分是对 A 的 SYN 包的确认,确认号为 seq=x + 1。同时,B 也会发送自己的初始序列号(seq=y)。此时 B 进入 SYN_RCVD 状态。
    • 第三次握手:A 收到 B 的 SYN + ACK 包后,向 B 发送一个 ACK 包,确认号为 seq=y + 1,表示 A 已经收到 B 的 SYN 包。此时 A 和 B 都进入 ESTABLISHED 状态,连接建立成功。

在 Node.js 的 TCP 编程中,虽然我们不需要直接处理三次握手的细节,但了解这个过程有助于我们理解 TCP 连接的建立机制,以及在出现连接问题时进行排查。

  • 四次挥手:这是 TCP 断开连接的过程。
    • 第一次挥手:A 向 B 发送一个 FIN(结束标志)包,表示 A 没有数据要发送了,请求断开连接。此时 A 进入 FIN_WAIT_1 状态。
    • 第二次挥手:B 收到 A 的 FIN 包后,向 A 发送一个 ACK 包,确认收到 A 的 FIN 包。此时 B 进入 CLOSE_WAIT 状态,A 进入 FIN_WAIT_2 状态。
    • 第三次挥手:B 处理完自己的数据后,向 A 发送一个 FIN 包,表示 B 也没有数据要发送了,请求断开连接。此时 B 进入 LAST_ACK 状态。
    • 第四次挥手:A 收到 B 的 FIN 包后,向 B 发送一个 ACK 包,确认收到 B 的 FIN 包。此时 A 进入 TIME_WAIT 状态,等待一段时间(2MSL,MSL 为最长报文段寿命)后,A 进入 CLOSED 状态,B 收到 A 的 ACK 包后也进入 CLOSED 状态,连接彻底断开。

2. Node.js 中的 Socket 编程

2.1 Socket 基本概念

Socket(套接字)是一种抽象层,它提供了应用程序与网络协议之间的接口,使得应用程序能够通过网络进行通信。Socket 可以看作是两个网络应用程序之间通信的端点,它结合了 IP 地址和端口号,标识了网络中的唯一通信连接。在 Node.js 中,我们可以使用 net 模块来进行 Socket 编程,net 模块提供了创建 TCP 服务器和客户端的功能。

2.2 创建 TCP 服务器

在 Node.js 中创建一个简单的 TCP 服务器示例如下:

const net = require('net');

const server = net.createServer((socket) => {
    console.log('A client has connected.');

    socket.on('data', (data) => {
        console.log(`Received data: ${data.toString()}`);
        socket.write('Server received your data.');
    });

    socket.on('end', () => {
        console.log('The client has disconnected.');
    });
});

const port = 4000;
server.listen(port, () => {
    console.log(`Server listening on port ${port}`);
});

在上述代码中:

  • 首先通过 net.createServer() 创建一个 TCP 服务器实例,传入的回调函数会在有客户端连接时被调用,socket 参数表示与客户端建立的连接。
  • socket.on('data', callback) 事件监听器用于接收客户端发送的数据,当接收到数据时,会将数据打印出来,并向客户端回写一条消息。
  • socket.on('end', callback) 事件监听器用于监听客户端断开连接的事件,当客户端断开连接时,会打印相应的日志。
  • 最后通过 server.listen(port, callback) 方法让服务器监听指定的端口,当服务器成功监听端口后,会执行回调函数并打印日志。

2.3 创建 TCP 客户端

接下来创建一个与上述服务器通信的 TCP 客户端:

const net = require('net');

const client = new net.Socket();

client.connect(4000, '127.0.0.1', () => {
    console.log('Connected to server');
    client.write('Hello, server!');
});

client.on('data', (data) => {
    console.log(`Received from server: ${data.toString()}`);
    client.end();
});

client.on('close', () => {
    console.log('Connection closed');
});

在这个客户端代码中:

  • 通过 new net.Socket() 创建一个 TCP 客户端实例。
  • 使用 client.connect(port, host, callback) 方法连接到指定端口和主机的服务器,连接成功后会打印日志并向服务器发送一条消息。
  • client.on('data', callback) 事件监听器用于接收服务器返回的数据,接收到数据后打印日志并关闭连接。
  • client.on('close', callback) 事件监听器用于监听连接关闭的事件,当连接关闭时打印相应的日志。

2.4 Socket 通信中的数据处理

在实际的 Socket 通信中,数据的处理是非常关键的。由于网络传输的不确定性,数据可能会分多次到达,或者到达的数据并不是完整的一个数据包。因此,我们需要一些机制来处理这些情况。

  • 缓冲区与流:在 Node.js 中,net.Socket 继承自 stream.Duplex,它是一个可读可写的流。当数据从网络到达时,会先存储在可读缓冲区中,我们可以通过 data 事件来读取这些数据。同样,当我们调用 socket.write() 方法发送数据时,数据会先写入到可写缓冲区,然后再发送到网络中。

  • 粘包与拆包问题:粘包是指多个数据包被粘连在一起发送,拆包是指一个数据包被分成多个部分发送。这两种情况在网络通信中很常见,特别是在 TCP 协议中。为了解决这些问题,我们可以采用一些方法,比如在数据包前加上长度字段,这样接收方就可以根据长度字段来正确地解析数据包。

以下是一个改进后的服务器和客户端代码,通过在数据包前加上长度字段来解决粘包和拆包问题:

服务器端代码

const net = require('net');

const server = net.createServer((socket) => {
    let buffer = Buffer.alloc(0);

    socket.on('data', (data) => {
        buffer = Buffer.concat([buffer, data]);

        while (buffer.length >= 4) {
            const length = buffer.readUInt32BE(0);
            if (buffer.length >= length + 4) {
                const message = buffer.slice(4, length + 4);
                console.log(`Received data: ${message.toString()}`);
                buffer = buffer.slice(length + 4);
            } else {
                break;
            }
        }
    });

    socket.on('end', () => {
        console.log('The client has disconnected.');
    });
});

const port = 4000;
server.listen(port, () => {
    console.log(`Server listening on port ${port}`);
});

客户端代码

const net = require('net');

const client = new net.Socket();

client.connect(4000, '127.0.0.1', () => {
    console.log('Connected to server');
    const message = 'Hello, server!';
    const lengthBuffer = Buffer.alloc(4);
    lengthBuffer.writeUInt32BE(message.length, 0);
    client.write(Buffer.concat([lengthBuffer, Buffer.from(message)]));
});

client.on('data', (data) => {
    const length = data.readUInt32BE(0);
    const message = data.slice(4, length + 4);
    console.log(`Received from server: ${message.toString()}`);
    client.end();
});

client.on('close', () => {
    console.log('Connection closed');
});

在上述代码中:

  • 服务器端维护一个缓冲区 buffer,每次接收到数据时,将新的数据追加到缓冲区。然后通过检查缓冲区中是否有足够的数据(长度字段 + 实际数据)来解析数据包。
  • 客户端在发送数据前,先将数据的长度转换为 4 字节的 Buffer,然后将长度 Buffer 和实际数据 Buffer 拼接在一起发送。接收数据时,先读取长度字段,再根据长度字段读取实际数据。

3. UDP 编程基础

3.1 UDP 协议特点

UDP(User Datagram Protocol)是一种无连接的、不可靠的传输层协议。与 TCP 相比,UDP 具有以下特点:

  • 无连接:UDP 在发送数据之前不需要像 TCP 那样通过三次握手建立连接,直接将数据报发送出去。这使得 UDP 的传输速度更快,延迟更低,适用于对实时性要求较高但对数据准确性要求相对较低的应用场景,如视频流、音频流的传输。
  • 不可靠:UDP 不保证数据报一定能到达目的地,也不保证数据报的顺序。接收方可能会收到重复的数据报,或者某些数据报丢失。因此,UDP 没有确认、重传等机制来保证数据的可靠性。
  • 开销小:由于 UDP 不需要维护连接状态,也没有复杂的确认和重传机制,所以 UDP 的首部开销比 TCP 小。UDP 首部只有 8 个字节,而 TCP 首部最少有 20 个字节。

3.2 Node.js 中 UDP 服务器与客户端创建

在 Node.js 中,使用 dgram 模块来进行 UDP 编程。以下是创建 UDP 服务器和客户端的示例代码。

UDP 服务器代码

const dgram = require('dgram');

const server = dgram.createSocket('udp4');

server.on('message', (msg, rinfo) => {
    console.log(`Received message: ${msg.toString()} from ${rinfo.address}:${rinfo.port}`);
    const response = 'Server received your message.';
    server.send(response, rinfo.port, rinfo.address, (err) => {
        if (err) {
            console.error(err);
        }
    });
});

server.on('listening', () => {
    const address = server.address();
    console.log(`Server listening on ${address.address}:${address.port}`);
});

server.bind(4001);

在上述服务器代码中:

  • 通过 dgram.createSocket('udp4') 创建一个 UDP v4 套接字实例。
  • server.on('message', callback) 事件监听器用于接收客户端发送的消息,msg 是接收到的消息 Buffer,rinfo 包含了发送方的地址和端口信息。服务器接收到消息后,向客户端回发一条响应消息。
  • server.on('listening', callback) 事件监听器在服务器开始监听指定端口时触发,打印服务器监听的地址和端口。
  • 最后通过 server.bind(port) 方法让服务器监听指定的端口。

UDP 客户端代码

const dgram = require('dgram');

const client = dgram.createSocket('udp4');

const message = 'Hello, UDP server!';
client.send(message, 0, message.length, 4001, '127.0.0.1', (err) => {
    if (err) {
        console.error(err);
    }
});

client.on('message', (msg, rinfo) => {
    console.log(`Received from server: ${msg.toString()} from ${rinfo.address}:${rinfo.port}`);
    client.close();
});

在客户端代码中:

  • 通过 dgram.createSocket('udp4') 创建一个 UDP v4 套接字实例。
  • 使用 client.send(message, offset, length, port, address, callback) 方法向服务器发送消息,其中 offset 为 0,length 为消息的长度,portaddress 是服务器的端口和地址。
  • client.on('message', callback) 事件监听器用于接收服务器返回的消息,接收到消息后打印并关闭客户端。

3.3 UDP 应用场景

由于 UDP 的特点,它在以下场景中得到广泛应用:

  • 实时音视频传输:如视频会议、在线直播等应用,对实时性要求非常高。即使偶尔丢失一些数据帧,对整体的音视频质量影响也不大,而 UDP 的低延迟特性正好满足这种需求。
  • 网络游戏:在网络游戏中,数据的实时性至关重要,玩家的操作需要及时反馈到游戏服务器并同步给其他玩家。虽然 UDP 可能会丢失一些数据包,但游戏客户端可以通过预测、补偿等算法来尽量减少对游戏体验的影响。
  • DNS 服务:DNS(Domain Name System)查询通常使用 UDP 协议。因为 DNS 查询的数据量较小,并且对响应速度要求较高,UDP 的快速传输特性适合这种场景。虽然 UDP 不可靠,但 DNS 服务器通常会设置较短的超时时间并进行重试,以确保查询的准确性。

4. 综合案例 - 简单的即时通讯系统

4.1 需求分析

我们要构建一个简单的即时通讯系统,支持多个客户端之间的消息发送和接收。这个系统基于 TCP 协议,使用 Node.js 进行开发。主要功能包括:

  • 客户端能够连接到服务器。
  • 客户端可以向服务器发送消息。
  • 服务器将接收到的消息广播给所有已连接的客户端。

4.2 服务器端实现

const net = require('net');

const clients = [];

const server = net.createServer((socket) => {
    clients.push(socket);

    socket.on('data', (data) => {
        const message = data.toString();
        console.log(`Received from client: ${message}`);
        clients.forEach((client) => {
            if (client!== socket) {
                client.write(message);
            }
        });
    });

    socket.on('end', () => {
        const index = clients.indexOf(socket);
        if (index!== -1) {
            clients.splice(index, 1);
        }
        console.log('A client has disconnected.');
    });
});

const port = 4000;
server.listen(port, () => {
    console.log(`Server listening on port ${port}`);
});

在上述服务器代码中:

  • 定义一个数组 clients 来存储所有已连接的客户端套接字。
  • 当有新客户端连接时,将其套接字添加到 clients 数组中。
  • 接收到客户端发送的数据时,将消息打印出来,并遍历 clients 数组,将消息发送给除发送方之外的所有客户端。
  • 当客户端断开连接时,从 clients 数组中移除该客户端套接字,并打印相应的日志。

4.3 客户端实现

const net = require('net');

const client = new net.Socket();

client.connect(4000, '127.0.0.1', () => {
    console.log('Connected to server');
    process.stdin.on('data', (data) => {
        client.write(data.toString());
    });
});

client.on('data', (data) => {
    console.log(`Received message: ${data.toString()}`);
});

client.on('close', () => {
    console.log('Connection closed');
});

在客户端代码中:

  • 连接到服务器后,监听标准输入流 process.stdindata 事件,当用户在控制台输入内容时,将其发送给服务器。
  • 接收到服务器发送的消息时,将消息打印出来。
  • 当连接关闭时,打印相应的日志。

通过以上服务器端和客户端的实现,我们就构建了一个简单的即时通讯系统。当然,在实际应用中,还需要考虑更多的因素,如安全性、消息持久化、用户认证等。

通过对 TCP/IP 基础以及 Node.js 中 Socket 编程(包括 TCP 和 UDP)的介绍和实践,我们可以看到 Node.js 在网络编程方面提供了强大而灵活的功能。无论是开发高性能的服务器应用,还是构建实时通信的应用程序,掌握这些知识和技能都非常重要。在实际开发中,我们需要根据具体的需求和场景,选择合适的协议和编程方式来实现高效、可靠的网络通信。