分析http类及请求接收
基础
epoll
epoll_create函数
#include <sys/epoll.h>
int epoll_create(int size)
创建一个指示epoll内核事件表的文件描述符,该描述符将用作其他epoll系统调用的第一个参数,size不起作用。
epoll_ctl函数
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
该函数用于操作内核事件表监控的文件描述符上的事件:注册、修改、删除
- epfd:为epoll_creat的句柄
- op:表示动作,用3个宏来表示:
-
- EPOLL_CTL_ADD (注册新的fd到epfd),
- EPOLL_CTL_MOD (修改已经注册的fd的监听事件),
- EPOLL_CTL_DEL (从epfd删除一个fd);
- event:告诉内核需要监听的事件
上述event是epoll_event结构体指针类型,表示内核所监听的事件,具体定义如下:
typedef union epoll_data {void *ptr;int fd;uint32_t u32;uint64_t u64;
} epoll_data_t;struct epoll_event {uint32_t events; /* Epoll 监视的事件类型 */epoll_data_t data; /* 用户数据 */
};
events: 事件集合
通过位掩码的方式表示不同的事件,可以同时设置多个,通过“|” 连接,可选项如下。
事件类型 | 描述 |
EPOLLIN | 文件描述符是否可读 |
EPOLLOUT | 文件描述符是否可写 |
EPOLLRDHUP | 对端关闭连接(被动),或者套接字处于半关闭状态(主动),这个事件会被触发。当使用边缘触发模式时,很方便写代码测试连接的对端是否关闭了连接 |
EPOLLPRI | 文件描述符是否异常 |
EPOLLERR | 文件描述符是否错误。如果文件描述符已经关闭,继续写入也会收到这个事件。这个事件用户不设置也会被上报 |
EPOLLHUP | 套接字被挂起,这个事件用户不设置也会被上报 |
EPOLLET | 设置epoll的触发模式为边缘触发模式。如果没有设置这个参数,epoll默认情况下是水平触发模式 |
EPOLLONESHOT | 设置添加的事件只触发一次,当epoll_wait(2)报告一次事件后,这个文件描述符后续所有的事件都不会再报告。只是禁用,文件描述符还在监视队列中,用户可以通过epoll_ctl()的EPOLL_CTL_MOD重新添加事件 |
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout)
该函数用于等待所监控文件描述符上有事件的产生,返回就绪的文件描述符个数
- events:用来存内核得到事件的集合,
- maxevents:告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,
- timeout:是超时时间
-
- -1:阻塞
- 0:立即返回,非阻塞
- >0:指定毫秒
- 返回值:成功返回有多少文件描述符就绪,时间到时返回0,出错返回-1
select/poll/epoll
- 调用函数
-
- select和poll都是一个函数,epoll是一组函数
- 文件描述符数量
-
- select通过线性表描述文件描述符集合,文件描述符有上限,一般是1024,但可以修改源码,重新编译内核,不推荐
- poll是链表描述,突破了文件描述符上限,最大可以打开文件的数目
- epoll通过红黑树描述,最大可以打开文件的数目,可以通过命令ulimit -n number修改,仅对当前终端有效
- 将文件描述符从用户传给内核
-
- select和poll通过将所有文件描述符拷贝到内核态,每次调用都需要拷贝
- epoll通过epoll_create建立一棵红黑树,通过epoll_ctl将要监听的文件描述符注册到红黑树上
- 内核判断就绪的文件描述符
-
- select和poll通过遍历文件描述符集合,判断哪个文件描述符上有事件发生
- epoll_create时,内核除了帮我们在epoll文件系统里建了个红黑树用于存储以后epoll_ctl传来的fd外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。
- epoll是根据每个fd上面的回调函数(中断函数)判断,只有发生了事件的socket才会主动的去调用 callback函数,其他空闲状态socket则不会,若是就绪事件,插入list
- 应用程序索引就绪文件描述符
-
- select/poll只返回发生了事件的文件描述符的个数,若知道是哪个发生了事件,同样需要遍历
- epoll返回的发生了事件的个数和结构体数组,结构体包含socket的信息,因此直接处理返回的数组即可
- 工作模式
-
- select和poll都只能工作在相对低效的LT模式下
- epoll则可以工作在ET高效模式,并且epoll还支持EPOLLONESHOT事件,该事件能进一步减少可读、可写和异常事件被触发的次数。
- 应用场景
-
- 当所有的fd都是活跃连接,使用epoll,需要建立文件系统,红黑书和链表对于此来说,效率反而不高,不如selece和poll
- 当监测的fd数目较小,且各个fd都比较活跃,建议使用select或者poll
- 当监测的fd数目非常大,成千上万,且单位时间只有其中的一部分fd处于就绪状态,这个时候使用epoll能够明显提升性能
ET、LT、EPOLLONESHOT
- LT水平触发模式
-
- epoll_wait检测到文件描述符有事件发生,则将其通知给应用程序,应用程序可以不立即处理该事件。
- 当下一次调用epoll_wait时,epoll_wait还会再次向应用程序报告此事件,直至被处理
- ET边缘触发模式
-
- epoll_wait检测到文件描述符有事件发生,则将其通知给应用程序,应用程序必须立即处理该事件
- 必须要一次性将数据读取完,使用非阻塞I/O,读取到出现eagain
- EPOLLONESHOT
-
- 一个线程读取某个socket上的数据后开始处理数据,在处理过程中该socket上又有新数据可读,此时另一个线程被唤醒读取,此时出现两个线程处理同一个socket
- 我们期望的是一个socket连接在任一时刻都只被一个线程处理,通过epoll_ctl对该文件描述符注册epolloneshot事件,一个线程处理socket时,其他线程将无法处理,当该线程处理完后,需要通过epoll_ctl重置epolloneshot事件
HTTP报文格式
HTTP报文分为请求报文和响应报文两种,每种报文必须按照特有格式生成,才能被浏览器端识别。
其中,浏览器端向服务器发送的为请求报文,服务器处理后返回给浏览器端的为响应报文。
请求报文
HTTP请求报文由请求行(request line)、请求头部(header)、空行和请求数据四个部分组成。
其中,请求分为两种,GET和POST,具体的:
- GET
1 GET /562f25980001b1b106000338.jpg HTTP/1.12 Host:img.mukewang.com3 User-Agent:Mozilla/5.0 (Windows NT 10.0; WOW64)4 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.365 Accept:image/webp,image/*,*/*;q=0.86 Referer:http://www.imooc.com/7 Accept-Encoding:gzip, deflate, sdch8 Accept-Language:zh-CN,zh;q=0.89 空行
10 请求数据为空
- POST
1 POST / HTTP1.1
2 Host:www.wrox.com
3 User-Agent:Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)
4 Content-Type:application/x-www-form-urlencoded
5 Content-Length:40
6 Connection: Keep-Alive
7 空行
8 name=Professional%20Ajax&publisher=Wiley
- 请求行,用来说明请求类型,要访问的资源以及所使用的HTTP版本。
GET说明请求类型为GET,/562f25980001b1b106000338.jpg(URL)为要访问的资源,该行的最后一部分说明使用的是HTTP1.1版本。 - 请求头部,紧接着请求行(即第一行)之后的部分,用来说明服务器要使用的附加信息。
-
- HOST,给出请求资源所在服务器的域名。
- User-Agent,HTTP客户端程序的信息,该信息由你发出请求使用的浏览器来定义,并且在每个请求中自动发送等。
- Accept,说明用户代理可处理的媒体类型。
- Accept-Encoding,说明用户代理支持的内容编码。
- Accept-Language,说明用户代理能够处理的自然语言集。
- Content-Type,说明实现主体的媒体类型。
- Content-Length,说明实现主体的大小。
- Connection,连接管理,可以是Keep-Alive或close。
- 空行,请求头部后面的空行是必须的即使第四部分的请求数据为空,也必须有空行。
- 请求数据也叫主体,可以添加任意的其他数据。
响应报文
HTTP响应也由四个部分组成,分别是:状态行、消息报头、空行和响应正文。
1HTTP/1.1 200 OK2Date: Fri, 22 May 2009 06:07:21 GMT3Content-Type: text/html; charset=UTF-84空行5<html>6 <head></head>7 <body>8 <!--body goes here-->9 </body>
10</html>
- 状态行,由HTTP协议版本号, 状态码, 状态消息 三部分组成。
第一行为状态行,(HTTP/1.1)表明HTTP版本为1.1版本,状态码为200,状态消息为OK。 - 消息报头,用来说明客户端要使用的一些附加信息。
第二行和第三行为消息报头,Date:生成响应的日期和时间;Content-Type:指定了MIME类型的HTML(text/html),编码类型是UTF-8。 - 空行,消息报头后面的空行是必须的。
- 响应正文,服务器返回给客户端的文本信息。空行后面的html部分为响应正文。
HTTP状态码
HTTP有5种类型的状态码,具体的:
- 1xx:指示信息--表示请求已接收,继续处理。
- 2xx:成功--表示请求正常处理完毕。
-
- 200 OK:客户端请求被正常处理。
- 206 Partial content:客户端进行了范围请求。
- 3xx:重定向--要完成请求必须进行更进一步的操作。
-
- 301 Moved Permanently:永久重定向,该资源已被永久移动到新位置,将来任何对该资源的访问都要使用本响应返回的若干个URI之一。
- 302 Found:临时重定向,请求的资源现在临时从不同的URI中获得。
- 4xx:客户端错误--请求有语法错误,服务器无法处理请求。
-
- 400 Bad Request:请求报文存在语法错误。
- 403 Forbidden:请求被服务器拒绝。
- 404 Not Found:请求不存在,服务器上找不到请求的资源。
- 5xx:服务器端错误--服务器处理请求出错。
-
- 500 Internal Server Error:服务器在执行请求时出现错误。
有限状态机
有限状态机,是一种抽象的理论模型,它能够把有限个变量描述的状态变化过程,以可构造可验证的方式呈现出来。比如,封闭的有向图。
有限状态机可以通过if-else,switch-case和函数指针来实现,从软件工程的角度看,主要是为了封装逻辑。
带有状态转移的有限状态机示例代码。
1STATE_MACHINE(){2 State cur_State = type_A;3 while(cur_State != type_C){4 Package _pack = getNewPackage();5 switch(){6 case type_A:7 process_pkg_state_A(_pack);8 cur_State = type_B;9 break;
10 case type_B:
11 process_pkg_state_B(_pack);
12 cur_State = type_C;
13 break;
14 }
15 }
16}
该状态机包含三种状态:type_A,type_B和type_C。其中,type_A是初始状态,type_C是结束状态。
状态机的当前状态记录在cur_State变量中,逻辑处理时,状态机先通过getNewPackage获取数据包,然后根据当前状态对数据进行处理,处理完后,状态机通过改变cur_State完成状态转移。
有限状态机一种逻辑单元内部的一种高效编程方法,在服务器编程中,服务器可以根据不同状态或者消息类型进行相应的处理逻辑,使得程序逻辑清晰易懂。
http处理流程
首先对http报文处理的流程进行简要介绍,然后具体介绍http类的定义和服务器接收http请求的具体过程。
http报文处理流程
- 浏览器端发出http连接请求,主线程创建http对象接收请求并将所有数据读入对应buffer,将该对象插入任务队列,工作线程从任务队列中取出一个任务进行处理。
- 工作线程取出任务后,调用process_read函数,通过主、从状态机对请求报文进行解析。
- 解析完之后,跳转do_request函数生成响应报文,通过process_write写入buffer,返回给浏览器端。
http类
主要是http类的定义。
stat结构体
stat
结构体是用于获取文件或文件系统信息的一个结构体,在大多数基于Unix的操作系统中,如Linux。stat
结构体包含了文件的详细信息,包括文件的属性、大小、时间戳等。以下是 stat
结构体中包含的主要字段信息:
struct stat {dev_t st_dev; // 文件所属设备的IDino_t st_ino; // 文件的节点号(inode number)mode_t st_mode; // 文件的类型和权限nlink_t st_nlink; // 硬链接的数量uid_t st_uid; // 文件所有者的用户IDgid_t st_gid; // 文件所有者的组IDdev_t st_rdev; // 设备ID(如果是特殊设备)off_t st_size; // 文件的总大小,以字节为单位blksize_t st_blksize; // 文件系统I/O的块大小blkcnt_t st_blocks; // 分配给文件的块数time_t st_atime; // 文件最后一次访问的时间(access time)time_t st_mtime; // 文件最后一次修改的时间(modification time)time_t st_ctime; // 文件最后一次状态更改的时间(change time)
};
在http请求接收部分,会涉及到init和read_once函数,但init仅仅是对私有成员变量进行初始化
// 初始化HTTP连接对象
void http_conn::init() {// 将MySQL连接设置为nullptr,确保没有数据库连接mysql = nullptr;// 初始化发送缓冲区字节数bytes_to_send= 0;// 初始化已发送字节数bytes_have_send = 0;// 设置请求解析状态为请求行检测m_check_state = CHECK_STATE_REQUESTLINE;// 是否允许linger的标志位,初始化为falsem_linger = false;// 请求方法默认为GETm_method = GET;// 请求URL和版本号初始化m_url = 0;m_version = 0;// 内容长度、主机、起始行、检查索引、读索引、写索引和CGI标志初始化m_content_length = 0;m_host = 0;m_start_line = 0;m_checked_idx = 0;m_read_idx = 0;m_write_idx = 0;cgi = 0;// 状态和定时器标志初始化m_state = 0;timer_flag = 0;// 优化标志初始化improv = 0;// 清空读缓冲区memset(m_read_buf, '\0', READ_BUFFER_SIZE);// 清空写缓冲区memset(m_write_buf, '\0', WRITE_BUFFER_SIZE);// 清空文件名缓存区memset(m_real_file, '\0', FILENAME_LEN);
}
这里,对read_once进行介绍。read_once读取浏览器端发送来的请求报文,直到无数据可读或对方关闭连接,读取到m_read_buffer中,并更新m_read_idx。
/*** @brief 从客户端套接字读取数据* * 该函数根据m_TRIGMode的值选择使用LT(水平触发)或ET(边缘触发)模式读取数据。* 在LT模式下,一旦recv函数返回就结束读取操作。* 在ET模式下,会持续调用recv直到recv返回EAGAIN或EWOULDBLOCK错误,表示没有更多数据可读。* * @return true 成功读取到数据或全部数据读取完毕* @return false 发生错误或连接关闭*/
bool http_conn::read_once() {// 检查读缓冲区是否已满if (m_read_idx >= READ_BUFFER_SIZE) {return false;}int bytes_read = 0;// LT模式读取数据if (0 == m_TRIGMode) {bytes_read = recv(m_sockfd, m_read_buf + m_read_idx, READ_BUFFER_SIZE - m_read_idx, 0);m_read_idx += bytes_read;// 如果recv返回非正数,视为错误或连接关闭if (bytes_read <= 0) {return false;}return true;}// ET模式读取数据else {while (true) {bytes_read = recv(m_sockfd, m_read_buf + m_read_idx, READ_BUFFER_SIZE - m_read_idx, 0);// 如果recv返回-1且错误码为EAGAIN或EWOULDBLOCK,表示没有更多数据可读if (bytes_read == -1) {if (errno == EAGAIN || errno == EWOULDBLOCK) {break;}return false;}// 如果recv返回0,视为连接关闭else if (bytes_read == 0) {return false;}m_read_idx += bytes_read;}return true;}
}
epoll相关代码
项目中epoll相关代码部分包括非阻塞模式、内核事件表注册事件、删除事件、重置EPOLLONESHOT事件四种。
- 非阻塞模式
/*** 设置文件描述符为非阻塞模式* * 此函数的目的是将给定文件描述符(fd)的模式从阻塞改为非阻塞* 在非阻塞模式下,I/O 操作不会因为等待数据而阻塞当前线程* * @param fd 要设置为非阻塞模式的文件描述符* @return 返回更改前的文件描述符的阻塞选项*/
int setnonblocking(int fd) {// 获取文件描述符的当前状态int old_option = fcntl(fd, F_GETFL);// 新选项是在原有选项上加上非阻塞标志int new_option = old_option | O_NONBLOCK;// 设置文件描述符为非阻塞模式fcntl(fd, F_SETFL, new_option);// 返回更改前的阻塞选项return old_option;
}
内核事件表注册新事件,开启EPOLLONESHOT,针对客户端连接的描述符,listenfd不用开启
- 内核事件表删除事件
/*** 从epoll句柄中移除文件描述符fd,并关闭该fd。* * @param epollfd epoll创建的文件描述符。* @param fd 需要移除的文件描述符。* * 该函数首先使用epoll_ctl函数将指定的文件描述符从epoll监控表中移除,* 然后关闭该文件描述符。这一操作通常用于不再需要监控的文件描述符,* 以减少系统资源的占用并更新epoll监控列表。*/
void removefd(int epollfd, int fd) {epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, 0);close(fd);
}
重置EPOLLONESHOT事件
//将事件重置为EPOLLONESHOT
/*** 修改文件描述符在epoll中的事件类型* * @param epollfd epoll文件描述符* @param fd 需要修改的文件描述符* @param ev 新的事件类型* @param TRIGMode 触发模式,1为边缘触发模式,否则为水平触发模式* * 此函数通过epoll_ctl函数修改文件描述符fd在epoll中的事件类型根据TRIGMode的不同,* 设置不同的事件类型如果TRIGMode为1,将事件类型设置为边缘触发模式(EPOLLET),* 否则使用默认的水平触发模式在两种模式下,都会设置事件为一次性(EPOLLONESHOT)和关闭远程挂起(EPOLLRDHUP)*/
void modfd(int epollfd, int fd, int ev, int TRIGMode) {epoll_event event;event.data.fd = fd;if (1 == TRIGMode) {event.events = ev | EPOLLET | EPOLLONESHOT | EPOLLRDHUP;}else {event.events = ev | EPOLLONESHOT | EPOLLRDHUP;}epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &event);
}
服务器接收http请求
浏览器端发出http连接请求,主线程创建http对象接收请求并将所有数据读入对应buffer,将该对象插入任务队列,工作线程从任务队列中取出一个任务进行处理。
// 事件循环函数,处理所有事件,包括新客户端连接、读写事件等
void WebServer::eventLoop() {// 用于标识是否超时,用于定时器处理bool timeout = false;// 用于标识是否停止服务器bool stop_server = false;// 主循环,不断轮询和处理事件,直到stop_server为truewhile (!stop_server) {// 调用epoll_wait等待事件发生,m_epollfd为epoll句柄,events为事件数组,MAX_EVENT_NUMBER为数组大小,-1表示不超时int number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);// 如果epoll_wait返回值小于0且不是因为中断引起,则视为epoll出错if (number < 0 && errno != EINTR ) {LOG_ERROR("%s", "epoll failure");break;}// 遍历发生的事件数组,处理每一个事件for (int i = 0; i < number; i++) {int sockfd = events[i].data.fd;// 如果事件对应的socket为监听socket,则有新的客户端连接请求if (sockfd == m_listenfd) {bool flag = dealclientdata();// 如果处理客户端数据失败,则跳过当前循环,继续等待其他事件if (false == flag) continue;}// 如果事件为挂起读、连接关闭或错误,则处理对应的定时器else if(events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) {util_timer *timer = users_timer[sockfd].timer;deal_timer(timer, sockfd);}// 如果事件对应的socket为管道的读端且有读事件,则处理信号else if((sockfd == m_pipefd[0]) && (events[i].events & EPOLLIN)) {bool flag = dealwithsignal(timeout, stop_server);// 如果处理信号失败,则记录错误日志if (false == flag) {LOG_ERROR("%s", "deal client data failure");}}// 如果事件为读事件,则处理读操作else if (events[i].events & EPOLLIN) {dealwithread(sockfd);}// 如果事件为写事件,则处理写操作else if(events[i].events & EPOLLOUT) {dealwithwrite(sockfd);}}// 如果有超时发生,则处理定时器,并记录信息if (timeout) {utils.timer_handler();LOG_INFO("%s", "timer tick");timeout = false;}}
}
上篇,我们对http连接的基础知识、服务器接收请求的处理流程进行了介绍,本篇将结合流程图和代码分别对状态机和服务器解析请求报文进行详解。
状态机和服务器解析请求报文
流程图部分,描述主、从状态机调用关系与状态转移过程。
代码部分,结合代码对http请求报文的解析进行详解。
流程图与状态机
从状态机负责读取报文的一行,主状态机负责对该行数据进行解析,主状态机内部调用从状态机,从状态机驱动主状态机。
主状态机
三种状态,标识解析位置。
- CHECK_STATE_REQUESTLINE,解析请求行
- CHECK_STATE_HEADER,解析请求头
- CHECK_STATE_CONTENT,解析消息体,仅用于解析POST请求
从状态机
三种状态,标识解析一行的读取状态。
- LINE_OK,完整读取一行
- LINE_BAD,报文语法有误
- LINE_OPEN,读取的行不完整
代码分析-http报文解析
上篇中介绍了服务器接收http请求的流程与细节,简单来讲,浏览器端发出http连接请求,服务器端主线程创建http对象接收请求并将所有数据读入对应buffer,将该对象插入任务队列后,工作线程从任务队列中取出一个任务进行处理。
各子线程通过process函数对任务进行处理,调用process_read函数和process_write函数分别完成报文解析与报文响应两个任务。
// 处理HTTP请求的主函数
// 该函数负责整体控制HTTP请求的读取和写入过程
void http_conn::process() {// 尝试读取HTTP请求,并返回读取状态HTTP_CODE read_ret = process_read();// 如果请求信息不完整或未准备好,不需要立即处理if (read_ret == NO_REQUEST) {// 调整epoll监听模式为读事件,等待更多数据到来modfd(m_epollfd, m_sockfd, EPOLLIN, m_TRIGMode);return;}// 处理写入HTTP响应,并返回写入状态bool write_ret = process_write(read_ret);// 如果写入失败,关闭连接if (!write_ret) {close_conn();}// 调整epoll监听模式为写事件,等待数据写入modfd(m_epollfd, m_sockfd, EPOLLOUT, m_TRIGMode);
}
HTTP_CODE含义
表示HTTP请求的处理结果,在头文件中初始化了八种情形
// 定义HTTP请求的可能状态码
enum HTTP_CODE {NO_REQUEST, // 尚未收到请求GET_REQUEST, // 成功接收GET请求BAD_REQUEST, // 请求语法错误,无法解析NO_REQUEST, // 重复定义,可能是错误?FORBIDDEN_REQUEST, // 请求资源禁止访问FILE_REQUEST, // 请求的资源为文件INTERNAL_ERROR, // 服务器内部错误CLOSED_CONNECTION // 连接已关闭
};
在报文解析时只涉及到四种。
- NO_REQUEST
-
- 请求不完整,需要继续读取请求报文数据
- GET_REQUEST
-
- 获得了完整的HTTP请求
- BAD_REQUEST
-
- HTTP请求报文有语法错误
- INTERNAL_ERROR
-
- 服务器内部错误,该结果在主状态机逻辑switch的default下,一般不会触发
解析报文整体流程
process_read通过while循环,将主从状态机进行封装,对报文的每一行进行循环处理。
- 判断条件
-
- 主状态机转移到CHECK_STATE_CONTENT,该条件涉及解析消息体
- 从状态机转移到LINE_OK,该条件涉及解析请求行和请求头部
- 两者为或关系,当条件为真则继续循环,否则退出
- 循环体
-
- 从状态机读取数据
- 调用get_line函数,通过m_start_line将从状态机读取数据间接赋给text
- 主状态机解析text
http_conn::HTTP_CODE http_conn::process_read(){// 初始化行状态为正常LINE_STATUS line_status = LINE_OK;// 初始化返回值为未接收到请求HTTP_CODE ret = NO_REQUEST;// 定义一个字符指针用于存储读取的文本行char* text= 0;// ● 判断条件
// ○ 主状态机转移到CHECK_STATE_CONTENT,该条件涉及解析消息体
// ○ 从状态机转移到LINE_OK,该条件涉及解析请求行和请求头部
// ○ 两者为或关系,当条件为真则继续循环,否则退出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;// 输出日志信息,打印解析的文本行LOG_INFO("%s", text);// 根据当前的解析状态,分别处理不同的HTTP请求部分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) {return do_request();}break;}// 解析请求内容case CHECK_STATE_CONTENT: {// 解析请求内容文本,返回解析结果ret = parse_content(text);// 如果解析出错,返回错误if (ret == BAD_REQUEST) return BAD_REQUEST;// 保持行状态为打开,继续解析内容line_status = LINE_OPEN;break;}// 默认情况下,返回内部错误default:return INTERNAL_ERROR;}}// 如果解析未完成,返回未请求状态return NO_REQUEST;
}
从状态机逻辑
在HTTP报文中,每一行的数据由\r\n作为结束字符,空行则是仅仅是字符\r\n。因此,可以通过查找\r\n将报文拆解成单独的行进行解析,项目中便是利用了这一点。
从状态机负责读取buffer中的数据,将每行数据末尾的\r\n置为\0\0,并更新从状态机在buffer中读取的位置m_checked_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
/*** 解析一行数据* * 本函数负责解析接收缓冲区中的数据,以判断是否成功接收到一行完整的数据* 主要处理以下几种情况:* 1. 行数据完整且以'\r'\n'结束* 2. 行数据只有'\n'没有'\r'* 3. 数据未接收完,需要继续接收* * @return 返回解析状态,可能为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 ) {temp = m_read_buf[m_checked_idx];// 如果找到'\r',需要检查接下来是否是'\n'if (temp == '\r') {// 如果是文件结束符,返回LINE_OPENif ((m_checked_idx + 1) == m_read_idx) return LINE_OPEN;// 如果接下来是'\n',则正确结束这一行else if (m_read_buf[m_checked_idx + 1] == '\n') {// 将行结束符置为字符串结束符,连续两个行结束符都需处理m_read_buf[m_checked_idx++] = '\0';m_read_buf[m_checked_idx++] = '\0';return LINE_OK;}// 单独的'\r'被认为是错误的return LINE_BAD;}// 如果找到单独的'\n',需要检查上一个是否应该是'\r'else if (temp == '\n') {// 如果前一个是'\r',则正确结束这一行if (m_checked_idx > 1 && m_read_buf[m_checked_idx - 1] == 'r') {// 将行结束符置为字符串结束符,并跳过'\n'm_read_buf[m_checked_idx - 1] = '\0';m_read_buf[m_checked_idx ++] = '\0';return LINE_OK;}// 单独的'\n'被认为是错误的return LINE_BAD;}}// 数据未接收完,需要继续接收return LINE_OPEN;
}
主状态机逻辑
主状态机初始状态是CHECK_STATE_REQUESTLINE,通过调用从状态机来驱动主状态机,在主状态机进行解析前,从状态机已经将每一行的末尾\r\n符号改为\0\0,以便于主状态机直接取出对应字符串进行处理。
- CHECK_STATE_REQUESTLINE
-
- 主状态机的初始状态,调用parse_request_line函数解析请求行
- 解析函数从m_read_buf中解析HTTP请求行,获得请求方法、目标URL及HTTP版本号
- 解析完成后主状态机的状态变为CHECK_STATE_HEADER
/*** 解析请求行* * @param text 请求行的文本* @return 请求的处理状态* * 请求行的格式为:方法 URL HTTP版本* 该函数解析传入的请求行文本,提取并验证方法、URL和HTTP版本* 如果解析过程中遇到不符合HTTP规范的请求,返回BAD_REQUEST*/
http_conn::HTTP_CODE http_conn::parse_request_line(char* text) {// 提取URLm_url = strpbrk(text, " \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;// 跳过URL中的空白字符m_url += strspn(m_url, " \t");// 提取HTTP版本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;// 移除URL中的协议部分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, '/');}// 验证URL格式if (!m_url || m_url[0] != '/')return BAD_REQUEST;// 当URL为根路径时,设置默认资源if (strlen(m_url) == 1)strcat(m_url, "judge.html");// 切换到解析头部的状态m_check_state = CHECK_STATE_HEADER;return NO_REQUEST;
}
让我们逐行分析这段代码,并为每个部分提供详细的解释和示例。
http_conn::HTTP_CODE http_conn::parse_request_line(char* text) {
这行代码定义了一个名为 parse_request_line
的函数,属于 http_conn
类。它接收一个 char*
类型的参数 text
,该参数表示请求行的文本。函数的返回类型是 http_conn::HTTP_CODE
,表示解析请求行后的处理状态。
m_url = strpbrk(text, " \t");if (!m_url) {return BAD_REQUEST;}*m_url++ = '\0';
strpbrk(text, " \t")
用于查找text
中第一个空格或制表符的位置,并返回指向它的指针。这个指针即为m_url
。- 如果没有找到空格或制表符(意味着请求行格式不正确),函数会返回
BAD_REQUEST
。 *m_url++ = '\0';
将空格或制表符替换为字符串结束符\0
,然后将m_url
移动到下一个字符,指向 URL 的起始位置。
示例:
- 输入
"GET /index.html HTTP/1.1"
,m_url
将指向" /index.html HTTP/1.1"
中的第一个空格,并通过*m_url++ = '\0'
将"GET"
与后面的部分分割开,m_url
现在指向"/index.html HTTP/1.1"
。
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;
char* method = text;
保存方法字符串的起始位置。strcasecmp
忽略大小写地比较method
与"GET"
或"POST"
,并根据结果设置m_method
(表示请求方法)为GET
或POST
。- 如果方法既不是
GET
也不是POST
,则返回BAD_REQUEST
。
示例:
- 输入
"GET /index.html HTTP/1.1"
,method
是"GET"
,所以m_method
被设置为GET
。 - 如果输入
"DELETE /index.html HTTP/1.1"
,则会返回BAD_REQUEST
。
m_url += strspn(m_url, " \t");
strspn(m_url, " \t")
计算m_url
中连续出现的空白字符的数量,m_url +=
会跳过这些空白字符,使得m_url
指向实际的 URL 开始位置。
示例:
- 输入
"GET /index.html HTTP/1.1"
,此时m_url
会跳过额外的空格,指向"/index.html"
。
m_version = strpbrk(m_url, " \t");if (!m_version) return BAD_REQUEST;*m_version += '\0';m_version += strspn(m_version, " \t");
strpbrk(m_url, " \t")
查找 URL 后的第一个空格或制表符的位置,并将其指针保存为m_version
。- 如果没有找到空格或制表符,返回
BAD_REQUEST
。 - 将找到的空格或制表符替换为字符串结束符
\0
,然后跳过任何多余的空格,使m_version
指向 HTTP 版本号的开始位置。
示例:
- 输入
"GET /index.html HTTP/1.1"
,m_version
指向"HTTP/1.1"
。
if (strcasecmp(m_version, "HTTP/1.1") != 0)return BAD_REQUEST;
strcasecmp
忽略大小写地比较m_version
与"HTTP/1.1"
。如果版本不是"HTTP/1.1"
,返回BAD_REQUEST
。
示例:
- 输入
"GET /index.html HTTP/1.1"
,这行将成功通过。 - 输入
"GET /index.html HTTP/2.0"
,会返回BAD_REQUEST
。
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, '/');}
- 这些代码用于移除
http://
或https://
前缀,使m_url
只包含路径部分。 strchr(m_url, '/')
查找路径的开始位置(第一个斜杠)。
示例:
- 输入
"GET http://example.com/index.html HTTP/1.1"
,m_url
将变为"/index.html"
。
if (!m_url || m_url[0] != '/')return BAD_REQUEST;
- 检查
m_url
是否为空或者第一个字符是否为/
。如果不是,则返回BAD_REQUEST
。
示例:
- 输入
"GET http://example.com/index.html HTTP/1.1"
,将通过此检查。 - 输入
"GET example.com/index.html HTTP/1.1"
,将返回BAD_REQUEST
。
if (strlen(m_url) == 1)strcat(m_url, "judge.html");
- 如果
m_url
只包含一个字符/
,则将默认路径设置为judge.html
。
示例:
- 输入
"GET / HTTP/1.1"
,将把m_url
设置为"/judge.html"
。
m_check_state = CHECK_STATE_HEADER;return NO_REQUEST;
- 解析完请求行后,设置
m_check_state
为CHECK_STATE_HEADER
,表示接下来将解析请求头部。 - 返回
NO_REQUEST
,表示解析成功,等待进一步的处理。
总结:
这段代码用于解析 HTTP 请求行,通过逐步分割并验证请求行的不同部分(方法、URL、HTTP版本)来确定其合法性。
解析完请求行后,主状态机继续分析请求头。在报文中,请求头和空行的处理使用的同一个函数,这里通过判断当前的text首位是不是\0字符,若是,则表示当前处理的是空行,若不是,则表示当前处理的是请求头。
- CHECK_STATE_HEADER
-
- 调用parse_headers函数解析请求头部信息
- 判断是空行还是请求头,若是空行,进而判断content-length是否为0,如果不是0,表明是POST请求,则状态转移到CHECK_STATE_CONTENT,否则说明是GET请求,则报文解析结束。
- 若解析的是请求头部字段,则主要分析connection字段,content-length字段,其他字段可以直接跳过,各位也可以根据需求继续分析。
- connection字段判断是keep-alive还是close,决定是长连接还是短连接
- content-length字段,这里用于读取post请求的消息体长度
/*** 解析HTTP请求的头部信息* * @param text 指向接收缓冲区中当前解析的位置* @return 返回HTTP请求的状态代码,表示请求的状态* * 功能描述:* 本函数旨在解析HTTP请求的头部信息。根据传入的文本参数,识别不同的HTTP头部字段,* 并在解析完成后更新类的内部状态。特别地,函数会识别内容长度、连接类型和主机信息,* 并对持久连接作出处理。* * 注意事项:* 函数返回NO_REQUEST表示请求消息尚未解析完成,需要继续解析更多的数据;返回GET_REQUEST* 表示请求消息已经完全解析成功。*/
http_conn::HTTP_CODE http_conn::parse_headers(char* text) {// 如果text为空字符串,表示头部信息解析完毕if (text[0] == '\0') {// 如果内容长度不为0,下一步进入内容解析状态if (m_content_length != 0) {m_check_state = CHECK_STATE_CONTENT;return NO_REQUEST;}// 如果内容长度为0,表示请求头部已经完全解析,可以处理请求return GET_REQUEST;}else if (strncasecmp(text, "Connection:", 11) == 0) {// 解析连接类型头部,跳过头部名称text += 11;// 跳过头部值前的空白字符text += strspn(text, " \t");// 如果连接类型为keep-alive,设置m_linger为true,表示需要保持连接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);}else if (strncasecmp(text, "Host:", 5) == 0) {// 解析主机头部,跳过头部名称text += 5;// 跳过头部值前的空白字符text += strspn(text, " \t");// 设置主机信息m_host = text;}else {// 遇到未知的头部信息,记录日志LOG_INFO("oop!unkonw header: %s", text);}// 表示请求消息尚未解析完成,需要继续解析更多的数据return NO_REQUEST;
}
如果仅仅是GET请求,如项目中的欢迎界面,那么主状态机只设置之前的两个状态足矣。
因为在上篇推文中我们曾说道,GET和POST请求报文的区别之一是有无消息体部分,GET请求没有消息体,当解析完空行之后,便完成了报文的解析。
但后续的登录和注册功能,为了避免将用户名和密码直接暴露在URL中,我们在项目中改用了POST请求,将用户名和密码添加在报文中作为消息体进行了封装。
为此,我们需要在解析报文的部分添加解析消息体的模块。
while((m_check_state==CHECK_STATE_CONTENT && line_status==LINE_OK)||((line_status=parse_line())==LINE_OK))
那么,这里的判断条件为什么要写成这样呢?
在GET请求报文中,每一行都是\r\n作为结束,所以对报文进行拆解时,仅用从状态机的状态line_status=parse_line())==LINE_OK语句即可。
但,在POST请求报文中,消息体的末尾没有任何字符,所以不能使用从状态机的状态,这里转而使用主状态机的状态作为循环入口条件。
那后面的&& line_status==LINE_OK又是为什么?
解析完消息体后,报文的完整解析就完成了,但此时主状态机的状态还是CHECK_STATE_CONTENT,也就是说,符合循环入口条件,还会再次进入循环,这并不是我们所希望的。
为此,增加了该语句,并在完成消息体解析后,将line_status变量更改为LINE_OPEN,此时可以跳出循环,完成报文解析任务。
- CHECK_STATE_CONTENT
-
- 仅用于解析POST请求,调用parse_content函数解析消息体
- 用于保存post请求消息体,为后面的登录和注册做准备
/*** 解析HTTP请求的主体内容* * @param text 指向读取到的请求主体内容的指针* @return 返回解析后的请求状态*/
http_conn::HTTP_CODE http_conn::parse_content(char* text) {// 检查是否读取到了足够的主体内容if (m_read_idx >= (m_content_length + m_checked_idx)) {// 终止字符串,并将其赋值给m_string成员变量text[m_content_length] = '\0';m_string = text;// 请求解析完成,返回GET_REQUEST状态return GET_REQUEST;}// 请求解析未完成,返回NO_REQUEST状态return NO_REQUEST;
}
状态机和HTTP报文解析是项目中最繁琐的部分,这次我们一举解决掉它
上一篇详解中,我们对状态机和服务器解析请求报文进行了介绍。
本篇,我们将介绍服务器如何响应请求报文,并将该报文发送给浏览器端。首先介绍一些基础API,然后结合流程图和代码对服务器响应请求报文进行详解。
服务器响应请求报文
上一篇详解中,我们对状态机和服务器解析请求报文进行了介绍。
本篇,我们将介绍服务器如何响应请求报文,并将该报文发送给浏览器端。首先介绍一些基础API,然后结合流程图和代码对服务器响应请求报文进行详解。
基础API部分,介绍stat
、mmap
、iovec
、writev
。
流程图部分,描述服务器端响应请求报文的逻辑,各模块间的关系。
代码部分,结合代码对服务器响应请求报文进行详解。
基础API
为了更好的源码阅读体验,这里提前对代码中使用的一些API进行简要介绍,更丰富的用法可以自行查阅资料。
stat
stat函数用于取得指定文件的文件属性,并将文件属性存储在结构体stat里,这里仅对其中用到的成员进行介绍。
1#include <sys/types.h>2#include <sys/stat.h>3#include <unistd.h>45//获取文件属性,存储在statbuf中6int stat(const char *pathname, struct stat *statbuf);78struct stat 9{
10 mode_t st_mode; /* 文件类型和权限 */
11 off_t st_size; /* 文件大小,字节数*/
12};
mmap
用于将一个文件或其他对象映射到内存,提高文件的访问速度。
1void* mmap(void* start,size_t length,int prot,int flags,int fd,off_t offset);
2int munmap(void* start,size_t length);
- start:映射区的开始地址,设置为0时表示由系统决定映射区的起始地址
- length:映射区的长度
- prot:期望的内存保护标志,不能与文件的打开模式冲突
-
- PROT_READ 表示页内容可以被读取
- flags:指定映射对象的类型,映射选项和映射页是否可以共享
-
- MAP_PRIVATE 建立一个写入时拷贝的私有映射,内存区域的写入不会影响到原文件
- fd:有效的文件描述符,一般是由open()函数返回
- off_t offset:被映射对象内容的起点
iovec
定义了一个向量元素,通常,这个结构用作一个多元素的数组。
struct iovec {void *iov_base; /* starting address of buffer */size_t iov_len; /* size of buffer */};
- iov_base指向数据的地址
- iov_len表示数据的长度
writev
writev函数用于在一次函数调用中写多个非连续缓冲区,有时也将这该函数称为聚集写。
#include <sys/uio.h>
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt);
- filedes表示文件描述符
- iov为前述io向量机制结构体iovec
- iovcnt为结构体的个数
若成功则返回已写的字节数,若出错则返回-1。writev
以顺序iov[0]
,iov[1]
至iov[iovcnt-1]
从缓冲区中聚集输出数据。writev
返回输出的字节总数,通常,它应等于所有缓冲区长度之和。
特别注意: 循环调用writev时,需要重新处理iovec中的指针和长度,该函数不会对这两个成员做任何处理。writev的返回值为已写的字节数,但这个返回值“实用性”并不高,因为参数传入的是iovec数组,计量单位是iovcnt,而不是字节数,我们仍然需要通过遍历iovec来计算新的基址,另外写入数据的“结束点”可能位于一个iovec的中间某个位置,因此需要调整临界iovec的io_base和io_len。
流程图
浏览器端发出HTTP请求报文,服务器端接收该报文并调用process_read
对其进行解析,根据解析结果HTTP_CODE
,进入相应的逻辑和模块。
其中,服务器子线程完成报文的解析与响应;主线程监测读写事件,调用read_once
和http_conn::write
完成数据的读取与发送。
HTTP_CODE含义
表示HTTP请求的处理结果,在头文件中初始化了八种情形,在报文解析与响应中只用到了七种。
- NO_REQUEST
-
- 请求不完整,需要继续读取请求报文数据
- 跳转主线程继续监测读事件
- GET_REQUEST
-
- 获得了完整的HTTP请求
- 调用do_request完成请求资源映射
- NO_RESOURCE
-
- 请求资源不存在
- 跳转process_write完成响应报文
- BAD_REQUEST
-
- HTTP请求报文有语法错误或请求资源为目录
- 跳转process_write完成响应报文
- FORBIDDEN_REQUEST
-
- 请求资源禁止访问,没有读取权限
- 跳转process_write完成响应报文
- FILE_REQUEST
-
- 请求资源可以正常访问
- 跳转process_write完成响应报文
- INTERNAL_ERROR
-
- 服务器内部错误,该结果在主状态机逻辑switch的default下,一般不会触发
代码分析
do_request
process_read
函数的返回值是对请求的文件分析后的结果,一部分是语法错误导致的BAD_REQUEST
,一部分是do_request
的返回结果.该函数将网站根目录和url
文件拼接,然后通过stat判断该文件属性。另外,为了提高访问速度,通过mmap进行映射,将普通文件映射到内存逻辑地址。
为了更好的理解请求资源的访问流程,这里对各种各页面跳转机制进行简要介绍。其中,浏览器网址栏中的字符,即url
,可以将其抽象成ip:port/xxx
,xxx
通过html
文件的action
属性进行设置。
m_url为请求报文中解析出的请求资源,以/开头,也就是/xxx
,项目中解析后的m_url有8种情况。
- /
-
- GET请求,跳转到judge.html,即欢迎访问页面
- /0
-
- POST请求,跳转到register.html,即注册页面
- /1
-
- POST请求,跳转到log.html,即登录页面
- /2CGISQL.cgi
-
- POST请求,进行登录校验
- 验证成功跳转到welcome.html,即资源请求成功页面
- 验证失败跳转到logError.html,即登录失败页面
- /3CGISQL.cgi
-
- POST请求,进行注册校验
- 注册成功跳转到log.html,即登录页面
- 注册失败跳转到registerError.html,即注册失败页面
- /5
-
- POST请求,跳转到picture.html,即图片请求页面
- /6
-
- POST请求,跳转到video.html,即视频请求页面
- /7
-
- POST请求,跳转到fans.html,即关注页面
如果大家对上述设置方式不理解,不用担心。具体的登录和注册校验功能会在第12节进行详解,到时候还会针对html进行介绍。
// 处理HTTP请求的主要函数
// 根据不同的URL请求来定位资源文件并进行相应的处理
http_conn::HTTP_CODE http_conn::do_request() {// 将doc_root路径复制到m_real_file中,作为基础路径strcpy(m_real_file, doc_root);// 获取基础路径的长度int len = strlen(doc_root);// 查找URL中的最后一个'/'字符const char* p = strrchr(m_url, '/');// 处理cgi请求if (cgi == 1 && (*(p + 1) == '2' || *(p+ 1) == '3')) {// 通过URL中的标志判断是登录验证还是注册验证char flag = m_url[1];// 动态构造真实的URL路径char *m_url_real = (char*) malloc(sizeof(char) * 200);strcpy(m_url_real, "/");strcat(m_url_real, m_url+ 2);strncpy(m_real_file + len, m_url_real,FILENAME_LEN - len - 1);free(m_url_real);// 提取用户名和密码char name[100], password[100];int i; for (int i = 5; m_string[i] != '&'; ++i)name[i - 5] = m_string[i];name[i - 5 ] = '\0';int j = 0;for (j = i + 10; m_string[i] = '\0'; ++i, ++j) password[j] = m_string[i];password[j] = '\0';// 处理注册请求if (*(p + 1) == '3') {// 检查数据库中是否有同名用户// 若没有,则插入新用户数据char* sql_insert = (char*)malloc(sizeof(char) * 200);strcpy(sql_insert, " INSERT INTO user (username, passwd) VALUES(");strcat(sql_insert, "'");strcat(sql_insert, name);strcat(sql_insert, "', '");strcat(sql_insert, password);strcat(sql_insert,"')");if (users.find(name) == users.end()){// 执行数据库插入操作m_lock.lock();int res = mysql_query(mysql, sql_insert);users.insert(pair<string, string> (name, password));m_lock.unlock();// 根据操作结果重定向用户if (!res) {strcpy(m_url, "/log.html");}else {strcpy(m_url, "/registerError.html");}}else {// 若已存在同名用户,重定向到注册错误页面strcpy(m_url, "/registerError.html");}}// 处理登录请求else if (*(p + 1) == '2') {// 验证用户名和密码if (users.find(name) != users.end() && users[name] == password) {strcpy(m_url, "/welcome.html");}else {strcpy(m_url, "/logError.html");}}// 其他特殊请求的处理if (*(p + 1) == '0') {char* m_url_real = (char*)malloc(sizeof(char) * 200);strcpy(m_url_real, "/register.html");strncpy(m_real_file + len, m_url_real, strlen(m_url_real));free(m_url_real);}else if (*(p + 1) == '1'){char *m_url_real = (char *)malloc(sizeof(char) * 200);strcpy(m_url_real, "/log.html");strncpy(m_real_file + len, m_url_real, strlen(m_url_real));free(m_url_real);}else if (*(p + 1) == '5'){char *m_url_real = (char *)malloc(sizeof(char) * 200);strcpy(m_url_real, "/picture.html");strncpy(m_real_file + len, m_url_real, strlen(m_url_real));free(m_url_real);}else if (*(p + 1) == '6'){char *m_url_real = (char *)malloc(sizeof(char) * 200);strcpy(m_url_real, "/video.html");strncpy(m_real_file + len, m_url_real, strlen(m_url_real));free(m_url_real);}else if (*(p + 1) == '7'){char *m_url_real = (char *)malloc(sizeof(char) * 200);strcpy(m_url_real, "/fans.html");strncpy(m_real_file + len, m_url_real, strlen(m_url_real));free(m_url_real);}// 默认情况,直接使用URL构造文件路径elsestrncpy(m_real_file + len, m_url, FILENAME_LEN - len - 1);}// 检查文件状态if (stat(m_real_file, &m_file_stat) < 0)return NO_RESOURCE;// 检查文件权限if (!(m_file_stat.st_mode & S_IROTH)) {return FORBIDDEN_REQUEST;}// 检查是否为目录if (S_ISDIR(m_file_stat.st_mode)) {return BAD_REQUEST;}// 打开文件并映射到内存int fd = open(m_real_file, O_RDONLY);m_file_address = (char* )mmap(0, m_file_stat.st_size, PROT_READ, MAP_PRIVATE, fd, 0);close(fd);return FILE_REQUEST;
}
process_write
根据do_request
的返回状态,服务器子线程调用process_write
向m_write_buf
中写入响应报文。
- add_status_line函数,添加状态行:http/1.1 状态码 状态消息
- add_headers函数添加消息报头,内部调用add_content_length和add_linger函数
-
- content-length记录响应报文长度,用于浏览器端判断服务器是否发送完数据
- connection记录连接状态,用于告诉浏览器端保持长连接
- add_blank_line添加空行
上述涉及的5个函数,均是内部调用add_response
函数更新m_write_idx
指针和缓冲区m_write_buf
中的内容。
/*** @brief 将给定的格式化字符串添加到响应中* * 本函数用于向HTTP响应中添加一段格式化的字符串。它使用vsnprintf函数来进行格式化,* 并确保添加的内容不会超出写缓冲区的大小。如果内容超出了剩余的缓冲区空间,函数将返回false,* 表示失败。否则,它将更新写入索引m_write_idx以反映新添加的内容长度,并记录日志信息。* * @param format 格式化字符串的格式,与printf中的格式字符串类似* @param ... 可变参数列表,与format对应的实际值* @return true 成功添加了格式化字符串到响应中* @return false 因为缓冲区空间不足,未能成功添加格式化字符串*/
bool http_conn::add_response(const char* format, ...) {// 检查剩余缓冲区空间是否足够,如果不够则返回falseif (m_write_idx >= WRITE_BUFFER_SIZE) return false;// 初始化可变参数列表va_list arg_list;va_start(arg_list, format);// 进行格式化输出,注意避免缓冲区溢出int len = vsnprintf(m_write_buf + m_write_idx, WRITE_BUFFER_SIZE - 1 - m_write_idx, format, arg_list);// 如果格式化内容长度超过剩余缓冲区空间,表示失败if (len >= (WRITE_BUFFER_SIZE - 1- m_write_idx)) {va_end(arg_list);return false;}// 更新写入索引m_write_idx += len;// 释放可变参数列表资源va_end(arg_list);// 记录日志,输出当前请求的响应内容LOG_INFO("request:%s", m_write_buf);return true;
}
// 向HTTP响应中添加状态行
// @param status: HTTP状态码
// @param title: 状态标题
// @return: 添加是否成功
bool http_conn::add_status_line(int status, const char* title) {return add_response("%s %d %s\r\n", "HTTP/1.1", status, title);
}// 添加HTTP响应头,包括Content-Length、Connection和空白行
// @param content_len: 内容长度
// @return: 添加是否成功
bool http_conn::add_headers(int content_len) {return add_content_length(content_len) && add_linger() && add_blank_line();
}// 添加Content-Length响应头
// @param content_len: 内容长度
// @return: 添加是否成功
bool http_conn::add_content_length(int content_len) {return add_response("Content-Length:%d\r\n", content_len);
}// 添加Content-Type响应头
// @return: 添加是否成功
bool http_conn::add_content_type() {return add_response("Content-Type:%s\r\n", "text/html");
}// 添加Connection响应头,决定连接是否保持
// @return: 添加是否成功
bool http_conn::add_linger() {return add_response("Connection:%s\r\n", (m_linger == true) ? "keep-alive": "close");
}// 添加空白行,标志响应头的结束
// @return: 添加是否成功
bool http_conn::add_blank_line() {return add_response("%s", "\r\n");
}// 添加响应内容
// @param content: 响应内容
// @return: 添加是否成功
bool http_conn::add_content(const char* content) {return add_response("%s", content);
}
响应报文分为两种,一种是请求文件的存在,通过io
向量机制iovec
,声明两个iovec
,第一个指向m_write_buf
,第二个指向mmap
的地址m_file_address
;一种是请求出错,这时候只申请一个iovec
,指向m_write_buf
。
- iovec是一个结构体,里面有两个元素,指针成员iov_base指向一个缓冲区,这个缓冲区是存放的是writev将要发送的数据。
- 成员iov_len表示实际写入的长度
// 根据不同的HTTP响应码处理写入内容到HTTP响应中
// @param ret: HTTP响应码
// @return: 处理是否成功
bool http_conn::process_write(HTTP_CODE ret) {switch(ret) {case INTERNAL_ERROR: {add_status_line(500, error_500_title);add_headers(strlen(error_500_form));if (!add_content(error_500_form)) {return false;}break;}case BAD_REQUEST: {add_status_line(404, error_404_title);add_headers(strlen(error_400_form));if (!add_content(error_404_form)) {return false;}break;}case FORBIDDEN_REQUEST: {add_status_line(403, error_403_title);add_headers(strlen(error_403_form));if (!add_content(error_403_form)) {return false;}break;}case FILE_REQUEST: {add_status_line(200, ok_200_title);if (m_file_stat.st_size != 0) {m_iv[0].iov_base = m_write_buf;m_iv[0].iov_len = m_write_idx;m_iv[1].iov_base = m_file_address;m_iv[1].iov_len = m_file_stat.st_size;m_iv_count = 2;bytes_to_send = m_write_idx + m_file_stat.st_size;return true;}else {const char* ok_string = "<html><body></body></html>";add_headers(strlen(ok_string));if (!add_content(ok_string)) {return false;}}}default:return false;}m_iv[0].iov_base = m_write_buf;m_iv[0].iov_len = m_write_idx;m_iv_count = 1;bytes_to_send = m_write_idx;return true;
}
http_conn::write
服务器子线程调用process_write
完成响应报文,随后注册epollout
事件。服务器主线程检测写事件,并调用http_conn::write
函数将响应报文发送给浏览器端。
该函数具体逻辑如下:
在生成响应报文时初始化byte_to_send,包括头部信息和文件数据大小。通过writev函数循环发送响应报文数据,根据返回值更新byte_have_send和iovec结构体的指针和长度,并判断响应报文整体是否发送成功。
- 若writev单次发送成功,更新byte_to_send和byte_have_send的大小,若响应报文整体发送成功,则取消mmap映射,并判断是否是长连接.
-
- 长连接重置http类实例,注册读事件,不关闭连接,
- 短连接直接关闭连接
- 若writev单次发送不成功,判断是否是写缓冲区满了。
-
- 若不是因为缓冲区满了而失败,取消mmap映射,关闭连接
- 若eagain则满了,更新iovec结构体的指针和长度,并注册写事件,等待下一次写事件触发(当写缓冲区从不可写变为可写,触发epollout),因此在此期间无法立即接收到同一用户的下一请求,但可以保证连接的完整性。
// 从内存中取消映射文件
void http_conn::unmap() {if (m_file_address) {munmap(m_file_address, m_file_stat.st_size); // 使用munmap函数取消对文件的内存映射m_file_address = 0; // 清空文件地址,表示不再有文件映射}
}// 将数据写入到客户端socket中
bool http_conn::write() {int temp = 0;if (bytes_to_send == 0) { // 如果没有数据需要发送modfd(m_epollfd, m_sockfd, EPOLLIN, m_TRIGMode); // 修改epoll事件为读事件,准备下一次读取init(); // 重置连接对象,为下一次请求做准备return true; // 成功处理空发送请求}while(1) {temp = writev(m_sockfd, m_iv, m_iv_count); // 使用writev进行scatter write(分散写)if (temp < 0) { // 如果写入失败if (errno == EAGAIN) { // 如果是因为缓冲区满,EPOLLOUT事件会再次触发modfd(m_epollfd, m_sockfd, EPOLLOUT, m_TRIGMode); // 重新注册EPOLLOUT事件return true; // 表示处理将被再次尝试}unmap(); // 取消文件内存映射return false; // 写入失败,返回false}bytes_have_send += temp; // 更新已经发送的字节数bytes_to_send -= temp; // 更新剩余待发送的字节数if (bytes_have_send >= m_iv[0].iov_len){ // 如果第一部分数据已经发送完毕m_iv[0].iov_len = 0; // 置空第一部分数据的长度m_iv[1].iov_base = m_file_address + (bytes_have_send - m_write_idx); // 更新第二部分数据的基地址m_iv[1].iov_len= bytes_to_send; // 更新第二部分数据的长度}else { // 如果第一部分数据未发送完毕m_iv[0].iov_base = m_write_buf + bytes_have_send; // 更新第一部分数据的基地址m_iv[0].iov_len = m_iv[0].iov_len - bytes_have_send; // 更新第一部分数据的长度}if (bytes_to_send <= 0) { // 如果所有数据发送完毕unmap(); // 取消文件内存映射modfd(m_epollfd, m_sockfd, EPOLLIN, m_TRIGMode); // 修改epoll事件为读事件,准备下一次读取if (m_linger) { // 如果设置为保持连接init(); // 重置连接对象,为下一次请求做准备return true; // 表示成功处理发送请求}else { // 如果设置为非保持连接return false; // 表示处理完成,连接将被关闭}}}
}