ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

第三篇 HTTP 请求解析状态机

2026/9/30 14:19:21 拓冰建站 浏览量
第三篇 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{GET0,POST};当前只支持 GET 和 POST。其他方法暂时返回BAD_REQUEST。4.3.2 解析状态enumCHECK_STATE{CHECK_STATE_REQUESTLINE0,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_OK0,LINE_BAD,LINE_OPEN};含义如下返回码含义LINE_OK成功取出一行并以\r\n结尾LINE_BAD行的结束符格式错误LINE_OPEN当前行尚未接收到完整的\r\n4.4 增加解析成员和函数在http_conn的私有区域增加解析函数char*get_line(){returnm_read_bufm_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_idxparse_line已经检查到的字节位置。m_start_line当前准备解析的这一行在缓冲区中的起始位置。m_check_state当前解析状态。m_method解析出的请求方法。m_content_length请求正文长度初始值为 0。m_linger是否请求长连接。m_url请求行中的 URL。m_versionHTTP 版本。m_hostHost 请求头。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_idx0;m_checked_idx0;m_start_line0;m_write_idx0;m_bytes_have_send0;m_state0;m_check_stateCHECK_STATE_REQUESTLINE;m_methodGET;m_content_length0;m_lingerfalse;m_urlnullptr;m_versionnullptr;m_hostnullptr;m_stringnullptr;memset(m_read_buf,\0,READ_BUFFER_SIZE);memset(m_write_buf,\0,WRITE_BUFFER_SIZE);}文件描述符关闭后可能被系统重新分配给新连接因此这些成员必须在初始化时清空。否则新连接可能读到上一个连接遗留的解析状态。4.6 实现 parse_lineHTTP/1.1 的文本行使用\r\n作为结束符因此需要从m_checked_idx开始逐字节查找http_conn::LINE_STATUS http_conn::parse_line(){for(;m_checked_idxm_read_idx;m_checked_idx){chartempm_read_buf[m_checked_idx];if(temp\r){if(m_checked_idx1m_read_idx)returnLINE_OPEN;if(m_read_buf[m_checked_idx1]\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_idx1m_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_urlstrpbrk(text, \t);if(!m_url)returnBAD_REQUEST;*m_url\0;if(strcasecmp(text,GET)0)m_methodGET;elseif(strcasecmp(text,POST)0)m_methodPOST;elsereturnBAD_REQUEST;m_urlstrspn(m_url, \t);m_versionstrpbrk(m_url, \t);if(!m_version)returnBAD_REQUEST;*m_version\0;m_versionstrspn(m_version, \t);if(strcasecmp(m_version,HTTP/1.1)!0)returnBAD_REQUEST;if(strncasecmp(m_url,http://,7)0){m_url7;m_urlstrchr(m_url,/);}if(strncasecmp(m_url,https://,8)0){m_url8;m_urlstrchr(m_url,/);}if(!m_url||m_url[0]!/)returnBAD_REQUEST;std::coutrequest line: (m_methodGET?GET:POST) m_url m_versionstd::endl;m_check_stateCHECK_STATE_HEADER;returnNO_REQUEST;}4.7.1 拆分方法、URL 和版本strpbrk查找第一个空格或制表符m_urlstrpbrk(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_stateCHECK_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_stateCHECK_STATE_CONTENT;returnNO_REQUEST;}returnGET_REQUEST;}if(strncasecmp(text,Connection:,11)0){text11;textstrspn(text, \t);if(strcasecmp(text,keep-alive)0)m_lingertrue;}elseif(strncasecmp(text,Content-Length:,15)0){text15;textstrspn(text, \t);m_content_lengthatol(text);}elseif(strncasecmp(text,Host:,5)0){text5;textstrspn(text, \t);m_hosttext;}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-Lengthatol把字符串转换成数值。例如Content-Length: 7转换后m_content_length7;后续需要等待 7 字节正文到达才能认为请求完整。4.8.4 解析 HostHost 指向m_read_buf中的一行文本因此结构体回收前不需要额外复制。4.9 解析请求正文请求正文不是按行解析而是按字节长度判断http_conn::HTTP_CODE http_conn::parse_content(char*text){if(m_read_idxm_content_lengthm_checked_idx){text[m_content_length]\0;m_stringtext;returnGET_REQUEST;}returnNO_REQUEST;}m_checked_idx在进入正文状态时指向正文起始位置。判断条件m_read_idx m_checked_idx m_content_length表示已经接收到的数据长度足以覆盖整个正文。正文完整后在正文末尾写入\0并让m_string指向正文起点正文起始位置 | v namecc\0 ^ 额外写入的结束符如果正文尚未接收完整就返回NO_REQUEST。4.10 组织完整解析循环各个解析函数通过process_read组合起来http_conn::HTTP_CODE http_conn::process_read(){LINE_STATUS line_statusLINE_OK;HTTP_CODE retNO_REQUEST;char*textnullptr;while((m_check_stateCHECK_STATE_CONTENTline_statusLINE_OK)||((line_statusparse_line())LINE_OK)){textget_line();m_start_linem_checked_idx;switch(m_check_state){caseCHECK_STATE_REQUESTLINE:{retparse_request_line(text);if(retBAD_REQUEST)returnBAD_REQUEST;break;}caseCHECK_STATE_HEADER:{retparse_headers(text);if(retBAD_REQUEST)returnBAD_REQUEST;if(retGET_REQUEST)returnGET_REQUEST;break;}caseCHECK_STATE_CONTENT:{retparse_content(text);if(retGET_REQUEST)returnGET_REQUEST;line_statusLINE_OPEN;break;}default:returnBAD_REQUEST;}}returnNO_REQUEST;}循环条件分成两部分m_check_stateCHECK_STATE_CONTENTline_statusLINE_OK当状态已经切换到正文并且上一行是请求头后的空行时不再调用parse_line而是直接解析正文。另一部分是普通文本解析(line_statusparse_line())LINE_OK只有成功取出一整行时才进入switch处理。每次进入循环时执行textget_line();m_start_linem_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_retprocess_read();if(read_retNO_REQUEST){modfd(m_epollfd,m_sockfd,EPOLLIN);return;}if(read_ret!GET_REQUEST){close_conn();return;}constchar*bodyhtmlbodyh1Hello TinyWebServer/h1/body/html\n;intbody_lengthstatic_castint(strlen(body));m_write_idxsnprintf(m_write_buf,WRITE_BUFFER_SIZE,HTTP/1.1 200 OK\r\nContent-Type: text/html; charsetutf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s,body_length,body);if(m_write_idx0||m_write_idxWRITE_BUFFER_SIZE){close_conn();return;}m_state1;modfd(m_epollfd,m_sockfd,EPOLLOUT);}状态分支如下解析结果处理方式NO_REQUEST继续保持EPOLLIN等待剩余数据GET_REQUEST构造响应并切换到EPOLLOUTBAD_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 -stdc11没有错误也没有警告。然后启动服务器./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; charsetutf-8 Content-Length: 55 Connection: close htmlbodyh1Hello 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\-HContent-Length: 7\--data-binarynamecc\http://127.0.0.1:9006/本次返回HTTP/1.1 200 OK Content-Type: text/html; charsetutf-8 Content-Length: 55 Connection: close htmlbodyh1Hello TinyWebServer/h1/body/html服务器终端输出request line: POST / HTTP/1.1POST 请求包含Content-Length: 7所以状态机执行CHECK_STATE_REQUESTLINE - CHECK_STATE_HEADER - CHECK_STATE_CONTENT - GET_REQUEST正文namecc一共 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/本次输出为htmlbodyh1Hello TinyWebServer/h1/body/html htmlbodyh1Hello TinyWebServer/h1/body/html htmlbodyh1Hello TinyWebServer/h1/body/html htmlbodyh1Hello TinyWebServer/h1/body/html htmlbodyh1Hello 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 映射到服务器根目录下的真实文件并实现静态文件的打开、内存映射和响应发送。