轻量级web并发服务器——TinyWebServer的学习了解
闪耀于终焉之枪 2024-09-09 15:03:02 阅读 58
轻量级web并发服务器——TinyWebServer的学习了解
前言TinyWebServer是什么WebServer是什么TinyWebServer是什么相关基础知识
用户如何与服务器进行通信代码架构I/O多路复用I/O模型什么是I/O多路复用I/O多路复用的三种实现方式selectpollepoll
epoll与LT/ETepoll的三大函数ET(边缘触发)模式和LT(水平触发)模式epoll的常用框架
HTTP——HTTP连接与请求响应有限状态机http模块中的主从状态机解析请求报文的解析从状态机主状态机解析请求行解析请求头解析消息体
请求报文的响应do_request函数process_write函数write函数
ThreadPool——线程池池式结构线程池线程池主要适用的场景线程池的本质及设计要点proactor模型和reactor模型Reactor模型Proactor模型Reactor模型和Proactor模型的区别
线程池的定义线程池的创建待办工作加入请求队列线程处理
CGIMysql——数据库连接池单例模式创建初始化获取、释放连接销毁连接池RAII机制释放数据库连接
Timer——定时器模块基础知识模块功能基础API信号处理机制信号的接收信号的检测信号的处理信号通知逻辑
定时器设计定时器类的定义定时器的创建与销毁添加定时任务任务超时时间调整删除定时任务定时任务处理函数
定时器的使用(webserver.cpp)
Lock——信号同步机制封装竟态信号量——sem类互斥锁——locker类条件变量——cond类锁机制的功能
Log——日志系统基础知识同步日志与异步日志的比较单例模式阻塞队列条件变量与生产者-消费者模型阻塞队列(block_queue.h)
日志类基础API日志类流程具体实现va_start和va_end
主要参考文章项目代码项目整体理解I/O多路复用http模块理解线程池模块理解数据库连接池模块理解定时器模块理解信号同步机制理解日志系统理解
前言
本文旨在学习该项目的同时对其代码、原理等内容有更深的理解,学习过程中借鉴大量网上文章,如理解存在不当之处或有所遗漏欠缺,还望各位大佬提点指教
部分图片来自网络
TinyWebServer是什么
WebServer是什么
一个WebServer指的是一个服务器程序或者运行该服务器程序的硬件,其主要功能是通过http协议与客户端(通常是浏览器)进行通信,能够接收、存储、处理来自客户端的http请求,并对其作出一定的响应,返回客户端请求的内容或返回一个Error信息
TinyWebServer是什么
TinyWebServer是一个在Linux操作系统下的轻量级web服务器,能够实现以下几种功能:
使用线程池 + 非阻塞socket + epoll + 事件处理的并发模型使用状态机解析http请求报文,支持解析POST和GET请求访问服务器数据库实现web端用户注册、登录功能,可以请求服务器图片和视频文件可以实现上万的并发连接数据交换(Webbench测试)
相关基础知识
需要对Linux编程、网络编程有一定了解
书籍推荐:《深入理解计算机系统》、《Unix网络编程》、《Linux高性能服务器编程》
项目中一些相关知识会在对应模块内提到
用户如何与服务器进行通信
用户通常使用web浏览器与服务器进行通信,web浏览器则通过将用户输入的域名解析得到对应的ip地址,通过TCP协议的三次握手建立与目标web服务器的连接,之后HTTP协议生成http请求报文发送到目标web服务器上,服务器则使用socket监听来自用户的请求。关于socket建立连接方面的内容可以通过我之前的文章进行一定的了解socket实现简单的文件传输
当服务器处理一个http请求的时候,还需要继续监听其他用户的请求并为其分配另一逻辑单元用来处理,即并发(后面会提到线程池并发)。在该项目中,服务器使用epoll这种多路I/O复用技术来实现对监听socket和连接socket的同时监听
注意:I/O复用可以同时监听多个文件描述符,但其本身是阻塞的,并且当有多个文件描述符同时就绪的时候,如不采取额外措施则程序顺序处理其中就绪的每个文件描述符
因此,为了提高效率,项目中使用了线程池来实现多线程并发,为每个就绪的文件分配一个逻辑单元(线程)来处理
代码架构
该项目中的代码架构如下
接下来我将分模块进行学习理解
I/O多路复用
I/O模型
Linux提供了五种I/O处理模型(详见《Unix网络编程》):
同步阻塞I/O
需要阻塞调用线程等待数据到来需要阻塞等待数据从内核态拷贝到用户态
若服务器端采用单线程,当accept一个请求后,在recv或send调用阻塞时,无法accept其他请求,无法处理并发
若服务器端采用多线程,当accept一个请求后,开启线程进行recv,可以完成并发处理,但随请求数增加需要增加系统线程,占用大量内存空间,且线程切换会带来很大的开销
同步非阻塞I/O
调用线程不需要等待数据到来,但需要不断查询数据到来等待线程同步需要阻塞等待数据从内核态拷贝到用户态
服务器端accept一个请求后,加入fds集合(一般为数组),每次轮询一遍fds集合recv数据(非阻塞),没有数据立即返回错误。轮询操作会浪费大量不必要的CPU资源
同步I/O多路复用
目前用的最多的I/O模型同阻塞同步I/O,但等待的是多个文件描述符的数据
服务器端对一组文件描述符进行相关事件的注册(fd列表),采用单线程通过select/poll/epoll等系统调用获取fd列表,遍历有事件的fd进行accept/recv/send,使其能支持更多的并发连接请求
信号驱动I/O
利用信号机制让调用线程不用阻塞等待数据到来需要阻塞等待数据从内核态拷贝到用户态
在网络编程中,与socket相关的读写事件太多,无法在信号对应处理函数中区分产生该信号的事件。只适合在I/O事件单一情况下使用,例如监听端口的socket
异步I/O
不需要阻塞等待数据到来,信号通知调用线程获取数据不需要阻塞等待数据拷贝,内核自动完成拷贝过程
在Linux下的异步I/O是不完善的,aio系列函数是由POSIX定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,且仅支持基于本地文件的aio异步操作,网络编程中的socket是不支持的
在windows里实现了一套完整的支持socket的异步编程接口IOCP,是由操作系统级别实现的异步I/O
综合以上几种I/O模型的优缺点,目前Linux的高性能服务器
什么是I/O多路复用
I/O多路复用是一种同步I/O模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出CPU
多路指的是网络连接复用指的是同一个线程复用
简单来说,I/O多路复用就是一种时分复用,在同一个线程中,通过类似切换开关的方式来宏观上同时传输多个I/O流
I/O多路复用的三种实现方式
select
<code>//select函数接口
#include <sys/select.h>
#include <sys/time.h>
#define FD_SETSIZE 1024
#define NFDBITS (8 * sizeof(unsigned long))
#define __FDSET_LONGS (FD_SETSIZE/NFDBITS)
// 数据结构 (bitmap)
typedef struct {
unsigned long fds_bits[__FDSET_LONGS];
} fd_set;
// API
int select(
int max_fd,
fd_set *readset,
fd_set *writeset,
fd_set *exceptset,
struct timeval *timeout
) // 返回值就绪描述符的数目
FD_ZERO(int fd, fd_set* fds) // 清空集合
FD_SET(int fd, fd_set* fds) // 将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds) // 判断指定描述符是否在集合中
FD_CLR(int fd, fd_set* fds) // 将给定的描述符从文件中删除
select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生。检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中
select将监听的文件描述符分为三组,每一组监听不同的需要进行的IO操作。readfds是需要进行读操作的文件描述符,writefds是需要进行写操作的文件描述符,exceptfds是需要进行异常事件处理的文件描述符。这三个参数可以用NULL来表示对应的事件不需要监听。当select返回时,每组文件描述符会被select过滤,只留下可以进行对应IO操作的文件描述符
select的调用会阻塞到有文件描述符可以进行IO操作或被信号打断或者超时才会返回(条件触发)
select的缺点:
select 使用固定长度的 BitsMap表示文件描述符集合,单个进程所打开的fd是有限制的,通过FD_SETSIZE设置,默认1024每次调用select,都要把fd集合从用户态拷贝到内核态,在多个fd时开销较大对socket扫描时是线性扫描,采用轮询的方式,效率较低(高并发时)
poll
#include <poll.h>
// 数据结构
struct pollfd {
int fd; // 需要监视的文件描述符
short events; // 需要内核监视的事件
short revents; // 实际发生的事件
};
// API
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
和select用三组文件描述符不同的是,poll只有一个pollfd数组,数组中的每个元素都表示一个需要监听IO操作事件的文件描述符。events参数是我们需要关心的事件,revents是所有内核监测到的事件。poll也不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长
poll的缺点:
每次调用poll,都需要把fd集合从用户态拷贝到内核态,多个fd时开销很大对socket扫描时是线性扫描,采用轮询的方式,效率较低(高并发时)
epoll
#include <sys/epoll.h>
// 数据结构
// 每一个epoll对象都有一个独立的eventpoll结构体
// 用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
// epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
struct eventpoll {
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
};
// API
int epoll_create(int size); // 内核中间加一个 ep 对象,把所有需要监听的 socket 都放到 ep 对象中
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 负责把 socket 增加、删除到内核红黑树
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// epoll_wait 负责检测可读队列,没有可读 socket 则阻塞进程
以上两种方式没有解决需要多次在用户态和内核态切换造成大量数据开销和轮询扫描socket导致效率低的问题,而epoll通过两种方面很好地解决了以上问题:
epoll在内核中使用红黑树来跟踪进程所有待检测的文件描述符,把需要监控的socket通过epoll_ctl()函数加入内核中的红黑树里,通过对红黑树进行操作,每次只需要传入一个待检测的socket,减少了内核和用户空间大量的数据拷贝和内存分配epoll使用事件驱动的机制,内核里维护了一个链表“rdlist”来记录事件,当某个socket有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要轮询扫描整个 socket 集合,大大提高了检测的效率
程序可能随时调用epoll_ctl添加监视socket,也可能随时删除。当删除时,若该socket已经存放在就绪列表中,它也应该被移除。因此就绪列表应是一种能够快速插入和删除的数据结构,epoll选择了双向链表来实现就绪队列epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的socket,至少要方便的添加和移除,还要便于搜索,以避免重复添加,epoll选择了效率较好的红黑树作为索引结构因为操作系统要兼顾多种功能,以及有更多需要保存的数据,rdlist并非直接引用socket,而是通过epitem间接引用,红黑树的节点也是epitem对象;同理,文件系统也非直接引用socket
epoll的缺点:
epoll在内核态维护文件描述符集合,每次添加文件描述符需要执行一个系统调用,在有多个短期活跃连接的情况下,epoll执行效率较低
epoll与LT/ET
epoll的三大函数
创建epoll函数
#include <sys / epoll.h>
int epoll_create(int size)
size:最大监听的fd+1
return:成功返回文件描述符fd;失败返回-1,可根据错误码判断错误类型
创建一个epoll的句柄eventpoll,会占用一个fd值,在linux下查看“/proc/进程id/fd/”能够看到该fd,因此在使用完epoll后需要调用close()关闭,否则可能导致fd耗尽
自从Linux2.6.8版本以后,size值只需要保证大于0,因为内核可以动态的分配大小,不需要size这个提示了
在linux 2.6.27中加入了epoll_create1(int flag)
flag为0时表示与epoll_create()完全一样;
flag = EPOLL_CLOEXEC,创建的epfd会设置FD_CLOEXEC;
flag = EPOLL_NONBLOCK,创建的epfd会设置为非阻塞
epoll事件的注册函数
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event)
epfd:epoll_create()返回的epoll fd
op:操作值
fd:需要监听的fd
event:需要监听的事件
return:成功返回0;失败返回-1,可根据错误码判断错误类型
epoll_ctl()中操作数op的三种操作类型
EPOLL_CTL_ADD:注册目标fd到epoll fd中,同时关联event到fd上EPOLL_CTL_MOD:修改已经注册到fd的监听事件EPOLL_CTL_DEL:从epoll fd中删除/移除已注册的fd epoll_ctl()中事件event的枚举如下:
EPOLLIN:表示关联的fd可以进行读操作EPOLLOUT:表示关联的fd可以进行写操作EPOLLRDHUP:表示socket关闭了连接(Linux2.6.17后上层只需通过EPOLLRDHUP判断对端是否关闭socket,减少一次系统调用)EPOLLPRI:表示关联的fd有紧急优先事件可以进行读操作EPOLLERR:表示关联的fd发生了错误,epoll_wait会一直等待这个事件,一般无需设置该属性EPOLLHUP:表示关联的fd挂起,epoll_wait会一直等待这个事件,一般无需设置该属性EPOLLET:设置关联的fd为ET的工作方式,epoll默认的工作方式是LTEPOLLONESHOT:设置关联的fd为one-shot的工作方式,表示只监听一次事件,如果要再次监听,则需再次把该socket放入epoll队列中
当socket接收到数据后,中断程序会给eventpoll的就绪列表“rdlist”添加socket引用,而不是直接唤醒进程
epoll等待事件函数
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
epfd:epoll描述符
events:分配好的 epoll_event结构体数组,epoll将会把发生的事件复制到 events数组中(events不可以是空指针,内核只负责把数据复制到这个 events数组中,不会去帮助我们在用户态中分配内存。内核这种做法效率很高)
maxevents:本次可以返回的最大事件数目,通常与预分配的events数组的大小是相等的
timeout:在没有检测到事件发生时最多等待的时间(单位为毫秒),如果 timeout为0,则表示 epoll_wait在 rdllist链表中为空,立刻返回,不会等待
return:成功返回需要处理的事件数目,返回0表示已超时;失败则返回-1,可以根据错误码判断错误类型
当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程
当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程通过rdlist知道哪些socket发生了变化而无需轮询socket列表
ET(边缘触发)模式和LT(水平触发)模式
边缘触发(edge-triggered)
socket的接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接收到数据时触发读事件socket的发送缓冲区状态变化时触发写事件,即满的缓冲区刚空出空间时触发读事件epoll_ctl()的events = EPOLLIN | EPOLLET 或 events = EPOLLOUT | EPOLLET 表示ET模式使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完或者遇到EAGAIN错误,需要设置socket描述符为非阻塞套接字 事件触发(level-triggered)
socket接收缓冲区不为空,有数据可读,则读事件一直触发socket发送缓冲区不满,可以继续写入数据,则写事件一直触发epoll_ctl()的events = EPOLLIN | EPOLLLT 或 events = EPOLLOUT | EPOLLLT 表示ET模式使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,epoll默认采用LT模式工作(select和poll只有LT模式) ET的处理过程
accept一个一个连接,添加到epoll中监听EPOLLIN|EPOLLOUT事件当EPOLLIN事件到达时,read fd中的数据并处理,read需要一直读,直到返回EAGAIN为止当需要写出数据时,把数据write到fd中,直到数据全部写完,或者write返回EAGAIN当EPOLLOUT事件到达时,继续把数据write到fd中,直到数据全部写完,或者write返回EAGAIN LT的处理过程
accept一个连接,添加到epoll中监听EPOLLIN事件当EPOLLIN事件到达时,read fd中的数据并处理当需要写出数据时,把数据write到fd中;如果数据较大,无法一次性写出,那么在epoll中监听EPOLLOUT事件当EPOLLOUT事件到达时,继续把数据write到fd中;如果数据写出完毕,那么在epoll中关闭EPOLLOUT事件
ET模式的要求是需要一直读写,直到返回EAGAIN,否则就会遗漏事件;LT的并不要求读写到返回EAGAIN为止,但通常会读写到返回EAGAIN,并且LT比ET多了一个开关EPOLLOUT的步骤
ET模式在某些场景下更加高效,但另一方面容易遗漏事件,容易产生bug
对于nginx这种高性能服务器,ET模式是很好的,而其他的通用网络库,很多是使用LT,避免使用的过程中出现bug
epoll的常用框架
for( ; ; )
{
nfds = epoll_wait(epfd,events,20,500);
for(i=0;i<nfds;++i)
{
if(events[i].data.fd==listenfd) //有新的连接
{
connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept这个连接
ev.data.fd=connfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的监听队列中
}
else if( events[i].events&EPOLLIN ) //接收到数据,读socket
{
n = read(sockfd, line, MAXLINE)) < 0 //读
ev.data.ptr = md; //md为自定义类型,添加数据
ev.events=EPOLLOUT|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改标识符,等待下一个循环时发送数据,异步处理的精髓
}
else if(events[i].events&EPOLLOUT) //有数据待发送,写socket
{
struct myepoll_data* md = (myepoll_data*)events[i].data.ptr; //取数据
sockfd = md->fd;
send( sockfd, md->ptr, strlen((char*)md->ptr), 0 ); //发送数据
ev.data.fd=sockfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改标识符,等待下一个循环时接收数据
}
else
{
//其他的处理
}
}
}
HTTP——HTTP连接与请求响应
在readme文档中提到,该类通过主从状态机封装了http连接类,主状态机在内部调用从状态机,从状态机将处理状态和数据传给主状态机
有限状态机
有限状态机(Finite_state machine,FSM),又称有限状态自动机,简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型,其作用主要是描述对象在它的生命周期内所经历的状态序列,以及如何响应来自外界的各种事件,在计算机科学中,有限状态机被广泛运用于建模、硬件电路系统设计、软件工程、编译器、网络协议等
有限状态机主要有三个特征:
状态总数是有限的任意时刻只处在一种状态之中某种条件下,会从一个状态转变到另一个状态
http模块中的主从状态机解析
http报文的结构如下图所示
以一个具体的报文为例
其中"POST"为请求方法,“/v3/cloudconf”为URL,“HTTP/1.1”为协议版本,之后到空行前的为请求头,空行后的为请求包体
头文件中分别定义了主状态机的三种状态和从状态机的三种状态
主状态机的状态表明当前正在处理请求报文的哪一部分
从状态机的状态表明对请求报文当前部分的处理是否出现问题
请求报文的解析
当webserver的线程池有空闲线程时,某一线程调用process()来完成请求报文的解析及响应
<code>void http_conn::process()
{
HTTP_CODE read_ret = process_read();
if (read_ret == NO_REQUEST)//表示请求不完整,需要继续接收请求数据
{
modfd(m_epollfd, m_sockfd, EPOLLIN, m_TRIGMode);//注册并监听读事件
return;
}
bool write_ret = process_write(read_ret);//调用process_write完成报文响应
if (!write_ret)
{
close_connect();
}
modfd(m_epollfd, m_sockfd, EPOLLOUT, m_TRIGMode);//注册并监听写事件
}
主状态机的状态转换使用process_read()封装,从状态机则用parse_line()封装
process_read()函数中,主状态机初始化从状态机,然后通过while循环实现主从状态机的状态转换以及循环处理报文内容。从状态机负责解析指定报文内容,并根据解析结果更改从状态机的状态;主状态机根据从状态机的返回值判断是否退出循环(终止处理/结束处理),并根据从状态机的驱动更改自身状态
主状态机与从状态机的状态转换及其关系如下图所示
从状态机
在HTTP报文中,每一行的数据由“\r”、“\n”作为结束字符,空行则是仅仅是字符“\r”、“\n”。因此,可以通过查找“\r”、“\n”将报文拆解成单独的行进行解析。从状态机负责读取buffer中的数据,将每行数据末尾的“\r”、“\n”符号改为“\0”,并更新从状态机在buffer中读取的位置m_checked_idx,以此来驱动主状态机解析
<code>//从状态机,用于分析出一行内容
//返回值为行的读取状态,有LINE_OK,LINE_BAD,LINE_OPEN
http_conn::LINE_STATUS http_conn::parse_line()
{
char temp;
for (; m_checked_idx < m_read_idx; ++m_checked_idx)
//m_read_idx指向缓冲区m_read_buf的数据末尾的下一个字节
//m_checked_idx指向从状态机目前正在分析的字节
{
temp = m_read_buf[m_checked_idx];//temp:将要分析的字节
if (temp == '\r')// \r有可能是完整行
{
if ((m_checked_idx + 1) == m_read_idx)//该行仍有内容,并未读完
return LINE_OPEN;
else if (m_read_buf[m_checked_idx + 1] == '\n')//出现换行符,说明该行读完
{
m_read_buf[m_checked_idx++] = '\0';// \r、\n都改为结束符\0
m_read_buf[m_checked_idx++] = '\0';
return LINE_OK;
}
return LINE_BAD;
}
else if (temp == '\n')
{
if (m_checked_idx > 1 && m_read_buf[m_checked_idx - 1] == '\r')//前一个字符是\r,则接收完整
{
m_read_buf[m_checked_idx - 1] = '\0';
m_read_buf[m_checked_idx++] = '\0';
return LINE_OK;
}
return LINE_BAD;
}
}
return LINE_OPEN;//未发现换行符,说明读取的行不完整
}
/*
LINE_OK:完整读取一行
LINE_BAD:报文语法有误
LINE_OPEN:读取的行不完整
*/
主状态机
主状态机初始状态为CHECK_STATE_REQUESTLINE,通过调用从状态机来驱动主状态机。在主状态机解析前,从状态机已经将每一行末尾的“\r”、“\n”符号改为“\0”,以便主状态机直接取出对应字符串进行处理
为了避免用户名和密码直接暴露在url中,项目中改用了POST请求,将用户名和密码添加在报文中作为消息体进行了封装
而在POST请求报文中,消息体的末尾没有任何字符,不能使用从状态机的状态作为主状态机的while判断条件,因此在process_read()中额外添加了使用主状态机的状态进行判断的条件
解析完消息体后,报文的完整解析就完成了,但主状态机的状态还是CHECK_STATE_CONTENT,符合循环条件会再次进入循环,因此增加了“line_status == LINE_OK”并在完成消息体解析后将该变量更改为LNE_OPEN,此时可以跳出循环完成报文解析任务
//通过while循环,封装主状态机,对每一行进行循环处理
//此时,从状态机已经修改完毕,主状态机可以取出完整的行进行解析
http_conn::HTTP_CODE http_conn::process_read()
{
LINE_STATUS line_status = LINE_OK;//初始化从状态机的状态
HTTP_CODE ret = NO_REQUEST;
char* text = 0;
//判断条件,从状态机驱动主状态机
while ((m_check_state == CHECK_STATE_CONTENT && line_status == LINE_OK) || ((line_status = parse_line()) == LINE_OK))
{
text = get_line();
m_start_line = m_checked_idx;//m_start_line:每一个数据行在m_read_buf中的起始位置
//m_checked_idx:从状态机在m_read_buf中的读取位置
LOG_INFO("%s", text);
switch (m_check_state)//三种状态转换逻辑
{
case CHECK_STATE_REQUESTLINE://正在分析请求行
{
ret = parse_request_line(text);//解析请求行
if (ret == BAD_REQUEST)
return BAD_REQUEST;
break;
}
case CHECK_STATE_HEADER://正在分析头部字段
{
ret = parse_headers(text);//解析请求头
if (ret == BAD_REQUEST)
return BAD_REQUEST;
else if (ret == GET_REQUEST)//get请求,需要跳转到报文响应函数
{
return do_request();//响应客户请求
}
break;
}
case CHECK_STATE_CONTENT://解析消息体
{
ret = parse_content(text);
if (ret == GET_REQUEST)//post请求,跳转到报文响应函数
return do_request();
line_status = LINE_OPEN;//更新,跳出循环,代表解析完了消息体
break;
}
default:
return INTERNAL_ERROR;
}
}
return NO_REQUEST;
}
解析请求行
主状态机所处状态:CHECK_STATE_REQUESTLINE解析函数从m_read_buf中解析HTTP请求行,获得请求方法、目标以及HTTP版本号解析完成后主状态机的状态变为CHECK_STATE_HEADER
//解析http请求行,获得请求方法,目标url及http版本号
http_conn::HTTP_CODE http_conn::parse_request_line(char* text)
{
m_url = strpbrk(text, " \t");//请求该行中最先含有空格和\t任一字符的位置并返回
if (!m_url)//没有目标字符,则代表报文格式有问题
{
return BAD_REQUEST;
}
*m_url++ = '\0';//将前面的数据取出,后移找到请求资源的第一个字符
char* method = text;
if (strcasecmp(method, "GET") == 0)//确定请求方式
m_method = GET;
else if (strcasecmp(method, "POST") == 0)
{
m_method = POST;
cgi = 1;
}
else
return BAD_REQUEST;
m_url += strspn(m_url, " \t");//得到url地址
m_version = strpbrk(m_url, " \t");
if (!m_version)
return BAD_REQUEST;
*m_version++ = '\0';
m_version += strspn(m_version, " \t");//得到http版本号
if (strcasecmp(m_version, "HTTP/1.1") != 0)
return BAD_REQUEST;//只接受HTTP/1.1版本
if (strncasecmp(m_url, "http://", 7) == 0)
{
m_url += 7;
m_url = strchr(m_url, '/');
}
if (strncasecmp(m_url, "https://", 8) == 0)
{
m_url += 8;
m_url = strchr(m_url, '/');
}
if (!m_url || m_url[0] != '/')//不符合规则的报文
return BAD_REQUEST;
//当url为/时,显示判断界面
if (strlen(m_url) == 1)//url为/,显示欢迎界面
strcat(m_url, "judge.html");
m_check_state = CHECK_STATE_HEADER;//主状态机状态转移
return NO_REQUEST;
}
解析请求头
主状态机所处状态:CHECK_STATE_HEADER判断是空行还是请求头
是空行,则判断content-length是否为0
不为零,则是POST请求为零,则是GET请求 是请求头,则主要分析connection、content-length等字段
connection字段判断连接类型是长连接还是短连接content-length用于读取POST请求的消息体长度
//解析http请求的一个头部信息
http_connect::HTTP_CODE http_connect::parse_headers(char* text)
{
if (text[0] == '\0')//判断是空头还是请求头
{
if (m_content_length != 0)//具体判断是get请求还是post请求
{
m_check_state = CHECK_STATE_CONTENT;//post请求需要改变主状态机的状态
return NO_REQUEST;
}
return GET_REQUEST;
}
else if (strncasecmp(text, "Connection:", 11) == 0)//解析头部连接字段
{
text += 11;
text += strspn(text, " \t");
if (strcasecmp(text, "keep-alive") == 0)//判断是否为长连接
{
m_linger = true;//为长连接,设置延迟关闭连接
}
}
else if (strncasecmp(text, "Content-length:", 15) == 0)//解析请求头的内容长度字段
{
text += 15;
text += strspn(text, " \t");
m_content_length = atol(text);//atol(const char*str):将str所指的字符串转换为一个long int的长整数
}
else if (strncasecmp(text, "Host:", 5) == 0)//解析请求头部host字段
{
text += 5;
text += strspn(text, " \t");
m_host = text;
}
else
{
LOG_INFO("oop!unknow header: %s", text);
}
return NO_REQUEST;
}
解析消息体
主状态机所处状态:CHECK_STATE_CONTENT仅用于解析POST请求用于保存POST请求消息体,为登录和注册做准备
//判断http请求是否被完整读入
http_connect::HTTP_CODE http_connect::parse_content(char* text)
{
if (m_read_idx >= (m_content_length + m_checked_idx))
{
text[m_content_length] = '\0';
//POST请求中最后为输入的用户名和密码
m_string = text;
return GET_REQUEST;
}
return NO_REQUEST;
}
请求报文的响应
在完成请求报文的解析后,明确用户想要登录/注册,需要跳转到相应的界面、添加用户名、验证用户等等,并将相应的数据写入相应报文返回给浏览器,具体流程图如下(图片取自
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。