首页 / 资讯中心 / 文章详情

HTTP请求解析源码剖析:从请求行、请求头到URI的工程实践与避坑指南

HTTP请求解析源码剖析:从请求行、请求头到URI的工程实践与避坑指南 ★ FEATURED ARTICLE
1. 从一行文本到一次响应HTTP请求解析到底在做什么很多人第一次读WebServer源码看到parse_request_line、parse_headers这类函数名第一反应是“不就是按空格切字符串吗”。我当初也这么想直到自己动手写了一个能扛住压测的服务器才发现这一层是整个服务端最容易被低估、也最容易埋雷的地方。HTTP请求解析说白了就是把浏览器或客户端发来的一坨字节流翻译成服务器能理解的结构化数据请求方法、目标URI、协议版本、一堆请求头字段以及后面可能跟着的请求体。它解决的问题很具体——让服务器知道“谁在请求什么资源、用什么方式、带什么附加条件”。适合正在啃WebServer项目源码的初学者也适合写过Demo但没深究过边界情况的开发者。你不需要背RFC文档但必须理解解析器为什么长这样、每一步在防什么。我见过太多项目功能跑通就完事结果一上生产环境遇到超长URL、畸形请求行、大小写混杂的Header就崩。解析HTTP请求这件事核心难点从来不是“怎么切”而是“切错了怎么办”。这一篇就围绕请求行、请求头、URI这三块把源码里那些看似平淡的代码逐行拆开讲清楚每个判断背后的意图以及我踩过的坑。2. 请求行解析三个字段背后的取舍与陷阱2.1 请求行的标准结构与解析顺序请求行是HTTP报文的第一行格式固定为Method SP Request-URI SP HTTP-Version CRLF。SP是空格CRLF是回车换行。看起来简单但解析顺序和分隔策略直接决定了健壮性。常见做法是先找第一个空格定位方法再找第二个空格定位URI和版本的分界。我试过用sscanf一把梭代码是短但遇到URI里带空格虽然规范不允许但现实中有客户端会发就直接解析错位。更稳的方式是手动扫描从缓冲区头部开始遇到第一个空格前的字节就是方法继续往后遇到下一个空格前的就是URI剩下的到CRLF之前就是版本号。为什么强调手动扫描因为你要控制边界。比如方法长度必须限制否则一个恶意客户端发来一万个字符的“方法”你的缓冲区就爆了。我在项目里给方法设了16字节上限URI设了2048字节上限版本号固定匹配HTTP/1.0或HTTP/1.1。这些数字不是拍脑袋方法名最长的标准方法是OPTIONS7个字符留一倍余量足够URI 2048是业界常见默认值兼顾兼容性和内存安全。解析完三个字段后通常会把方法转成枚举值比如GET、POST、HEAD方便后续用switch分发。URI先原样存下来版本号转成数字存主次版本。这里有个细节版本号比较不要用字符串比较HTTP/1.10和HTTP/1.9字符串比会出错虽然现在没有1.10但养成转数字的习惯没坏处。2.2 方法识别大小写敏感与非法方法的处理HTTP方法在规范里是大小写敏感的get和GET不是一回事。但现实中有些客户端会发小写你怎么处理我的选择是严格匹配大写不匹配就返回405 Method Not Allowed。为什么不做兼容因为兼容会带来歧义而且规范就是规范服务器没有义务容忍客户端的错误。但这里有个坑返回405的时候响应里必须带Allow头列出服务器支持的方法否则某些客户端会困惑。另一个坑是扩展方法。除了GET、POST、HEAD、PUT、DELETE、OPTIONS、TRACE、CONNECT还有WebDAV的PROPFIND之类。如果你的服务器只做静态资源遇到不认识的方法直接拒绝就好但拒绝的时机要在解析完请求行之后、读请求体之前否则可能白白读了一堆数据再拒绝浪费带宽。我实测下来方法解析最容易被忽略的是HEAD。很多人把HEAD当GET处理返回了响应体这是错的。HEAD的响应必须和GET完全一致除了没有body。解析阶段就要把HEAD标记出来后续生成响应时统一处理。2.3 URI长度限制与缓冲区安全URI长度限制是安全底线。我见过一个项目URI缓冲区是固定大小的数组解析时用strcpy结果被一个超长URI直接溢出。正确做法是解析前先检查从行首到第一个CRLF的总长度超过阈值直接返回414 URI Too Long并且关闭连接或丢弃剩余数据。注意414响应本身也要能发出去所以不能因为URI太长就把整个缓冲区搞乱。具体阈值怎么定我参考了常见服务器的默认值Apache默认8190字节Nginx默认8K左右。我自己的项目设了4096因为我的应用场景URI不会太长留足余量同时省内存。你可以根据业务调整但一定要有上限。另外URI里可能包含百分号编码解析阶段不需要解码原样存下来等到路由匹配时再解码。为什么因为解码后长度可能变化而且%2F解码成/会改变路径语义提前解码容易出安全问题。注意URI里允许出现空格吗规范说不允许必须编码成%20。但有些客户端会直接发空格这时候你的解析器如果把空格当分隔符就会把URI截断。我的做法是请求行里只认第一个和第二个空格作为分隔之后到CRLF之间的所有内容都算URI这样即使URI里有空格也能保留后续再判断合法性。3. 请求头解析从键值对到结构化存储3.1 请求头的行格式与折叠处理请求头是一行一个键值对格式为Field-Name: Field-Value CRLF直到遇到一个空行只有CRLF表示头结束。解析时逐行读取每行找冒号冒号前是键冒号后是值值要去掉前导空格。这里有个历史遗留问题老规范允许头字段折叠也就是一行以空格或制表符开头表示续上一行。比如X-Custom: part1 part2等价于X-Custom: part1 part2。现代规范已经废弃了折叠但为了兼容老客户端有些服务器还支持。我的建议是如果你的项目不需要兼容上古客户端直接不支持折叠遇到以空格开头的行就当非法请求返回400。这样代码简单也避免折叠带来的解析复杂度。另一个细节是冒号前后的空格。规范说冒号后可以有零个或多个空格冒号前不能有空格。但现实中有人发Host : example.com冒号前有空格。严格解析应该拒绝但很多服务器会宽容地去掉键尾空格。我选择严格键里出现空格就报400。为什么因为宽容会带来歧义而且这种请求大概率是客户端bug早暴露早修。3.2 关键头字段的语义与存储结构请求头字段很多但服务器真正关心的就那么几个Host、Content-Length、Content-Type、Connection、Transfer-Encoding、Cookie、User-Agent、Referer等。存储结构上可以用哈希表也可以用数组加线性查找。如果头字段不多通常十几个数组遍历比哈希表更快因为哈希计算也有开销。我自己的项目用了一个固定大小的数组每个元素存键和值解析时逐个填入查找时遍历比较。Host是HTTP/1.1的必选头没有它返回400。为什么因为一个IP可能对应多个域名没有Host服务器不知道你要访问哪个站点。解析到Host后通常要校验格式比如不能有空格、不能有非法字符。Content-Length和Transfer-Encoding不能同时存在同时存在返回400这是防请求走私的基本要求。Connection: close表示短连接Connection: keep-alive表示长连接HTTP/1.1默认长连接所以没有Connection头时按长连接处理。Cookie头可能很长而且多个Cookie用分号分隔。解析阶段不需要拆开原样存下来等到业务层再解析。但要注意Cookie长度限制防止内存耗尽。3.3 头字段大小写与重复字段的处理HTTP头字段名是大小写不敏感的Content-Length和content-length是同一个字段。所以存储时要么统一转小写要么比较时忽略大小写。我选择解析时统一转小写存储这样后续查找直接用全小写键省去每次比较的大小写转换。但注意值的大小写是敏感的比如Content-Type: Text/HTML和text/html语义不同不要动值。重复字段怎么处理规范允许某些字段重复比如Set-Cookie但请求头里Content-Length重复就是错误。我的做法是对于已知的单值字段Host、Content-Length等如果重复出现直接返回400对于多值字段Cookie、Accept等可以合并或者保留多个。简单起见我统一用“后出现的覆盖先出现的”但Content-Length这种关键字段重复必须拒绝。实操心得解析请求头时每读完一行就检查缓冲区剩余空间防止头字段总长度超过限制。我设了8KB的上限超过返回431 Request Header Fields Too Large。这个状态码很多人不知道但它是专门为这种情况设计的。4. URI基础知识不只是路径那么简单4.1 URI的组成结构与解析维度URI全称统一资源标识符HTTP请求行里的Request-URI通常是origin-form也就是/path/to/resource?querystring。它由路径和查询字符串组成用?分隔。路径里可能包含百分号编码查询字符串里可能有多个keyvalue用连接。解析URI不是简单按?切一刀因为路径里也可能有?的编码形式%3F但未编码的?就是分隔符。更完整的URI结构包括scheme、authority、path、query、fragment但在HTTP请求行里通常只有path和query。如果客户端发的是绝对URI比如GET http://example.com/path HTTP/1.1那还要解析出host。代理服务器会收到这种形式普通服务器一般收到的是origin-form。我的项目只支持origin-form遇到绝对URI返回400因为我不做代理。解析URI时我建议把路径和查询字符串分开存。路径用于路由匹配查询字符串用于参数解析。路径要做规范化比如去掉..和.防止目录穿越。但规范化要在解码之前做还是之后做我的经验是先按/切分逐段处理遇到..就回退一级遇到.就跳过遇到空段也跳过。处理完再拼接最后再对每段做百分号解码。这样能防止%2e%2e这种编码绕过。4.2 百分号编码与解码时机百分号编码是把非ASCII字符或特殊字符转成%XX形式XX是十六进制。比如空格是%20斜杠是%2F。解码就是把%XX还原成原字符。什么时候解码我的做法是解析阶段不解码原样存储路由匹配前对路径解码查询字符串解析时对每个值解码。为什么不在解析阶段统一解码因为解码后可能引入分隔符比如%2F解码成/会改变路径层级提前解码会让路由匹配出错。解码时要注意%后面必须跟两个十六进制数字否则是非法编码返回400。大小写都接受%2f和%2F等价。解码后的字节如果是控制字符也要拒绝。我写过一个解码函数逐字节扫描遇到%就检查后两位转成数字再拼回去。这个函数要处理边界比如字符串末尾是%或者%后面只有一个字符。注意查询字符串里的号在application/x-www-form-urlencoded里表示空格但URI规范里就是。所以解析查询字符串时要不要把转空格取决于Content-Type。GET请求的查询字符串通常按URI规范处理就是POST表单才把当空格。这个区别很多人搞混。4.3 路径规范化与安全防护路径规范化是防目录穿越的关键。攻击者可能发GET /../../etc/passwd如果你的服务器直接拼接文件路径就把系统文件读出来了。规范化步骤按/切分路径维护一个栈遇到.跳过遇到..弹出栈顶如果栈非空其他段压栈最后用/拼接。这样/a/b/../c变成/a/c/../../etc/passwd变成/etc/passwd如果栈空时遇到..直接忽略或返回400。但规范化要在解码之后做因为%2e%2e解码后才是..。所以顺序是先按原始路径切分对每段解码再对解码后的段做规范化。我见过一个项目先规范化再解码结果%2e%2e绕过了检查。这个坑我踩过后来改成先解码再规范化问题解决。另外路径末尾的/要不要保留/dir和/dir/语义不同前者是文件后者是目录。我的做法是保留原始末尾斜杠路由匹配时再决定是否重定向。还有路径里的连续斜杠//要不要合并规范说可以合并但合并可能改变语义我选择不合并原样保留路由层再处理。5. 完整解析流程与代码实现要点5.1 从字节流到请求对象的步骤拆解整个解析流程可以拆成几步读数据到缓冲区找CRLF定位请求行结束解析请求行循环解析请求头直到空行根据Content-Length或Transfer-Encoding读请求体最后组装成请求对象。每一步都要检查边界和错误。读数据时我用的是非阻塞IO加状态机。为什么不用阻塞读因为一个连接可能只发了一半数据就卡住阻塞读会拖死整个线程。状态机记录当前解析到哪一步下次数据来了继续。状态包括读请求行、读请求头、读请求体、完成。每次读到的数据追加到缓冲区然后尝试推进状态。找CRLF时我用memchr找\n再检查前一个字符是不是\r。为什么不用strstr找\r\n因为缓冲区里可能有二进制数据strstr遇到\0就停了。memchr更安全。找到CRLF后请求行就是缓冲区开头到CRLF之前的内容。解析请求行时我写了一个函数输入是行首指针和行长度输出是方法枚举、URI字符串、版本号。函数内部手动扫描空格做长度检查。解析请求头类似循环调用每次解析一行直到遇到空行。5.2 缓冲区管理与内存分配策略缓冲区管理是性能关键。我见过两种做法一种是固定大小数组简单但可能不够用另一种是动态扩容灵活但容易内存碎片。我的选择是固定大小加溢出保护。请求行和请求头共用一个8KB缓冲区请求体单独用一个大缓冲区比如1MB因为上传文件可能很大。如果请求头超过8KB返回431如果请求体超过1MB返回413 Payload Too Large。内存分配上我尽量避免在解析过程中malloc。请求对象里的字符串指针都指向缓冲区不复制。但这样有个问题缓冲区会被复用下次读数据会覆盖。所以解析完成后要么把请求对象里的数据复制出来要么在处理完请求前不复用缓冲区。我选择后者每个连接一个缓冲区处理完请求才重置。这样省去了复制开销但内存占用高一些。对于高并发场景可以用内存池但那是另一个话题。实操心得解析请求头时不要每读一行就realloc一次缓冲区。我一开始这么干压测时CPU全花在内存分配上了。后来改成固定缓冲区加状态机性能提升明显。5.3 错误处理与状态码返回解析过程中可能遇到各种错误每种错误对应不同的状态码。请求行格式错返回400方法不支持返回405URI太长返回414版本不支持返回505请求头太大返回431请求体太大返回413。返回错误响应时要尽量带上Connection: close因为解析出错后连接状态可能已经乱了继续复用容易出问题。错误响应的生成也要小心。比如返回400时响应体可以是一个简单的HTML页面但Content-Length要算对。我写了一个辅助函数输入状态码和消息输出完整的HTTP响应字节流。这个函数不依赖请求对象因为请求对象可能没解析完。还有一个细节如果解析请求行就失败了请求头还没读这时候直接返回错误并关闭连接。但如果解析请求头失败请求体可能还在路上要不要读完再关我的做法是直接关因为读请求体可能被恶意客户端用超大body拖死。关连接是最安全的。6. 常见问题与排查技巧实录6.1 解析失败典型场景与速查表现象可能原因排查方法解决返回400请求行解析失败方法或URI里有非法字符打印原始请求行十六进制检查客户端发送格式严格按规范解析返回400请求头解析失败头字段缺少冒号或冒号前有空格打印出错行内容严格校验冒号位置拒绝非法格式返回414URI超过缓冲区限制打印URI长度调整限制或优化客户端返回431请求头总大小超限统计所有头字段长度调整限制或让客户端精简头长连接下个请求解析错乱上个请求体没读完检查Content-Length读取逻辑确保读完请求体再进入下一轮中文路径乱码百分号编码未解码或解码错误打印解码前后字节检查解码函数确保UTF-8正确6.2 我踩过的三个坑与避坑指南第一个坑请求行解析用strtok。strtok会修改原字符串把空格换成\0导致后续无法获取完整URI。而且strtok不是线程安全的。后来我改用手动扫描问题解决。第二个坑请求头字段名没转小写。查找Content-Length时用strcasecmp性能差而且容易漏。后来统一转小写存储查找用strcmp快且准。第三个坑URI解码后没做路径规范化。测试时用/../能读到上级目录文件。后来加了规范化步骤先解码再规范化漏洞修复。提示解析HTTP请求时永远不要相信客户端发来的任何长度字段。Content-Length可能是假的Transfer-Encoding可能是恶意的。所有长度都要和实际读到的字节数交叉验证。6.3 性能优化与压测经验解析性能的瓶颈通常在字符串操作和内存分配。我做过对比用sscanf解析请求行比手动扫描慢30%用malloc分配请求对象比用栈上结构体慢50%。所以我的建议是能用手动扫描就不调库函数能用栈上分配就不用堆。请求对象可以放在连接结构体里连接建立时分配一次后续复用。压测时我用wrk和ab发现QPS上不去用perf一看热点在memchr找CRLF。后来优化成一次扫描同时找CRLF和记录位置减少重复扫描。另外请求头解析时如果头字段很多线性查找慢可以改成解析时直接填到固定偏移的结构体字段里比如Host填到req-hostContent-Length填到req-content_length这样后续不用查找。最后分享一个小技巧解析请求行时可以顺便把方法转成枚举URI的哈希值算出来后续路由匹配直接用哈希查表省去每次比较字符串。这个优化在路由规则多的时候效果明显。
阅读完成 · 觉得有帮助?
咨询建站