FlowingDev

解构HTTP报文:构建网络的原始数据包

解剖HTTP原始报文的结构,从起始行、头部到复杂的多部分正文,搞懂Web客户端和服务器之间是如何交流的。

试用工具: HTTP 消息构建器

一句话概括

HTTP报文就是一块格式化的纯文本,Web浏览器和服务器用它来互相通信。对于每一次网络互动,它都像是一张集运单、说明书和包裹本身于一体的组合体。

它解决了什么问题

在 1990 年代早期 Web 的混沌时期,一切都很简单。浏览器需要一种方式去问服务器:“嘿,能把那个 science.html 文件给我吗?”而服务器需要一种方式来回答:“当然,给你,”或者“抱歉,没找到。”这种对话需要规则——也就是协议。这个协议就成了 HTTP,即超文本传输协议(Hypertext Transfer Protocol)。

它解决的“问题”是为 Web 创建一种通用、无歧义的语言。如果没有一个标准格式,某个服务器可能期望请求只有一行,而另一个可能需要你写首俳句。那场面可就乱套了。最初的 HTTP/0.9 简单到爆:GET /the-page-i-want.html。服务器就会直接把 HTML 甩回来。

但 Web 并未止步于简单。我们需要向服务器发送数据来填写表单。我们需要处理不同类型的内容,比如图片,以及后来的 JSON。我们需要安全性、缓存,以及一种让浏览器自我介绍的方式。简单的单行请求演变成了一个结构化的、多部分的“报文”,包含一个起始行、一个元数据块(头部),以及一个可选的、用于存放实际负载的正文。对于任何直接跟 Web 基础设施、API 或安全打交道的人来说,手动构建这些报文成了一项基本技能,解决了如何在 Web 简单的请求-响应对话框上进行日益复杂的业务往来的问题。

底层工作原理

HTTP 报文的核心就是文本。如果你兴致来了,完全可以自己手动在终端里敲一个出来,然后用管道(pipe)发给服务器。这段文本分为三个部分:一个起始行、一个头部块,以及一个可选的正文,所有部分都由特定的换行符(\r\n,即 CRLF,“回车换行”)分隔。

报文有两种类型:请求(客户端到服务器)和响应(服务器到客户端)。它们看起来几乎一样,但第一行不同。

请求报文的解剖

这是你的浏览器在请求东西。

GET /documentation/guides/http-builder HTTP/1.1
Host: flowing.dev
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0
Accept: text/html,*/*
Accept-Language: en-US,en;q=0.5
Connection: keep-alive

<-- 正文会放在这里,但 GET 请求通常没有正文 -->
  1. 起始行 (Start-Line): GET /documentation/guides/http-builder HTTP/1.1

    • GET:HTTP 方法 (method)(或动词)。它代表你想做什么。GET 获取数据,POST 提交新数据,PUT 更新现有数据,DELETE 删除数据。
    • /documentation/...:资源路径。与 Host 头部结合,构成完整的 URL。
    • HTTP/1.1:协议版本。
  2. 头部 (Headers): 一系列键值对,提供了关于请求的关键元数据。

    • Host: flowing.dev:这个请求是发给谁的?在 HTTP/1.1 中,这个头部是强制性的。
    • User-Agent: Mozilla/5.0...:这个请求是谁发的?浏览器在这里表明身份。
    • Accept: text/html,*/*:我能理解哪种响应格式?这里,浏览器偏好 HTML,但也接受任何类型。
  3. 空行: 最后一个头部之后,一个单独的空行 (\r\n) 表示“头部结束,接下来是正文。”这玩意儿没得商量。要是漏了它,一切都得玩完。

  4. 正文 (Body): 实际的数据负载。对于 GET 请求,它通常是空的。对于 POST 或 PUT 请求,这里就是你的表单数据或 JSON 负载所在的地方。

响应报文的解剖

这是服务器对请求的回复。

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 15328
Server: Vercel
Date: Mon, 25 Sep 2023 10:30:00 GMT
Cache-Control: public, max-age=0, must-revalidate

<!DOCTYPE html>
<html>
  <head>...</head>
  <body>...</body>
</html>
  1. 状态行 (Status-Line): HTTP/1.1 200 OK

    • HTTP/1.1:协议版本,与请求相同。
    • 200:状态码。一个三位数的数字,总结了请求的结果。2xx 表示成功,3xx 表示重定向,4xx 表示你(客户端)搞砸了,5xx 表示我(服务器)搞砸了。
    • OK:原因短语。对状态码的人类可读的摘要。
  2. 头部 (Headers): 关于响应的元数据。

    • Content-Type: text/html:“我发给你的正文是 HTML。”这对于浏览器知道如何渲染负载至关重要。
    • Content-Length: 15328:“正文长度正好是 15,328 字节。”
    • Set-Cookie: ...:服务器告诉浏览器存储 cookie 的方式。
    • Cache-Control: ...:给浏览器或中间代理关于如何缓存此响应的指令。
  3. 正文 (Body): 客户端请求的资源——HTML、CSS、一个 JSON 对象、图片数据等等。

正文狂欢:给负载编码

当请求有正文时,它需要一个 Content-Type 头部来解释其格式。最常见的三种是:

  • application/x-www-form-urlencoded: 老派 HTML 表单的默认格式。它就是放在正文里的一个查询字符串。

    name=Grace+Hopper&title=Rear+Admiral
    
  • application/json: 现代 API 之王。正文是一个 JSON 字符串。

    {
      "name": "Grace Hopper",
      "title": "Rear Admiral"
    }
    
  • multipart/form-data: 用于提交包含文件上传的表单的格式。它就像是信中信。正文被分解成多个部分,每个部分由一个“边界”字符串分隔。每个部分都可以有自己的迷你头部(比如 Content-Disposition 和 Content-Type)和自己的内容。

    POST /profiles/edit HTTP/1.1
    Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
    
    ----WebKitFormBoundary7MA4YWxkTrZu0gW
    Content-Disposition: form-data; name="username"
    
    ada_lovelace
    ----WebKitFormBoundary7MA4YWxkTrZu0gW
    Content-Disposition: form-data; name="avatar"; filename="portrait.jpg"
    Content-Type: image/jpeg
    
    <...图片的原始二进制数据放在这里...>
    ----WebKitFormBoundary7MA4YWxkTrZu0gW--
    

真实世界的案例

丢失 Content-Type 悬案

一个开发者正在构建他的第一个 REST API。这个端点应该接受一个 JSON 负载来创建新用户。他写好了服务器代码,并用命令行工具测试,发送了一个完全有效的 JSON 对象。但服务器总是返回 400 Bad Request。他花了两个小时盯着他的 JSON,坚信自己漏掉了一个逗号。绝望中,他向一位资深开发者求助。资深开发者看了一眼请求,问道:“你的 Content-Type 头部呢?”原来,这位开发者发送了 JSON 数据,但从未告诉服务器那是 JSON。服务器框架期望的是默认的 x-www-form-urlencoded,于是试图将 JSON 当作查询字符串来解析,结果惨败,并拒绝了请求。

教训: 如果没有 Content-Type 头部来提供上下文,报文的正文就毫无意义。你必须给你的包裹贴对标签。

Multipart 的混淆

一个团队正在创建一个“设置”页面,用户可以更改他们的名字,并选择性地上传新的个人资料图片。初级前端开发者用两个独立的 API 调用实现了这个功能:一个 PUT 请求,JSON 正文中包含用户名;然后,如果选择了图片,再发一个 POST 请求,包含图片数据。这能用,但很笨拙,而且会产生竞态条件。要是名字修改成功了但图片上传失败了怎么办?用户就会处于一个不一致的状态。一位后端工程师看到了网络流量,把他们拉到一边解释说:“这正是 multipart/form-data 的完美应用场景。”他们重构了代码,构建了一个单一的 POST 请求,包含两个部分:一个用于用户名字段,一个用于图片文件。这简化了代码,并使整个更新成为一个原子操作。

教训: multipart 不仅仅用于文件。它用于在单个、可靠的请求中发送一堆混合数据——文本字段、文件、不同的内容类型。

缓存中的幽灵

一家电商网站正在进行闪购活动,但用户抱怨说他们看到的是过时的价格。运维团队百思不得其解;他们的服务器端缓存配置是正确的。一位 Web 性能专家被请来救场。她没有使用浏览器开发者工具,而是用了一个工具来检查产品页面的原始 HTTP 响应。她立刻就找到了罪魁祸首。在 Web 服务器前面,一个配置错误的负载均衡器正在注入它自己的 Cache-Control: public, max-age=3600 头部,覆盖了服务器期望的 Cache-Control: no-cache 头部。这个流氓头正在告诉浏览器和 CDN 把价格缓存一个小时,不管应用服务器怎么说。

教训: 原始 HTTP 报文是最终的真相来源。高级工具有时会隐藏或误解那些在文本本身中一目了然的细节。

常见错误和陷阱

  • 忘记空行。 一个 HTTP 报文在头部和正文之间必须有一个 CRLF (\r\n)。如果漏了,解析器会认为你的正文是另一个格式错误的头部,请求就会失败。
  • Content-Length 不匹配。 如果你声明了一个 Content-Length 头部,它的值必须是正文的确切字节大小。如果太小,你的数据会被截断。如果太大,服务器会永远地等待那些永远不会到来的字节。
  • 错误的 Content-Type。 发送一个 JSON 正文,却把它标记为 text/plain,这绝对是导致 4xx 错误的配方。头部和正文必须保持一致。
  • CRLF vs. LF。 官方规范要求使用 \r\n 作为换行符。大多数现代服务器都很宽容,会接受简单的 \n(换行符)。然而,依赖这一点可能会导致你的请求在遇到更老、更严格的服务器、代理或防火墙时失败。
  • 特殊字符编码。 忘记对查询字符串或 x-www-form-urlencoded 正文中的数据进行 URL 编码是一个经典的 bug。一个空格必须变成 %20,一个 & 必须变成 %26,等等,否则你的数据可能会被损坏。

为什么你应该关注它

大多数时候,你的浏览器、框架或库(比如 axios 或 requests)会为你处理构建 HTTP 报文的脏活累活。但你应该知道在以下情况下如何手动操作:

  • 你正在深度调试。 当一个 API 调用不工作且错误信息很模糊时,检查或重新创建原始 HTTP 报文是最终的裁决者。它让你能看到网络线路上到底传输了什么,不受任何抽象的干扰。
  • 你正在构建或测试一个 API。 理解报文结构是设计良好 API 端点和编写有效集成测试的基础。安全测试人员整天都在精心构造格式错误的报文来寻找漏洞。
  • 你正在爬取一个网站。 为了成功模拟真实浏览器并绕过反机器人措施,你通常需要构建一个具有非常特定头部组合(User-Agent, Referer, Accept-* 等)的请求。
  • 你正在使用 webhook。 当你的应用程序从 Stripe 或 GitHub 等服务接收 webhook 时,你就是原始 HTTP 请求的接收方。你需要解析它的头部(例如,为了安全签名)和它的正文来对事件采取行动。

知道如何从零开始组装一个 HTTP 报文,就像一个机械师懂内燃机的工作原理一样。你不是每天都这么做,但当出现问题时,这种基础知识是无价的。

深入了解

理论搞定,动手试试吧——100% 在你的浏览器中运行。

试用工具: HTTP 消息构建器