原项目:qinguoyi/TinyWebServer
复刻仓库:L2501031968/ccTinyWebServer
完整 20 章教程:仓库内docs/TinyWebServer-Recreation.md
第 4 章 HTTP 请求解析状态机
4.1 本章目标
第 3 章已经能够通过epoll接收多个客户端连接,但process()仍然直接
返回固定 HTML,并没有检查客户端发来的请求内容。
本章将为http_conn增加 HTTP 请求解析状态机,完成以下目标:
- 逐行查找请求中的
\r\n。 - 判断请求行、请求头和请求正文是否已经完整到达。
- 解析请求方法、URL、HTTP 版本、Host 和 Content-Length。
- 在请求尚未接收完整时返回
NO_REQUEST,等待下一次EPOLLIN。 - 在请求完整时返回
GET_REQUEST,进入响应阶段。 - 在请求格式错误时返回
BAD_REQUEST,关闭连接。 - 分别使用 GET 和 POST 请求验证解析结果。
- 记录并解决重复成员声明和构造顺序警告。
本章仍然只返回固定 HTML。请求解析完成后的 URL 映射、静态文件和动态响应
会在后续章节继续实现。
4.2 为什么要使用状态机
TCP 是字节流协议,不保证一次recv就能收到一个完整的 HTTP 请求。同一个
请求可能被拆成多次到达,例如:
第一次 recv: GET /index.html HTTP/1.1\r\n Host: 127.0.0.1\r\n 第二次 recv: Accept: */*\r\n \r\n程序不能默认缓冲区里已经存在完整的请求,也不能假设每行数据恰好对应一次recv。因此需要同时记录:
- 已经读取了多少字节。
- 已经检查到缓冲区中的哪个位置。
- 当前正在解析请求行、请求头还是请求正文。
本章使用的状态转换如下:
CHECK_STATE_REQUESTLINE | v CHECK_STATE_HEADER | +-- 没有正文 ---> GET_REQUEST | v CHECK_STATE_CONTENT | v GET_REQUEST只要当前数据不足,状态机就返回NO_REQUEST,但已经解析出的状态会保留在http_conn对象中,等待下一次可读事件继续处理。
4.3 定义状态和返回码枚举
在http_conn类中增加四组枚举。
4.3.1 请求方法
enumMETHOD{GET=0,POST};当前只支持 GET 和 POST。其他方法暂时返回BAD_REQUEST。
4.3.2 解析状态
enumCHECK_STATE{CHECK_STATE_REQUESTLINE=0,CHECK_STATE_HEADER,CHECK_STATE_CONTENT};三个状态分别表示:
| 状态 | 含义 |
|---|---|
CHECK_STATE_REQUESTLINE | 正在解析请求行 |
CHECK_STATE_HEADER | 正在解析请求头 |
CHECK_STATE_CONTENT | 正在解析请求正文 |
4.3.3 HTTP 处理结果
enumHTTP_CODE{NO_REQUEST,GET_REQUEST,BAD_REQUEST};含义如下:
| 返回码 | 含义 |
|---|---|
NO_REQUEST | 当前数据还不完整,需要继续读取 |
GET_REQUEST | 已经收到完整请求 |
BAD_REQUEST | 请求格式错误 |
这里的GET_REQUEST表示“成功取得一个完整请求”,并不表示请求方法一定是
GET。这个命名与参考项目保持一致。
4.3.4 行读取状态
enumLINE_STATUS{LINE_OK=0,LINE_BAD,LINE_OPEN};含义如下:
| 返回码 | 含义 |
|---|---|
LINE_OK | 成功取出一行,并以\r\n结尾 |
LINE_BAD | 行的结束符格式错误 |
LINE_OPEN | 当前行尚未接收到完整的\r\n |
4.4 增加解析成员和函数
在http_conn的私有区域增加解析函数:
char*get_line(){returnm_read_buf+m_start_line;}LINE_STATUSparse_line();HTTP_CODEprocess_read();HTTP_CODEparse_request_line(char*text);HTTP_CODEparse_headers(char*text);HTTP_CODEparse_content(char*text);再增加以下成员:
longm_checked_idx;intm_start_line;CHECK_STATE m_check_state;METHOD m_method;longm_content_length;boolm_linger;char*m_url;char*m_version;char*m_host;char*m_string;成员作用如下:
m_checked_idx:parse_line已经检查到的字节位置。m_start_line:当前准备解析的这一行在缓冲区中的起始位置。m_check_state:当前解析状态。m_method:解析出的请求方法。m_content_length:请求正文长度,初始值为 0。m_linger:是否请求长连接。m_url:请求行中的 URL。m_version:HTTP 版本。m_host:Host 请求头。m_string:请求正文。
m_read_idx表示已经接收的数据末尾,m_checked_idx表示已经检查的数据
位置,两者不能混用:
m_read_buf |-----------------------|----------------| 已接收且已检查 已接收但未检查 尚未接收 ^ ^ ^ 0 m_checked_idx m_read_idx4.5 初始化新增状态
每次接入连接时,init()必须重置本章新增的所有成员:
voidhttp_conn::init(){m_read_idx=0;m_checked_idx=0;m_start_line=0;m_write_idx=0;m_bytes_have_send=0;m_state=0;m_check_state=CHECK_STATE_REQUESTLINE;m_method=GET;m_content_length=0;m_linger=false;m_url=nullptr;m_version=nullptr;m_host=nullptr;m_string=nullptr;memset(m_read_buf,'\0',READ_BUFFER_SIZE);memset(m_write_buf,'\0',WRITE_BUFFER_SIZE);}文件描述符关闭后可能被系统重新分配给新连接,因此这些成员必须在初始化时
清空。否则新连接可能读到上一个连接遗留的解析状态。
4.6 实现 parse_line
HTTP/1.1 的文本行使用\r\n作为结束符,因此需要从m_checked_idx开始逐字节查找:
http_conn::LINE_STATUS http_conn::parse_line(){for(;m_checked_idx<m_read_idx;++m_checked_idx){chartemp=m_read_buf[m_checked_idx];if(temp=='\r'){if(m_checked_idx+1==m_read_idx)returnLINE_OPEN;if(m_read_buf[m_checked_idx+1]=='\n'){m_read_buf[m_checked_idx++]='\0';m_read_buf[m_checked_idx++]='\0';returnLINE_OK;}returnLINE_BAD;}if(temp=='\n'){if(m_checked_idx>1&&m_read_buf[m_checked_idx-1]=='\r'){m_read_buf[m_checked_idx-1]='\0';m_read_buf[m_checked_idx++]='\0';returnLINE_OK;}returnLINE_BAD;}}returnLINE_OPEN;}主要分支说明如下:
- 遇到
\r,但它是当前已接收数据的最后一个字节,说明\n还没到,
返回LINE_OPEN。 - 遇到
\r\n,把\r和\n都改成\0,然后返回LINE_OK。 - 遇到
\r后不是\n,说明换行格式错误,返回LINE_BAD。 - 遇到孤立的
\n,同样返回LINE_BAD。 - 扫描到
m_read_idx仍没有换行符,返回LINE_OPEN。
把\r\n替换为两个\0后,调用方可以直接把这一行当作文本处理。例如:
原始数据: GET / HTTP/1.1\r\n 处理后的内存: GET / HTTP/1.1\0\0m_checked_idx会在成功取行后移动到下一行的起始位置,因此下一次调用会从
新位置继续查找。
4.7 解析请求行
请求行格式如下:
METHOD SP request-target SP HTTP-version CRLF例如:
GET /index.html HTTP/1.1实现代码如下:
http_conn::HTTP_CODE http_conn::parse_request_line(char*text){m_url=strpbrk(text," \t");if(!m_url)returnBAD_REQUEST;*m_url++='\0';if(strcasecmp(text,"GET")==0)m_method=GET;elseif(strcasecmp(text,"POST")==0)m_method=POST;elsereturnBAD_REQUEST;m_url+=strspn(m_url," \t");m_version=strpbrk(m_url," \t");if(!m_version)returnBAD_REQUEST;*m_version++='\0';m_version+=strspn(m_version," \t");if(strcasecmp(m_version,"HTTP/1.1")!=0)returnBAD_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,'/');}if(!m_url||m_url[0]!='/')returnBAD_REQUEST;std::cout<<"request line: "<<(m_method==GET?"GET":"POST")<<" "<<m_url<<" "<<m_version<<std::endl;m_check_state=CHECK_STATE_HEADER;returnNO_REQUEST;}4.7.1 拆分方法、URL 和版本
strpbrk查找第一个空格或制表符:
m_url=strpbrk(text," \t");找到后写入\0,把请求方法和 URL 拆成两个 C 字符串:
拆分前: GET /index.html HTTP/1.1 拆分后: GET\0/index.html HTTP/1.1 ^ ^ 方法 URL随后使用strspn跳过 URL 和版本之间的一个或多个空格。
4.7.2 判断请求方法
当前只接受 GET 和 POST,并使用strcasecmp做不区分大小写的比较。
4.7.3 判断 HTTP 版本
当前只接受HTTP/1.1。如果版本不是HTTP/1.1,返回BAD_REQUEST。
4.7.4 处理绝对 URL
如果请求目标是完整地址:
http://127.0.0.1:9006/index.html则跳过协议和主机部分,只保留:
/index.html后续静态文件服务会统一使用以/开头的路径。
4.7.5 状态转换
请求行解析成功后,把状态改为:
m_check_state=CHECK_STATE_HEADER;函数先返回NO_REQUEST,表示还要继续解析请求头。
4.8 解析请求头
请求头一次只解析一行。最重要的判断是空行:
http_conn::HTTP_CODE http_conn::parse_headers(char*text){if(text[0]=='\0'){if(m_content_length!=0){m_check_state=CHECK_STATE_CONTENT;returnNO_REQUEST;}returnGET_REQUEST;}if(strncasecmp(text,"Connection:",11)==0){text+=11;text+=strspn(text," \t");if(strcasecmp(text,"keep-alive")==0)m_linger=true;}elseif(strncasecmp(text,"Content-Length:",15)==0){text+=15;text+=strspn(text," \t");m_content_length=atol(text);}elseif(strncasecmp(text,"Host:",5)==0){text+=5;text+=strspn(text," \t");m_host=text;}returnNO_REQUEST;}4.8.1 空行表示请求头结束
最后一个请求头后面还有一个空行:
Host: 127.0.0.1:9006\r\n Accept: */*\r\n \r\nparse_line会把空行处理成\0,因此text[0] == '\0'表示请求头结束。
此时分两种情况:
Content-Length == 0:没有请求正文,直接返回GET_REQUEST。Content-Length != 0:还有正文,切换到CHECK_STATE_CONTENT并返回NO_REQUEST。
4.8.2 解析 Connection
如果请求头包含:
Connection: keep-alive则把m_linger设置为true。当前响应仍然固定发送Connection: close,所以这个字段暂时只作为解析示例。
4.8.3 解析 Content-Length
atol把字符串转换成数值。例如:
Content-Length: 7转换后:
m_content_length=7;后续需要等待 7 字节正文到达,才能认为请求完整。
4.8.4 解析 Host
Host 指向m_read_buf中的一行文本,因此结构体回收前不需要额外复制。
4.9 解析请求正文
请求正文不是按行解析,而是按字节长度判断:
http_conn::HTTP_CODE http_conn::parse_content(char*text){if(m_read_idx>=m_content_length+m_checked_idx){text[m_content_length]='\0';m_string=text;returnGET_REQUEST;}returnNO_REQUEST;}m_checked_idx在进入正文状态时指向正文起始位置。判断条件:
m_read_idx >= m_checked_idx + m_content_length表示已经接收到的数据长度足以覆盖整个正文。
正文完整后,在正文末尾写入\0,并让m_string指向正文起点:
正文起始位置 | v name=cc\0 ^ 额外写入的结束符如果正文尚未接收完整,就返回NO_REQUEST。
4.10 组织完整解析循环
各个解析函数通过process_read组合起来:
http_conn::HTTP_CODE http_conn::process_read(){LINE_STATUS line_status=LINE_OK;HTTP_CODE ret=NO_REQUEST;char*text=nullptr;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;switch(m_check_state){caseCHECK_STATE_REQUESTLINE:{ret=parse_request_line(text);if(ret==BAD_REQUEST)returnBAD_REQUEST;break;}caseCHECK_STATE_HEADER:{ret=parse_headers(text);if(ret==BAD_REQUEST)returnBAD_REQUEST;if(ret==GET_REQUEST)returnGET_REQUEST;break;}caseCHECK_STATE_CONTENT:{ret=parse_content(text);if(ret==GET_REQUEST)returnGET_REQUEST;line_status=LINE_OPEN;break;}default:returnBAD_REQUEST;}}returnNO_REQUEST;}循环条件分成两部分:
m_check_state==CHECK_STATE_CONTENT&&line_status==LINE_OK当状态已经切换到正文,并且上一行是请求头后的空行时,不再调用parse_line,而是直接解析正文。
另一部分是普通文本解析:
(line_status=parse_line())==LINE_OK只有成功取出一整行时,才进入switch处理。
每次进入循环时执行:
text=get_line();m_start_line=m_checked_idx;text指向当前行起点;随后把m_start_line更新为下一行的起点,保证下一
轮get_line()能取到正确的数据。
如果parse_line返回LINE_OPEN,循环结束,process_read返回NO_REQUEST,当前解析状态和检查位置都会被保留。
4.11 接入 process
原来的process()直接生成响应,现在先调用process_read():
voidhttp_conn::process(){HTTP_CODE read_ret=process_read();if(read_ret==NO_REQUEST){modfd(m_epollfd,m_sockfd,EPOLLIN);return;}if(read_ret!=GET_REQUEST){close_conn();return;}constchar*body="<html><body>""<h1>Hello TinyWebServer</h1>""</body></html>\n";intbody_length=static_cast<int>(strlen(body));m_write_idx=snprintf(m_write_buf,WRITE_BUFFER_SIZE,"HTTP/1.1 200 OK\r\n""Content-Type: text/html; charset=utf-8\r\n""Content-Length: %d\r\n""Connection: close\r\n""\r\n""%s",body_length,body);if(m_write_idx<0||m_write_idx>=WRITE_BUFFER_SIZE){close_conn();return;}m_state=1;modfd(m_epollfd,m_sockfd,EPOLLOUT);}状态分支如下:
| 解析结果 | 处理方式 |
|---|---|
NO_REQUEST | 继续保持EPOLLIN,等待剩余数据 |
GET_REQUEST | 构造响应并切换到EPOLLOUT |
BAD_REQUEST | 关闭连接 |
4.12 排错:重复声明成员
第一次编译时出现了下面四组错误:
error: redeclaration of ‘http_conn::CHECK_STATE http_conn::m_check_state’ error: redeclaration of ‘http_conn::METHOD http_conn::m_method’ error: redeclaration of ‘http_conn::long int http_conn::m_content_length’ error: redeclaration of ‘http_conn::bool http_conn::m_linger’原因是http_conn.h中把下面四行粘贴了两次:
CHECK_STATE m_check_state;METHOD m_method;longm_content_length;boolm_linger;同一个类中,每个非静态成员只能声明一次。修复方式是只保留一组声明:
CHECK_STATE m_check_state;METHOD m_method;longm_content_length;boolm_linger;char*m_url;char*m_version;char*m_host;char*m_string;出现redeclaration时,不要先修改构造函数或process_read,应该先检查
头文件中是否重复粘贴了成员、方法或枚举。
4.13 排错:构造函数初始化顺序警告
编译时还会出现:
warning: ‘http_conn::m_bytes_have_send’ will be initialized after warning: ‘int http_conn::m_state’C++ 成员的实际初始化顺序由它们在类中的声明顺序决定,与构造函数初始化
列表的书写顺序无关。
原来的初始化列表为:
http_conn::http_conn():m_sockfd(-1),m_read_idx(0),m_write_idx(0),m_bytes_have_send(0),m_state(0){}但m_state在类中先声明,所以实际先初始化m_state。让它和声明顺序
保持一致:
http_conn::http_conn():m_state(0),m_sockfd(-1),m_read_idx(0),m_write_idx(0),m_bytes_have_send(0){}这样-Wreorder警告就会消失。
4.14 编译
修改完成后执行:
cd/home/cc/ccTinyWebServermakecleanmakeserver本次修复后编译输出为:
g++ -o server main.cpp webserver.cpp \ http/http_conn.cpp -g -Wall -Wextra -std=c++11没有错误,也没有警告。然后启动服务器:
./server终端输出:
server is listening on port 90064.15 验证 GET 请求
在另一个终端执行:
curl-sS-i--max-time5http://127.0.0.1:9006/本次返回:
HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Length: 55 Connection: close <html><body><h1>Hello TinyWebServer</h1></body></html>服务器终端输出:
request line: GET / HTTP/1.1说明请求行已经成功拆分成:
method = GET url = / version = HTTP/1.1GET 请求没有正文,因此解析完最后一个请求头后的空行后,直接返回GET_REQUEST。
4.16 验证 POST 请求
执行:
curl-sS-i--max-time5\-XPOST\-H'Content-Length: 7'\--data-binary'name=cc'\http://127.0.0.1:9006/本次返回:
HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Length: 55 Connection: close <html><body><h1>Hello TinyWebServer</h1></body></html>服务器终端输出:
request line: POST / HTTP/1.1POST 请求包含:
Content-Length: 7所以状态机执行:
CHECK_STATE_REQUESTLINE -> CHECK_STATE_HEADER -> CHECK_STATE_CONTENT -> GET_REQUEST正文name=cc一共 7 字节。只有这 7 字节全部到达后,parse_content
才会返回GET_REQUEST。
4.17 验证多个并行请求
使用 curl 的并行模式一次发送 5 个请求:
curl-sS--parallel--max-time5\http://127.0.0.1:9006/\http://127.0.0.1:9006/\http://127.0.0.1:9006/\http://127.0.0.1:9006/\http://127.0.0.1:9006/本次输出为:
<html><body><h1>Hello TinyWebServer</h1></body></html> <html><body><h1>Hello TinyWebServer</h1></body></html> <html><body><h1>Hello TinyWebServer</h1></body></html> <html><body><h1>Hello TinyWebServer</h1></body></html> <html><body><h1>Hello TinyWebServer</h1></body></html>服务器终端会输出 5 次:
request line: GET / HTTP/1.1不同客户端可能复用不同的文件描述符。每次m_users[connfd].init()都会重置m_read_idx、m_checked_idx、m_start_line和m_check_state,因此每个连接都从CHECK_STATE_REQUESTLINE独立开始解析。
4.18 当前实现的边界
本章的状态机已经可以解析基础 GET 和 POST,但仍然存在以下边界:
- 只接受 GET 和 POST。
- 只接受 HTTP/1.1。
- 只解析 Host、Connection 和 Content-Length 三个请求头。
- 请求头和正文都必须放入 2048 字节读缓冲区。
Content-Length使用atol转换,没有做溢出和非法字符检查。Content-Length过大时没有直接返回错误。- 请求错误时直接关闭连接,没有返回 400 Bad Request。
Connection: keep-alive虽然会被记录,但响应仍然关闭连接。- URL 已经解析出来,但还没有映射到
root目录中的真实文件。 - 所有成功请求仍然返回同一个固定 HTML。
- 网络事件仍然由主线程串行处理,尚未接入线程池。
这些边界会随着静态文件服务、线程池和日志模块的实现逐步消除。
4.19 本章小结
本章完成了 HTTP 请求解析状态机,核心流程为:
recv -> parse_line -> parse_request_line -> parse_headers -> parse_content -> process_read -> GET_REQUEST / NO_REQUEST / BAD_REQUEST状态机最重要的作用是保存“解析到哪里”和“还缺多少数据”。这样,即使一次recv没有收到完整请求,也可以在下一次EPOLLIN到达后从中断位置继续。
本章同时解决了重复成员声明和构造函数初始化顺序问题。下一章将把请求中的
URL 映射到服务器根目录下的真实文件,并实现静态文件的打开、内存映射和
响应发送。