C++ 预约系统实战(三):服务端实现——libevent 事件驱动与业务路由

一、为什么服务端选择事件驱动

服务端要同时服务多个客户端。最直观的做法是"一个连接一个线程":

但这个模型在预约系统这类场景下并不划算,原因是:

  • 预约业务的计算量极小,主要时间花在等网络和等数据库。

  • 线程大部分时间在阻塞等待,却各自占用栈空间和内核调度资源。

  • 连接数一多,线程切换开销急剧上升。

本项目采用的是事件驱动模型(Reactor)

优势一目了然

维度 多线程模型 事件驱动模型
线程数 随连接数增长 固定 1 个
内存占用 每线程 MB 级栈 共享一份
上下文切换 频繁 几乎没有
编程模型 直观 回调式
适用场景 CPU 密集 IO 密集

预约系统是典型的 IO 密集型:CPU 只在解析 JSON、拼 SQL 时短暂工作,其余时间都在等。事件驱动正好把"等待"这段时间让给其他连接,单线程就能撑住大量并发。

libevent 帮我们把这些细节封装好了------底层自动选择 epoll(Linux)、kqueue(BSD/macOS)或 select,上层统一用 event 接口。


二、libevent 三个核心概念

用一句话概括 libevent 的模型:

把"fd + 关心的事件 + 回调函数"打包成一个 event,注册到 event_base,然后启动循环,事件就绪时自动调用回调。

三个核心对象:

本项目里,event_base 只有一个,event 有两个层级:

  1. 监听套接字的 event:关心"有新连接到来"。

  2. 每个客户端连接的 event:关心"这个连接有数据可读"。


三、socket_listen:监听者的职责

socket_listen 类封装了服务端的"入口"。

3.1 初始化:socket / bind / listen 三步走

cpp 复制代码
bool socket_listen::socket_init()
{
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == sockfd) return false;

    struct sockaddr_in saddr;
    memset(&saddr, 0, sizeof(saddr));
    saddr.sin_family      = AF_INET;
    saddr.sin_port        = htons(6000);
    saddr.sin_addr.s_addr = inet_addr(m_ips.c_str());

    int res = bind(sockfd, (struct sockaddr *)&saddr, sizeof(saddr));
    if (-1 == res) { close(sockfd); return false; }

    res = listen(sockfd, LIS_MAX);
    if (res == -1) return false;

    return true;
}

这三步是 TCP 服务端的标准动作:

LIS_MAX = 10全连接队列长度 :已完成三次握手但还没被 accept 取走的连接,最多排 10 个。

3.2 accept_client:从队列里取一个连接

cpp 复制代码
int socket_listen::accept_client()
{
    int c = accept(sockfd, NULL, NULL);
    return c;
}

accept 返回一个全新的 fd,代表"这条具体的客户端连接"。

这里有一个容易混淆的点:

两个 fd 分开,是事件驱动模型的关键:监听 fd 上的可读事件 = 有新连接;连接 fd 上的可读事件 = 有数据到达。两者用不同的回调处理。

3.3 持有 event_base

cpp 复制代码
class socket_listen
{
public:
    void Set_base(struct event_base* base) { this->base = base; }
    int  Get_sockfd() const { return sockfd; }
    struct event_base* Get_base() const { return base; }
private:
    int sockfd;
    short m_port;
    string m_ips;
    struct event_base* base;   // 与 main 共享同一个事件循环
};

socket_listen 不自己创建 event_base,而是通过 Set_base 接收外部传入的。这样设计的好处是:

  • event_base 全局唯一,所有 event 都挂在同一个循环上。

  • 类本身的职责纯粹:只管监听,不管循环。

这是一种依赖注入的思想:谁创建,谁管理;谁使用,谁接收。


四、每个连接一个 socket_con

这是本项目服务端最精妙的设计之一:为每个客户端连接创建一个独立的对象

4.1 为什么需要"每连接对象"?

每条连接都有自己的上下文

  • 通信用的 fd(c

  • 挂在自己身上的 event(c_ev

  • 本次请求解析出的 JSON(val

如果所有连接共用一个结构,就无法区分"现在处理的是哪个客户端"。

所以:

cpp 复制代码
class socket_con
{
public:
    socket_con(int fd) : c(fd) { c_ev = NULL; }
    void Set_ev(struct event* ev) { c_ev = ev; }
    ~socket_con()
    {
        event_free(c_ev);
        close(c);
    }

    void Recv_data();
    void Send_err();
    void Send_ok();

    void User_Resgister();
    void User_Login();
    void User_Show_Ticket();
    void User_Subscribe_Ticket();
    void User_Show_Sub_Ticket();
    void User_Cancel_Sub_Ticket();

private:
    int c;                    // 本连接的 fd
    struct event* c_ev;       // 本连接注册的事件
    Json::Value val;          // 本连接当前的请求数据
};

一个 socket_con = 一条连接的完整生命周期

4.2 生命周期的自管理

连接断开时,recv 返回 0 或负数,Recv_data 里的处理是:

cpp 复制代码
void socket_con::Recv_data()
{
    char buff[256] = {0};
    int n = recv(c, buff, 255, 0);
    if (n <= 0)
    {
        cout << "client close" << endl;
        delete this;      // ← 自己删自己
        return;
    }
    // ...
}

delete this 会触发析构函数:

cpp 复制代码
~socket_con()
{
    event_free(c_ev);   // 从 event_base 摘掉
    close(c);           // 关闭 fd
}

三步连锁释放

这样保证了连接断开时资源不会泄漏 ,也不需要外部维护一个"连接表"来清理。每个对象自己管好自己,是典型的 RAII(资源获取即初始化) 思想。


五、两级回调:从新连接到业务处理

libevent 的回调驱动分两级:监听级连接级

5.1 监听级回调:SOCK_LIS_CALLBACK

cpp 复制代码
void SOCK_LIS_CALLBACK(int sockfd, short ev, void *arg)
{
    socket_listen *p = (socket_listen *)arg;
    if (p == NULL) return;

    if (ev & EV_READ)
    {
        int c = p->accept_client();
        if (c == -1) return;

        cout << "accept c=" << c << endl;

        socket_con *q = new socket_con(c);

        struct event *c_ev = event_new(p->Get_base(), c,
                                       EV_READ | EV_PERSIST,
                                       SOCK_CON_CALLBACK, q);
        if (c_ev == NULL)
        {
            close(c);
            delete q;
            return;
        }
        q->Set_ev(c_ev);
        event_add(c_ev, NULL);
    }
}

这段代码做了四件事:

这里体现了"回调套回调"的设计

事件从"监听层"向下传递到"连接层",每一层只关心自己的事件,职责清晰。

5.2 连接级回调:SOCK_CON_CALLBACK

cpp 复制代码
void SOCK_CON_CALLBACK(int c, short ev, void *arg)
{
    socket_con *q = (socket_con *)arg;

    if (ev & EV_READ)
    {
        q->Recv_data();
    }
}

它只做一件事:把事件转发给对象自己的方法。

argevent_new 时传入的 qsocket_con*),所以回调能拿到具体是哪个连接------这就是把上下文和事件绑定在一起的关键。

5.3 EV_PERSIST:一次注册,多次触发

cpp 复制代码
event_new(base, c, EV_READ | EV_PERSIST, SOCK_CON_CALLBACK, q);

EV_PERSIST 的含义是:事件触发后不自动移除,保持注册状态

不加 EV_PERSIST 会怎样?

加了 EV_PERSIST 之后:

一条 TCP 连接通常会承载多次请求 (用户登录后还会预约、查看、取消),所以连接级事件必须是 EV_PERSIST

这是一个从"短连接思维"切换到"长连接思维"的关键点。


六、业务路由:从 JSON 到函数调用

事件到达后,Recv_data 完成从字节流 → JSON → 函数的转换。

6.1 完整的路由链路

cpp 复制代码
void socket_con::Recv_data()
{
    char buff[256] = {0};
    int n = recv(c, buff, 255, 0);
    if (n <= 0) { delete this; return; }

    cout << "recv:" << buff << endl;

    Json::Reader Read;
    if (!Read.parse(buff, val))
    {
        cout << "Recv_data:解析Json失败" << endl;
        Send_err();
        return;
    }

    int ops = val["type"].asInt();
    switch (ops)
    {
    case DL:    User_Login();              break;
    case ZC:    User_Resgister();          break;
    case CKYY:  User_Show_Ticket();        break;
    case YD:    User_Subscribe_Ticket();   break;
    case CKYD:  User_Show_Sub_Ticket();    break;
    case QXYD:  User_Cancel_Sub_Ticket();  break;
    default: break;
    }
}

链路图:

每一层都在做"类型收窄"

字节流 → 字符串 → JSON对象 → 整数 → 函数指针

这是典型的分层解析思路。每一层只负责一件事,出错时也好定位。

6.2 参数直接取自 JSON

业务函数不接收参数,而是自己从 val 成员里取:

cpp 复制代码
void socket_con::User_Subscribe_Ticket()
{
    int    tk_id = val["index"].asInt();
    string tel   = val["tel"].asString();

    mysql_client cli;
    if (!cli.mysql_ConnectServer()) { Send_err(); return; }
    if (!cli.mysql_Subscribe_Ticket(tk_id, tel)) { Send_err(); return; }

    Send_ok();
}

为什么不让函数带参数?

  • val成员变量 ,每个 socket_con 一份,天然隔离。

  • 业务函数可以按需取字段,不同操作取不同字段,不用为每个函数定制参数列表。

  • 路由 switch 里写起来统一:case YD: User_Subscribe_Ticket(); break;

这是一种"共享上下文 + 按需取值"的设计,简洁且扩展方便。


七、统一响应:Send_ok / Send_err

服务端有两个发送辅助函数:

cpp 复制代码
void socket_con::Send_err()
{
    Json::Value res_val;
    res_val["status"] = "ERR";
    send(c, res_val.toStyledString().c_str(),
         strlen(res_val.toStyledString().c_str()), 0);
}

void socket_con::Send_ok()
{
    Json::Value res_val;
    res_val["status"] = "OK";
    send(c, res_val.toStyledString().c_str(),
         strlen(res_val.toStyledString().c_str()), 0);
}

所有业务函数的返回路径都是这两者之一

统一用 status 字段表达结果,客户端处理也统一:

cpp 复制代码
string st = val["status"].asString();
if (st.compare("OK") != 0)
{
    cout << "操作失败" << endl;
    return;
}

协议统一 → 服务端统一 → 客户端统一,这是接口设计中非常值得学习的一致性思维。

唯一例外是 User_Login,它需要在成功时额外携带用户名

cpp 复制代码
void socket_con::User_Login()
{
    string tel = val["user_tel"].asString();
    string password = val["user_password"].asString();
    string user_name;

    mysql_client cli;
    if (!cli.mysql_ConnectServer()) { Send_err(); return; }
    if (!cli.mysql_Login(tel, password, user_name)) { Send_err(); return; }

    Json::Value res_val;
    res_val["status"]    = "OK";
    res_val["user_name"] = user_name;
    send(c, res_val.toStyledString().c_str(),
         strlen(res_val.toStyledString().c_str()), 0);
}

这是一种渐进式响应格式 :基础格式固定,需要时再加字段。客户端只有登录逻辑会读 user_name,其它逻辑只读 status,互不干扰。


八、主函数:把一切串起来

cpp 复制代码
int main()
{
    // 1. 监听套接字
    socket_listen sock_ser;
    if (!sock_ser.socket_init())
    {
        cout << "socket_init err" << endl;
        exit(1);
    }

    // 2. 创建 libevent base
    struct event_base *base = event_init();
    if (base == NULL)
    {
        cout << "base nurr" << endl;
        exit(1);
    }

    // 3. 把 base 交给监听者
    sock_ser.Set_base(base);

    // 4. 监听套接字也作为事件注册
    struct event *sock_ev = event_new(base,
                                      sock_ser.Get_sockfd(),
                                      EV_READ | EV_PERSIST,
                                      SOCK_LIS_CALLBACK,
                                      &sock_ser);
    event_add(sock_ev, NULL);

    // 5. 启动事件循环
    event_base_dispatch(base);

    // 6. 释放资源
    event_free(sock_ev);
    event_base_free(base);
}

整个服务端的骨架就这六步:

event_base_dispatch 是分界线

  • 之前:初始化阶段,一切同步执行。

  • 之后:交给 libevent,回调开始驱动业务。主线程"消失"在事件循环里,直到程序退出才返回。

这就是 Reactor 模型的核心思想------把控制权交给事件循环,让回调驱动一切


九、事件驱动设计的四个优点

回到整体设计,本项目的服务端体现了事件驱动模型的几个核心优势:

9.1 资源占用极低

一个线程就能处理所有连接,不需要为每个客户端分配线程栈。

多线程模型: 线程栈 × N + 内核调度开销

本模型: 1 个线程 + event_base + 每连接一个小对象

9.2 上下文天然隔离

每个连接一个 socket_convalc 都是成员变量:

socket_con#1 ─ val: {"type":5,...} c: 5

socket_con#2 ─ val: {"type":3,...} c: 6

socket_con#3 ─ val: {"type":1,...} c: 7

不存在共享数据,也不需要加锁。并发安全性由架构保证。

9.3 扩展路径清晰

如果业务逻辑变重(比如加缓存、加业务规则),只需要在 socket_con 的业务函数里扩展,事件循环和网络层完全不用改

三层解耦,改一层不影响其他层。

9.4 回调参数传递统一

两个回调函数签名一致:

cpp 复制代码
void SOCK_LIS_CALLBACK(int sockfd, short ev, void *arg);
void SOCK_CON_CALLBACK (int c,      short ev, void *arg);

arg 分别指向 socket_listensocket_con,通过 void* 传递。这就是 libevent 的通用回调接口设计------任何对象只要提供回调函数,就能被纳入事件循环。


十、面试高频题

Q1:libevent 的 Reactor 模型是怎么工作的?

答:Reactor 核心是"事件循环 + 回调"。libevent 把 fd、事件类型、回调函数打包成 event,注册到 event_base。底层用 epoll/select 监听所有 fd,任一 fd 就绪时,event_base_dispatch 调用对应回调。本项目里监听 socket 的回调负责 accept,每个连接的回调负责 recv 和业务处理。

Q2:为什么每个连接要 new 一个 socket_con?

答:每条连接需要独立的上下文------自己的 fd、自己的 event、自己的请求 JSON。用对象封装这些状态,一是隔离性好 ,不同连接互不干扰;二是生命周期清晰delete this 时统一释放 fd 和 event,符合 RAII 思想。

Q3:EV_PERSIST 有什么用?

答:让事件触发后保持注册状态。不加的话事件触发一次就被移除,只能处理一条消息;加上之后可以持续监听,一条长连接可以处理多次请求,正好匹配"用户登录后连续操作"的场景。

Q4:事件驱动相比多线程,优势在哪?

答:预约系统是 IO 密集型,业务计算量小,大部分时间在等网络和数据库。事件驱动用一个线程处理所有连接,避免线程切换和栈开销,资源占用低,扩展性好。多线程模型适合 CPU 密集型任务,IO 密集场景反而浪费资源。

Q5:主函数里 event_base_dispatch 之后为什么没代码了?

答:event_base_dispatch阻塞 的,进入事件循环后不会返回,直到调用 event_base_loopbreak 或出错。之后的资源释放代码只在程序退出时执行。这就是 Reactor 的分界------初始化在主线程同步执行,之后就交给回调驱动。

十一、一句话总结

服务端的本质是"一个 event_base 驱动两级回调"------监听回调负责迎接新连接,连接回调负责处理请求,每个连接一个 socket_con 管好自己的上下文与生命周期。

相关推荐
布莱克6053 小时前
C++中的内存分区详解
c++
All for pursuit.3 小时前
【链表-9】146.LRU缓存
数据结构·c++·算法·leetcode
j7~4 小时前
【C++微服务项目开发脚手架】(接口篇一)gflags + gtest + spdlog 接口学习笔记
c++·学习·gtest·项目开发·spdlog·gflags·c++项目微服务开发脚手架
星期八不上发条4 小时前
智能指针是什么?使用场景,循环引用解决办法,面试回答
开发语言·c++·stl
ao-weilai4 小时前
Linux网络编程:Socket UDP
linux·服务器·网络·c++
.YM.Z6 小时前
C++——【继承】的底层逻辑与【多态】实现机制
c++·多态·继承
汉克老师6 小时前
GESP2026年9月认证C++三级( 第一部分选择题(1~7题)精讲
c++·gesp·小学生·学c++编程
小小龙学IT7 小时前
Qt QPainter 2D 绘图系统深度解析:从画笔到渲染引擎适
c++·qt
wabs6667 小时前
关于二叉树【力扣100.相同的树的思考】
数据结构·c++·算法·leetcode·二叉树·递归法