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
访问:
https://atlas.example/chapters/4URI 标识目标 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 章对应的资源:
GET /chapters/4 HTTP/1.1
Host: atlas.example
Accept: text/plain空行结束 header section。服务器回应:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 6
Cache-Control: max-age=60
helloHTTP/1.1 message 的形状是:
start-line CRLF
zero or more field lines, each ending CRLF
CRLF
optional message bodyrequest 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 命名风格。
| Method | Safe | Idempotent | 常见意图 |
|---|---|---|---|
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 是机器可读的结果类别
状态码第一位给出大类:
| 范围 | 类别 | 处理直觉 |
|---|---|---|
| 1xx | Informational | 最终响应前的中间信息 |
| 2xx | Successful | 请求已按该状态语义处理 |
| 3xx | Redirection | 需要重定向或使用已有 representation |
| 4xx | Client Error | 请求本身或客户端状态有问题 |
| 5xx | Server 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 |
| 400 | server 无法或不愿处理明显的 client error,例如畸形语法 |
| 401 | 缺少有效 authentication credentials,必须带至少一个 challenge 的 WWW-Authenticate |
| 403 | 已理解请求但拒绝执行;补认证不一定能解决 |
| 404 | 找不到 target,或 server 不愿透露其存在 |
| 405 | method 对目标不允许,response 必须带 Allow |
| 409 | 与资源当前状态冲突 |
| 413 | request content 超出 server 愿意处理的大小 |
| 429 | 请求频率过高,可以带 Retry-After |
| 500 | 未预期的 server failure |
| 502 | gateway/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:
GET /guide HTTP/1.1
Host: atlas.example
Accept-Language: zh-CN
Accept-Encoding: gzip若 response 依据这些 request field 变化,cache 需要知道选择维度:
Vary: Accept-Language, Accept-Encoding缺少正确 Vary 可能让 shared cache 把一种语言或编码的 response 交给不匹配的请求。反过来,随意写 Vary: * 或加入大量高基数字段会显著降低复用率。
8. Freshness 与 validation 是两步
缓存先判断 response 能否存储,再判断当前是否 fresh。过期后可以向上游验证已有表示,不一定重新传输全部 content。下面的 directive 使用常见的无 field-name 参数形式:
| Directive | 含义 |
|---|---|
max-age=N | freshness lifetime 为 N 秒;current age 小于它时为 fresh |
s-maxage=N | shared cache 的 freshness lifetime |
private | shared cache 不得存储,private cache 仍可 |
public | 明确允许 shared cache 存储,即使通常规则不允许 |
no-cache | 可以存,但复用前必须成功 validation |
no-store | cache 不得存储;它不是防窃听或隐私的充分手段 |
must-revalidate | stale 后不得在未成功 validation 时复用 |
Age 估计响应自 origin 生成或成功验证以来的年龄,包含此前缓存停留等时间,不是刚进入当前缓存就从零计时。freshness 还会受 Date、Expires、heuristic 和 shared/private 规则影响,不能只搜索一个 max-age 字符串下结论。
例如收到时修正后的初始年龄为 40 秒,又在本地缓存停留 25 秒,max-age=60 已不够。下面只计算年龄加本地停留时间;完整算法还要处理 Date、响应延迟和时钟差异:
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 False9. ETag 让条件请求省掉 content
第一次请求:
HTTP/1.1 200 OK
ETag: "chapter-4-v3"
Cache-Control: no-cache
Content-Length: 6
hello再次使用前,client/cache 验证:
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" 也匹配:
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]))运行:
python3 conditional_get.py预期输出:
200 6
304 0ETag 可以是 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 不保证省掉全部生成成本。
10. Cookie 把状态放在 user agent
HTTP 核心交互不要求 server 保存 client session。Cookie 让 server 通过 response 要求 user agent 存一小段状态,并在匹配的后续请求中发送:
Set-Cookie: session=opaque-token; Path=/; Secure; HttpOnly; SameSite=Lax随后:
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:
OPTIONS /notes HTTP/1.1
Host: api.example
Origin: https://app.example
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-typeserver 同意后返回相应 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. 练习与验收
- 为“创建任务”“把任务 7 标记为完成”“读取任务 7”“删除任务 7”选择 method,并说明哪些请求可自动重试。
- 写出 401、403、404、405 的差异;405 response 还缺哪个 field?
- 修改条件请求脚本,同时把 ETag 与 CONTENT 更新为 v4 对应值,验证旧
If-None-Match得到 200 和新 content。 - 设计一个中英文两种 representation 的 response,写出必要的
Vary。 - 解释为什么
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 恰好收到完整消息。