FlowingDev

HTTP:驱动 Web 世界的明信片

学习原始 HTTP 请求和响应是如何构成的。这些作为 Web 基石的纯文本消息,由标头、正文和状态行组成。

试用工具: HTTP 消息查看器

一句话概括

HTTP 消息是一种特殊格式的纯文本块,客户端(比如你的浏览器)和服务端就靠它在互联网上互相“唠嗑”,请求和发送网页、数据,还有猫猫图。

它解决了什么问题

早在数字石器时代(80 年代末/90 年代初),互联网有点像个“狂野西部”。你有用于不同任务的不同协议:FTP 用于文件传输,Gopher 用于文档菜单,还有一堆其他的小众系统。它们之间基本鸡同鸭讲。就好比你想联系的每个人,都得用不同的邮递员和信封,麻烦得要死。

然后,蒂姆·伯纳斯-李爵士(Sir Tim Berners-Lee)带着他对“万维网”(World Wide Web)的愿景登场了——一个由超文本文档链接起来的统一系统。为了实现这个愿景,他需要一种简单、通用的语言,任何计算机都可以用它来请求和接收文档。这种语言需要是无状态的(stateless),也就是说每个请求都是一个独立的事件,服务器不需要记住过去的对话。而且至关重要的一点是,它至少在原则上需要是人类可读的(human-readable),以便于调试。

于是,超文本传输协议(Hypertext Transfer Protocol),简称 HTTP,应运而生。它通过定义一种标准的消息格式,一种 Web 通用的“明信片”,解决了这个问题。这张明信片上有指定的位置用来填写收件人地址(服务器和路径)、发件人信息、关于内容的简短说明(标头),以及实际内容(正文)。这种标准化的结构意味着任何客户端都可以与任何服务器对话,从而创造了我们今天所熟知和喜爱的、互联互通的 Web 世界。

底层工作原理

从本质上讲,HTTP 消息就是一串文本流。但它不是随便什么文本,而是有着严格的结构。你不能随便在一张数字餐巾纸上涂鸦一句“赶紧把首页交出来!”,然后把它扔给服务器。消息主要分为两种类型:请求(索要)和响应(给予)。

请求消息的剖析

这是当你在浏览器地址栏输入 URL 或点击链接时,你的浏览器发送的东西。它最多由三部分组成,由特定的换行符(在代码中是 CRLF 或 \r\n)分隔。

  1. 起始行 (Start-Line): 单独的一行,说明你想要什么,它在哪里,以及你正在说的是哪个语言版本。 METHOD /path/to/resource HTTP/Version

    GET /documentation/guides/http HTTP/1.1
    
    • GET 是方法 (Method)。它是请求的动词。
    • /documentation/guides/http 是资源路径 (Resource Path)。
    • HTTP/1.1 是协议版本 (Protocol Version)。
    常见方法 含义 是否有正文?
    GET “请给我这个资源。” 否
    POST “给你些数据,帮我创建点东西。” 是
    PUT “给你些数据,帮我更新/替换掉。” 是
    DELETE “请删除这个资源。” 否
    HEAD “只要标头,别给正文。” 否
  2. 标头 (Headers): 一系列 Key: Value 键值对,提供关于请求的元数据。可以把它们想象成明信片背面的勾选框和备注。

    Host: flowing.dev
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...
    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
    Accept-Language: en-US,en;q=0.5
    
    • Host:最重要的标头。它告诉服务器你想访问哪个网站,对于在同一个 IP 地址上托管多个站点的服务器来说至关重要。
    • User-Agent:“我用的是这个浏览器/工具。”
    • Accept:“我希望接收这些格式的内容。”
  3. 至关重要的空行: 在最后一个标头之后,有一个完全空白的行(CRLF)。这是一个不可或缺的分隔符。它标志着:“标头到此结束,接下来是正文(如果有的话)。”

  4. 正文 (Body)(可选): 也就是载荷 (payload)。对于 GET 或 HEAD 请求,这部分是空的。对于 POST 或 PUT 请求,这里存放着你发送的数据——比如 API 调用的 JSON 载荷、表单提交的内容等。

    {
      "username": "dev-guru",
      "email": "guru@example.com"
    }
    

响应消息的剖析

这是服务器发回来的东西。它的结构与请求类似,但作用不同。

  1. 状态行 (Status-Line): 单独的一行,告诉你请求是否成功以及原因。 HTTP/Version StatusCode StatusText

    HTTP/1.1 200 OK
    
    • StatusCode(状态码)是最关键的部分。它是一个三位数,概括了请求的结果。
    状态码家族 含义 示例
    2xx 成功!一切顺利。 200 OK
    3xx 重定向。你得去别处看看。 301 Moved Permanently
    4xx 客户端错误。你搞砸了。 404 Not Found
    5xx 服务端错误。我搞砸了。 500 Internal Server Error
  2. 标头 (Headers): 描述响应的 Key: Value 键值对。

    Date: Fri, 24 May 2024 12:00:00 GMT
    Content-Type: text/html; charset=utf-8
    Content-Length: 4096
    Cache-Control: max-age=600
    
    • Content-Type:“我发给你的是这个。具体来说,是一个 UTF-8 编码的 HTML 文档。”
    • Content-Length:“我的响应正文不多不少,正好 4096 字节。”
    • Cache-Control:“你(或中间的任何代理)可以把这份内容的副本缓存 600 秒。”
  3. 那个空行: 没错,这儿也有它。用来分隔标头和正文。

  4. 正文 (Body): 你请求的真正内容!网页的 HTML、API 返回的 JSON 数据、图片文件等等。这就是 Content-Type 中提到的“内容”。

实战故事

神秘的 401 悬案

一位开发者正在集成一个第三方 API。她确信自己发送了正确的 API key,但每个请求都返回 401 Unauthorized 错误。她的代码看起来完美无瑕:api.setAuth('my-secret-key')。在抓狂之际,她捕获了她的框架发送的原始 HTTP 请求。

原始消息揭示了真相:

POST /v1/widgets HTTP/1.1
Host: api.thirdparty.com
Content-Type: application/json
Api-Key: my-secret-key

{ "name": "New Widget" }

她再次查阅 API 文档。原来,认证标头应该是 Authorization,而不是 Api-Key,并且值需要加上 Bearer 前缀。她的框架的抽象过于简单,用了错误的标头名称。她绕过了那个辅助方法,手动设置了标头,下一个请求便顺利通过,并返回了 201 Created。

教训: 框架和库是很有用的抽象,但原始 HTTP 消息才是“铁证”。当感觉不对劲时,就去检查原始消息,看看网络上到底发送了什么。

缓存之谜

一个营销团队上线了一个新的落地页,但公司里有一半人看到的仍然是旧的“即将上线”页面,即使他们疯狂地按 Ctrl+F5 也没用。开发者坚称这不是服务端代码的问题。他怀疑是缓存问题,于是用工具检查了该页面的原始 HTTP 响应标头。

来自服务器的响应是这样的:

HTTP/1.1 200 OK
Content-Type: text/html
...
Cache-Control: public, max-age=86400
Age: 34500

Cache-Control 标头告诉整个链路上的每个浏览器和代理服务器,把这个页面缓存 86400 秒(整整一天!)。而 Age 标头显示,当前提供的版本已经超过 9 个小时了。原来是他们的 CDN(内容分发网络)上一个错误的配置,对所有 HTML 页面都应用了过于激进的缓存策略。他们修复了 CDN 规则后,新页面立刻对所有人可见了。

教训: 响应标头不仅仅是元数据;它们是控制浏览器、代理和 CDN 的指令。理解 Cache-Control、Expires 和 ETag 对于管理内容的交付方式至关重要。

无声的正文窃贼

一个新手程序员构建了一个简单的 API 端点来接收用户反馈。在他的本地机器上运行得非常完美。但在预发环境 (staging environment) 中,表单提交总是失败。服务器日志显示收到了 POST /feedback 请求,但请求体总是空的。用户数据凭空消失了。

他百思不得其解,于是将服务器收到的完整原始 HTTP 请求打印了出来。对于一个测试提交,他看到了这个:

POST /feedback HTTP/1.1
Host: staging.myapp.com
Content-Type: application/json
Content-Length: 0

{}

但他知道他的客户端代码明明发送了一个完整的 JSON 对象!Content-Length 是 0,正文也是空的。他反向追查,发现预发环境的 Web 应用防火墙(WAF)中有一条安全规则被错误地配置了,它会剥离任何发往未知路径的 POST 请求的正文。因为 /feedback 是一个新的端点,WAF 就通过默默“吃掉”数据的方式来“保护”服务器。

教训: Content-Length 和 Content-Type 标头是客户端与服务器之间的一份契约。如果它们不能准确描述正文,事情就会以令人困惑的方式崩溃。在调试数据传输问题时,一定要检查它们。

常见错误和陷阱

  • 忘了那个空行。 标头和正文之间的那个空行(CRLFCRLF)不是可选的空白。它是最根本的分隔符。没有它,整个消息就是格式错误的,服务器将不知道标头在哪里结束,载荷从哪里开始。
  • Content-Length 不匹配。 如果你的标头说 Content-Length: 100,但你只发送了一个 50 字节的正文,服务器会一直挂起,等待另外 50 字节直到超时。如果你发送了 150 字节,多出来的 50 字节可能会被误解为一个新的、混乱的请求的开始。
  • CRLF 与 LF 换行符之争。 HTTP 规范非常严格:行必须以一个回车符(Carriage Return)后跟一个换行符(Line Feed)(\r\n)结束。虽然许多现代服务器比较宽容,会接受简单的换行符(\n),但一些老旧或更严格的服务器会拒绝该消息或错误地解析它。
  • 忽略 Content-Type。 你可能在你的 POST 正文中发送了一个完全有效的 JSON 对象,但如果你不包含 Content-Type: application/json 标头,服务器可能会认为它是 application/x-www-form-urlencoded(表单的默认类型),从而导致解析失败。
  • 标头的大小写混淆。 标头名称是大小写不敏感的(Content-Type 和 content-type 是一样的)。然而,标头值可能是,而且通常是,大小写敏感的。API key 或 Base64 编码的值就是很好的例子。

为什么你应该关注它

如果你从事任何与 Web 开发、API 设计甚至网络安全相关的工作,理解原始 HTTP 消息就不是可选项,而是基本功。你的高层框架和库在隐藏这些细节方面做得很好,但当它们失灵或行为异常时,你必须能够剥开层层封装,查看原始的通信内容。

在以下任何时候,你都应该考虑原始 HTTP 消息:

  • 调试任何与网络相关的错误(4xx 或 5xx 状态码)。
  • 试图优化 Web 性能(缓存、压缩)。
  • 构建或使用 API。
  • 设置重定向、代理或负载均衡器。
  • 调查 Web 安全漏洞(例如,标头注入 (header injection))。

知道如何阅读和解释这些消息,就像一个机械师懂发动机的工作原理。你不需要每次开车时都想着它,但当车抛锚时,这是唯一能搞清楚到底发生了什么的方法。

深入探索

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

试用工具: HTTP 消息查看器