第 5 章 首部字段:协议的扩展插槽
首部是 HTTP 最伟大的发明之一:它让你在不改协议版本、不改现有代码的情况下,凭空”长”出新功能。
5.0 引子:加一个功能,要改协议版本吗?
小美:老哥,能不能给接口加个”请求 ID”,就是每次请求带一个唯一 ID,方便我们对账排查?
你:行,我在 body 里加个字段 request_id。
小美:可是有的接口是 GET,没有 body 啊。而且运维想在网关日志里就能看到这个 ID,不用进到 body 里解析。
你:那……放 URL 参数里??request_id=xxx?
老张:放 URL 里,参数一多就乱,而且会污染缓存键。
墨叔:行了,别纠结了。你想要的这个功能,HTTP 在 30 年前就给你准备好了位置——首部。
GET /orders HTTP/1.1
Host: api.example.com
X-Request-Id: 8f14e45f-ea2b-4c9a-b3d1-7a2c5e8f9b01 <- 就放这儿你:所以首部就是”随便加键值对的地方”?
墨叔:是,但也不全是。首部有一套精妙的设计规则,你用对了,它就是你的扩展利器;用错了,它会反过来咬你。今天这一章,讲透首部。
5.1 先把问题问对:首部到底是干嘛的
墨叔:你觉得,协议里为什么需要首部?方法、路径、body 不够吗?
你:body 放业务数据,首部放元数据?
墨叔:对,但更准确的说法是——
首部是”关于报文本身的元数据”,是协议为了可扩展性而预留的”扩展插槽”。
你想加一个新能力——认证、压缩、缓存控制、跨域、追踪——都不需要改协议的语法,只需要加一个首部。这解决了协议设计里一个根本难题:
协议发布之后,怎么加新功能?
你:改版本?升级?
墨叔:改版本是最笨的办法,成本极高。第 1 章说过,HTTP 要活很多年,中间设备遍布全球,你不可能让所有人一起升级。
首部给出了一个优雅的答案:把”功能”和”报文”解耦。新功能 = 新首部。老客户端不认识新首部,忽略它就行;新客户端要用新功能,就带上它。
这一条,就是首部设计的核心契约,也是全章最重要的一句话:
任何实现,遇到不认识的首部,必须忽略它,而不是报错。
5.2 为什么”未知首部必须忽略”是命根子
墨叔:为什么这条契约这么重要?我给你一个反事实的推演。
假设规则反过来了——遇到不认识的首部就报错。那么:
- 你今天加一个
X-Request-Id首部。 - 全球有 50 亿台设备,其中 49 亿台的老客户端/老代理不认识它。
- 结果:这 49 亿台设备,收到带这个首部的报文就报错。
你的新功能还没用上,先把半个互联网搞崩了。
你:所以”忽略未知首部”是为了让”加法”变成安全的?
墨叔:精确。它把”给协议加新东西”从一个全局协调问题(所有人必须同步升级)变成了一个本地独立问题(我只管我这一端加不加)。
这就是 HTTP 可扩展性的真正秘密:它不是”设计者预留了很多功能”,而是”设计者预留了一个安全的、无协调成本的加法机制”。
5.3 首部的语法与分类
墨叔:首部的语法很简单:字段名: 字段值,一行一个,名字不区分大小写,值里可以有各种东西。但有几个细节你必须知道:
名字的规范化
首部名是大小写不敏感的,Content-Type 和 content-type 和 CONTENT-TYPE 是同一个。
但 Go 里有个经典的坑:
// 你以为设置的是 Content-Type,实际 net/http 会把它规范化
w.Header().Set("content-type", "application/json")
// Go 内部用 textproto.CanonicalMIMEHeaderKey 把它规范成 "Content-Type"
// 大多数情况下没问题,但自定义首部要小心:
w.Header().Set("X_Custom_Header", "v") // 会变成 "X_custom_header"?不,是 "X-Custom-Header"你:规范化有什么坑?
墨叔:坑在于——如果你的下游代理/网关是用”原始大小写”来做匹配的(虽然按标准不该这样,但现实里有人这么干),你发的 X-Request-Id 到了它那儿可能对不上。这是”标准说大小写不敏感,但实现有人敏感”的又一个”现实污染标准”的例子。
首部的四大分类
| 分类 | 含义 | 例子 |
|---|---|---|
| 通用首部(General) | 请求和响应都能用 | Date、Cache-Control、Connection |
| 请求首部(Request) | 只在请求里 | Host、Accept、Authorization、User-Agent |
| 响应首部(Response) | 只在响应里 | Server、Set-Cookie、WWW-Authenticate |
| 实体首部(Entity) | 描述 body | Content-Type、Content-Length、Content-Encoding、ETag |
你:这个分类,是给谁用的?
墨叔:主要是给人理解用的。机器其实不太区分这些。真正影响机器行为的,是下一节那个更致命的分类。
5.4 端到端 vs 逐跳:这一章最要命的概念
墨叔:现在讲全章最致命的一个概念,搞不清它,你的生产环境会出各种”诡异”的 bug。
HTTP 首部,从**“能不能被中间设备改/吃”**的角度,分两类:
端到端首部(End-to-end)
客户端发给最终目标服务器的,中间设备必须原样转发,不能改、不能吃。
例子:Authorization(认证信息)、Content-Type、ETag。这些是业务语义,代理碰不得。
逐跳首部(Hop-by-hop)
只对”当前这一跳”有效,中间设备转发时会处理掉(或重新生成),不会透传给下一跳。
例子:Connection、Keep-Alive、Transfer-Encoding、Upgrade、Proxy-Authenticate。
你:等等,Transfer-Encoding: chunked 是逐跳的?那第 2 章讲的 chunked 边界……
墨叔:对,这是理解 chunked 的关键补充。Transfer-Encoding: chunked 是逐跳的,意思是:
每一跳都各自决定”我和我下一跳之间”用不用 chunked。
- 客户端 → 代理:可能用 chunked。
- 代理 → 源站:代理可以解掉 chunked,换成 Content-Length 再发给源站。
每一跳的传输编码是独立的。 这就是为什么它叫”Transfer-Encoding”(传输编码),而不是”Content-Encoding”(内容编码)——前者是”传输的姿势”,后者是”内容本身的格式”(第 10 章详讲,这是另一个高频混淆点)。
Connection 头的作用
Connection 头是逐跳头里的”老大”,它有两个作用:
作用一:声明”下面这些头是逐跳的”。
Connection: keep-alive
Connection: UpgradeConnection: Upgrade 意思是”Upgrade 这个头是逐跳的,别透传”。这是第 14 章 WebSocket 的关键。
作用二:控制连接要不要保持。
Connection: keep-alive 或 Connection: close。
你:为什么逐跳头要”声明”自己是逐跳的?
墨叔:这就是历史包袱了。HTTP/1.0 时代,Connection 这个机制是为了让老代理知道”这些头你不能透传”。因为老代理是”傻瓜式”转发——它看到什么头就转发什么头,根本不知道哪些头是逐跳的。
所以设计者想了个办法:把所有逐跳头,都列在 Connection 头的值里,这样代理一看 Connection: keep-alive, upgrade,就知道”哦,keep-alive 和 upgrade 这两个头是逐跳的,我该处理掉它们再转发”。
真实事故:代理吃掉了你的头
墨叔:我给你讲个真实场景,你就明白”端到端 vs 逐跳”为什么是命根子:
场景:你写了个接口,用 Authorization: Bearer xxx 做鉴权。上线后,通过内网的一个反向代理访问时,后端总是收到”未认证”。
排查:抓包发现,代理转发时,把 Authorization 头吃掉了。
为什么:这个代理的配置里,有一条规则误把 Authorization 当成了逐跳头处理(或者它做了某种”重写”,把认证信息剥离了)。
教训:
分层系统里,中间设备会改你的报文。端到端头是”应该被尊重”的,但”应该”不等于”一定”。 你没法保证每个代理都守规矩。
所以,关键的业务语义,不要只依赖一个”理论上端到端”的首部——要做好”中间设备可能改写”的防御。
你:那怎么防?
墨叔:几个办法:①用 HTTPS(加密了,代理看不到也改不了明文内容,但这又引出企业 MITM 代理的问题,第 11 章讲);②在服务端和客户端两侧都做严格校验;③重要信息放到加密的 body 里而不是首部。核心思路是”不信任中间层”,这也叫”端到端原则(end-to-end principle)“。
5.5 多值首部与两个写法
墨叔:有些首部可以有多个值,比如 Accept、Accept-Encoding。写法有两种:
# 写法一:逗号分隔
Accept: text/html, application/xhtml+xml, application/xml;q=0.9
# 写法二:重复出现
Set-Cookie: a=1
Set-Cookie: b=2
你:这两种有区别吗?
墨叔:有,而且是个坑。逗号分隔和重复出现,语义上不完全等价:
- 对
Accept这种”值的顺序有含义”的首部,用逗号分隔(顺序表示优先级,第 10 章讲 q 值)。 - 对
Set-Cookie这种”每个值是独立实体”的首部,用重复出现(因为逗号不能出现在 Cookie 值里,所以不能用逗号分隔——Cookie 的值里经常有逗号)。
Go 里的坑:
// 错误的做法:把一个多值首部用逗号拼成一个字符串
w.Header().Set("Set-Cookie", "a=1, b=2") // 错!会变成一条 cookie,值里带逗号
// 正确的做法:用 Add 添加多个
w.Header().Add("Set-Cookie", "a=1")
w.Header().Add("Set-Cookie", "b=2")规则:一个首部能不能用逗号分隔,取决于它自身的语法定义。Set-Cookie 不能(因为逗号是合法值字符),Accept 能。别想当然。
5.6 自定义首部的兴衰:X- 前缀的故事
墨叔:你肯定见过 X-Request-Id、X-Powered-By 这种带 X- 前缀的头。这个前缀有个故事。
起源:早期大家约定——自定义头加 X- 前缀,表示”这是私有的、非标准的”,这样就不会和未来的标准头撞名。
结局:这个约定失败了。原因有两个:
X-头一旦广泛使用,就”事实上标准化”了。X-Request-Id被无数公司用,你想把它正式标准化,反而尴尬——去掉X-会破坏兼容,留着X-又显得”非正式”。- 标准头自己也乱用
X-。
于是 2012 年(RFC 6648)正式宣布:废弃 X- 前缀的做法。新头不要加 X-,直接起名即可,比如就用 Request-Id。
你:所以现在的自定义头,直接 My-Company-Header 就行?
墨叔:对。不过现实中 X- 头还是满天飞,这是历史惯性。你知道”不再推荐”就行。
这个故事和第 4 章 418、302 语义污染是同一个道理:命名约定一旦被大规模采用,就产生了惯性,你想收回都收不回。
5.7 首部的大小限制:一个真实的性能坑
墨叔:首部不是无限大的。这里有个你可能踩过的坑——Header 太大。
问题:请求头如果太大(比如 Cookie 膨胀到几 KB),会导致:
- 服务器拒绝:Nginx 默认 8KB 上限,超了返回
431 Request Header Fields Too Large。 - 性能下降:每个请求都要重复传这些头,尤其 Cookie。一个 2KB 的 Cookie,每个请求都带着,一天几百万请求,就是几个 GB 的无效传输。
- HTTP/2 的 HPACK 就是为这个设计的(第 12 章):把首部压缩,减少重复传输。
你:Cookie 膨胀很常见吗?
墨叔:太常见了。很多系统把一堆信息塞 Cookie 里(用户信息、AB 实验标识、追踪 ID),没人清理,越积越大。这是”无状态协议”的隐形成本——第 9 章会专门讲。
排查技巧:curl -v 看一下请求头多大;监控里看 request_header_size。
5.8 首部的现代案例:CORS 是怎么”长”出来的
墨叔:讲了这么多理论,给你一个首部如何”凭空长出新子系统”的完整案例——CORS(跨域资源共享)。
问题的背景(第 1 章场景七):浏览器有同源策略——a.com 的页面里,JS 不能随便请求 b.com 的接口。这是浏览器的安全规则,不是 HTTP 的规则。
那 a.com 要怎么合法地请求 b.com?答案是:b.com 用一组首部,显式声明”我允许谁跨域访问我”。
# 响应里,b.com 声明允许 a.com 跨域
Access-Control-Allow-Origin: https://a.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Authorization, X-Request-Id
Access-Control-Max-Age: 86400你:所以 CORS 整个机制,就是靠这几个首部?
墨叔:对。没有引入新方法,没有引入新状态码,完全靠首部搭起了一整套跨域授权体系。
这就是首部作为”扩展插槽”的最强证明:
一套全新的、复杂的子系统,可以完全用”新增若干首部”的方式实现,而不动协议的任何其它部分。
你:那预检请求(OPTIONS)呢?它也算吧?
墨叔:对。预检请求(preflight)是:当跨域请求”不简单”时(比如用了自定义头、用了 PUT),浏览器会先发一个 OPTIONS 请求,问 b.com”你允不允许我用 PUT + 自定义头跨域访问”?b.com 用 Access-Control-* 首部回答。回答允许了,浏览器才发真正的请求。
一个经典误区:很多人以为 OPTIONS 请求是”CORS 机制的一部分”,所以看到 OPTIONS 就头疼。其实 OPTIONS 是早就存在的通用方法(“问这个资源支持什么”),CORS 只是”借用”了它来做预检。这是”复用现有机制”而不是”发明新机制”的又一个例子。
5.9 Go 落地:首部操作与三个陷阱
墨叔:Go 里操作首部的三个陷阱,踩过的人无数:
// 陷阱一:Set 会覆盖,Add 会追加
w.Header().Set("X-Foo", "a")
w.Header().Set("X-Foo", "b") // 结果是 b(覆盖)
w.Header().Add("X-Foo", "a")
w.Header().Add("X-Foo", "b") // 结果是 a, b(追加,中间逗号分隔)
// 陷阱二:先 Write 再 Set 头,无效!
w.Write([]byte("body")) // 头已经发出去了
w.Header().Set("X-Late", "1") // 无效,不会出现在响应里
// 陷阱三:读 Header 时,值已经按逗号拆分好了
// 客户端发 "X-Foo: a, b",Go 的 r.Header 会给你 ["a", "b"] 两个元素
for _, v := range r.Header.Values("X-Foo") {
fmt.Println(v) // a, b
}你:陷阱二和第 4 章”先 Write 自动 WriteHeader(200)“是同一个道理?
墨叔:对,都是”首部在 body 前面,body 一写出去,首部就冻结了”这个报文结构的必然结果。你越理解报文结构,越能预测 API 的行为。
5.10 排查清单:首部相关问题的定位
第 1 步:这个首部是端到端的,还是逐跳的?
→ 逐跳头会被代理吃掉,别指望它跨跳存活
第 2 步:中间代理有没有改写/吃掉我的首部?
→ 前后各抓一次包,对比首部
第 3 步:多值首部,我用对写法了吗?
→ Set-Cookie 用 Add,别用逗号拼
第 4 步:首部是不是太大了?
→ 看 431 错误、Cookie 大小、请求头大小
第 5 步:自定义头的名字会不会和标准/网关冲突?
→ 别加 X- 前缀,用明确的公司前缀
思考题
第 1 题(概念)
HTTP 首部从”能否被中间设备改”的角度,分为”端到端”和”逐跳”两类。请判断下列首部各属于哪类,并说明为什么:
AuthorizationKeep-AliveContent-LengthUpgradeETag
第 2 题(概念)
为什么”未知首部必须忽略”是 HTTP 可扩展性的命根子?请用反事实推演(假设这条规则不存在)来说服一个”觉得这规则可有可无”的同事。
第 3 题(设计)
你要给公司内部微服务设计一个”链路追踪”方案,要求每个请求贯穿 A → B → C 三个服务时,能用一个统一的 trace-id 串起来,且这个 ID 要能出现在每一跳的日志和网关日志里。
- 你会把
trace-id放首部、URL 参数、还是 body 里?为什么? - 这个首部应该是”端到端”还是”逐跳”?
- 如果中间有个老网关,它会”原样转发未知首部”吗?根据 5.2 节的规则,它应该怎么做?
第 4 题(生活类比)
墨叔:寄快递时,包裹单上有两样东西——“收件人信息”和”快递公司内部的流转码/中转信息”。
请问:
- “收件人信息”对应哪种首部?(端到端还是逐跳)
- “中转信息”对应哪种首部?
- 为什么快递公司不会把”内部的流转码”写进”收件人信息”里?这跟”逐跳头不该透传”是同一个道理,请说透。
第 5 题(更深一层的发问)
“未知首部必须忽略”这条规则,保证了协议的可扩展性。但它也埋下了一个隐患。
想象:你发明了一个重要的首部 X-Auth-Token,用来传递认证令牌。但你的下游恰好有一个老网关,它不认识这个首部,按照规则”忽略”了它——于是你的认证信息被静默丢弃了。
那么:
- 这个”忽略”是符合规则的,但对你的业务是致命的。这暴露了”可扩展性”和”安全性/可靠性”之间的什么张力?
- 你会怎么设计,才能既利用首部的可扩展性,又不让”关键信息被静默忽略”?
- 提示:想一想 HTTPS(加密)、以及”把关键信息放在协议保证会传递的地方(如请求行)“这两种思路,各自的代价。
参考答案
第 1 题答案
| 首部 | 类型 | 原因 |
|---|---|---|
Authorization | 端到端 | 认证信息是给最终源站的,中间设备不该碰 |
Keep-Alive | 逐跳 | 它是”这一跳连接”的参数,只对相邻两跳有意义 |
Content-Length | 端到端(但常被逐跳调整) | 描述 body 长度,本应是端到端;但代理转发时可能解 chunked 重新定长度,所以实践中常被中间设备改写 |
Upgrade | 逐跳 | 协议升级是”点对点”的,代理要自己决定要不要跟着升级 |
ETag | 端到端 | 资源的版本标识,属于资源本身的属性,中间设备不该改 |
补充:Content-Length 是最有迷惑性的。它”理论上是端到端”(因为它是 body 的属性),但”实践中常被逐跳改写”(因为传输编码是逐跳的,代理改了传输编码就得跟着改长度)。这个”理论和实践的裂缝”,正是报文边界(第 2 章)和传输编码逐跳性叠加的结果。
第 2 题答案
反事实推演(假设”未知首部必须报错”):
- 今天,公司 A 发明了首部
X-A-Token,用于自己的新认证方案。 - 公司 A 的客户端都升级了,认识这个头。
- 但互联网上还有无数老代理、老缓存、老网关,它们不认识
X-A-Token。 - 公司 A 的请求经过某个老代理时,老代理看到
X-A-Token,按规则报错,把请求拒了,甚至可能整个连接崩掉。
结果:公司 A 的新功能,还没落地,先被沿途的”老设备”掐死了。
于是每个想扩展协议的人,都面临一个荒谬的处境:“我要加功能,得先求全世界所有中间设备都认识我的新功能。” 这在物理上做不到(设备遍布全球、无人维护、无法升级),于是协议就被”冻死”了——再也加不了任何新东西。
“未知首部必须忽略”这条规则,恰恰解开了这个死结:老设备不认识新头,就”无视”它,照样转发,照样工作。新功能只影响”认识它的那两端”。
结论:
“忽略未知”不是可选项,而是协议能够持续演进的唯一前提。它把”扩展协议”从一个”全球协调的集体行动”降维成了一个”两端之间的局部行为”。
没有这条规则,HTTP 早就在 1990 年代末”进化死”了,我们今天用的会是另一个”什么都定死了、也什么都做不了”的协议。
第 3 题答案
1. 放首部,不放 URL 参数,更不放 body。
| 方案 | 问题 |
|---|---|
| URL 参数 | 会污染缓存键(不同 trace-id 会导致同一 URL 有无数缓存副本);会出现在访问日志里(泄露);URL 有长度限制 |
| body | GET 没有 body;且要解析 body 才能拿到 ID,网关无法低成本提取 |
| 首部 | 网关可以零成本读取(就在报文最前面);不影响缓存键;GET/POST 都适用;这正是首部的设计目的 |
2. 端到端。因为 trace-id 要贯穿 A→B→C 全链路,不能被中间某一跳吃掉。
3. 老网关”应该”原样转发(根据”未知首部必须忽略”——注意这里”忽略”指的是”不解析、不报错”,但转发时仍应原样透传,而不是删除)。
但这里要区分两个词,很多人搞混:
- “忽略”(ignore):指”我不理解它,所以我不基于它做任何决策”。
- “删除”(drop):指”我不理解它,所以我把它从报文中拿掉,不转发”。
标准要求的是”忽略”(透传但不理解),而不是”删除”(拿掉)。 一个守规矩的代理,对未知首部是”看不懂但原样转发”。
但现实中,有的代理会配置”白名单转发”——只转发它认识的头,不认识的全删掉。这就是 5.4 节”代理吃掉你的头”的事故来源。所以关键链路追踪头,要确保沿途所有代理都配置了白名单放行。
第 4 题答案
1. “收件人信息” = 端到端首部。
它描述的是”最终收件人是谁、东西是什么”,是业务语义,从寄件人到收件人全程不变,任何中转环节都不能改它。这对应 Authorization、Content-Type 这类头。
2. “中转信息”(内部流转码) = 逐跳首部。
它描述的是”这一段运输怎么走、用什么车、跟哪个中转站对接”,只对相邻两个中转站之间有意义,到了下一个中转站就换成新的了。这对应 Connection、Keep-Alive、Transfer-Encoding 这类头。
3. 为什么不把流转码写进收件人信息?
因为两者的生命周期和作用域完全不同:
- 收件人信息要全程不变(变了就送错地方)。
- 流转码要逐段更新(每一段运输的安排都不一样)。
如果把它们写在一起,就会出现两种灾难:
- 要么:中转站为了更新”流转码”,不得不同时改写”收件人信息” → 收件人信息被污染,包裹送错。
- 要么:中转站为了不碰”收件人信息”,就不敢更新”流转码” → 每一段的运输信息全乱套。
所以必须把”全程不变的业务语义”和”逐段变化的传输细节”分开。这跟”逐跳头不该透传”是同一个道理:
端到端语义要保护(全程不变),逐跳细节要隔离(逐段更新)。混在一起,不是污染了业务,就是僵化了传输。
这就是 HTTP 把首部分成”端到端/逐跳”两类的根本原因——它是为了在”分层的中转网络”里,精确地区分”哪些东西归你管、哪些东西归中转站管”。
第 5 题答案
1. 暴露了什么张力?
这是”开放的可扩展性”和”确定的可靠性”之间的根本张力:
- 可扩展性要求”未知即忽略”,这样新东西能安全地加进来。
- 可靠性/安全性要求”关键信息必须被准确传递”,不能被静默丢弃。
“忽略未知”这个规则,天然地无法区分”这个未知首部是无关紧要的装饰”还是”这个是承载关键安全信息的令牌”。它一视同仁地”忽略”了。
换句话说:协议给了你”随便加头”的自由,但没有给你”加了头一定被传递”的保证。自由和保证,在这里是矛盾的。
2. 怎么设计才能兼顾?
三条思路,代价各不相同:
思路一:加密(HTTPS)。
用 TLS 把整个报文加密。这样中间代理根本看不到你的 X-Auth-Token(它看到的是密文),也就无从”忽略”或”删除”——它只能原样转发密文。
- 代价:需要 HTTPS 基础设施;且企业 MITM 代理(公司内部为了安全审计,会解密再加密)会重新引入”中间人能看到内容”的问题(第 11 章详讲)。
思路二:把关键信息放在”协议保证会传递”的地方。
HTTP 保证一定会传递的东西,最典型的是请求行里的 URI(GET /path)和目标主机(Host 头,HTTP/1.1 强制必须)。把 token 放 URI 里,任何代理都必须原样转发 URI(否则请求就错了)。
- 代价:URI 会出现在访问日志、浏览器历史、referer 里,泄露风险高;且会污染缓存键。
思路三:用标准化的、人人认识的首部。
用 Authorization: Bearer xxx 而不是自定义的 X-Auth-Token。因为 Authorization 是标准首部,所有守规矩的代理都认识它,都会原样转发(很多代理还专门保护它)。
- 代价:只能表达标准支持的认证方式;且仍然不防”不守规矩的代理”。
总结(这是本题的核心结论):
可扩展性和可靠性不可兼得时,你要做的不是”选一个”,而是”分场景选”:
- 非关键的、丢了也无妨的信息 → 随便用自定义首部,享受可扩展性。
- 关键的、丢了会出事故的信息 → 用”协议保证传递”的载体(URI、标准首部、加密),放弃一点”随便加”的自由。
这和第 1 章第 5 题”宽容的边界”、第 3 章”幂等的层次”,是同一个方法论:在协议的自由度和保证度之间,根据”错了的代价”来划线。 代价高的地方,宁可牺牲自由,也要保证。
一页纸小结
首部是协议的扩展插槽:新功能 = 新首部,不用改版本、不用全局协调。
命根子契约:未知首部必须忽略(透传但不理解),而不是报错、不是删除。它把”扩展协议”从”全球协调”降维成”两端行为”。
端到端 vs 逐跳:端到端头承载业务语义(Authorization、ETag),逐跳头承载传输细节(Connection、Transfer-Encoding、Upgrade)。Connection 头用来声明哪些头是逐跳的。分不清这个,代理会吃掉你一半的头。
多值首部:逗号分隔 vs 重复出现,取决于首部自身的语法(Set-Cookie 不能用逗号)。
X- 前缀的兴衰:命名约定一旦大规模采用,就收回不了(RFC 6648 废弃 X-)。
CORS 案例:一整套跨域授权体系,完全靠若干首部搭起来,没动协议任何其它部分——这是首部作为扩展插槽的最强证明。
Go 陷阱:Set 覆盖、Add 追加;先 Write 再 Set 头无效;读 Header 时值已按逗号拆分。
下一章:首部讲完了,但有一个”首部”你天天写、却从没认真想过它的结构——URL。它号称是 HTTP 里最成功的抽象,也被称为”最拧巴的一次命名”。我要问你:http://a.com/path?x=1#frag 里那个 #frag,为什么服务器根本收不到? 以及——到底什么是”资源”?