跳到内容

第 4 卷 / 第 4 章 / 第 1 课

4.1 HTTP 消息、方法与缓存语义 ​

TCP 交付连续字节,应用还需要知道客户端要读取哪个资源,服务器处理后又应返回什么结果。HTTP 为请求方法、目标资源、状态码和表示格式定义共同语义,让客户端能分清“成功”“没找到”和“稍后再试”。

不要把 request/response 等同于文本消息:HTTP/1.1 使用文本形态的 start-line 和 field,content 可以是任意 byte;HTTP/2 与 HTTP/3 则使用二进制 frame。三种版本共享方法、状态码、field 和 representation 语义,wire format 并不相同。

本课先让一份请求拥有明确含义。下一课再建服务器,处理 CRLF、body 长度、持久连接和恶意 framing。

1. Resource、representation 与 message ​

访问:

text
https://atlas.example/chapters/4

URI 标识目标 resource。服务器返回的 HTML、JSON 或图片是这个 resource 在某次交互中的 representation,不等于 resource 本身。资源可能随时间变化,也可能依据语言、压缩能力或权限选择不同表示。

一次 HTTP exchange 包含 request 与一个或多个 response。request 表达客户端意图,response 用 status code、field 和可选 content 表达处理结果。

这一区分解释了几个常见现象:

  • 同一 URI 可以返回 JSON 或 HTML;
  • PUT 修改的是目标资源的状态,不是“把一个 packet 放进去”;
  • cache 保存的是 response/representation,需要知道它对应哪些选择条件;
  • 204 可以表示操作成功,却没有 response content。

2. 一次 HTTP/1.1 往返 ​

客户端请求第 4 章对应的资源:

http
GET /chapters/4 HTTP/1.1
Host: atlas.example
Accept: text/plain

空行结束 header section。服务器回应:

http
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 6
Cache-Control: max-age=60

hello

HTTP/1.1 message 的形状是:

text
start-line CRLF
zero or more field lines, each ending CRLF
CRLF
optional message body

request start-line 包含 method、request-target 和 version;response start-line 包含 version、status code 和可选 reason phrase。reason phrase 只供人阅读,客户端逻辑必须依据 status code。

这段示意没有展示 packet 边界。一次 recv 可能只拿到半行,也可能同时拿到两条 message 的数据。怎样从 stream 读出完整 HTTP/1.1 message,是 4.2 的主题。

这两个 http 代码块为了阅读省略了不可见字符:请求末尾必须有空行,响应 content 是 hello\n,即 6 个字节。HTTP 行分隔符是 CRLF,content 内的换行在本例中是单字节 LF;不能按编辑器显示行数推导 Content-Length。

3. Field 名称不区分大小写,值不能一概处理 ​

Content-Type 与 content-type 是同一个 field name。解析器通常把名称规范成小写,但 field value 的语法由每个 field 自己定义:

  • Content-Length 是十进制长度;
  • Content-Type 包含 media type 与 parameter;
  • Cache-Control 是 directive 列表;
  • Set-Cookie 有自己的语法,不能随意用逗号合并;
  • Vary 列出参与 representation 选择的 request field。

因此,“把所有重复 header 用逗号拼起来”不是通用规则。field section 和单个 field 都要有明确大小上限;HTTP 规范不替实现规定一个全球固定值。

Header 也不应直接承载任意中文文本。field value 通常受 US-ASCII 范围和各自语法约束,需要更广字符集的 field 会定义自己的编码方式。业务文本放在有明确 media type 与 charset 的 content 中更稳妥。

4. 方法先表达意图 ​

Method 表达客户端希望对目标资源执行的操作。这个“动词”是协议合同的一部分,不是 URL 命名风格。

MethodSafeIdempotent常见意图
GET是是获取当前 representation
HEAD是是获取与 GET 相同的 metadata,不传 response content
OPTIONS是是查询通信选项
POST否通常否让目标按自身语义处理 content
PUT否是用给定 representation 创建或替换目标状态
DELETE否是请求移除目标资源的当前关联
PATCH否规范未保证按 patch document 修改资源

Safe 表示客户端请求的语义是只读;服务端仍可能记录日志、计费或更新统计。Idempotent 表示重复同一请求时,服务器“预期效果”与一次相同,response status 不必每次一样。

例如,重复 DELETE /notes/7 的第一次可能返回 204,第二次返回 404;删除效果仍是“该关联不存在”。重试 POST /orders 却可能创建两份订单,除非应用额外设计 idempotency key 或去重合同。

5. Method 与 body 是两条规则 ​

“GET 绝对不能有 body”说得太满。HTTP/1.1 framing 可以标出 request content,但 RFC 9110 没有为 GET content 定义普遍语义;客户端不应发送,除非与 origin 有明确约定。中间件和代理也可能拒绝或忽略它。

浏览器 Fetch API 更严格:构造带 body 的 GET/HEAD 请求会被拒绝。即使你掌握 origin,也不能假定所有客户端库和代理都会转发这样的请求。

同样,method 允许 content 不代表任何 media type 都合理。服务端要同时检查:

  • method 是否接受 content;
  • Content-Type 是否受支持;
  • content framing 是否完整;
  • 大小是否在限制内;
  • 业务 schema 是否有效。

这几类失败应分开处理。畸形 framing 通常是 400;不支持的 media type 常用 415;语法正确但业务内容无法处理可用 422,具体合同由 API 定义。

6. Status code 是机器可读的结果类别 ​

状态码第一位给出大类:

范围类别处理直觉
1xxInformational最终响应前的中间信息
2xxSuccessful请求已按该状态语义处理
3xxRedirection需要重定向或使用已有 representation
4xxClient Error请求本身或客户端状态有问题
5xxServer Error服务器处理有效请求时失败

工程中更重要的是具体语义:

Code含义与边界
200成功,content 取决于 method
201已创建资源,常用 Location 指向新资源
202已接受处理,不表示异步任务已经完成
204成功且没有 content
301/308永久重定向;301 允许历史上的 POST 转 GET,308 不允许改 method
302/303/307临时或间接重定向;307 保留 method,303 通常转为 GET
304条件 GET/HEAD 可复用缓存 representation,不含 message content
400server 无法或不愿处理明显的 client error,例如畸形语法
401缺少有效 authentication credentials,必须带至少一个 challenge 的 WWW-Authenticate
403已理解请求但拒绝执行;补认证不一定能解决
404找不到 target,或 server 不愿透露其存在
405method 对目标不允许,response 必须带 Allow
409与资源当前状态冲突
413request content 超出 server 愿意处理的大小
429请求频率过高,可以带 Retry-After
500未预期的 server failure
502gateway/proxy 从 upstream 收到无效响应
503暂时无法处理,例如 overload 或 maintenance,可以带 Retry-After

客户端不能把所有 4xx 都永久失败、所有 5xx 都自动重试。重试还要检查 method 是否可安全重放、body 能否重发、总 deadline、backoff 和应用幂等合同。

7. Content negotiation 要配合 Vary ​

客户端可以用 Accept、Accept-Language、Accept-Encoding 等 field 表达偏好。服务器可能对同一 URI 选择不同 representation:

http
GET /guide HTTP/1.1
Host: atlas.example
Accept-Language: zh-CN
Accept-Encoding: gzip

若 response 依据这些 request field 变化,cache 需要知道选择维度:

http
Vary: Accept-Language, Accept-Encoding

缺少正确 Vary 可能让 shared cache 把一种语言或编码的 response 交给不匹配的请求。反过来,随意写 Vary: * 或加入大量高基数字段会显著降低复用率。

8. Freshness 与 validation 是两步 ​

缓存先判断 response 能否存储,再判断当前是否 fresh。过期后可以向上游验证已有表示,不一定重新传输全部 content。下面的 directive 使用常见的无 field-name 参数形式:

Directive含义
max-age=Nfreshness lifetime 为 N 秒;current age 小于它时为 fresh
s-maxage=Nshared cache 的 freshness lifetime
privateshared cache 不得存储,private cache 仍可
public明确允许 shared cache 存储,即使通常规则不允许
no-cache可以存,但复用前必须成功 validation
no-storecache 不得存储;它不是防窃听或隐私的充分手段
must-revalidatestale 后不得在未成功 validation 时复用

Age 估计响应自 origin 生成或成功验证以来的年龄,包含此前缓存停留等时间,不是刚进入当前缓存就从零计时。freshness 还会受 Date、Expires、heuristic 和 shared/private 规则影响,不能只搜索一个 max-age 字符串下结论。

例如收到时修正后的初始年龄为 40 秒,又在本地缓存停留 25 秒,max-age=60 已不够。下面只计算年龄加本地停留时间;完整算法还要处理 Date、响应延迟和时钟差异:

python
freshness_lifetime = 60
corrected_initial_age = 40  # simplified: includes the received Age
resident_time = 25
current_age = corrected_initial_age + resident_time
print(current_age, current_age < freshness_lifetime)  # 65 False

9. ETag 让条件请求省掉 content ​

第一次请求:

http
HTTP/1.1 200 OK
ETag: "chapter-4-v3"
Cache-Control: no-cache
Content-Length: 6

hello

再次使用前,client/cache 验证:

http
GET /chapters/4 HTTP/1.1
Host: atlas.example
If-None-Match: "chapter-4-v3"

representation 未变时,server 返回 304,不发送 content;变了则返回新的 200 representation。请求仍发生,节省的是 response content 和传输/生成工作,不是“零请求”。

下面的 Python 3.9+ 脚本只演示已有资源的 GET 条件判断,不实现 wire parser。参数是假设由 HTTP 库解析并验证过的 ETag tuple、通配符 "*" 或 None;不能拿它直接解析逗号分隔的原始 field。If-None-Match 使用 weak comparison,所以 W/"chapter-4-v3" 也匹配:

python
CURRENT_ETAG = '"chapter-4-v3"'
CONTENT = "hello\n".encode("utf-8")

def conditional_get(if_none_match):
    headers = {
        "ETag": CURRENT_ETAG,
        "Cache-Control": "no-cache",
    }
    if if_none_match is not None and if_none_match != "*":
        if not isinstance(if_none_match, tuple):
            raise ValueError("pass parsed tags as a tuple, or '*'")
    matched = if_none_match == "*" or any(
        tag.removeprefix("W/") == CURRENT_ETAG
        for tag in (if_none_match or ())
    )
    if matched:
        return 304, headers, b""

    headers["Content-Type"] = "text/plain; charset=utf-8"
    headers["Content-Length"] = str(len(CONTENT))
    return 200, headers, CONTENT

first = conditional_get(None)
validated = conditional_get((first[1]["ETag"],))
assert conditional_get(('W/"chapter-4-v3"',))[0] == 304
assert conditional_get("*")[0] == 304

assert first[0] == 200 and first[2] == b"hello\n"
assert validated[0] == 304 and validated[2] == b""
print(first[0], first[1]["Content-Length"])
print(validated[0], len(validated[2]))

运行:

bash
python3 conditional_get.py

预期输出:

text
200 6
304 0

ETag 可以是 strong 或 weak validator;Last-Modified / If-Modified-Since 也能做时间精度较低的 validation。修改请求还可以用 If-Match 防止 lost update,这与“是否使用缓存”是相关但不同的用途。

If-Match 使用 strong comparison;客户端 A 用 v3 更新到 v4 后,客户端 B 再拿 v3 修改应得到 412。服务器必须把比较与写入做成原子条件更新,否则两次先检查后写入仍会互相覆盖。生成 ETag 本身也可能需要渲染或读取数据,304 不保证省掉全部生成成本。

HTTP 核心交互不要求 server 保存 client session。Cookie 让 server 通过 response 要求 user agent 存一小段状态,并在匹配的后续请求中发送:

http
Set-Cookie: session=opaque-token; Path=/; Secure; HttpOnly; SameSite=Lax

随后:

http
Cookie: session=opaque-token

通常应让 cookie 保存不可猜测的 session identifier,真实权限和业务状态留在 server。几个 attribute 解决不同边界:

  • Secure 限制 cookie 通过 secure channel 发送;
  • HttpOnly 阻止非 HTTP API(例如页面脚本)读取 cookie,但不能消除所有 XSS 影响;
  • SameSite 控制部分 cross-site request 是否携带 cookie;
  • Domain 与 Path 决定发送范围,不是强授权边界;
  • expiry 控制 user agent 保留多久,不保证 server session 同时有效。

Cookie 的基础规则见 RFC 6265,SameSite 等扩展见后继 Cookie 规范。实现登录与跨站场景时,应以目标 browser 与最新规范为准,并结合 CSRF token、origin 检查和 server-side authorization。

11. CORS 不是 server authorization ​

在浏览器里,same-origin policy 限制页面读取 cross-origin response。CORS 让 server 声明哪些 origin 可以共享 response;它不会阻止非浏览器 client,也不能替 server 验证身份和权限。

某些 cross-origin request 会先发送 preflight:

http
OPTIONS /notes HTTP/1.1
Host: api.example
Origin: https://app.example
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type

server 同意后返回相应 Access-Control-Allow-* field。实际 request 才会继续。

两个容易踩坑的边界:

  • “浏览器不让 JavaScript 读取 response”不等于 server 没收到 request;有副作用的 endpoint 仍要做 CSRF 与 authorization 防护;
  • credential mode 为 include 时,Access-Control-Allow-Origin: * 不能与 credentials 组合使用,server 要返回明确 origin,并谨慎设置 Access-Control-Allow-Credentials: true。

Preflight 本身不携带 credentials。CORS cache 与普通 HTTP cache 也不是同一套状态。

这里的无 credentials 是 Fetch 规范对跨源预检的要求;实际浏览器与企业客户端证书场景可能有兼容差异。若 Access-Control-Allow-Origin 依请求 Origin 动态变化,可缓存响应还应带 Vary: Origin,且返回值来自允许列表,不能无条件照抄请求。

12. 常见误解 ​

“HTTP 就是纯文本协议。” 这只近似描述 HTTP/1.1 的 start-line 与 field;content 可为任意 byte,HTTP/2/3 使用 binary framing。

“GET 一定没有 body。” GET content 没有普遍定义的语义,客户端通常不应发送;这和 wire framing 能否表达是两件事。

“幂等就是每次 response 完全一样。” 幂等约束 intended effect,status、时间戳和 representation 可以变化。

“no-cache 表示不能缓存。” 它要求复用前 validation;no-store 才禁止 cache 存储。

“304 是跳过请求。” 条件请求已经到达 server;304 省掉 response content。

“CORS 拦住了攻击者。” 它主要约束支持 Fetch/CORS 的 user agent 是否把 response 暴露给页面,不是 authentication 或 firewall。

13. 练习与验收 ​

  1. 为“创建任务”“把任务 7 标记为完成”“读取任务 7”“删除任务 7”选择 method,并说明哪些请求可自动重试。
  2. 写出 401、403、404、405 的差异;405 response 还缺哪个 field?
  3. 修改条件请求脚本,同时把 ETag 与 CONTENT 更新为 v4 对应值,验证旧 If-None-Match 得到 200 和新 content。
  4. 设计一个中英文两种 representation 的 response,写出必要的 Vary。
  5. 解释为什么 Cache-Control: no-store 仍不能替代 TLS。

验收条件:能从一条 request 说清 target、method、field 和 content;能从 response status 判断下一步,却不会仅凭状态码决定盲目重试。

14. 消息语义明确后,还要守住 framing 边界 ​

下一课进入4.2 HTTP/1.1 Framing 与最小服务器。服务端必须处理半行 request、重复 Content-Length、慢速 client 和连接关闭,不能假设一次 recv 恰好收到完整消息。

参考 ​

Built with VitePress | Software Systems Atlas