Reactor与多Reactor设计:epoll实战

前言

在上一篇系列文章《多路转接之epoll:理论篇》中,我们讲解了epoll模型,以及它的三个调用函数。

这些只是最简单的理论知识,我们都知道,实践出真知,要想彻底掌握一个知识,就只有去实践。

所以本文将会带领大家深入epoll模型,探究其背后的应用与思想智慧。

Epoll工作模式的问题

epollserver的简单样例

上一篇文章中,我们讲解了epoll函数,但是没有给大家进行一个具体的使用案例,我们就以在select与poll中写的简单服务器代码为基础,进行一个简单的改写。

这个改写十分的简单,变化也不多,所以我这里就直接给出我们的代码:

c++ 复制代码
#pragma once

#include <iostream>
#include <string>
#include "log.hpp"
#include "Socket.hpp"
#include <memory>
#include <sys/epoll.h>

#define NUM sizeof(fd_set) * 8
const int defaultfd = -1;
#define MAX 4096
class EpollServer
{
    static const int revs_num = 64;

public:
    EpollServer(uint16_t port)
        : _port(port),
          _listen_socket(std::make_unique<TcpSocket>()),
          _isrunning(false),
          _epollfd(defaultfd)
    {
    }

    /**
     * @brief 初始化服务器
     */
    void Init()
    {
        _listen_socket->BuildTcpSocketMethod(_port);

        _epollfd = ::epoll_create(256);
        if (_epollfd < 0)
        {
            LOG(LogLevel::ERROR) << "epoll create error";
            exit(EPOLL_CREATE_ERR);
        }
        LOG(LogLevel::DEBUG) << "epoll create success,_epollfd: " << _epollfd;

        // 我们至少需要把listenfd加入epoll模型中,监听是否有新连接
        struct epoll_event event;
        event.events = EPOLLIN;
        event.data.fd = _listen_socket->Fd();
        int n = ::epoll_ctl(_epollfd, EPOLL_CTL_ADD, _listen_socket->Fd(), &event);
        if (n < 0)
        {
            LOG(LogLevel::ERROR) << "epoll_ctl error";
            exit(EPOLL_CTL_ERR);
        }
    }

    /**
     * @brief 启动服务器主循环
     */
    void Loop() // start函数
    {
        int timeout = -1;
        _isrunning = true;
        while (_isrunning)
        {
            int n = ::epoll_wait(_epollfd, _revs, revs_num, timeout);
            // 返回值n是准备就绪的事件数量

            switch (n)
            {
            case 0:
                // 在指定的超时时间内,没有任何文件描述符就绪,返回0
                std::cout << "time out......" << std::endl;
                break;
            case -1:
                // 返回-1,调用出错,需要检查errno判断具体错误类型
                perror("epoll_wait");
                break;
            default:
                // 发生事件就绪
                std::cout << "有事件就绪了......" << "timeout: " << timeout << std::endl;
                Dispatcher(n); // 我们的回调函数其实是一个派发功能
                break;
            }
        }
    }

    void Accepter()
    {
        InetAddr client;
        SockPtr newsockfd = _listen_socket->Accepter(&client); // 我们这里进行accept的时候不会阻塞,因为我们已经确认了,一定是就绪的状态
        if (newsockfd)                                         // 不为空指针
        {
            std::cout << "获取了一个新连接:" << newsockfd->Fd() << "  client info:" << client.Addr() << std::endl;
            // 获取了新连接,我们就需要把新连接添加给epoll模型中进行监控
            struct epoll_event ev;
            ev.data.fd = newsockfd->Fd();
            ev.events = EPOLLIN;
            int n = ::epoll_ctl(_epollfd, EPOLL_CTL_ADD, newsockfd->Fd(), &ev);
            if (n < 0)
            {
                LOG(LogLevel::ERROR) << "epoll_ctl error";
                close(newsockfd->Fd());
            }
            LOG(LogLevel::DEBUG) << "epoll_ctl success: " << newsockfd->Fd();
        }
        else
        {
            return;
        }
    }
    void Reciver(int fd)
    { // 此时如果走到这里,就是合法的,已经准备就绪的,普通的fd,那么我们可以直接调用recv等函数了
        // 此时并不会发生阻塞,因为我们的fd已经准备好了
        char buffer[1024];
        ssize_t n = ::recv(fd, buffer, sizeof(buffer) - 1, 0);

        // 接下来就是公式化处理n的情况并打印了
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client# " << buffer << std::endl;

            // 添加回显信息
            std::string message = "echo#";
            message += buffer;
            ::send(fd, message.c_str(), message.size(), 0);
        }
        else if (n == 0)
        {
            std::cout << "客户端退出" << " : " << fd << std::endl;
            // 把fd从epoll中移除, 必须保证fd是一个合法的fd
            int m = ::epoll_ctl(_epollfd, EPOLL_CTL_DEL, fd, nullptr);

            if (m < 0)
            {
                LOG(LogLevel::ERROR) << "epoll_ctl error";
                return;
            }
            LOG(LogLevel::DEBUG) << "epoll_ctl success: " << fd;
            close(fd);
        }
        else
        {
            LOG(LogLevel::ERROR) << "客户端读取出错" << fd;
            int m = ::epoll_ctl(_epollfd, EPOLL_CTL_DEL, fd, nullptr);

            if (m < 0)
            {
                LOG(LogLevel::ERROR) << "epoll_ctl error";
                return;
            }
            LOG(LogLevel::DEBUG) << "epoll_ctl success: " << fd;
            close(fd);
            // 理论上这个错误应该进行处理异常,但是我们这里就不考虑了
        }
    }

     /**
     * @brief 事件分发器:根据就绪事件类型调用对应处理函数
     * @param n epoll_wait 返回的就绪事件数量
     *
     * 分发逻辑:
     *   - 遍历所有就绪事件
     *   - 若 fd == listen socket fd → 调用 Accepter()
     *   - 否则 → 调用 Reciver(fd)
     *
     * ⚠️ 安全检查:
     *   - 跳过无效 fd(fd == defaultfd)
     *   - 仅处理 EPOLLIN 事件(当前未处理 EPOLLRDHUP / EPOLLERR 等)
     */
    void Dispatcher(int n)
    {
        for (int i = 0; i < n; ++i)
        {
            uint32_t events = _revs[i].events; // 事件类型(如 EPOLLIN)
            int fd = _revs[i].data.fd;         // 对应的文件描述符

            // 跳过无效 fd(防御性编程)
            if (fd == defaultfd)
                continue;

            // 判断是否为监听 socket
            if (fd == _listen_socket->Fd())
            {
                if (events & EPOLLIN)
                {
                    Accepter(); // 处理新连接
                }
                // 注意:理论上 listen fd 不会出现 EPOLLERR,但可扩展处理
            }
            else
            {
                // 普通客户端连接
                if (events & EPOLLIN)
                {
                    Reciver(fd); // 处理数据接收
                }
                // TODO: 可扩展处理 EPOLLRDHUP(对端关闭写端)以优化断连检测
            }
        }
    }
    ~EpollServer()
    {
        _listen_socket->Close();
        if (_epollfd >= 0)
            close(_epollfd);
    }

private:
    uint16_t _port;                         // 服务器监听的端口号
    std::unique_ptr<Socket> _listen_socket; // 监听socket的智能指针,使用unique_ptr实现独占所有权
    // 采用智能指针管理Socket资源,确保异常安全性和自动资源释放
    bool _isrunning; // 服务器运行的状态

    int _epollfd;                       // epoll 实例的文件描述符
    struct epoll_event _revs[revs_num]; // 存储 epoll_wait 返回的就绪事件
};

相较于pollserver的代码,这里最主要的变化主要有四处,相信大家看了之后就知道怎么回事了。

首先是我们类成员参数的变化:

c++ 复制代码
private:
	......
    int _epollfd;                       // epoll 实例的文件描述符
    struct epoll_event _revs[revs_num]; // 存储 epoll_wait 返回的就绪事件

根据上节课所学,相信大家也看得出来,这里的_epollfd就应该用于存储epoll_create函数的返回值,因为这个函数的返回值就是epoll模型的文件描述符。

而下面的这个结构体数组,则是给函数epoll_wait所使用,让该函数把就绪的事件event存储到数组中,由于这个数组还是类成员,所以在类中的函数里都可以进行调用而避免了额外传参。


接着,就是初始化函数

c++ 复制代码
void Init()
    {
        _listen_socket->BuildTcpSocketMethod(_port);

        _epollfd = ::epoll_create(256);
        if (_epollfd < 0)
        {
            LOG(LogLevel::ERROR) << "epoll create error";
            exit(EPOLL_CREATE_ERR);
        }
        LOG(LogLevel::DEBUG) << "epoll create success,_epollfd: " << _epollfd;

        // 我们至少需要把listenfd加入epoll模型中,监听是否有新连接
        struct epoll_event event;
        event.events = EPOLLIN;
        event.data.fd = _listen_socket->Fd();
        int n = ::epoll_ctl(_epollfd, EPOLL_CTL_ADD, _listen_socket->Fd(), &event);
        if (n < 0)
        {
            LOG(LogLevel::ERROR) << "epoll_ctl error";
            exit(EPOLL_CTL_ERR);
        }
    }

我们这里的初始化操作很简单,就是通过epoll_create创建一个epoll模型,为了后续获取连接,以及循环检查,我们还需要调用epoll_ctllisten套接字放入epoll模型中监听起来。至于监听事件就选择EPOLLIN

这里要说明一下,有些同学会好奇,为什么epoll_ctl的第三个参数已经是要监听的目标fd了,为什么event的结构体中还需要初始化联合体中的fd?

这是因为结构体event中的fd是为了方便之后,事件就绪后我们获得的就是这个结构体,而我们要知道就绪事件对应的fd是谁,所以是为了这里对fd的确认。


随后就是LOOP函数,这个函数我们之前叫做start:

c++ 复制代码
 void Loop() // start函数
    {
        int timeout = -1;
        _isrunning = true;
        while (_isrunning)
        {
            int n = ::epoll_wait(_epollfd, _revs, revs_num, timeout);
            // 返回值n是准备就绪的事件数量

            switch (n)
            {
            case 0:
                // 在指定的超时时间内,没有任何文件描述符就绪,返回0
                std::cout << "time out......" << std::endl;
                break;
            case -1:
                // 返回-1,调用出错,需要检查errno判断具体错误类型
                perror("epoll_wait");
                break;
            default:
                // 发生事件就绪
                std::cout << "有事件就绪了......" << "timeout: " << timeout << std::endl;
                Dispatcher(n); // 我们的回调函数其实是一个派发功能
                break;
            }
        }
    }

这里只需要在轮循中调用epoll_wait函数,我们之前之所以需要现在初始化时把监听套接字放到epoll模型中,就是为了在轮循中调用等待函数时有等待的对象。

如果epoll_wait的返回值为n,大于0,就代表有n个事件就绪了。这些就绪的事件会放在我们的_revs数组中,此时我们就要执行派发功能,也就是没改名前的回调函数调用。


我们把之前的回调函数进行了一个功能的拆分,变成了三个函数:

c++ 复制代码
 void Accepter()
    {
        InetAddr client;
        SockPtr newsockfd = _listen_socket->Accepter(&client); // 我们这里进行accept的时候不会阻塞,因为我们已经确认了,一定是就绪的状态
        if (newsockfd)                                         // 不为空指针
        {
            std::cout << "获取了一个新连接:" << newsockfd->Fd() << "  client info:" << client.Addr() << std::endl;
            // 获取了新连接,我们就需要把新连接添加给epoll模型中进行监控
            struct epoll_event ev;
            ev.data.fd = newsockfd->Fd();
            ev.events = EPOLLIN;
            int n = ::epoll_ctl(_epollfd, EPOLL_CTL_ADD, newsockfd->Fd(), &ev);
            if (n < 0)
            {
                LOG(LogLevel::ERROR) << "epoll_ctl error";
                close(newsockfd->Fd());
            }
            LOG(LogLevel::DEBUG) << "epoll_ctl success: " << newsockfd->Fd();
        }
        else
        {
            return;
        }
    }
    void Reciver(int fd)
    { // 此时如果走到这里,就是合法的,已经准备就绪的,普通的fd,那么我们可以直接调用recv等函数了
        // 此时并不会发生阻塞,因为我们的fd已经准备好了
        char buffer[1024];
        ssize_t n = ::recv(fd, buffer, sizeof(buffer) - 1, 0);

        // 接下来就是公式化处理n的情况并打印了
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "client# " << buffer << std::endl;

            // 添加回显信息
            std::string message = "echo#";
            message += buffer;
            ::send(fd, message.c_str(), message.size(), 0);
        }
        else if (n == 0)
        {
            std::cout << "客户端退出" << " : " << fd << std::endl;
            // 把fd从epoll中移除, 必须保证fd是一个合法的fd
            int m = ::epoll_ctl(_epollfd, EPOLL_CTL_DEL, fd, nullptr);

            if (m < 0)
            {
                LOG(LogLevel::ERROR) << "epoll_ctl error";
                return;
            }
            LOG(LogLevel::DEBUG) << "epoll_ctl success: " << fd;
            close(fd);
        }
        else
        {
            LOG(LogLevel::ERROR) << "客户端读取出错" << fd;
            int m = ::epoll_ctl(_epollfd, EPOLL_CTL_DEL, fd, nullptr);

            if (m < 0)
            {
                LOG(LogLevel::ERROR) << "epoll_ctl error";
                return;
            }
            LOG(LogLevel::DEBUG) << "epoll_ctl success: " << fd;
            close(fd);
            // 理论上这个错误应该进行处理异常,但是我们这里就不考虑了
        }
    }

     /**
     * @brief 事件分发器:根据就绪事件类型调用对应处理函数
     * @param n epoll_wait 返回的就绪事件数量
     *
     * 分发逻辑:
     *   - 遍历所有就绪事件
     *   - 若 fd == listen socket fd → 调用 Accepter()
     *   - 否则 → 调用 Reciver(fd)
     *
     * ⚠️ 安全检查:
     *   - 跳过无效 fd(fd == defaultfd)
     *   - 仅处理 EPOLLIN 事件(当前未处理 EPOLLRDHUP / EPOLLERR 等)
     */
    void Dispatcher(int n)
    {
        for (int i = 0; i < n; ++i)
        {
            uint32_t events = _revs[i].events; // 事件类型(如 EPOLLIN)
            int fd = _revs[i].data.fd;         // 对应的文件描述符

            // 跳过无效 fd(防御性编程)
            if (fd == defaultfd)
                continue;

            // 判断是否为监听 socket
            if (fd == _listen_socket->Fd())
            {
                if (events & EPOLLIN)
                {
                    Accepter(); // 处理新连接
                }
                // 注意:理论上 listen fd 不会出现 EPOLLERR,但可扩展处理
            }
            else
            {
                // 普通客户端连接
                if (events & EPOLLIN)
                {
                    Reciver(fd); // 处理数据接收
                }
                // TODO: 可扩展处理 EPOLLRDHUP(对端关闭写端)以优化断连检测
            }
        }
    }

这里的改动看似很大,实际上很简单。

首先派发函数的参数n代表着就绪事件的数量,所以我们就可以通过for循环遍历每一个准备好的事件,随后通过fd来判断,这个就绪好的事件,是否是listen套接字的就绪,是的话,说明就是有新的连接了。不是的话,说明就是我们其他的普通文件描述符就绪了,有新的消息发送过来。

通过判断来执行不同的逻辑。

如果是要执行Accepter,获取新连接,我们就还需要调用epoll_ctl把获取的新连接也放入epoll模型中监管起来:

c++ 复制代码
		struct epoll_event ev;
        ev.data.fd = newsockfd->Fd();
        ev.events = EPOLLIN;
        int n = ::epoll_ctl(_epollfd, EPOLL_CTL_ADD, newsockfd->Fd(), &ev);

如果要执行Reciver,我们就还需要传递就绪事件的fd,随后进行读取处理并打印。

这里有一个值得注意的地方,就是在处理读取信息的事件时,如果遇见异常或者客户端退出,信号中断等情况,我们需要关闭文件描述符了。

我们不能率先close,而是应该调用epoll_ctl,将epoll模型中的该fd移出红黑树,不再监管,保证在epoll模型的fd都是合法有效的fd,这是一件很重要的事情。

最后再关闭fd。


epollserver中的报文读取问题

回到正题,在上面的例子中,有一个问题:

c++ 复制代码
        char buffer[1024];
        ssize_t n = ::recv(fd, buffer, sizeof(buffer) - 1, 0);

在这个代码中,我们在读取网络报文的时候,是不是有可能读取不到一个完整的报文?

所以我们就需要保证读取一个完整报文,那我们是不是应该循环读取?

但是循环读取会不会导致我们的服务端阻塞呢?这是有可能的

接下来我们就要引入epoll模型的工作模式的概念了。


epoll的工作模式:LT | ET

在Linux的网络编程中,epoll 是一种高效的I/O事件通知机制,用于监视多个文件描述符(如网络套接字)的状态变化。epoll 提供了两种工作模式:水平触发(Level Triggered, LT)和边缘触发(Edge Triggered, ET),它们决定了当某个文件描述符变得可读或可写时,如何通知程序。

水平触发模式(LT)

  • 定义 :在水平触发模式下,只要文件描述符处于就绪状态(例如,对于一个socket来说,缓冲区中有数据可以读取,或者有空间可以写入数据),每次调用 epoll_wait() 都会返回该文件描述符。
  • 特点
    • 对于程序员来说更易于使用,因为即使错过了某次事件通知,下次调用 epoll_wait() 仍然可以获得通知。
    • 在这种模式下,如果一个事件没有被处理完,它会在后续的 epoll_wait() 调用中继续被报告。
    • 这种模式适合那些可能无法在一个事件循环中完全处理所有到来的数据的情况。

边缘触发模式(ET)

  • 定义:在边缘触发模式下,只有当文件描述符的状态发生变化(例如,从不可读变为可读,或从不可写变为可写)时,才会触发一次通知。这意味着如果你错过了这个通知,你将不会再次收到关于同一事件的通知,直到下一个状态变化发生。
  • 特点
    • 更加高效,因为它减少了重复通知的可能性,但同时对程序设计提出了更高的要求。
    • 如果不谨慎处理,可能会错过一些事件。因此,在使用边缘触发模式时,通常需要确保一次性读取或写入尽可能多的数据,直到操作不再阻塞为止。
    • 适用于那些能够在一个事件循环中快速处理完所有数据的场景。

如何理解两种模式的差别

我们先来打个比方。

假设你在网上买了10个快递,都给你送到了菜鸟驿站放着。

LT模式 就是:此时,驿站派出张三,拿着你的六个快递以及以及若干其他人的快递。张三到了你的宿舍楼下,他就会开始给你们打电话。

此时你接到电话,他会给你说:xxx,你的6个快递到了,下来取一趟吧。

你此时正在打游戏,你就说:好的,我马上下来取。但是实际上你仍然还在打游戏,没有下来。

他打完你的电话之后会跟其他人打电话。然后打了一轮了下来,发现你还没有下来取,他就会继续给你打电话。如果你还没有下来,他就会一直打一直打。

什么是LT水平触发模式呢?就是像张三这样子,只要他的快递车里有你的快递。他就会一直跟你打电话叫你下来领取。哪怕别人的快递全部领完了,车里只剩你的,他也不会回去,他还是会一直给打电话,通知你下来领取。

换到代码的例子上来说,就是如果你的就绪事件,一直没有去处理。他就会一直通知你,陷入一个死循环。我们的select,与poll,就是典型的LT模式的函数。

如果你在使用 select/poll 时,对一个可读但未完全读取的 socket 不做任何处理,那么下一次调用 select/poll 时,它仍然会报告该 fd 可读,从而在循环中不断返回,导致"死循环式地打印一句话"。

而什么是ET模式呢?

菜鸟驿站又派出一个快递员李四,李四的快递车中含有你的另外四个快递,此时他到你的宿舍楼了,开始给你打电话:xxx,快递到了,赶紧下来,我只通知你一次。

倘若他把你这栋宿舍楼的电话都打完了,你都没下来,他就会直接离开了。

或者说,就算你下来了,但是你不能一次性的拿完四个快递,只拿了两个上去,他也会离开。这就是ET 模式(Edge Triggered,边缘触发) ,核心思想是:

"只在文件描述符状态发生变化的'边缘'通知一次,之后即使条件仍满足,也不再重复通知。"

什么叫做只在文件描述符状态发生变化的'边缘'通知一次呢?这个边缘是什么意思?

还是我们送快递的例子。当李四拿了四个快递送过去的时候,你只拿了两个上去,剩下两个快递还在李四的快递车上。但是此时李四不会再通知你,他又会去其他的地方,继续送他的快递了。

但如果此时你网上又买了几个快递,比如说买了3个快递,到了菜鸟驿站又让李四去派发给你,那么李四会把之之前剩下的两个快递以及这次新投递的快递一共5个,拿到你的宿舍楼下去,给你通知打电话。你新买的3个快递到了菜鸟驿站,然后派发给李四,这就是是文件描述符状态发生变化的边缘。

更加通俗的说,在ET模式下,只有两种情况会进行通知,第一种情况就是从无到有:你的缓冲区从无到有的时候,这个变化情况,会通知你一次。第二种就是由少到多,你的缓冲区的内容由少到多,新增了内容,此时会再次进行通知一次。除此之外都不会进行通知。

ET与LT的高效问题

了解了ET与LT模式之后,我想问一下大家,你们觉得是LT模式更加高效,还是ET模式更加高效呢?

直觉告诉你,是不是可能ET会更加高效一点?

为什么会这样想呢?这是因为,ET模式有两个特点:

  1. ET模式不会做重复的通知
  2. 通知了你之后,要求你要一次性的把数据读取完

这两个特点就倒逼程序员,在ET模式下就必须把数据取完,在这样的情况下,就会给对方通告一个更大的接受窗口,进而提高对方滑动窗口的大小,于是对方的滑动窗口就可以发送大量数据

这就变相的提高了IO带宽,进而提高了IO效率。

但是这样做是有代价的,你必须保证你每一次都把数据从缓冲区里读取完。

你怎么保证你的数据读取完了呢?------循环读取!

但是循环读取又会造成两个问题。第一种问题就是你实际读取的数据是小于你期望读取的数据大小的。第二种就是可能会造成阻塞的问题。

为了规避第二种问题,我们就必须保证在ET模式下我们的文件描述符是非阻塞的状态(尤其目前还是单进程的代码的情况下)。

在LT模式下,可以只读取一次,但是ET模式下,要求我们必须一次性读完,ET模式的高效,是为了应付高吞吐量的IO而设计的。

现在还剩下一个问题,我们都知道网络报文在网络中是以加密的形式传递的。那么你怎么保证你读取的你的报文一定是一条完整的报文呢??

这就需要我们对历史的读取情况进行缓存!!


Reactor实战:重新设计我们的epollserver

讲解完上文所说的问题,那我们就来完全重新设计一个使用ET模式的epollserver吧(本文所使用的log.hpp等头文件都是我们之前在Linux篇章中一点一滴写出来的,如有遗忘,欢迎随时会看)!

基本头文件的创建:

之前我们所使用的poll,select只有一个函数,但是我们的epoll却是多个函数的组合,所以我这里会把epoll模型封装成一个Epoller类。

我们首先创建这几个文件:

随后,对两个头文件进行简单的一个轮廓定义,就是定义出Epoller类(只需要名字就行,功能后面慢慢添加),以及我们的EpollServer服务器类:

Connection连接类的设计思路:

在之前的EpollServer的Demo代码中,对于我们Accepter之后的新连接,我们并未进行封装,但是我们这里需要对其进行封装成Connection.hpp,这样做有一个好处,我们可以在这个connection结构体内部增加属于这个连接的参数,比如用2个string来代表这个连接的输出与输入缓冲区信息,进行历史读取信息的存储

这里我们引入了之前封装的InetAddr.hpp,这是因为我们需要让这个连接有着可以知道自己对应的客户端的属性信息。

当然,连接也是有种类之分的,比如普通的fd,与listenfd。

所以,我们可以使用继承的方法,为connection新建两个子类:


当然,如果你不想使用继承多态的思想,也可以选择传入回调函数,进行不同功能的实现,那我们今天就使用回调函数的手法带着大家看看:

解释一下代码,首先,我们对类EpollServer进行了一个前置声明,使得我们可以在后面定义这个类的指针变量。

第二个箭头的代码是定义了一个通用函数包装器 ,这个std::function<void()> 可以存储、复制、调用任何符合该签名的可调用对象,随后给这个类型起了一个别名func_t

下面定义的三个回调函数类型分别对应着我们的输入输出异常处理三种情况,还有最后的一个EpollServer指针,其实是为了方便我们通过Connection对象找到自己的服务器对象,这个后面有大作用。

各类初始化函数的定义与完善:

我们很明确,需要在EpollServer服务器中,对各个连接信息进行管理,所以我们在服务器中,使用哈希表来对Connection进行管理:

变化有三处,1就是引入各个头文件,2就是定义一个类型,并重命名为connection_t方便管理,3就是新建一个类成员变量来管理Connection对象。

接下来就是对Connection的参数进行一个简单的初始化:

对于其他参数,比如owner指针,我们会在后面进行处理。

接下来是我们的Epoller,我们对这个封装有着很明确的作用,就是代表着我们创建的一个Epoll模型,底层进行各种函数调用,比如epoll_create,epoll_ctl,epoll_wait

除了基本的包含头文件操作之外,我们主要是在构造函数中给fd设置为初识的-1,随后在Init函数中调用epoll_create创建一个epoll模型,获取文件描述符给epfd。

当我们的epoll的初始化函数写好后,我们就可以在服务器初始化的时候进行调用,对应的,服务器的简单初始化:

我们在服务器的初始化中调用epoller的初始化,获取epoll模型,并提供一个Insert插入接口,从外界提供一个Connection对象给我们,插入到哈希表中进行管理。

可以看见这里我们需要Connection对象提供一个返回自己的fd的接口,因为哈希表需要一个值作为key值,我们这里直接对应连接的文件描述符来作为key。所以相应的,我们应该在Connection中实现这个接口。

当然这个接口实现也很简单,只需要在函数内返回一下类成员变量sockfd的值就行:

c++ 复制代码
	int Sockfd()const 
    {
        return _sockfd;
    }

接下里写一下服务器的循环逻辑

和之前的一样,我们服务器仍然是通过一个while循环来维持的,所以我们要新增一个参数代表服务器的运行状态,在while循环前将其置为真,while循环结束了说明服务器不再运行。

当然,在构造函数时,可以将_isrunning放在初始化列表中,用false对其进行初始化:

c++ 复制代码
EpollServer(int port)
        : _port(port),
          _isrunning(false)
    {
    }

可以看见,我们在while循环内部一定是通过epoll_wait来获取准备好的各种就绪事件,而这个epoll_wait一定是被封装到了epoller类中,我们调用接口,通过返回值n来知道有几个事件就绪了。

在完成wait接口前,先想好Wait要传递什么参数?需要传参吗?

传统的epoll_wait函数需要我们传递epoll模型的文件描述符,一个结构体数组作为输出型参数,一个这个数组的大小,以及一个timeout参数,很显然,我们不止需要拿到返回值n,还需要拿到输出型参数数组,所以我们就需要外部传进去一个结构体数组,如果你想在外部控制我们的阻塞行为,那么你还可以把timeout参数的值也传进去!

那么这个数组定义在哪里呢?

自然不可能定义成一个局部变量,最恰当的做法,还是定义成为类成员变量,像这样:

c++ 复制代码
struct epoll_event _revs[event_num];               // 用于接收就绪的事件的结构体数组,大小为64,64一般够用

//const static int event_num = 64;

把这个添加到EpollServer的参数中去,我们设置的数组大小是64,根据一般经验来看,这个64已经基本够我们使用了。

那么紧接着来完成Wait接口:


在我们之前的讨论中,我们已经构建了一个基本的 EpollServer 架构,它能够通过 epoll 监听多个文件描述符(fd),并处理这些 fd 上发生的事件。然而,目前我们的服务器还缺少一个关键组件:如何接受新的客户端连接?

我们的目标是创建一个专门负责监听新连接的模块,这个模块应该能够完成下面的功能:

  1. 监听指定端口,等待客户端发起连接请求。
  2. 接受新连接 ,并将每个新连接封装成 Connection 对象以便后续处理。
  3. 将新连接注册到 EpollServer ,这样新的连接就能被 epoll 管理起来,参与到 I/O 多路复用中来。

在之前的Demo代码中,我们是构建了一个TCP套接字,然后通过判断就绪事件是否为套接字,随后执行响应的连接逻辑。

这里我们自然不能这么简陋,所以我们引入了一个新的模块------Listener

在这里我们引入了之前封装的Socket.hpp,在这个文件中,我们将Socket进行了封装,并用继承的方法,将TCP与UDP的套接字进行分门别类,并对其各种虚函数接口进行重定义,完成了多态。

我们的服务器肯定还是依靠TCP,所以我们接下来也会使用TCP套接字。

大家注意到没有,我们之前的代码已经有了多余,因为我们服务器一开始携带port参数,是先前我们没有对Listen进行封装,而真正要使用到port参数的应该是我们的套接字功能,所以我们这里就可以把服务器文件中的_port参数与对应初始化代码进行一个删除

随后在Listener中对其套接字与port信息进行初始化:

那么我们的Main.cc很自然而然的就可以写成这样:

但是我们自然也会产生疑问,你把Listener对象新建出来了,但是如何给服务器使用呢?

我们的Listener内部是创建的TCP套接字,在创建时,会含有对应的Listenfd信息,我们可以把这个fd信息封装成Connection连接信息,随后插入到服务器中进行管理。

我们目前的几层封装就是这样的一个形式:

到了这里,我请大家好生想一下,我们实现listener的目的是什么?

最主要的目的是接收每个新连接,并将其封装成 Connection 对象,随后通过EpollServer服务器插入到epoll模型中进行管理。

这里我们的listener如何找到我们的服务器呢?

我们先前是不是说了Connection的一个参数:EpollServer *owner;,这个函数就发挥到了作用。

我们可以让listener类继承Connection类,为什么呢?

因为这样,他就可以天然的调用父类里的owner参数,找到服务器。另外,每一个连接,本质上管理的其实就是文件描述符,而我们的listener的功能,大多也是通过监听listenfd实现的。所以这两个可以通过继承来关联起来。

对于普通的连接,我们用Connection类的相关参数与方法就已经可以进行管理,对于listenfd,我们可以通过子类添加的方法,来进行调用!

但是如果我们之间进行继承,你会发现编译器有一个报错提示:

这是因为我们之前已经对Connection的构造函数进行了重新,他现在唯一的构造函数是我们写的需要传递sockfd的构造函数,而不是不需要参数的默认构造函数。

那我们可以把sockfd这些参数的设置通过另外的单独接口进行设置,在构造函数中就使用一个默认值进行默认构造:

实现继承之后,我们就可以通过插入Connection的接口将我们的listener插入进去:

但是你会发现这还有一个报错,这个报错是因为我们的插入函数传参是用的引用,而我们的listener是Connection的子类,在将子类对象赋值给基类时,会产生隐式转换,生成临时对象。

而临时对象是一个右值,不能被左值引用。那么解决这个问题的方法有很多,最常见的就是使用万能引用,或者直接改成传值。

我们这里就改为传值 void InsertConnection(connection_t conn)),因为万能引用涉及左值右值的转发问题,我们这里就不使用这个,以免增加不必要的复杂度。


传递完我们的listener到服务器之后,我们目前的插入函数,只会将其插入到哈希表中,但是我们的循环里是等待的epoll模型里的fd,所以,你还需要将这个listener的连接插入到epoll模型里,也就是我们封装的epoller中。

当然,为了最基本的安全,我们还需要进行安全检查,避免插入重复的Connection,确认并不重复后,我们就执行插入,先将其插入到哈希表,再插入到epoller中。

那么,epoller中的插入是怎么实现的呢?

当然是调用epoll_ctl函数,所以我们还要在epoller中提供epoll_ctl的调用接口。但是我们的epoll_ctl的作用有好几种,并且根据传递的宏不同,作用也会随之变化。

最主要的作用当然是删除与新增,还有修改。所以我们其实可以这样定义与实现:

我们将Ctrl函数保持私有,对外提供Add,Del,Update三个公开接口,保持接口的安全。

对于外界来说,你想修改epoll模型监控的内容,你就调用Update这个接口,你想删除,就调用Del这个接口。没有必要强行调用Ctrl,避免传入错误的参数。


那么目光继续回到我们的插入函数中,我们已经知道,要将连接插入给epoll模型,这是由我们通过调用Add函数实现的,但是Add函数有两个参数需要传入,第一个参数我们可以通过连接的接口获取文件描述符,那么第二个接口event事件该怎么办呢?

所以我们的Connection类中就要新增一个参数_event,用于表示连接要关心的事件:

uint32_t _event; // 关心的事件

随后我们给其提供对应初始化与设置信息接口:

那么我们就在调用Add的时候,在第二个参数中调用GetEvent,返回事件就可以。

但是我们第一个插入的连接必然是listener,我们此时的listener并没有设置连接吧?

所以我们还需要完善一下Listener的初始化逻辑:

这里我们必须要注意的是,我们不仅要设置Event信息,也记得要设置sockfd信息,否则这个参数的值就会一直是错误的-1。


那么,我们继续来实现这个Loop的逻辑啊。

关于这个Loop,我们之前只实现了while循环里的Wait功能,他会给我们返回准备就绪的事件数量。

很明显我们接下来要做的就是根据这个数量,来进行一个一个的派发。

每次的派发,我们都需要收集当前处理的事件的对应文件描述符与事件信息,随后根据这两个来判断是否是listener的信息,还是其他的IO信息。

并且可以根据Event来发现异常信息,比如EPOLLERREPOLLHUP。这两个宏是 Linux epoll 机制中用于检测异常或连接状态变化 的重要事件标志。epoll 会自动触发,即使你没有显式注册它们。

EPOLLERR表示对应的文件描述符(fd)发生了错误

  • 常见场景
    • TCP 连接出现 RST(对方强制关闭)
    • 网络不可达、协议错误等底层 socket 错误
    • 对端崩溃或异常断开
  • 关键特性
    • 无需显式注册 :即使你在 epoll_ctl 中只注册了 EPOLLIN,只要 fd 出错,epoll_wait 仍会返回 EPOLLERR
    • 通常和 EPOLLINEPOLLOUT 一起出现,但单独出现也有效

EPOLLHUP表示对应的 fd 上发生了 "挂起"(Hang Up)事件 ,即对端关闭了连接(正常或异常)。

  • 常见场景
    • 客户端调用 close() 正常关闭连接
    • 客户端进程退出,内核自动关闭 socket
    • TCP 连接收到 FIN 包(四次挥手开始)
  • 关键特性
    • 也无需显式注册:epoll 会自动上报。
    • 对于 stream socket(如 TCP)EPOLLHUP 通常意味着读端已关闭(你还能发数据,但对方不会再收)。
    • 在某些情况下,EPOLLHUP 可能和 EPOLLIN 同时出现(例如对端关闭后,你还能读到 EOF)。

当使用 epoll 时,即使你没有在 epoll_ctl 中显式注册 EPOLLERREPOLLHUP,只要底层文件描述符(fd)发生了对应的错误或挂起事件,epoll_wait 返回的 epoll_event.events 字段中也会自动包含这些标志位

我们这里先判断是否出现异常,如果出现异常信息,就先将其转化为读写信息,随后挨个对读写信息进行判断处理。

但是我们这里有个安全风险, 因为我们这里处理的办法,一定是通过管理连接的哈希表来管理

而我们要通过哈希表来找到对应的连接,是通过key值,也就是这里获取到的sockfd。

根据哈希表的特性,如果这个key值不存在,就会新建一个对象,那么这个连接就会是个空指针 ,如果调用其内部办法容易导致程序崩溃

并且由于程序的滞后性, epoll 事件可能"滞后"于连接关闭,此时也会造成程序崩溃的问题,所以我们在调用哈希表的时候一定要先判断一下,这个连接还存在哈希表中吗?

那我们这里提供一个接口来专门检查一下:

我们把这个函数定义在EpollServer的private中,成为私有函数。所有不想让外部调用看见的函数,都可以定义为私有成员。

那么我们的循环逻辑就应该改成:

c++ 复制代码
 void Loop()
    {
        _isrunning = true;
        int timeout = 1000;
        while (_isrunning)
        {
            int n = _epoller->Wait(_revs, event_num, timeout);

            for (int i = 0; i < n; ++i)
            {
                // 开始进行派发, 派发给指定的模块
                // 就绪的事件我们已经使用了一个数组来接收,所以接下来要做的就是对这个数组进行处理

                // 1.先获得这个成员对应的连接的编号,确认就绪的是listener还是什么其他Connection
                int sockfd = _revs[i].data.fd;
                uint32_t ev = _revs[i].events;
                if ((ev & EPOLLERR) || (ev & EPOLLHUP)) // 判断是否出现异常
                {
                    // 如果出现异常,我们这里的做法就是把其转化为读写信息
                    ev = (EPOLLIN || EPOLLOUT); // 异常事件,转换成为读写事件
                }
                // 随后再判断读写事件
                if ((ev & EPOLLIN) && IsConnectionExist(sockfd))
                {
                    // 进行读事件处理
                }
                if ((ev & EPOLLOUT) && IsConnectionExist(sockfd))
                {
                    // 进行写事件处理
                }
            }
        }
        _isrunning = false;
    }

接下来进行我们事件的处理。

我们刚刚说,我们对于各种读写事件的处理,是通过哈希表,根据key值 ,找到对应事件的Connection对象 ,但实际上,这个对象不一定 真的是Connection类型。他完全有可能是Connection的子类对象,比如Listener。

我们都知道,基类指针,是可以指向子类对象的。

这一点,就可以实现我们的多态!!!

我们不同连接对于读写信息的处理,就可以使用多态。我们只需要在Connection中,把读写方法声明为虚函数,然后在子类中分别实现读写,就可以了。

诶,想到这里,是不是就感觉我们定义在Connection中的三个回调函数参数就多余了,我们直接定义三个纯虚函数就行了。所以你可以把这些代码删了,改成:

我们改成:

接下来只需要让子类进行重构就行:

以我们的Listener为例,其实他真正需要重构 的函数只有一个Recver,因为Listener的功能就是为了获取新连接,不会对外发送消息。而异常处理函数也没必要进行重构,因为我们的异常处理一般就是,哪个连接出现问题,我们就直接关闭这个连接,而Listen连接关闭的话,你的服务器就直接瘫痪了。

一般来说,监听套接字(listen fd)几乎不会触发真正的"异常" 。因为它不传输数据 ,只用于 accept() 接受新连接。 它不会收到 RST/FIN (因为没建立 TCP 数据连接)。常见的 EPOLLERR / EPOLLHUP 极少发生在 listen fd 上

实际上,监听 socket 出现 EPOLLERR 通常意味着严重系统错误(如协议栈崩溃),这种情况下整个进程可能已经不可靠了。

那么我们就来实现一下这个Recver函数。

这个函数需要实现什么功能呢?

我们首先要明确,Listener的Listen套接字,如果读事件就绪了,就说明有新连接到来了。

我们要做的,就是获取这个新连接信息,并把其插入到epoll模型中进行下一次的事件监管。

由于事件的通知,告诉你有了新连接,但是你不知道连接的具体个数,为了确保连接被读取完,我们应该循环进行读取

那么这里就产生了一个问题,我们之前的直接使用Accepter,是因为我们这个Accepter只执行一遍,在必定会有连接的情况下,这个基本是不会阻塞的。

但是我们现在是多次循环,那么将所有连接读取完后,必定还会执行一次循环。那么此时,你没有新连接,就一定会导致Accepter这里出现阻塞

那么你的服务器就完蛋了,服务器的运行怎么能出现阻塞呢?

所以根据我们之前在《 五种IO模型与非阻塞IO》中所学的知识,我们应该把每一个文件描述符设置为非阻塞的

这里就要引入我们的之前所写的SetNoBlock函数,我们将其定义在Common.hpp中:

随后我们在Socket.hpp中的这里,进行一个非阻塞的文件描述符设置:

那么此时我们的Listen的fd就是非阻塞的了。

那么此时代码又会出现一个疑问,我们的EpollServer只认识Connection,所以对于这个新连接,我们应该把其封装成为Connection对象,或者更加明确的说,封装成Connection的另外一个子类!

我们之前已经定义了Connection的一个子类Listener,这个类是专门用于监听套接字的。那么此时我们可以定义另外一个子类,专门用于处理这些普通的用于IO的连接!

我们称其为------IOService

这个就是我们用来处理IO信息的类,我们在构造函数中设置非阻塞,以及基本的fd,event信息。其他具体的实现我们后面讲,先把视角回到处理连接信息这里。

那么我们就可以将这个新获取到连接封装成一个IOService,随后插入到EpollServer中。

我们想要把Connection插入到EpollServer中应该怎么办呢?

还记得我们Connection中的owner指针吗,他一定会指向我们的EpollServer,但是我们之前并没有代码用来设置这个指针,所以我们这里记得添加一下相关接口,包括获取指针,设置指针:

对于每一个Connection,在插入到Epoller模型的过程中,就应该设置一下回指指针:


接下来就是我们失败逻辑处理,我们必须得分辨清楚哪些是真正的异常错误,哪些是假的错误。

这是因为我们的fd小于0的情况有好几种,有可能是Accepter的时候遭受信号中断,还有可能是没有连接了。


到目前为止,我们的代码就是这样的一个分层情况:

我们将EpollServer上添加Connection层,又使用Listener与IOService来处理不同的情况。

这样的EpollServer我们称为事件派发器,这个属于反应堆(Reactor)模式:

什么是反应堆模式呢?

Reactor 模式 是一种事件处理模式,它使用 同步 I/O 多路复用(如 epoll/kqueue/select) 来 demultiplex(解多路)并分发多个并发服务请求到对应的事件处理器。

其核心思想:

  • 单线程(或少量线程) 驱动整个 I/O 事件循环
  • 不阻塞等待 I/O,而是注册"当 fd 可读/可写时,调用哪个回调"
  • 事件驱动 + 回调机制
组件 作用 我们在代码中的对应
Synchronous Event Demultiplexer (同步事件多路分发器) 等待 I/O 事件就绪(如 epoll_wait Epoller::Wait()
Event Handler (事件处理器) 定义 I/O 事件接口(如 handle_input, handle_output Connection 基类(含 Recver, Sender, Excepter
Concrete Event Handler (具体事件处理器) 实现具体业务逻辑 IOService, Listener
Initiation Dispatcher (初始化分发器) 注册/删除事件处理器到 demultiplexer Reactor / EpollServer(含 InsertConnection, DelConnection
Event Loop (事件循环) 不断等待并派发事件 Reactor::Loop()

所以其实我们可以把我们的EpollServer改名为Reactor,这样更加符合一点。


Listener的三个函数的重构问题解决,那么接下来,就是我们的IOService,这个类的函数应该怎么重构呢?

我们先来写一个Recver,这个读IO的逻辑我们之前写过很多次了,所以应该对大家来说还是很轻松的:

这里为了保证我们读取的数据读完,我们应该使用while循环来保证,所以我们应该提供一个Append的功能,这个函数将我们每次读取到buffer的数据加载到我们的inbuffer接收缓冲区中:

c++ 复制代码
    void Append(const std::string &in)
    {
        _inbuffer+=in;
    }

我将其定义在了Connection类中。

回到while循环中,我们此时结束while循环后,基本就能把本轮数据读完了,但是,你能保证你读取的是一个完整的报文吗??

有没有缺少一部分内容,这是我们需要确定的。

那么怎么确定呢?答案是:引入协议。

我们将之前写过的一个头文件:Protocol.hpp加入到咱们的目录里。这相当于在IOService与Listener两层上面,再加一层协议层

_on_message是一个函数指针,返回类型为string,参数也是一个string。

我们将会使用它来帮助我们处理 粘包(多个请求拼在一起)半包(请求不完整)

怎么处理呢?我们慢慢来看,在这之前我们首先得先给_on_message写一个设置接口,要不然这个指针就是没定义的:

并且顺便定义几个获取参数的接口,这些后面会用到,到时候需要获取参数或者添加,都是很简单功能,所以我直接一起实现了:

回到while循环结尾,我们要确保自己读取的报文是完整的,所以我们将会把接收缓冲区的数据交给我们的处理回调函数进行处理:

这个回调函数的设置接口我们写了,那么在哪里使用呢?

这个回调函数是IOService使用的,所以我们可以在Listener添加IOService的时候,顺便设置,因为基本每一个IOService都会用到。

这里我们需要传递我们的回调函数,现在麻烦的就是这个回调函数,我们应该怎么写呢?

我们之前引入了协议头文件Protocol.hpp,为了防止大家遗忘,我会把目前Protocol.hpp的内容发到文末。我们就在这个头文件中定义一个回调吧!

在if判断中我们已经拿到了一个完整报文,并确保把他反序列化了,此时这个消息就可以进行业务处理 了,就是根据客户端发来的请求内容,执行具体的逻辑运算或服务操作,并生成对应的响应结果

我这里就为了简单,就把我们之前所写的一个网络版的计算器的业务拿过来进行处理,所以添加头文件:Calculator.hpp

随后先定义一个计算器对象,进行业务处理。

但是这里有一个小问题啊,不知道大家发现没有,我们的Calculator.hpp头文件,他所依赖的头文件中,包含了我们正在写的Protocol.hpp头文件,这样就形成了互相包含的场面

所以,我们可以把这部分代码先移动到Calculator中去:

接下来就是把这个HandlerRequest函数通过注册接口,设置给每一个IOService:

那么逻辑接着回到Recver中的调用_on_message这里:

此时我们的Result就应该拿到了返回的应答信息,此时我们应该做什么?

是不是就应该对写事件进行相应的判断处理了?因为我们已经拿到了应答信息了对不对?


在正式写代码前,我们先来聊一下多路转接模型对写事件的处理。

你觉得,什么叫做,写事件就绪?

读事件就绪是接收缓冲区为空,就是读事件就绪。那么写事件呢?

当然是,发送缓冲区为空,就是写事件就绪。

而每一个socket的发送缓冲区默认就是有空间的,所以只有当他被填写满了才叫做写条件不具备,这个时候我们就可以要求epoll模型帮我托管了

那我们应该怎么处理写事件呢?

答案就是直接写入,只有当我们把发送缓冲区写满了,我们才会将其托管给epoll,让其关心写事件。随后等待epoll发给我们的写事件就绪处理信号,将epoll模型关心的事件改成写事件不关心。

那么我们先来处理一下Sender函数:

这里的返回值n是实际发送过去的大小,这是因为对方的接收缓冲区的大小可能不够,所以我们就根据这个n在发送缓冲区中移除对应大小的数据。

循环结束后,一般只有两种情况:一种是outbuffer 为空了,另外一种是发送缓冲区被写满了 && outbuffer没有empty,写条件不满足,所以我们需要开启对应的事件关心。

这里我们新引入了一个接口,负责设置一个Connection的读写事件的开启与关闭,定义在Reactor中:

c++ 复制代码
void EnableReadWrite(int sockfd, bool readable, bool writeable)
    {
        if (IsConnectionExist(sockfd))
        {
            // 修改用户层connection的事件
            uint32_t events = ((readable ? EPOLLIN : 0) | (writeable ? EPOLLOUT : 0) | EPOLLET);
            _connection[sockfd]->SetEvent(events);
            // 写透到内核中
            _epoller->Update(sockfd, _connection[sockfd]->GetEvent());
        }
    }

那么我们的Sender函数就大功告成了,把视线移到Recver的结尾,对于Recver函数,我们在最后拿到了应答消息,并将应答消息添加到了发送缓冲区,此时我们对于这个函数有两种处理方式:

在检查玩应答信息的有效性后(应答信息不为空),我们第一种做法就是直接调用Sender将应答发回去,第二种就是通过EnableReadWrite函数打开写事件。


接下来就是我们的异常处理,对于异常处理,我们的做法很简单,就是"打印日志,差错处理,关闭连接,Reactor异常connection, 从内核中,移除对fd的关心"。

这里在Reactor中移除连接,注意移除的顺序,应该是先移除关心,再关闭文件描述符,最后从哈希表中删除:

c++ 复制代码
 void DelConnection(int sockfd)
    {
        if (IsConnectionExist(sockfd))
        {
            // 1. 从内核中异常对sockfd的关心
            _epoller->Del(sockfd);
            // 2. 关闭特定的文件描述
            _connection[sockfd]->Close();
            // 3. 从_connections中移除对应的connection
            _connection.erase(sockfd);
        }
    }

这里报错是因为我们没有定义这个接口,再在Connection中定义一个close就行,十分简单:

此时我们所写的代码就变成了这个结构:

最后的最后,我们就完善一下细节,比如在Reactor中,我们可以把循环一次的代码单独拿出来,变成这样的结构:

实现一个解耦合。

当然,在Listener中的析构函数,我们也不要忘记实现一个close,随手关门,是个好习惯

对于Reactor的Init,你会发现其中只调用了epoller的Init,所以你可以把这个代码写在构造函数中,整合一下就变成:

当然去除了Init的话,就要删除其在Main.cc中的代码,我们使用了序列化的头文件,在编译命令里也要加上对应的代码,所以Makefile就应该是这样的:

复制代码
epoll_server: Main.cc
	g++ -o $@ $^ -std=c++17 -ljsoncpp -lpthread

.PHONY: clean
clean:
	rm -f epoll_server

那么我们代码基本上就大功告成了。

最后还要说的是,我们之前在Listener的recver代码,设置了一个aerror的错误码,这个错误码我们并没有设置,所以可能会出现段错误。

那么我们应该在Accepter函数底层修改一下:

将aerrno值带出来:

除此之外应该就没有多大问题了,如果有同学没有运行成功,可以发评论区或者私信我。因为精力有限,我有时候改了代码但是忘记在文中描述,出现了我自己这里测试成功但是你们那里失败的情况。

总的来说,我们这次的实验虽然粗糙,还有很多小瑕疵小bug需要调试,但是大体的设计思路与思想是没有问题的。希望本篇文章能够给大家带来一定帮助,下周会有Linux网络编程的一些总结带给大家。


附录

全代码如下:

Socket.hpp:

c++ 复制代码
#pragma once

// 既然是封装套接字,我们就先把要用到的头文件先写上去
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "log.hpp"
#include "Common.hpp"
#include "InetAddr.hpp"
#include <memory>
#include <functional>
#include <sys/wait.h>

using namespace LogModule;
class Socket;
using SockPtr = std::shared_ptr<Socket>;

// 所谓模板方法模式,本质上就是C++多态继承特性的应用,所以我们就需要先定义一个基类
// 用来规定创建Socket的方法
class Socket
{
public:
    virtual ~Socket() = default; // 防止不调用子类析构函数的情况出现

    virtual void SocketOrDie() = 0; // 创建套接字接口,这里的OrDie是一种常见的命名约定,特别是在C/C++和系统编程中。
    // 表示操作成功则继续,失败则直接终止程序,=0表示父类并没有实现,要求子类继承后必须实现
    //= default 表示显式要求编译器生成该函数的默认实现。

    virtual bool BindOrDie(int port) = 0;

    virtual void SetSocketOpt() = 0; // 这个我们之前没有接触到这个需求

    virtual bool ListenOrDie() = 0;

    virtual SockPtr Accepter(InetAddr *client,int *error) = 0;

    virtual void Close() = 0;

    virtual int Recv(std::string *out) = 0;

    virtual int Send(std::string &in) = 0;

    virtual int Fd() = 0;

    // 模板方法:定义算法骨架
    void BuildTcpSocketMethod(int port)
    {
        SocketOrDie();   // 步骤1:创建socket
        SetSocketOpt();  // 步骤2:设置socket选项
        BindOrDie(port); // 步骤3:绑定端口
        ListenOrDie();   // 步骤4:开始监听
    }

private:
};

class TcpSocket : public Socket
{
public:
    TcpSocket(int fd) : _sockfd(fd)
    {
    }
    TcpSocket() : _sockfd(gdefaultsockfd)
    {
    }
    virtual ~TcpSocket()
    {
    }

    virtual void SocketOrDie() override
    {
        _sockfd = ::socket(AF_INET, SOCK_STREAM, 0);
        if (_sockfd < 0)
        {
            LOG(LogLevel::ERROR) << "socket create error";
            exit(SOCKET_ERR);
        }
        SetNoBlock(_sockfd); // 设置非阻塞
        LOG(LogLevel::DEBUG) << "socket create success";
    }

    virtual bool BindOrDie(int port) override
    {
        if (_sockfd == gdefaultsockfd) // 说明没有进行获取套接字
        {
            return false;
        }

        InetAddr addr(port);
        int n = ::bind(_sockfd, addr.Getsockaddr(), addr.GetSockaddrLen());
        if (n < 0)
        {
            LOG(LogLevel::ERROR) << "bind error";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::DEBUG) << "bind create success: " << _sockfd;
        return true;
    }
    virtual void SetSocketOpt() override
    {
        // 当我们的服务端主动断开连接或者异常断开连接时,再通过这个端口执行服务器,会产生绑定失败的问题
        // 所以我们需要对套接字的属性进行设置
        int opt = 1;
        int n = ::setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    }
    virtual bool ListenOrDie() override
    {
        if (_sockfd == gdefaultsockfd)
        {
            return false;
        }
        int n = ::listen(_sockfd, gbacklog); // 我们这里之前应该提到过,第二个参数表示监听队列的长度
        if (n < 0)
        {
            LOG(LogLevel::ERROR) << "listen error";
            exit(LISTEN_ERR);
        }
        LOG(LogLevel::DEBUG) << "listen create success: " << _sockfd;
        return true;
    }
    virtual SockPtr Accepter(InetAddr *client, int *aerror) override
    {
        if (!client)
        {
            return nullptr;
        }

        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        int newfd = ::accept(_sockfd, CONV(&peer), &len);
        *aerror = errno;
        if (newfd < 0)
        {
            LOG(LogLevel::WARN) << "accept error";
            return nullptr;
        }

        client->SetAddr(peer, len);
        return std::make_shared<TcpSocket>(newfd);
    }
    virtual void Close() override
    {
        if (_sockfd == gdefaultsockfd)
        {
            return;
        }

        ::close(_sockfd);
    }

    virtual int Recv(std::string *out) override
    {
        char buffer[4096];
        int n = ::recv(_sockfd, buffer, sizeof(buffer) - 1, 0);

        if (n > 0)
        {
            buffer[n] = 0;
            *out = buffer;
        }

        return n;
    }

    virtual int Send(std::string &in) override
    {
        int n = ::send(_sockfd, in.c_str(), in.size(), 0);
        return n;
    }

    virtual int Fd() override
    {
        return _sockfd;
    }

private:
    int _sockfd;
};

// int main()
// {
//     Socket *sk = new TcpSocket();
//     sk->BuildTcpSocketMethod(8080);
// }

Reactor.hpp:

c++ 复制代码
#pragma once

#include "Epoller.hpp"
#include <memory>
#include <iostream>
#include <unordered_map>
#include "Connection.hpp"

using namespace EpollModule;

using connection_t = std::shared_ptr<Connection>; // 使用一个智能指针来管理一个Connection对象

class Reactor
{
    const static int event_num = 64;

private:
    bool IsConnectionExist(int sockfd)
    {
        return _connection.find(sockfd) != _connection.end();
    }

public:
    Reactor()
        : _isrunning(false),
          _epoller(std::make_unique<Epoller>())
    {
        _epoller->Init(); // 建立epoll模型
    }

    void EnableReadWrite(int sockfd, bool readable, bool writeable)
    {
        if (IsConnectionExist(sockfd))
        {
            // 修改用户层connection的事件
            uint32_t events = ((readable ? EPOLLIN : 0) | (writeable ? EPOLLOUT : 0) | EPOLLET);
            _connection[sockfd]->SetEvent(events);
            // 写透到内核中
            _epoller->Update(sockfd, _connection[sockfd]->GetEvent());
        }
    }

    void DelConnection(int sockfd)
    {
        if (IsConnectionExist(sockfd))
        {
            // 1. 从内核中异常对sockfd的关心
            _epoller->Del(sockfd);
            // 2. 关闭特定的文件描述
            _connection[sockfd]->Close();
            // 3. 从_connections中移除对应的connection
            _connection.erase(sockfd);
        }
    }

    void Dispatcher(int n)
    {
        for (int i = 0; i < n; ++i)
        {
            // 开始进行派发, 派发给指定的模块
            // 就绪的事件我们已经使用了一个数组来接收,所以接下来要做的就是对这个数组进行处理

            // 1.先获得这个成员对应的连接的编号,确认就绪的是listener还是什么其他Connection
            int sockfd = _revs[i].data.fd;
            uint32_t ev = _revs[i].events;
            if ((ev & EPOLLERR) || (ev & EPOLLHUP)) // 判断是否出现异常
            {
                // 如果出现异常,我们这里的做法就是把其转化为读写信息
                ev = (EPOLLIN || EPOLLOUT); // 异常事件,转换成为读写事件
            }
            // 随后再判断读写事件
            if ((ev & EPOLLIN) && IsConnectionExist(sockfd))
            {
                // 进行读事件处理
                 _connection[sockfd]->Recver();
            }
            if ((ev & EPOLLOUT) && IsConnectionExist(sockfd))
            {
                // 进行写事件处理
                 _connection[sockfd]->Sender();
            }
        }
    }

    void LoopOnce(int timeout)
    {
        int n = _epoller->Wait(_revs, event_num, timeout);
        Dispatcher(n);
    }

    void Loop()
    {
        _isrunning = true;
        int timeout = 1000;
        while (_isrunning)
        {
            LoopOnce(timeout);
        }
        _isrunning = false;
    }

    // 提供接口来插入新连接给哈希表
    void InsertConnection(connection_t conn)
    {
        auto iter = _connection.find(conn->Sockfd()); // 进行安全检查

        if (iter == _connection.end())
        {
            // 1. 把连接,放到unordered_map中进行管理
            _connection.insert(std::make_pair(conn->Sockfd(), conn));

            // 2. 把新插入进来的连接,写透到内核的epoll中
            _epoller->Add(conn->Sockfd(), conn->GetEvent());

            // 3. 设置关联关系,让connection回指当前对象
            conn->SetOwner(this);
            LOG(LogLevel::DEBUG) << "add connection success: " << conn->Sockfd();
        }
    }
    ~Reactor() {}

public:
    std::unique_ptr<Epoller> _epoller;                 // 创建的Epoll模型
    bool _isrunning;                                   // 服务器的运行状态
    struct epoll_event _revs[event_num];               // 用于接收就绪的事件的结构体数组,大小为64,64一般够用
    std::unordered_map<int, connection_t> _connection; // 管理连接信息的哈希表
};

Epoller.hpp:

c++ 复制代码
#pragma once

#include <iostream>
#include <sys/epoll.h>
#include "log.hpp"
#include "Common.hpp"

using namespace LogModule;

namespace EpollModule
{
    class Epoller
    {
    public:
        Epoller() : _epfd(-1)
        {
        }

        void Init()
        {
            _epfd = ::epoll_create(256); // 获取epoll模型的文件描述符
            // 这里我们就可以引入我们的日志系统与Comman.hpp对其进行记录了
            if (_epfd < 0)
            {
                LOG(LogLevel::ERROR) << "epoll_create error";
                exit(EPOLL_CREATE_ERR);

                // 在Comman中我们定义了一下基本的全局变量,比如我们的错误码联合体:
                // enum
                // {
                //     USAGE_ERR = 1,
                //     SOCKET_ERR,
                //     BIND_ERR,
                //     LISTEN_ERR,
                //     EPOLL_CREATE_ERR,
                //     EPOLL_CTL_ERR
                // };
            }

            LOG(LogLevel::INFO) << "epoll_create success,epfd:" << _epfd;
        }

        int Wait(struct epoll_event revs[], int num, int timeout)
        {
            int n = epoll_wait(_epfd, revs, num, timeout); // 调用epoll_wait
            if (n < 0)
            {
                LOG(LogLevel::WARN) << "epoll_wait error";
            }
            return n; // 将就绪的数量返回
        }
private:
        void Ctrl(int sockfd, uint32_t event, int flag)
        {
            struct epoll_event ev;
            ev.events = event;
            ev.data.fd = sockfd;
            int n = epoll_ctl(_epfd, flag, sockfd, &ev);
            if (n < 0)
            {
                LOG(LogLevel::WARN) << "epoll_ctl error";
            }
        }
public:
        void Add(int sockfd, uint32_t event)
        {
            Ctrl(sockfd, event, EPOLL_CTL_ADD);
        }

        void Update(int sockfd, uint32_t event)
        {
            Ctrl(sockfd, event, EPOLL_CTL_MOD);
        }

        void Del(int sockfd)
        {
            int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
            if (n < 0)
            {
                LOG(LogLevel::WARN) << "epoll_ctl error";
            }
        }

        ~Epoller() {}

    private:
        int _epfd;
    };
}

Listener.hpp:

c++ 复制代码
#pragma once

#include <iostream>
#include <memory>
#include "Socket.hpp"
#include "IOService.hpp"
#include "Calculator.hpp"

class Listener : public Connection // 对Connection类进行一个继承操作
{
public:
    Listener(int port)
        : _port(port),
          _Listensock(std::make_unique<TcpSocket>())
    {
        _Listensock->BuildTcpSocketMethod(_port);

        // 关键:将监听 fd 设置到基类,供 epoll 注册和哈希表使用
        SetSockfd(_Listensock->Fd());

        // 监听 socket 只关心"读就绪"(即有新连接),使用边缘触发(ET)模式
        SetEvent(EPOLLIN | EPOLLET);
    }

    virtual void Sender() override
    {
    }
    virtual void Recver() override
    {
        // 读就绪,而是是listensock就绪
        // IO处理 --- 获取新连接
        // 你怎么知道,一次来的,就是一个连接呢?你怎么保证你一次读完了么?
        // 所以你要使用while循环读取
        while (true)
        {
            InetAddr peer;
            int aerrno=0;

            // 我们这里要把内部的Socket的处理过程,把对应的文件描述符设置为非阻塞的
            //  当accept不阻塞的时候,就可以看做是IO,那我们就像处理read一样处理accept就行
            auto newSockptr = _Listensock->Accepter(&peer,&aerrno);
            if (newSockptr && newSockptr->Fd() > 0)
            {
                // success
                // 我们毫不意外的是不能直接读取的!
                // sockfd 添加到epoll!
                // 因为epollserver只认connection
                LOG(LogLevel::DEBUG) << "Accepter success: " << newSockptr->Fd();
                // 普通的文件描述符,处理IO的,也是connection!
                // 2. sockfd包装成为Connection!
                auto conn = std::make_shared<IOService>(newSockptr->Fd());
                conn->RegisterOnMessage(HandlerRequest); // 设置回调函数,处理协议
                // 3. 插入到EpollServer
                GetOwner()->InsertConnection(conn);
            }
            else
            {
                // 失败的情况,我们就要处理一下,看看究极是没连接了还是真正的错误发生了
                // 区分"正常情况"和"真正错误",从而决定是继续、退出循环,还是报错。
                if (aerrno == EAGAIN || aerrno == EWOULDBLOCK) // 当前没有新连接可接受
                {
                    LOG(LogLevel::DEBUG) << "accetper all connection ... done";
                    break;
                }
                else if (aerrno == EINTR) // accept() 被信号(signal)中断了
                {
                    LOG(LogLevel::DEBUG) << "accetper intr by signal, continue";
                    // 系统调用被中断,但可以安全重试
                    continue;
                }
                else // 其他错误(真正的异常)
                {
                    LOG(LogLevel::WARN) << "accetper error ... Ignore";
                    break;
                }
            }
        }
    }
    virtual void Excepter() override
    {
    }

    ~Listener()
    {
        _Listensock->Close();
    }

private:
    std::unique_ptr<Socket> _Listensock; // TCP套接字
    int _port;                           // 服务端端口信息
};

IOService.hpp:

c++ 复制代码
#pragma once

#include <iostream>
#include <memory>
#include <functional>
#include "Epoller.hpp"
#include "InetAddr.hpp"
#include "Socket.hpp"
#include "log.hpp"
#include "Connection.hpp"
#include "Protocol.hpp"
#include "Reactor.hpp"
#include <string>

using func_t = std::function<std::string(std::string &)>;

class IOService : public Connection
{
    static const int size = 1024;

public:
    IOService(int sockfd)
    {
        SetNoBlock(sockfd);
        SetSockfd(sockfd);
        SetEvent(EPOLLIN | EPOLLET);
    }
    virtual void Sender() override
    {
        while (true)
        {
            ssize_t n = send(Sockfd(), OutString().c_str(), OutString().size(), 0);
            if (n > 0)
            {
                // 成功
                DisCardOutString(n); // 移除N个
            }
            else if (n == 0)
            {
                break;
            }
            else
            {
                if (errno == EAGAIN || errno == EWOULDBLOCK)
                {
                    // 缓冲区写满了,下次再来
                    break;
                }
                else if (errno == EINTR)
                {
                    continue;
                }
                else
                {
                    Excepter();
                    return;
                }
            }
        }

        // 循环结束后,一般只有两种情况:一种是outbuffer 为空了,
        // 另外一种是发送缓冲区被写满了 && outbuffer没有empty,写条件不满足,所以我们需要开启对应的事件关心。
        if (!IsOutBufferEmpty())
        {
            // 修改对sockfd的事件关心!-- 开启对写事件关心
            // 按需设置!
            GetOwner()->EnableReadWrite(Sockfd(), true, true);
        }
        else
        {
            GetOwner()->EnableReadWrite(Sockfd(), true, false);
        }
    }
    virtual void Recver() override
    {
        // 读取所有数据,所以我们应该使用while循环读取
        while (true)
        {
            char buffer[1024];
            ssize_t s = recv(Sockfd(), buffer, sizeof(buffer) - 1, 0);
            if (s > 0)
            {
                buffer[s] = 0;  // 设置结束的\0
                Append(buffer); // 加载到输入缓冲区中
            }
            else if (s == 0)
            {
                break;
            }
            else
            {
                if (errno == EAGAIN || errno == EWOULDBLOCK)
                {
                    // 缓冲区写满了,下次再来
                    break;
                }
                else if (errno == EINTR) // 被信号中断
                {
                    continue;
                }
                else
                {
                    Excepter();
                    return;
                }
            }
        }
        // while循环结束运行到这里时,一定是把本轮的数据读完了

        std::cout << "outbuffer: \n"
                  << InString() << std::endl;
        // 你能确保你读到的消息,就是一个完整的报文吗??不能!!!
        // 我们怎么知道,读到了完整的请求呢??协议!!!

        std::string result;
        if (_on_message)
        {
            result = _on_message(InString()); // 处理接收缓冲区,这里我们故意使用引用返回的Instring
        }
        LOG(LogLevel::INFO) << result;
        // 添加应答信息,result中此时就保存的我们的应答信息
        AppendToOut(result);

        if (!IsOutBufferEmpty())
        {
            // 方案一: Sender(); // 直接发送, 推荐做法
            // 方案二: 使能Writeable即可.
            GetOwner()->EnableReadWrite(Sockfd(), true, true);
        }
    }
    virtual void Excepter() override
    {
        // IO读取的时候,所有的异常处理,全部都会转化成为这个一个函数的调用
        // 出现异常,我们怎么做???
        // 打印日志,差错处理,关闭连接,Reactor异常connection, 从内核中,移除对fd的关心
        LOG(LogLevel::INFO) << "客户端连接可能结束,进行异常处理: " << Sockfd();
        GetOwner()->DelConnection(Sockfd());
    }
    void RegisterOnMessage(func_t on_message)
    {
        _on_message = on_message;
    }

    ~IOService() {}

private:
    func_t _on_message;
};

Connection.hpp:

c++ 复制代码
#pragma once

#include <iostream>
#include <string>
#include "InetAddr.hpp"
#include <functional>

class Reactor;

class Connection
{
public:
    Connection() : _sockfd(-1),
                   _event(0)
    {
    }
    void SetEvent(uint32_t event)
    {
        _event = event; // 设置该连接关心的 epoll 事件(如 EPOLLIN、EPOLLOUT)
    }

    uint32_t GetEvent()
    {
        return _event; // 获取当前关心的 epoll 事件
    }

    void SetOwner(Reactor *own)
    {
        owner = own; // 设置所属的 Reactor 对象(用于回调操作)
    }

    Reactor *GetOwner()
    {
        return owner; // 获取所属的 Reactor 对象
    }

    void SetSockfd(int sockfd)
    {
        _sockfd = sockfd; // 设置该连接对应的 socket 文件描述符
    }

    void SetInetAddr(const InetAddr &peer_addr)
    {
        _peer_addr = peer_addr; // 设置对端客户端的网络地址信息
    }

    int Sockfd() const
    {
        return _sockfd; // 获取 socket 文件描述符
    }

    void Append(const std::string &in)
    {
        _inbuffer += in; // 将数据追加到输入缓冲区(接收缓冲区)
    }

    void AppendToOut(const std::string &out)
    {
        _outbuffer += out; // 将数据追加到输出缓冲区(发送缓冲区)
    }

    std::string &OutString()
    {
        return _outbuffer; // 返回输出缓冲区的引用(用于发送)
    }

    std::string &InString()
    {
        return _inbuffer; // 返回输入缓冲区的引用(用于解析)
    }

    bool IsOutBufferEmpty()
    {
        return _outbuffer.empty(); // 判断输出缓冲区是否为空
    }

    void DisCardOutString(int n)
    {
        _outbuffer.erase(0, n); // 从发送缓冲区中移除对应大小的数据
    }

    // 纯虚函数,让子类必须进行重构
    virtual void Sender() = 0;
    virtual void Recver() = 0;
    virtual void Excepter() = 0;

    void Close()
    {
        if (_sockfd >= 0)
            close(_sockfd);
    }

private:
    int _sockfd;            // 这个连接的文件描述符信息
    std::string _inbuffer;  // 输入,接收缓冲区
    std::string _outbuffer; // 输出缓冲区
    InetAddr _peer_addr;    // 对应这个连接所属的客户端信息

    // 新增一个EpollServer的指针
    Reactor *owner; // 这个指针后面有大用处

    uint32_t _event; // 关心的事件
};

// //对不同种类的fd进行区分

// class ListenConnection : public Connection
// {
// };

// class NormalConnection : public Connection
// {
// };

Calculator.hpp:

c++ 复制代码
#pragma once
#include "Protocol.hpp"

class Calculator // 创建这个类的目的只是为了构造对象方便回调函数
{
public:
    Calculator()
    {
    }
    Response Execute(const Request &rq)
    {
        Response resp;
        switch (rq.Get_oper()) // 这里为了拿到类的成员变量的值,我们还需要增加接口返回值
        {
        case '+':
            // 这里为了给resp的成员变量赋值,我们同样需要补充几个接口:
            resp.SetResult(rq.Get_x() + rq.Get_y());
            break;
        case '-':
            resp.SetResult(rq.Get_x() - rq.Get_y());
            break;
        case '*':
            resp.SetResult(rq.Get_x() * rq.Get_y());
            break;
        case '/':
        {
            if (rq.Get_y() == 0)
            {
                resp.SetCode(1); // 表示除0错误
            }
            else
            {
                resp.SetResult(rq.Get_x() / rq.Get_y());
            }
        }
        break;
        case '%':
        {
            if (rq.Get_y() == 0)
            {
                resp.SetCode(2); // 2 就是mod 0
            }
            else
            {
                resp.SetResult(rq.Get_x() % rq.Get_y());
            }
        }
        break;
        default:
            resp.SetCode(3); // 3 用户发来的计算类型,无法识别
            break;
        }
        return resp;
    }
    ~Calculator()
    {
    }
};

Calculator cal;

std::string HandlerRequest(std::string &inbuffer)
{
    std::string request_str;
    std::string result_str;
    while (Decode(inbuffer, &request_str))
    {
        std::string resp_str;
        // 一定拿到了一个完整报文
        // 1. 反序列化
        if (request_str.empty())
            break;
        Request req;
        if (!req.Deserialize(request_str))
            break;
        std::cout << "#############" << std::endl;
        req.Print();
        std::cout << "#############" << std::endl;
        // 2. 业务处理
        Response resp = cal.Execute(req);
        // 3. 序列化
        resp.Serialize(resp_str);
        // 4. 添加长度说明 -- 协议
        Encode(resp_str);
        // 5. 添加所有的应答
        result_str += resp_str;
    }
    return result_str;
}

Main.cc:

c++ 复制代码
#include <iostream>
#include <string>
#include "Reactor.hpp"
#include "log.hpp"
#include "Listener.hpp"
#include "Connection.hpp"

using namespace LogModule;

int main(int argc, char *argv[])
{
    if (argc != 2) // 服务器的启动检查,确保启动时传入port信息
    {
        std::cout << "Usage: " << argv[0] << " port" << std::endl;
        return 1;
    }

    ENABLE_CONSOLE_LOG();                     // 初始化日志
    uint16_t local_port = std::stoi(argv[1]); // 将port由字符串转变为整形
    Reactor reactor;
     auto conn = std::make_shared<Listener>(local_port);

    reactor.InsertConnection(conn);
    reactor.Loop();
    return 0;
}
相关推荐
臭东西的学习笔记19 小时前
论文学习——机器学习引导的蛋白质工程
人工智能·学习·机器学习
大王小生19 小时前
说说CSV文件和C#解析csv文件的几种方式
人工智能·c#·csv·csvhelper·csvreader
m0_4626052220 小时前
第G3周:CGAN入门|生成手势图像
人工智能
bubiyoushang88820 小时前
基于LSTM神经网络的短期风速预测实现方案
人工智能·神经网络·lstm
中烟创新20 小时前
烟草专卖文书生成智能体与法规案卷评查智能体获评“年度技术最佳实践奖”
人工智能
得一录20 小时前
大模型中的多模态知识
人工智能·aigc
嵌入式×边缘AI:打怪升级日志20 小时前
[特殊字符] USBX 学习笔记(基于 Azure® RTOS)
网络
Nick.Q20 小时前
vim插件的管理与离线安装
linux·编辑器·vim
Github掘金计划20 小时前
Claude Work 开源平替来了:让 AI 代理从“终端命令“变成“产品体验“
人工智能·开源
ghgxm52020 小时前
Fastapi_00_学习方向 ——无编程基础如何用AI实现APP生成
人工智能·学习·fastapi