拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

第三篇 HTTP 请求解析状态机

第三篇 HTTP 请求解析状态机

原项目: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_idx

4.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\0

m_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\n

parse_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 9006

4.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.1

GET 请求没有正文,因此解析完最后一个请求头后的空行后,直接返回
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.1

POST 请求包含:

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 映射到服务器根目录下的真实文件,并实现静态文件的打开、内存映射和
响应发送。

返回列表