《Linux 网络架构:基于 TCP 协议的多人聊天系统搭建详解》

发布于:2025-03-20 ⋅ 阅读:(20) ⋅ 点赞:(0)

一、系统概述

        本系统是一个基于 TCP 协议的多人聊天系统,由一个服务器和多个客户端组成。客户端可以连接到服务器,向服务器发送消息,服务器接收到消息后将其转发给其他客户端,实现多人之间的实时聊天。系统使用 C 语言编写,利用了 Unix 系统的网络编程接口和多线程、I/O 多路复用等技术。

二、文件结构

  • server.c:服务器端程序,负责监听客户端连接、接收客户端消息并将消息转发给其他客户端。
  • client1.c:客户端程序,使用 poll 函数实现 I/O 多路复用,同时处理用户输入和服务器消息。
  • client2.c:客户端程序,使用多线程技术,一个线程负责接收服务器消息,另一个线程负责处理用户输入。

三、代码详细分析

1. 数据包结构体

        在三个文件中都定义了相同的数据包结构体 Packet,用于在客户端和服务器之间传输数据。

typedef struct {
    int type;  // 0 for message, 1 for disconnect
    char data[BUFFER_SIZE];
} Packet;
  • type:数据包类型,0 表示消息,1 表示断开连接。
  • data:数据包携带的数据,最大长度为 BUFFER_SIZE

2. 服务器端程序(server.c)

2.1 主要变量

  • server_fd:服务器套接字文件描述符。
  • client_fd:客户端套接字文件描述符。
  • max_fd:记录最大的文件描述符,用于 select 函数。
  • activity:记录 select 函数返回的活动文件描述符数量。
  • valread:记录从客户端读取的数据长度。
  • server_addr:存储服务器的地址信息。
  • client_addr:存储客户端的地址信息。
  • client_sockets:数组用于存储所有客户端的套接字文件描述符。
  • readfds:文件描述符集合,用于 select 函数监听可读事件。

2.2 主要步骤

  1. 创建套接字:使用 socket 函数创建一个 TCP 套接字。
  2. 绑定地址:使用 bind 函数将套接字绑定到指定的地址和端口。
  3. 监听连接:使用 listen 函数开始监听客户端连接。
  4. 循环处理:使用 select 函数监听服务器套接字和客户端套接字的可读事件。
    • 若服务器套接字有可读事件,说明有新的客户端连接请求,使用 accept 函数接受连接。
    • 若客户端套接字有可读事件,从客户端读取数据包,根据数据包类型进行相应处理。
      • 若数据包类型为消息,将消息转发给其他客户端。
      • 若数据包类型为断开连接或读取到的数据长度为 0,说明客户端断开连接,关闭客户端套接字。

3. 客户端程序(client1.c)

3.1 主要变量

  • client_fd:客户端套接字文件描述符。
  • server_addr:存储服务器的地址信息。
  • packet:用于存储要发送或接收的数据包。
  • fds:数组用于存储要监听的文件描述符及其事件。

3.2 主要步骤

  1. 创建套接字:使用 socket 函数创建一个 TCP 套接字。
  2. 连接服务器:使用 connect 函数连接到服务器。
  3. 初始化 poll 结构体:监听标准输入和客户端套接字的可读事件。
  4. 循环处理:使用 poll 函数监听文件描述符集合中的可读事件。
    • 若标准输入有可读事件,从标准输入读取数据,设置数据包类型为消息,发送给服务器。
    • 若客户端套接字有可读事件,从服务器读取数据包,根据数据包类型进行相应处理。
      • 若数据包类型为消息,输出接收到的消息。
      • 若数据包类型为断开连接或读取数据失败,说明服务器断开连接,关闭客户端套接字,跳出循环。

4. 客户端程序(client2.c)

4.1 主要变量

  • client_fd:客户端套接字文件描述符。
  • server_addr:存储服务器的地址信息。
  • packet:用于存储要发送或接收的数据包。
  • thread_id:存储线程的标识符。

4.2 主要步骤

  1. 创建套接字:使用 socket 函数创建一个 TCP 套接字。
  2. 连接服务器:使用 connect 函数连接到服务器。
  3. 创建线程:创建一个线程来接收服务器发送的消息。
  4. 循环处理:在主线程中,从标准输入读取数据,设置数据包类型为消息,发送给服务器。
  5. 线程函数:在子线程中,持续接收服务器消息,根据数据包类型进行相应处理。
    • 若数据包类型为消息,输出接收到的消息。
    • 若数据包类型为断开连接或读取数据失败,说明服务器断开连接,关闭客户端套接字,退出程序。

四、编译和运行

4.1 编译

gcc server.c -o server
gcc client1.c -o client1
gcc client2.c -o client2 -lpthread

4.2 运行

  1. 启动服务器:
./server
  1. 启动客户端:
./client1
./client2

4.3运行结果展示

五、源码

5.1服务器端程序(server.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/types.h>
#include <fcntl.h>
#include <errno.h>

// 定义服务器监听的端口号
#define PORT 8080
// 定义数据缓冲区的大小
#define BUFFER_SIZE 1024
// 定义服务器允许的最大客户端连接数
#define MAX_CLIENTS 10

/**
 * 定义数据包结构体,用于在服务器和客户端之间传输数据
 * type 数据包类型,0 表示消息,1 表示断开连接
 * data 数据包携带的数据
 */
typedef struct {
    int type;  // 0 for message, 1 for disconnect
    char data[BUFFER_SIZE];
} Packet;

/**
 * 主函数,服务器程序的入口点
 * @return 程序的退出状态码,0 表示正常退出
 */
int main() {
    // server_fd 为服务器套接字文件描述符,client_fd 为客户端套接字文件描述符
    // max_fd 记录最大的文件描述符,用于 select 函数
    // activity 记录 select 函数返回的活动文件描述符数量
    // valread 记录从客户端读取的数据长度
    int server_fd, client_fd, max_fd, activity, valread;
    // server_addr 存储服务器的地址信息,client_addr 存储客户端的地址信息
    struct sockaddr_in server_addr, client_addr;
    // client_len 存储客户端地址结构体的长度
    socklen_t client_len = sizeof(client_addr);
    // packet 用于存储从客户端接收的数据包
    Packet packet;
    // client_sockets 数组用于存储所有客户端的套接字文件描述符
    int client_sockets[MAX_CLIENTS] = {0};
    // readfds 是一个文件描述符集合,用于 select 函数监听可读事件
    fd_set readfds;

    // 创建服务器套接字,使用 IPv4 地址族和 TCP 协议
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        // 若套接字创建失败,输出错误信息并退出程序
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置服务器地址信息
    server_addr.sin_family = AF_INET;
    // 监听所有可用的网络接口
    server_addr.sin_addr.s_addr = INADDR_ANY;
    // 将端口号从主机字节序转换为网络字节序
    server_addr.sin_port = htons(PORT);

    // 将服务器套接字绑定到指定的地址和端口
    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        // 若绑定失败,输出错误信息,关闭套接字并退出程序
        perror("bind failed");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 开始监听客户端连接,允许的最大连接请求队列长度为 3
    if (listen(server_fd, 3) < 0) {
        // 若监听失败,输出错误信息,关闭套接字并退出程序
        perror("listen");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 输出服务器启动信息,显示监听的端口号
    printf("Server started on port %d\n", PORT);

    // 进入无限循环,持续处理客户端连接和数据
    while (1) {
        // 清空文件描述符集合
        FD_ZERO(&readfds);
        // 将服务器套接字添加到文件描述符集合中,监听其可读事件
        FD_SET(server_fd, &readfds);
        // 初始化最大文件描述符为服务器套接字文件描述符
        max_fd = server_fd;

        // 遍历客户端套接字数组
        for (int i = 0; i < MAX_CLIENTS; i++) {
            // 获取当前客户端的套接字文件描述符
            int sd = client_sockets[i];
            if (sd > 0) {
                // 若该客户端套接字有效,将其添加到文件描述符集合中
                FD_SET(sd, &readfds);
            }
            if (sd > max_fd) {
                // 更新最大文件描述符
                max_fd = sd;
            }
        }

        // 调用 select 函数监听文件描述符集合中的可读事件,无超时时间
        activity = select(max_fd + 1, &readfds, NULL, NULL, NULL);

        if ((activity < 0) && (errno != EINTR)) {
            // 若 select 函数调用失败且不是被信号中断,输出错误信息
            perror("select error");
        }

        if (FD_ISSET(server_fd, &readfds)) {
            // 若服务器套接字有可读事件,说明有新的客户端连接请求
            if ((client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len)) < 0) {
                // 若接受连接失败,输出错误信息并继续循环
                perror("accept");
                continue;
            }

            // 输出新客户端连接的信息,包括套接字文件描述符、IP 地址和端口号
            printf("New connection, socket fd is %d, ip is : %s, port : %d\n",
                   client_fd, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

            // 遍历客户端套接字数组,找到一个空闲位置存储新客户端的套接字文件描述符
            for (int i = 0; i < MAX_CLIENTS; i++) {
                if (client_sockets[i] == 0) {
                    client_sockets[i] = client_fd;
                    break;
                }
            }
        }

        // 遍历客户端套接字数组,检查每个客户端是否有可读事件
        for (int i = 0; i < MAX_CLIENTS; i++) {
            int sd = client_sockets[i];
            if (FD_ISSET(sd, &readfds)) {
                // 从客户端读取数据包
                valread = read(sd, &packet, sizeof(Packet));
                if (valread == 0) {
                    // 若读取到的数据长度为 0,说明客户端断开连接
                    getpeername(sd, (struct sockaddr*)&client_addr, &client_len);
                    // 输出客户端断开连接的信息,包括 IP 地址和端口号
                    printf("Host disconnected, ip %s, port %d\n",
                           inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

                    // 关闭客户端套接字
                    close(sd);
                    // 将该位置的客户端套接字文件描述符置为 0,表示空闲
                    client_sockets[i] = 0;
                } else {
                    if (packet.type == 0) {
                        // 若数据包类型为消息,输出接收到的消息
                        printf("Received from client %d: %s", sd, packet.data);

                        // 将消息转发给其他客户端
                        for (int j = 0; j < MAX_CLIENTS; j++) {
                            if (client_sockets[j] != sd && client_sockets[j] != 0) {
                                // 发送数据包给其他客户端
                                send(client_sockets[j], &packet, sizeof(Packet), 0);
                            }
                        }
                    } else if (packet.type == 1) {
                        // 若数据包类型为断开连接,输出客户端断开连接的信息
                        printf("Client %d disconnected\n", sd);
                        // 关闭客户端套接字
                        close(sd);
                        // 将该位置的客户端套接字文件描述符置为 0,表示空闲
                        client_sockets[i] = 0;
                    }
                }
            }
        }
    }

    return 0;
}

5.2客户端程序(client1.c)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <poll.h>
#include <errno.h>

// 定义服务器监听的端口号
#define PORT 8080
// 定义数据缓冲区的大小
#define BUFFER_SIZE 1024

/**
 * 定义数据包结构体,用于在客户端和服务器之间传输数据
 * type 数据包类型,0 表示消息,1 表示断开连接
 * data 数据包携带的数据
 */
typedef struct {
    int type;  // 0 for message, 1 for disconnect
    char data[BUFFER_SIZE];
} Packet;

/**
 * 主函数,客户端程序的入口点
 * @return 程序的退出状态码,0 表示正常退出
 */
int main() {
    // client_fd 为客户端套接字文件描述符
    int client_fd;
    // server_addr 存储服务器的地址信息
    struct sockaddr_in server_addr;
    // packet 用于存储要发送或接收的数据包
    Packet packet;
    // fds 数组用于存储要监听的文件描述符及其事件
    struct pollfd fds[2];

    // 创建客户端套接字,使用 IPv4 地址族和 TCP 协议
    if ((client_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        // 若套接字创建失败,输出错误信息并退出程序
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置服务器地址信息
    server_addr.sin_family = AF_INET;
    // 将端口号从主机字节序转换为网络字节序
    server_addr.sin_port = htons(PORT);
    // 将服务器的 IP 地址转换为网络字节序
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

    // 连接到服务器
    if (connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        // 若连接失败,输出错误信息,关闭套接字并退出程序
        perror("connect");
        close(client_fd);
        exit(EXIT_FAILURE);
    }

    // 输出连接成功的信息
    printf("Connected to server\n");

    // 初始化 poll 结构体
    // 监听标准输入的可读事件
    fds[0].fd = STDIN_FILENO;
    fds[0].events = POLLIN;
    // 监听客户端套接字的可读事件
    fds[1].fd = client_fd;
    fds[1].events = POLLIN;

    // 进入无限循环,持续处理输入和服务器消息
    while (1) {
        // 调用 poll 函数监听文件描述符集合中的可读事件,无超时时间
        int activity = poll(fds, 2, -1);

        if ((activity < 0) && (errno != EINTR)) {
            // 若 poll 函数调用失败且不是被信号中断,输出错误信息
            perror("poll error");
        }

        if (fds[0].revents & POLLIN) {
            // 若标准输入有可读事件
            // 清空数据包的数据部分
            memset(packet.data, 0, BUFFER_SIZE);
            if (fgets(packet.data, BUFFER_SIZE, stdin) != NULL) {
                // 若成功从标准输入读取数据
                // 设置数据包类型为消息
                packet.type = 0;
                // 发送数据包给服务器
                send(client_fd, &packet, sizeof(Packet), 0);
            }
        }

        if (fds[1].revents & POLLIN) {
            // 若客户端套接字有可读事件
            // 清空数据包
            memset(&packet, 0, sizeof(Packet));
            if (read(client_fd, &packet, sizeof(Packet)) > 0) {
                // 若成功从服务器读取数据
                if (packet.type == 0) {
                    // 若数据包类型为消息,输出接收到的消息
                    printf("Received from server: %s", packet.data);
                } else if (packet.type == 1) {
                    // 若数据包类型为断开连接,输出服务器断开连接的信息
                    printf("Server disconnected\n");
                    // 关闭客户端套接字
                    close(client_fd);
                    // 跳出循环
                    break;
                }
            } else {
                // 若读取数据失败,说明服务器断开连接
                printf("Server disconnected\n");
                // 关闭客户端套接字
                close(client_fd);
                // 跳出循环
                break;
            }
        }
    }

    // 关闭客户端套接字
    close(client_fd);
    return 0;
}

5.3客户端程序(client2.c)

// 包含标准输入输出库,用于使用 printf、perror 等函数进行输入输出操作
#include <stdio.h>
// 包含标准库,提供 exit 等函数用于程序退出等操作
#include <stdlib.h>
// 包含字符串处理库,提供 memset、strlen 等字符串操作函数
#include <string.h>
// 包含 Unix 标准库,提供 close、read、write 等系统调用函数
#include <unistd.h>
// 包含网络地址转换库,提供 inet_ntoa、htons 等网络地址转换函数
#include <arpa/inet.h>
// 包含线程相关的头文件,用于创建和管理线程
#include <pthread.h>

// 定义服务器监听的端口号
#define PORT 8080
// 定义数据缓冲区的大小
#define BUFFER_SIZE 1024

/**
 * 定义数据包结构体,用于在客户端和服务器之间传输数据
 * type 数据包类型,0 表示消息,1 表示断开连接
 * data 数据包携带的数据
 */
typedef struct {
    int type;  // 0 for message, 1 for disconnect
    char data[BUFFER_SIZE];
} Packet;

// 全局变量,存储客户端套接字文件描述符
int client_fd;

/**
 * 线程函数,用于接收服务器发送的消息
 * arg 线程函数的参数,此处未使用
 * @return 线程返回值,此处为 NULL
 */
void *receive_messages(void *arg) {
    // 定义数据包变量,用于存储从服务器接收的数据包
    Packet packet;
    // 进入无限循环,持续接收服务器消息
    while (1) {
        // 清空数据包
        memset(&packet, 0, sizeof(Packet));
        if (read(client_fd, &packet, sizeof(Packet)) > 0) {
            // 若成功从服务器读取数据
            if (packet.type == 0) {
                // 若数据包类型为消息,输出接收到的消息
                printf("Received from server: %s", packet.data);
            } else if (packet.type == 1) {
                // 若数据包类型为断开连接,输出服务器断开连接的信息
                printf("Server disconnected\n");
                // 关闭客户端套接字
                close(client_fd);
                // 退出程序,返回失败状态
                exit(EXIT_FAILURE);
            }
        } else {
            // 若读取数据失败,说明服务器断开连接
            printf("Server disconnected\n");
            // 关闭客户端套接字
            close(client_fd);
            // 退出程序,返回失败状态
            exit(EXIT_FAILURE);
        }
    }
    return NULL;
}

/**
 * 主函数,客户端程序的入口点
 * @return 程序的退出状态码,0 表示正常退出
 */
int main() {
    // server_addr 存储服务器的地址信息
    struct sockaddr_in server_addr;
    // packet 用于存储要发送的数据包
    Packet packet;
    // thread_id 存储线程的标识符
    pthread_t thread_id;

    // 创建客户端套接字,使用 IPv4 地址族和 TCP 协议
    if ((client_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        // 若套接字创建失败,输出错误信息并退出程序
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 设置服务器地址信息
    server_addr.sin_family = AF_INET;
    // 将端口号从主机字节序转换为网络字节序
    server_addr.sin_port = htons(PORT);
    // 将服务器的 IP 地址转换为网络字节序
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

    // 连接到服务器
    if (connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        // 若连接失败,输出错误信息,关闭套接字并退出程序
        perror("connect");
        close(client_fd);
        exit(EXIT_FAILURE);
    }

    // 输出连接成功的信息
    printf("Connected to server\n");

    // 创建线程来接收消息
    if (pthread_create(&thread_id, NULL, receive_messages, NULL) != 0) {
        // 若线程创建失败,输出错误信息,关闭套接字并退出程序
        perror("pthread_create");
        close(client_fd);
        exit(EXIT_FAILURE);
    }

    // 进入无限循环,持续从标准输入读取数据并发送给服务器
    while (1) {
        // 清空数据包的数据部分
        memset(packet.data, 0, BUFFER_SIZE);
        if (fgets(packet.data, BUFFER_SIZE, stdin) != NULL) {
            // 若成功从标准输入读取数据
            // 设置数据包类型为消息
            packet.type = 0;
            // 发送数据包给服务器
            send(client_fd, &packet, sizeof(Packet), 0);
        }
    }

    // 关闭客户端套接字
    close(client_fd);
    return 0;
}

网站公告

今日签到

点亮在社区的每一天
去签到