TinywebServer项目– 章节整理(16)

原项目地址(点击跳转)

博主添加注释后项目地址(点击跳转)


一、线程同步机制包装类

将线程互斥锁、条件变量、信号量通过类进行封装,保证线程同步




二、半同步半反应堆线程池

基于模板类,为连接任务构建工作线程数量一定的线程池,主线程将任务插入线程池的请求队列中,工作线程阻塞至请求队列上,竞争任务,并执行

工作线程的run函数中,会根据服务器的事件处理模式,对任务进行处理:

Reactor模式下:

  • 主线程仅负责套接字文件描述符的监听。线程池中的工作线程需要进行 IO 数据的读写,以及业务的逻辑处理

Proactor模式下:

  • 使用同步 IO 模拟Proactor模式,主线程不仅需要负责套接字文件描述符的监听,而且需要负责 IO 数据的读写。线程池工作线程仅需要进行业务的逻辑处理

Reactor模式下:

  • 若当前就绪事件为读事件,工作线程需要为连接任务,进行 IO 数据的读取,将客户端的请求报文,读取至缓冲区m_read_buf。并且进行业务的逻辑处理,对请求报文进行解析,并根据解析情况,生成响应报文至m_write_buf中,若是一个完整无错的请求,并且客户端访问的资源存在且有访问权限,则使用向量缓冲区iovec分别指向m_write_buf以及通过mmap函数对访问资源进行内存映射的地址
  • 若当前就绪事件为写事件,工作线程需要为连接任务进行 IO 数据的写操作,通过writev方法从向量缓冲区(多个缓冲区)收集数据写入通信套接字的写缓冲区发送给客户端。不需要进行额外的业务逻辑处理(没有数据需要进行分析处理)

Proactor模式下:

  • 工作线程仅需要对业务进行逻辑处理(数据分析处理),若当前就绪事件为读事件,工作线程需要对请求报文进行解析,并根据解析情况,生成响应报文至m_write_buf中,若是一个完整无错的请求,并且客户端访问的资源存在且有访问权限,则使用向量缓冲区iovec分别指向m_write_buf以及通过mmap函数对访问资源进行内存映射的地址
  • 若当前就绪事件为写事件,不需要进行额外的业务逻辑处理(没有数据需要进行分析处理)



三、HTTP连接处理

1.http报文处理流程

浏览器端发出http连接请求,主线程创建http对象接收请求,并且根据服务器事件处理模式,进行处理,若当前为同步IO模拟的Proactor模式,主线程还需要进行 IO 数据读取将请求报文读入m_read_buf中,并且将该请求对象插入线程池中的任务队列,工作线程从任务队列中取出一个任务进行处理;若当前为Reactor模式,则直接将请求对象插入线程池中的任务队列,工作线程从任务队列中取出一个任务,进行处理

工作线程取出任务之后,需要根据服务器事件处理模式,进行处理,若当前为同步IO模拟Proactor模式,工作线程则直接对主线程准备好的数据(读入至m_read_buf的数据),通过主、从状态机对请求报文进行解析;若当前为Reactor模式,工作线程则还需要进行 IO 数据读取,将请求报文读入m_read_buf中。后通过主、从状态机对请求报文进行解析

解析完成之后,完成资源文件的内存映射,并生成响应报文写入m_write_buf中,通过向量缓冲区iovec对资源文件的内存映射以及m_write_buf进行指向,并使用writev方法从向量缓冲区指向的多个缓冲区收集数据,返回给浏览器端


2.主-从状态机报文解析

使用主从状态机模式对请求报文进行解析,从状态机负责读取报文的一行,主状态机负责对该行数据进行解析,主状态机内部调用从状态机,从状态机驱动主状态机

主状态机

主状态机,根据请求报文的组成,请求行、请求头、空行、请求体(空行不为有效内容),分为三种状态,用于标识当前请求报文解析的位置

  • CHECK_STATE_REQUESTLINE,解析请求行
  • CHECK_STATE_HEADER,解析请求头
  • CHECK_STATE_CONTENT,解析消息体,仅用于解析POST请求

从状态机

从状态机,从请求报文中一行一行的读取解析,使用3种状态标识解析一行的读取状态

  • LINE_OK,完整读取一行
  • LINE_BAD,报文语法有误
  • LINE_OPEN,读取的行不完整

(1)从状态机逻辑

在HTTP报文中,每一行的数据由\r\n作为结束字符,空行则是仅仅是字符\r\n。因此,可以通过查找\r\n将报文拆解成单独的行进行解析,项目中便是利用了这一点

从状态机负责读取m_read_buf中的数据,将每行数据末尾的\r\n置为\0\0,并更新从状态机在m_read_buf中读取的位置m_checked_idx,将m_check_idx更新至下一行的开始位置,以此来驱动主状态机解析

从状态机从m_read_buf中逐字节读取,判断当前字节是否为\r

  • 接下来的字符是\n,将\r\n修改成\0\0,将m_checked_idx指向下一行的开头,则返回LINE_OK,表示读取到一个完整的行
  • 接下来达到了buffer末尾,表示buffer还需要继续接收,返回LINE_OPEN,表示行数据尚且不完整
  • 否则,表示语法错误,返回LINE_BAD

当前字节不是\r,判断是否是\n一般是上次读取到\r就到了buffer末尾,没有接收完整,再次接收时会出现这种情况

  • 如果前一个字符是\r,则将\r\n修改成\0\0,将m_checked_idx指向下一行的开头,则返回LINE_OK
  • 当前字节既不是\r,也不是\n,表示接收不完整,需要继续接收,返回LINE_OPEN

(2)主状态机逻辑

主状态机初始状态是CHECK_STATE_REQUESTLINE(请求行),通过调用从状态机来驱动主状态机,在主状态机进行解析前,从状态机(parse_line)已经将每一行的末尾\r\n符号改为\0\0,以便于主状态机直接取出对应字符串(每次从请求报文中取出一行)进行处理

在状态机中有一个标识m_start_line(初始为0),用于标识m_read_buf中一行数据的开始,主状态机可以取出m_start_line索引位置至下一个\0\0中间的内容作为请求报文的一行数据进行处理解析

当从状态机将一行末尾\r\n变为\0\0,并且主状态机取出m_start_line索引位置至\0\0中间的一行数据,就会将m_start_line通过m_check_idx进行更新,指向下一行的开始位置

解析请求行

CHECK_STATE_REQUESTLINE

  • 主状态机的初始状态,调用parse_request_line函数解析请求行
  • 解析函数从m_read_buf中解析HTTP请求行,获得请求方法、目标URLHTTP版本号
  • 解析完成后主状态机的状态变为CHECK_STATE_HEADER请求头

解析请求头

CHECK_STATE_HEADER

  • 调用parse_headers函数解析请求头部信息,以及空行信息
  • 判断是空行还是请求头(通过判断获取的一行数据的首位是否是\0
    • 若是空行,进而判断content-length是否为0,如果不是0,表明是POST请求,则状态转移到CHECK_STATE_CONTENT,否则说明是GET请求,则此时已经获得了一个完整的GET请求则报文解析结束
    • 若解析的是请求头部字段,则主要分析connection字段,content-length字段,其他字段可以直接跳过,也可以根据需求继续分析
    • connection字段判断是keep-alive还是close,决定是长连接还是短连接
    • content-length字段,这里用于读取post请求的消息体长度

解析请求体

当前请求为POST请求则还需要对请求报文的请求体部分进行解析

CHECK_STATE_CONTENT

  • 仅用于解析POST请求,调用parse_content函数解析消息体
  • 用于保存POST请求消息体,为后面的登录和注册做准备


3.功能逻辑单元d0_request

使用主-从状态机解析请求报文,若得到一个完整、正确的HTTP请求时,就需要分析目标文件的属性,如果目标文件存在、对所有用户可读,且不是目录,则使用mmap对该访问资源进行内存映射(可以把文件的某个部分直接映射到进程的地址空间中,这样可以通过指针直接访问文件内容,而无需调用读写文件的系统调用。这通常可以提高文件操作的性能,因为减少了内核与用户空间之间的数据拷贝),并告诉调用者获取文件成功



4.响应报文生成

根据主从状态机解析报文,以及功能逻辑单元的返回结果,为响应报文生成对应的信息,写入m_write_buf中,并且使用iovec向量缓冲区分别指向m_read_buf以及访问资源内存映射的地址(若访问资源存在且访问合法)



5.响应报文的发送

使用writev方法,从iovec向量缓冲区指向的缓冲区,写入数据至该http连接对应的套接字,写缓冲区,发送给浏览器端




四、定时器处理非活跃连接

1.相关概念

非活跃

  • 是指客户端(这里是浏览器)与服务器端建立连接后,长时间不交换数据,一直占用服务器端的文件描述符,导致连接资源的浪费

定时事件

  • 是指固定一段时间之后触发某段代码,由该段代码处理一个事件,如从内核事件表删除事件,并关闭文件描述符,释放连接资源

定时器

  • 是指利用结构体或其他形式,将多种定时事件进行封装起来。具体的,这里只涉及一种定时事件,即定期检测非活跃连接,这里将该定时事件与连接资源封装为一个结构体定时器

定时器容器

  • 是指使用某种容器类数据结构,将上述多个定时器组合起来,便于对定时事件统一管理。具体的,项目中使用升序链表将所有定时器串联组织起来


2.定时器定时方法

本项目中,服务器主循环accept每一个http连接之后,会为每一个http连接创建一个定时器,并对每个连接进行定时。另外,利用升序时间链表容器将所有定时器串联起来,若主循环接收到定时通知,则在链表中依次执行定时任务

项目中使用的定时方法(定时器系统工作原理):

利用alarm函数周期性地触发SIGALRM信号,信号处理函数利用管道通知主循环,主循环(使用 IO 复用系统在管道读端)接收到该信号后对升序链表上所有定时器进行处理,若该段时间内没有交换数据,则将该连接关闭,释放所占用的资源,若该端段时间内存在数据交换,则将该定时器的到期时间继续延长3个单位,并且调整该定时器在升序双向链表上的位置



3.信号通知流程

信号是软中断,Linux下的信号采用的异步处理机制,信号处理函数和当前进程是两条不同的执行路线。具体的,当进程收到信号时,操作系统会中断进程当前的正常流程,转而进入信号处理函数执行操作,完成后再返回中断的地方继续执行

为避免信号竞态现象发生,信号处理期间系统不会再次触发它。所以,为确保该信号不被屏蔽太久,信号处理函数需要尽可能快地执行完毕

这里的解决方案是,信号处理函数仅仅发送信号通知程序主循环,将信号对应的处理逻辑放在程序主循环中,由主循环执行信号对应的逻辑代码

统一事件源

  • 信号处理函数使用管道套接字将信号传递给主循环,信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出信号值,使用I/O复用系统调用来监听管道读端的可读事件,这样信号事件与其他文件描述符都可以通过epoll来监测,从而实现统一处理


4.定时器容器设计

项目中的定时器容器为带头尾结点的升序双向链表,具体的为每个连接创建一个定时器,将其添加到链表中,并按照超时时间升序排列。执行定时任务时,将到期的定时器从链表中删除

从实现上看,主要涉及双向链表的插入,删除操作,其中添加定时器的事件复杂度是O(n),删除定时器的事件复杂度是O(1)



5.定时任务处理函数

使用统一事件源,SIGALRM信号每次被触发,主循环中调用一次定时任务处理函数,处理链表容器中到期的定时器

具体逻辑,如下:

遍历定时器升序链表容器,从头结点开始依次处理每个定时器,直到遇到尚未到期的定时器

  • 因为链表是按照到期时间进行升序组织的,若当前定时器未到期,则后面的定时器更加没有到期

若当前时间小于定时器超时时间,跳出循环,即未找到到期的定时器

若当前时间大于定时器超时时间,即找到了到期的定时器,执行回调函数,然后将它从链表中删除,然后继续遍历




五、日志系统

1.相关概念

日志

  • 由服务器自动创建,并记录运行状态,错误信息,访问数据的文件

同步日志

  • 日志写入函数与工作线程串行执行,由于涉及到I/O操作,当单条日志比较大的时候,同步模式会阻塞整个处理流程,服务器所能处理的并发能力将有所下降,尤其是在峰值的时候,写日志可能成为系统的瓶颈

生产者-消费者模型

  • 并发编程中的经典模型。以多线程为例,为了实现线程间数据同步,生产者线程与消费者线程共享一个缓冲区,其中生产者线程往缓冲区中push消息,消费者线程从缓冲区中pop消息

阻塞队列

  • 将生产者-消费者模型进行封装,使用循环数组实现队列,作为两者共享的缓冲区

异步日志

  • 将所写的日志内容先存入阻塞队列,写线程从阻塞队列中取出内容,写入日志

单例模式

  • 最简单也是被问到最多的设计模式之一,保证一个类只创建一个实例,同时提供全局访问的方法


2.整体概述

本项目中,使用单例模式(懒汉式)创建日志系统,对服务器运行状态、错误信息和访问数据进行记录,该系统可以实现按天分类,超行分类功能,可以根据实际情况分别使用同步和异步写入两种方式

其中异步写入方式,将生产者-消费者模型封装为阻塞队列创建一个写线程,工作线程将要写的内容push进队列,写线程从队列中取出内容,写入日志文件

懒汉式单例模式

类的唯一实例对象直到第一次获取的时候才产生

  • 使用的是局部静态变量的懒汉式单例模式,获取实例对象的静态函数接口中,使用static创建静态的局部类对象,**c++11后这样的写法是线程安全的**


3.日志系统工作流程

获取日志类唯一实例对象,对日志系统进行初始化,按照当前时间当前年份_当前月份_当前日_日志文件名的方式生成日志文件,判断日志写入方式,异步\同步

  • 异步,则还需要创建string类型的阻塞队列,以及创建写线程用于从阻塞队列中取出日志信息,异步写入日志文件

写日志

  • 判断是否分文件,根据当前月份的日判断,若目前时间月份的日与系统中上一个日志文件记录的日不一致,则需要创建新的日志文件;当然系统中存在一个成员变量m_split_lines标识一个日志文件记录的最大行数,若当前日志文件的记录日志行数量超过m_split_lines,则需要在之前日志文件名称增加一个后缀,标识当前时间的第几份日志
  • 判断写入方式,若为异步,则将日志信息push至日志信息的阻塞队列中,之后由写线程从队列中取出内容,写入日志文件;若为同步,则将日志信息,直接写入日志文件

image-20240702160840732




六、数据库连接池

1.概念及优势

数据库连接池中的资源为一组数据库连接,由程序动态地对池中的连接进行使用,释放。数据库连接池是一种技术,主要用于管理数据库连接以提高数据库操作的效率和性能。它允许系统预先创建一定数量的数据库连接,并将这些连接存储在一个“池”中。当应用程序需要访问数据库时,它可以直接从池中取出一个现有的连接,使用完毕后再放回池中,而不是每次需要时都创建和销毁连接

主要优势

资源重用:重复使用现有的数据库连接,减少创建和销毁连接的开销

提高性能:减少数据库连接的开销可以显著提高应用程序的性能

控制并发:连接池可以限制系统中数据库连接的最大数量,避免过多的并发连接导致数据库崩溃

简化连接管理:开发者只需关心如何从连接池中获取和返回连接,无需手动管理每个数据库连接的生命周期

为什么创建数据库连接池

从一般流程中可以看出,若系统需要频繁访问数据库,则需要频繁创建和断开数据库连接,而创建数据库连接是一个很耗时的操作,也容易对数据库造成安全隐患

在程序初始化的时候,集中创建多个数据库连接,并把他们集中管理,供程序使用,可以保证较快的数据库读写速度,更加安全可靠。可以实现资源重用、提高性能、控制并发



2.数据库访问流程

连接数据库

  • 首先,需要通过数据库驱动或API建立与数据库的连接。在使用连接池的情况下,这通常意味着从连接池中获取一个可用的连接

执行查询或更新

  • 通过连接执行SQL命令。这些命令可以是数据查询(SELECT)、数据更新(INSERT、UPDATE、DELETE)或其他数据库操作(如事务控制)
  • SQL命令可以直接编写,或者通过使用预编译的查询来提高性能和安全性(例如,使用预编译的语句可以防止SQL注入攻击)

处理结果

  • 如果执行的是查询操作,需要处理返回的数据。这通常涉及从数据库获取结果集,并在应用程序中对其进行迭代,以读取单行或多行数据
  • 对于更新操作,可能需要检查操作的影响(例如,影响的行数)

关闭连接

  • 在数据访问完成后,及时关闭数据库连接是很重要的。这通常意味着将连接返回到连接池中,供后续使用
  • 确保释放资源,如结果集和数据库连接对象,以避免资源泄露和其他潜在问题

异常处理

  • 在整个数据库操作过程中,应妥善处理可能出现的异常或错误,例如连接失败、SQL错误等
  • 通常需要在代码中添加错误处理逻辑,以确保即使在出现错误的情况下,程序也能正常运行,同时保证所有资源都被正确清理

事务管理

  • 对于需要多个步骤共同完成的数据库操作,常常需要用到事务管理。事务确保这些步骤要么全部完成,要么全部不做,这是通过事务的提交(commit)和回滚(rollback)操作来控制的


3.实现

懒汉式单例模式

  • 使用局部静态变量的类对象的懒汉式单例模式,创建数据库连接池

RAII机制释放数据库连接

  • 使用RAII机制,将连接池中的数据库连接对象进行管理,当从连接池中连接队列中取出的数据库连接使用完毕,通过RAII类对象的析构函数,将使用完成的数据库连接,归还至连接池的连接队列中



七、登录注册

1.载入数据库用户名密码表

webServer 服务器初始化时,会从数据库连接池中取出一个数据库连接,从数据库中将所有以及注册的用户信息,加载到本地服务器的map中,之后用于对登录用户的校验,以及新注册用户的用户名时候已经存在的注册校验,若新用户注册成功,会更新本地服务器的map,以及数据库中的用户信息



2.注册登录流程

通过请求报文URL,/后面的第一个字符判断(对htmlaction行为设置标志位)当前是登录or注册

  • 若为登录或者注册,会先将当前POST请求,请求体中的用户名以及密码获取
  • 若为登录,则会直接使用请求体获取的用户名密码与服务器本地中的map进行校验,若map存在一致的用户名以及密码,则登录成功,浏览器端进行页面跳转至欢迎页面
  • 若为注册,则会直接使用请求体获取的用户名密码与服务器本地中的map进行校验,若map不存在一致的用户名,则注册成功,则浏览器跳转至登录页面


3.页面跳转

根据html提交表单中的action行为标志位进行判断

/

  • 首先解析,请求报文的URL,若URL中只有/,则浏览器端跳转至,判断页面,判断用户的需求,是登录账号,还是新用户注册新账号

/0

  • 解析,请求报文的URL,若URL中有/0,判断/后的第一个字符为0,则浏览器端跳转至,注册页面

/1

  • 解析,请求报文的URL,若URL中有/0,判断/后的第一个字符为0,则浏览器端跳转至,注册页面

/5

  • 解析,请求报文的URL,若URL中有/5,判断/后的第一个字符为5,则浏览器端跳转至,显示图片页面

/6

  • 解析,请求报文的URL,若URL中有/6,判断/后的第一个字符为6,则浏览器端跳转至,显示视频页面

/7

  • 解析,请求报文的URL,若URL中有/7,判断/后的第一个字符为7,则浏览器端跳转至,显示关注页面

具体的,描述了GET和POST请求下的页面跳转流程

image-20240712092745255