第 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)描述 bodyContent-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: Upgrade

Connection: 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- 前缀,表示”这是私有的、非标准的”,这样就不会和未来的标准头撞名。

结局:这个约定失败了。原因有两个:

  1. X- 头一旦广泛使用,就”事实上标准化”了。X-Request-Id 被无数公司用,你想把它正式标准化,反而尴尬——去掉 X- 会破坏兼容,留着 X- 又显得”非正式”。
  2. 标准头自己也乱用 X-。

于是 2012 年(RFC 6648)正式宣布:废弃 X- 前缀的做法。新头不要加 X-,直接起名即可,比如就用 Request-Id。

你:所以现在的自定义头,直接 My-Company-Header 就行?

墨叔:对。不过现实中 X- 头还是满天飞,这是历史惯性。你知道”不再推荐”就行。

这个故事和第 4 章 418、302 语义污染是同一个道理:命名约定一旦被大规模采用,就产生了惯性,你想收回都收不回。


5.7 首部的大小限制:一个真实的性能坑

墨叔:首部不是无限大的。这里有个你可能踩过的坑——Header 太大。

问题:请求头如果太大(比如 Cookie 膨胀到几 KB),会导致:

  1. 服务器拒绝:Nginx 默认 8KB 上限,超了返回 431 Request Header Fields Too Large。
  2. 性能下降:每个请求都要重复传这些头,尤其 Cookie。一个 2KB 的 Cookie,每个请求都带着,一天几百万请求,就是几个 GB 的无效传输。
  3. 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 首部从”能否被中间设备改”的角度,分为”端到端”和”逐跳”两类。请判断下列首部各属于哪类,并说明为什么:

  1. Authorization
  2. Keep-Alive
  3. Content-Length
  4. Upgrade
  5. ETag

第 2 题(概念)

为什么”未知首部必须忽略”是 HTTP 可扩展性的命根子?请用反事实推演(假设这条规则不存在)来说服一个”觉得这规则可有可无”的同事。

第 3 题(设计)

你要给公司内部微服务设计一个”链路追踪”方案,要求每个请求贯穿 A → B → C 三个服务时,能用一个统一的 trace-id 串起来,且这个 ID 要能出现在每一跳的日志和网关日志里。

  1. 你会把 trace-id 放首部、URL 参数、还是 body 里?为什么?
  2. 这个首部应该是”端到端”还是”逐跳”?
  3. 如果中间有个老网关,它会”原样转发未知首部”吗?根据 5.2 节的规则,它应该怎么做?

第 4 题(生活类比)

墨叔:寄快递时,包裹单上有两样东西——“收件人信息”和”快递公司内部的流转码/中转信息”。

请问:

  1. “收件人信息”对应哪种首部?(端到端还是逐跳)
  2. “中转信息”对应哪种首部?
  3. 为什么快递公司不会把”内部的流转码”写进”收件人信息”里?这跟”逐跳头不该透传”是同一个道理,请说透。

第 5 题(更深一层的发问)

“未知首部必须忽略”这条规则,保证了协议的可扩展性。但它也埋下了一个隐患。

想象:你发明了一个重要的首部 X-Auth-Token,用来传递认证令牌。但你的下游恰好有一个老网关,它不认识这个首部,按照规则”忽略”了它——于是你的认证信息被静默丢弃了。

那么:

  1. 这个”忽略”是符合规则的,但对你的业务是致命的。这暴露了”可扩展性”和”安全性/可靠性”之间的什么张力?
  2. 你会怎么设计,才能既利用首部的可扩展性,又不让”关键信息被静默忽略”?
  3. 提示:想一想 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 有长度限制
bodyGET 没有 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,为什么服务器根本收不到? 以及——到底什么是”资源”?