大号在练葵花宝典 发表于 2024-7-22 10:06:24

Linux下C++轻量级WebServer服务器 框架梳理



媒介

WebServer是一个很好的入门级C++项目,因为它涉及到了方方面面,不仅可以提高编程本领,还包罗了操纵体系、计算机网络、数据库等方面的知识,所以我很保举各人去入手这个项目。说细一点这个项目包罗体系编程、日记体系、线程池、网络知识、并发模型等实现,但是很多人一开始做这个项目的时间,会以为逻辑很混乱从而无从下手,所以我写下这篇文章目的就是资助各人起到一个梳理逻辑的作用,好了废话不多说,咱们往下看!
一、下载项目、功能测试

拿到一个项目首先不要着急自己复现、也不要着急去看功能,首先我们要测试一下能不能跑成功
qinguoyi/TinyWebServer: :fire: Linux下C++轻量级WebServer服务器 (github.com)以这个项目为例
https://img-blog.csdnimg.cn/direct/d21858f2adba4d87b3d004a9d7f48db5.png
https://img-blog.csdnimg.cn/direct/5cb89e4e1cff4af88535d50cd1298de5.png
在这个过程中,可能会遇到如下这个问题:
https://img-blog.csdnimg.cn/direct/5954efc7115340b299f87c861db6962b.png
不要慌~ 作者给出了办理方案
https://img-blog.csdnimg.cn/direct/d2bc8239037f4a249006c30e724c925a.png
二、Demo演示

https://img-blog.csdnimg.cn/direct/196b51bc6f5247ada73150fbc82e40c2.png
三、该怎么去看框架?

首先辈入到main函数里,一层一层的看依赖关系,直到找到最内里的那一层,也就是最底层,然后从最底层开始,一个文件一个文件的写。我们举例来看,main函数内里,开始是个Config类的对象,这个对象用来进行设置操纵,比如对端口号、日记写入方式、触发组合模式等等的赋值,所以我们暂且不管它。接下来我们可以观察到,有一个WebServer类的对象server,而且发现server调用了一堆函数,那我们便知道末了在main函数内里全部依赖server这个对象实现,所以不难得出,WebServer类就是我们的最高层,通过最终封装一个WebServer类来实现一切功能,但是我们要找到是最底层,从最底层一层一层往上写,所以接下来我们进入WebServer类进行观察,我们知道WebServer类的重要职责就是将之前设计的各个模块串联在了一起,我们可以观察到WebServer类中有许多数据成员和成员函数,我们暂时不必要知道这些成员是具体来干什么的,只必要先知道其中的函数是给自己相关的数据成员赋值的或者实现某个功能,比如在log_write()中来决定是异步写日记还是同步写日记,sql_pool()函数是用来申请一个数据库连接池的对象,而且进行初始化等操纵,thread_pool()是用来申请一个线程池对象,并进行相关初始化操纵的。我们现在不必要知道很多信息,但是请务必先留意一下eventListen()和eventLoop()这两个函数,我们先辈入webserver.cpp中去看,留意到eventListen()是用来进行监听的,当有新客户到临的时间,将它加入监听集合,那eventLoop()是用来干什么的呢?可以看到它的循环体内里,似乎是在等候事件发生,然后再处理各种各样的事情而且不绝在循环,是的,它就是我们的主循环函数,是用来处理各种业务的。接下来既然我们是WebServer服务器项目,那什么是最重要的呢?没错当然是http剖析了,所以接下来我们看http剖析的实现,欣赏器(客户端)发起http连接请求,我们的服务器就会接收到请求,而且必要将请求的内容记录起来,末了再做出响应的回复给欣赏器,在http类中,我们要做的事情就是接收连接,处理连接(设计两个状态机,即主状态机/从状态机来进行报文剖析),响应连接,末了封装了一个运行函数(process)。那我们在接收连接,处理连接,以及做出响应处理的时间不能就一个线程来实现吧,那效率该有多低呀,所以在这一层之前我们必须先实现线程池。一切的一切必要先有用户吧,他们才能进行后续的一切请求,所以在这一层之前,必要实现数据库连接池。实在我们不难想象,我们实现的所有东西,都必要有一个记录吧,日记体系险些是每一个实际的软件项目从开发、测试到交付,再到后期的维护过程中极为重要的查看软件代码运行流程,还原错误现场,记录运行错误位置等的重要依据。所以必要先实现一个日记体系。实在无论是写日记,还是请求各种资源等的操纵都必要信号量或锁机制来实现吧,至此,我们便得到了整个项目的第一层也就是最底层,便是锁机制的实现,接下来分别是日记体系、数据库连接池、线程池、http连接处理、封装WebServer类、设置文件再到main函数,接下来我们便一层一层去分析!
第一层:锁机制的实现

在这个类中,我们必要封装信号量、锁机制以及条件变量,互斥锁用于同步线程之间对共享数据的访问,而条件变量或信号量则时用于在线程之间同步共享数据的值。
1.sem类就是我们要用来实现信号量的类,在这个类中,封装了以下操纵
在构造函数中用sem_init来初始化一个信号量,有默认和带初值两种初始化方式,而且在析构函数中销毁信号量,释放其资源。
sem_wait操纵是用来以原子操纵的方式将信号量值 - 1,若信号量值为0则阻塞
sem_post操纵是用来以原子操纵将信号量+1
2.locker类同理,在构造函数与析构函数中分别初始化以及释放资源
lock操纵是上锁,unlock操纵是解锁,get操纵时获得互斥锁的指针
3.cond类亦是如此
第二层:Log类

顾名思义,LOG类就是项目的日记体系。所谓日记,即由服务器主动创建,并记录运行状态,错误信息,访问数据的文件等。
日记的实现有两种,一种是同步写日记,一种是异步写日记
同步日记:日记写入函数与工作线程串行执行,由于涉及I/O操纵,同步日记会阻塞整个处理流程,服务器所能处理的并发本领将有所降落,尤其是在访问峰值时,写日记可能会成为体系的瓶颈
异步日记:将工作线程所写的日记内容先存入阻塞队列,写线程从阻塞队列中取出内容,写入日记
在异步日记中,每个工作线程当有日记必要处理时,将所需写的内容地点内存加入一个阻塞队列,然后就不管了。而日记体系会单独分配一个写线程,不断地从阻塞队列中获得使命并写入日记文件中。从上面地日记工作流程描述中我们可以发现,这是一个典型的生产者-斲丧者模型。其中工作线程是生产,写线程是斲丧者。那么,生产者-斲丧者模型的临界区(缓冲区)是什么呢?在我们日记体系中,这个临界区就是一个队列。 在本项目中,我们使用循环队列来实现。
写日记的操纵是通关一个对象来实现的,所以必要用单例模式来获取一个实例。
1.get_instance用来获取唯一的Log类实例
2.m_count用来记录日记行数,m_is_async用来标记是否是异步写日记,它们俩通过构造函数进行初始化,*m_fp是打开log的文件指针,它在析构函数中进行资源释放
3.flush函数用来刷新文件缓冲区
4.flush_log_thread用来进行异步写日记操纵
5.init函数是对除了构造函数初始化之外的其余数据成员进行初始化的,可选择的参数有日记文件、日记缓冲区巨细、最大行数、以及最长日记条队列
6.write_log中对日记进行了分别等级
第三层:connection_pool类与connectionRAII类

在程序设计头脑中,"池"(Pool)通常指的是一种资源管理的模式,其中资源被集中管理并通过预先分配而不是按需创建。这种模式可以用于多种类型的资源,比如:
内存池
线程池
数据库连接池
那么,我们为什么要使用池呢?我们以数据库连接池举例,来说明一下池的优点和利益。
首先,我们先来看看数据库访问的一样平常流程:
1.当体系必要访问数据库时,先体系创建数据库连接
2.接着完成数据库操纵
3.末了断开数据库连接
这非常好明白,就仿佛把大象放入冰箱必要几步一样。但是,从这个流程中我们可以看出,除了第二步,第一步和第三步都是重复且耗时的无意义工作;而且当体系必要频繁地访问数据库时,就会频繁创建和断开数据库连接,这种举动不但耗时,甚至容易对数据库造成安全隐患。因此,我们使用“池”来办理这一问题。在程序初始化时,我们就立刻创建多个数据连接,把它们集中管理。当程序必要使用时,就从“池”中取出使用,用完再放回池中,如许就制止了频繁的数据库连接和断开操纵,而且更加地安全可靠。
通过上面地先容,我们发现,实在池是一个装着资源地容器。如果池里装的是历程就是历程池,如果是线程就是线程池,而如果是数据库连接,那就是数据库连接池。具体实现池的方法有许多,比如:数组,链表,队列等。
1.static connection_pool* Getinstance()是单例模式,是用来获取数据库连接池的,也就是我们最终从这一个池中获取数据库连接
2.init和上述几个类一样,是初始化操纵
3.MYSQL *Getconnection()用来获取数据库连接
4.bool ReleaseConnection(MYSQL *con)释放数据库连接
5.int GetFreeConn()获取空余连接数量
6.void DestroyPool()销毁所有连接,也就是摧毁池
其着实这一部分文件中,我们还涉及到了一个很重要的头脑,就是RAII类
RAII(Resource Acquisition Is Initialization)(资源获取即初始化)是一种C++编程中的重要设计原则,用于管理资源的获取和释放。RAII的核心头脑是通过对象的生命周期来控制资源的生命周期,从而确保资源在合适的时间被正确地获取和释放。我们的智能指针如unique_ptr,锁lock_gurad和文件流都采取了RAII的机制。
根据上述头脑,我们单独再创建一个RAII类,这个类的唯一作用就是与数据库连接池的资源进行绑定;可以看到这个类中只有构造函数和析构函数。
如许当类创建实例时就会调用构造函数,构造函数内就会调用数据库连接函数;当类的生命周期竣事时就会调用析构函数,析构函数会调用销毁数据库函数;从而实现了资源的获取与释放与类的实例的生命周期绑定。
第四层:定时器功能

我们的体系资源是有限的,当一个客户长时间不响应的时间,我们就要关闭这个连接,把资源分配给正在使用的客户,以此来提高服务器的运行效率,我们设定定时器的目的就是让它去处理非运动连接。
client_data类是我们用户数据布局体,用来封装我们的连接资源。client_data中包罗客户端的socket地址,socket文件描述符以及定时器。
util_timer类就是我们的定时器类,内里包罗超时时间,回调函数,连接资源,前向定时器,后向定时器等。
然后我们再设计定时器容器,这里我们选择的是升序的双向链表,当然,我们也有更高级的定时器容器比如时间轮或者时间堆,上升链表类就是用来监督是否超时的。
其中最重要的是tick()函数,也称为心搏函数,就是后期的主循环中,没颠末一段时间,就调用一次tick,tick函数就会完成一定的功能,很显然它的功能就是将逾期的定时器从链表中删除。
void sort_timer_lst::tick() {
    if (!head) {
      return;
    }
    /*获取时间*/
    time_t cur = time(NULL);
    util_timer *tmp = head;
    /*遍历定时器链表*/
    while (tmp) {
      /*因定时器链表为升序,则如果当前时间小于定时器超时时间,则后面所有定时器都未到期*/
      if (cur < tmp->expire) {
            break;
      }   
      /*如果当前时间超过定时器时间,调用回调函数*/
      tmp->cb_func(tmp->user_data);
      /*设置新的头节点*/
      head = tmp->next;
      if (head) {
            head->prev = NULL;
      }
      delete tmp;
      tmp = head;
    }
} 这在个文件中,又涉及到了一种头脑吗,就是Utils(使用程序)或称抽象工具类
我们定时器的设计基本完成了,接下来我们要来设计一些方法来公道的使用它。
首先我们必要思索一个问题是:我们以前写的传统的代码运行逻辑,是从上到下一行一行按次序运行的,我们不可能提前预判一些不稳定出现的事件然后用代码处理它,我们只能是等不稳定的事件出现后,让他通过某种方式通知我,然后我在通过一些预案(即处理这些事件的代码)来处理该事件。那么这里的某种方式是什么呢?
我们通常使用信号,这里的逻辑是,我们每过一段时间就给主循环一个信号,主循环收到信号就记录下来,等其他IO事件完成之后,就调用tick()处理非运动连接。所以接下来我们的需求是怎样设置信号,怎样发送信号,以及了解一下主循环怎样接收信号。
class Utils{
public:
    Utils(){}
    ~Utils(){}

    void init(int timeslot);

    /*对文件描述符设置非阻塞*/
    int setnonblocking(int fd);
    /*将内核事件表注册读事件,ET模式,选择开启EPOLLONESHOT*/
    void addfd(int epollfd, int fd, bool one_shot, int TRIGMode);
    /*信号处理函数*/
    static void sig_handler(int sig);
    /*设置信号处理函数   这里第二个参数void(handler)(int)等价于void(*handler)(int),再作函数参数时,后者的*可以省略*/
    void addsig(int sig, void(handler)(int), bool restart = true);
    /*定时处理任务, 重新定时以不触发SIGALRM信号*/
    void timer_handler();

    void show_error(int connfd, const char *info);

public:
    static int *u_pipefd;
    sort_timer_lst m_timer_lst;
    static int u_epollfd;
    int m_TIMESLOT;
}; 第五层:线程池的设计

线程池这里的设计头脑不算难,各人通过看代码就可以明白,在这个类中,必要设计两个模块,一个是用来存放线程的数组,一个是用来存放请求的链表,其他的一些成员变量都是用来辅助创建数组和链表的,当然必须要有的还有互斥锁和信号量,剩下的就是一些操纵了。
第六层:http连接处理

在这个项目中,http很重要,我也见地到了这一部分的内容是相当地长,在http_conn这个类中,大概有以下这些内容:担当连接,处理连接(设计两个状态机,即主状态机/从状态机来进行报文剖析),响应连接,末了封装了一个运行函数(process),接下来我们对这些部分逐一解说
http_conn类中,首先映入眼帘的必须是public中的3个静态数据成员,分别是
    //设置读取文件的名称m_read_file大小
    static const int FILENAME_LEN = 200;
    //设置读缓冲区 m_read_buf大小
    static const int READ_BUFFER_SIZE = 2048;
    //设置写缓冲m_write_buf大小
    static const int WRITE_BUFFER_SIZE = 1024; 接下来是报文的请求方法
//报文的请求方法,本项目只用到GET和Post
    enum METHOD{
      GET = 0,
      POST,
      HEAD,
      PUT,
      DELETE,
      TRACE,
      OPTIONS,
      CONNECT,
      PATH
    }; 涉及到了主状态机与从状态机,从状态机用于获取报文,主状态机用于剖析报文
//主状态机的状态
    enum CHECK_STATE{
      CHECK_STATE_REQUESTLINE = 0,
      CHECK_STATE_HEADER,
      CHECK_STATE_CONTENT
    };

    //报文解析结果
    enum HTTP_CODE{
      NO_REQUEST,
      GET_REQUEST,
      BAD_REQUEST,
      NO_RESOURCE,
      FORBIDDEN_REQUEST,
      FILE_REQUEST,
      INTERNAL_ERROR,
      CLOSED_CONNECTIONS
    };

    //从状态机状态
    enum LINE_STATUS{
      LINE_OK = 0,
      LINE_BAD,
      LINE_OPEN
    }; 接下来看函数的名字就可以知道它是实现什么功能的
//初始化套接字地址,函数内部调用私有方法init
    void init(int sockfd, const sockaddr_in &addr, char *, int, int, string user, string passwd, string sqlname);
    //关闭http连接
    void close_conn(bool real_close = true);
    //最终封装的一个process函数,用于实现读写数据
    void process();
    //读浏览器端发来的全部数据
    bool read_once();
    //响应报文写入函数
    bool write();
    sockaddr_in *get_address(){
      return &m_address;
    }
    //同步线程初始化数据库读取表
    void initmysql_reslut(connection_pool *connPool);
    int timer_flag;//用于标记定时器是否超时
    int improv;//用于标记是否检查定时器
   
    //从m_read_buf读取,并处理请求报文
    HTTP_CODE process_read();
    //向m_write_buf写入响应报文
    bool process_write(HTTP_CODE ret);
    //主状态机解析请求报文中的请求行数据
    HTTP_CODE parse_request_line(char *text);
    //主状态机解析请求报文中的请求头数据
    HTTP_CODE parse_headers(char *text);
    //主状态机解析报文中的请求内容
    HTTP_CODE parse_content(char *text);
    //生成响应报文
    HTTP_CODE do_request();

    //m_start_line是已解析的字符
    //get_line用于将指针往后偏移,指向未处理的字符
    char *get_line(){
      return m_read_buf + m_start_line;
    }

    //从状态机读取一行,分析是请求报文的哪一部分
    LINE_STATUS parse_line();

    void unmap();

    //根据响应报文格式,生成对应的8个部分,以下函数均由do_request调用
    bool add_response(const char *format, ...);
    bool add_content(const char *content);
    bool add_status_line(int status, const char *title);
    bool add_headers(int content_length);
    bool add_content_type();
    bool add_content_length(int content_length);
    bool add_linger();
    bool add_blank_line(); 末了一个,我们细致看一下process函数
实现的功能是,从状态机从欣赏器读取数据,从状态机将读到的数据写给主状态机
最终层:WebServer类

最顶层的一个server封装类,将我们至今为止写的所有代码,封装到一个类里,去实现它们的使命。定义为public的函数,这些函数都是要给外部调用的,而private部分的函数内容都是封装给EventLoop单独使用的。
总结

本篇文章的目的就是帮读者大概梳理一下整个项目的框架,让读者可以明白具体怎样实现,从而可以把握整个项目的实现流程,末了自己去细看代码,也可以自己尝试一部分一部分去实现!

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: Linux下C++轻量级WebServer服务器 框架梳理