网络程序设计作业:基于Socket API+epoll的在线聊天程序

一、 Socket API介绍

Socket API是一套网络通信的接口,支持 TCP、UDP协议。使用这套接口可以完成网络通信。

1.1 套接字函数

  • // 主机字节序的IP地址是字符串, 网络字节序IP地址是整形
    int inet_pton(int af, const char *src, void *dst);
    
  • // 创建套接字
    int socket(int domain, int type, int protocol);
    
  • // 将文件描述符和本地的IP与端口进行绑定   
    int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
    
  • // 给套接字设置监听
    int listen(int sockfd, int backlog);
    
  • // 等待并接受客户端的连接请求, 建立新的连接
    int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
    
  • // 接收数据
    ssize_t read(int sockfd, void *buf, size_t size);
    ssize_t recv(int sockfd, void *buf, size_t size, int flags);
    
  • // 发送数据
    ssize_t write(int fd, const void *buf, size_t len);
    ssize_t send(int fd, const void *buf, size_t len, int flags);
    
  • // 成功连接服务器后, 客户端会自动随机绑定一个端口
    int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
    

1.2通信流程

  • 使用 socket() 创建用于监听的套接字。

  • 使用 bind() 将得到的监听的文件描述符和本地的IP 端口进行绑定

  • TCP 服务端设置监听

  • 服务端使用 accept() 接受客户端的连接。

  • 客户端使用 connect() 连接服务器。

  • 使用 send()recv()(TCP)或 sendto()recvfrom()(UDP)在 socket 之间通信。

  • 使用 close()shutdown() 断开连接, 关闭套接字

二、 epoll介绍

2.1定义

  • epoll 全称 eventpoll,是 linux 内核实现IO多路转接/复用的一个实现。
  • IO多路转接的是在一个操作里同时监听多个输入输出源,在其中一个或多个输入输出源可用的时候返回,然后对其进行读写操作。epoll是select和poll的升级版,它更加高效。

2.2原理

  • 对于待检测集合select和poll是基于线性方式处理的,epoll是基于红黑树来管理待检测集合的。
  • select和poll每次都会线性扫描整个待检测集合,集合越大速度越慢,epoll使用的是回调机制,效率高,处理效率也不会随着检测集合的变大而下降。
  • select和poll工作过程中存在内核/用户空间数据的频繁拷贝问题,在epoll中内核和用户区使用的是共享内存。
  • 使用epoll没有最大文件描述符的限制,仅受系统中进程能打开的最大文件数目限制

2.3关键函数

  1. epoll_create 创建epoll实例,通过一棵红黑树管理待检测集合。
  2. **epoll_ctl **管理红黑树上的文件描述符(添加、修改、删除)。
  3. epoll_wait 检测epoll树中是否有就绪的文件描述符。

2.4使用流程

  • 创建监听的套接字,设置端口复用(可选)
  • 使用本地的IP与端口和监听的套接字进行绑定
  • 创建epoll实例对象,将用于监听的套接字添加到epoll实例中
  • 检测添加到epoll实例中的文件描述符是否已就绪,并将处理已就绪的文件描述符
  • 如果监听的文件描述符,和新客户端建立连接,将新文件描述符添加到epoll实例中
  • 如果通信的文件描述符,和对应的客户端连接已断开,将该文件描述符从epoll实例中删除

三、 多人在线聊天程序

3.1服务端

服务器负责处理和多个客户端的网络通信,使用 epoll 实现 I/O 多路复用。

1、初始化服务器Socket
  • 创建一个 TCP socket,设置 socket 地址结构。

    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);
    
  • 将 socket 绑定地址和端口,然后开始监听连接请求。

    bind(server_fd, (struct sockaddr *)&addr, sizeof(addr))
    listen(server_fd, SOMAXCONN)
    
2、设置非阻塞模式
  • 通过 make_socket_non_blocking 函数,将服务器socket 设置为非阻塞模式。
3、epoll配置
  • 创建一个新的 epoll 实例,用于管理多个 socket 的 I/O 事件。监听服务器 socket 上的读取事件(EPOLLIN)。EPOLLET 指定使用边缘触发(edge-triggered)模式。
int efd = epoll_create1(0);
struct epoll_event event;
event.data.fd = server_fd;
event.events = EPOLLIN | EPOLLET;
epoll_ctl(efd, EPOLL_CTL_ADD, server_fd, &event);
4、epoll处理代码
  • 设置一个循环,当有socket准备好读写操作或产生新连接请求时,使用 epoll_wait 返回。

    while (true) {
        int num = epoll_wait(efd, events, MAX_EVENTS, -1);
        for (int i = 0; i < num; i++) {
            // 事件处理代码
        }
    }
    
  • 处理连接请求

    使用 accept接受新连接,将新产生的客户端 socket 添加到 epoll 实例中。

  • 客户端信息管理

    服务器通过 std::vector<Client> 列表,记录所有已连接的客户端。以下为部分代码:

    struct Client {
        int fd;
        std::string name;
    };
     struct epoll_event events[MAX_EVENTS];
        std::vector<Client> clients;
    
  • 客户端加入聊天室

    服务器读取来自客户端的数据。如果是该客户端首次发送数据,服务器会查看用户名是否唯一,选择是否接受此连接。

    若数据来自已加入的客户端,则服务器读取并发送该消息至其他客户端。

  • 断开连接

    当客户端主动断开连接或服务端显示读取操作返回 0(即客户端已断开连接),服务器删除和关闭该客户端的信息。

5、服务端部分代码
  • 设置socket为非阻塞模式

    int make_socket_non_blocking(int sfd) {
        int flags = fcntl(sfd, F_GETFL, 0);
        if (flags == -1) {
            perror("fcntl");
            return -1;
        }
    
        flags |= O_NONBLOCK;
        if (fcntl(sfd, F_SETFL, flags) == -1) {
            perror("fcntl");
            return -1;
        }
    
        return 0;
    }
    
    
  • 判断用户名是否重名

    bool is_name_unique(const std::string& name, const std::vector<Client>& clients) {
        return std::none_of(clients.begin(), clients.end(), [&name](const Client& c) {
            return c.name == name;
        });
    }
    
  • 清除消息中换行符

void trim_newline(std::string &str) {
    if (!str.empty() && str[str.size() - 1] == '\n') {
        str.erase(str.size() - 1);
    }
    if (!str.empty() && str[str.size() - 1] == '\r') {
        str.erase(str.size() - 1);
    }
}
  • 发送消息至多个客户端实现聊天效果
void broadcast_message(const std::string& message, const std::vector<Client>& clients, int sender_fd = -1) {
    std::cout << message; // Print message on server
    for (const auto& client : clients) {
        if (client.fd != sender_fd) {
            send(client.fd, message.c_str(), message.length(), 0);
        }
    }
}
  • 处理新连接
if (server_fd == events[i].data.fd) {
    while (true) {
        sockaddr_in client_addr;
        socklen_t client_len = sizeof(client_addr);
        int client_fd = accept(server_fd, (struct sockaddr *)&client_addr,                         &client_len);
        if (client_fd == -1) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                break; 
            } else {
                perror("accept");
                break;
            }
        }

        make_socket_non_blocking(client_fd);
        event.data.fd = client_fd;
        event.events = EPOLLIN | EPOLLET;
        if (epoll_ctl(efd, EPOLL_CTL_ADD, client_fd, &event) < 0) {
            perror("epoll_ctl");
            close(client_fd);
        } else {
            clients.push_back({client_fd, ""}); 
        }
    }
}
  • 处理客户端消息,接收或发送
} else if (events[i].events & EPOLLIN) {
    if (server_fd == events[i].data.fd) {
        // 上面的代码处理了新连接,这里不会执行
    } else {
        // 处理已连接客户端的消息接收
        // ...
        if (!done) {
            auto it = std::find_if(clients.begin(), clients.end(), [fd = events[i].data.fd](const Client& c) {
                return c.fd == fd;
            });

            if (it == clients.end()) {
                continue; // 新客户端,跳过处理,等待下一条消息
            }
            // 处理客户端消息
            // ...
            if (done) {
                // 处理客户端离开
                // ...
            }
        }
    }
}

3.2 客户端

客户端负责与服务器建立连接,实现客户与服务端的通信。

1、创建和连接 Socket
  • 创建一个 TCP socket,设置服务器的地址和端口,通过 connect 函数与服务器建立连接。

    int sock = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in serv_addr;
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
    connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_ad dr));
    
2、新用户发送消息
getline(std::cin, name);
send(sock, (name + "\r\n").c_str(), name.length() + 2, 0);
3、接收服务端消息
  • 创建一个新线程 receiverThread,用于接收服务端消息。

    std::thread receiverThread(receiveMessages, sock);
    
4、发送消息至服务端
while (getline(std::cin, message)) {
    send(sock, (message + "\r\n").c_str(), message.size() + 2, 0);
}

5、回收通信线程
  • 当主线程中用户停止输入消息时,等待消息接收线程结束(join),关闭 socket。

    receiverThread.join();
    close(sock);
    
6、客户端部分代码
  • 发送用户名并处理服务器响应

    • 用户被要求输入用户名,然后使用 send() 函数将用户名发送给服务器。用户名以 “\r\n” 结尾,这可能是为了与服务器端的协议兼容(换行符的使用)。
    • 接着,通过 read() 函数从服务器接收响应。如果读取的字节数小于等于零,说明出现了读取错误或连接断开的情况。如果服务器的响应中不包含 “OK”,则表明用户名已经被占用,程序输出错误信息并退出。否则,用户成功加入聊天室,并输出提示信息。
    std::string name;
    std::cout << "Enter your name: ";
    getline(std::cin, name);
    send(sock, (name + "\r\n").c_str(), name.length() + 2, 0);
    
    char buffer[BUFFER_SIZE];
    memset(buffer, 0, BUFFER_SIZE);
    int bytes_read = read(sock, buffer, BUFFER_SIZE - 1);
    if (bytes_read <= 0) {
        std::cerr << "Failed to receive response from server\n";
        close(sock);
        return -1;
    }
    
    std::string response(buffer);
    if (response.find("OK") == std::string::npos) {
        std::cerr << "Name already taken, please restart and choose another one.\n";
        close(sock);
        return -1;
    } else {
        std::cout << name << " joined the chat\n";
    }
    

3.3运行结果

1、运行3个客户端

加入聊天室后,所有用户发送的消息都可以在终端进行显示。同时也会显示用户的加入与退出消息

服务端:在这里插入图片描述

客户端:

在这里插入图片描述

2、客户端主动退出

客户端退出后会在聊天室显示该消息
在这里插入图片描述
在这里插入图片描述

3、服务端退出

服务端退出后会在客户端的聊天室中显示该消息

在这里插入图片描述

四、心得体会

通过《网络程序设计》这门课程的学习,我对网络编程有了深入的理解。老师设置的多个实验项目使我能够更全面地了解计算机编程。不论是每章的基础实验还是期末的专题实验,这些实践任务让我在实际操作中巩固了课堂上学到的理论知识,有了很大进步。

  • 24
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值