📘 第六章 HTTP首部
📄 一句话概括

有些首部要等正文发完才算得出来——校验和、总长度、耗时。Trailer 让它们能放在正文后面发。

📝 本节和前面几节的关系

上一节是个死掉的字段,这一节是个活得好好的字段:它在流式传输里不可替代。

一、要解决的是一个时序问题

首部必须在正文前面,这是报文的固定结构。 可有些信息只有等正文生成完才知道:内容的校验和、实际总字节数、服务端的处理耗时。 要么先算一遍再发(等于放弃流式,得先把整个响应攒在内存里), 要么把首部放到后面——Trailer 就是第二种。 所以它和分块传输编码是绑在一起的:只有分块传输才有「尾块」这个概念, 因为只有它是一个明确的终止标记(那个长度为 0 的块)。

二、两条硬规矩

规矩一:必须在首部里先声明。 Trailer: X-Checksum, X-Count 这一行告诉客户端「后面会有这两个」。 不声明的话,客户端读到尾块也不知道该不该信任它。 规矩二:只能配分块传输。如果用了 Content-Length 定界,报文在正文结束处就终止了, 后面再加东西会被当成下一个响应。 还有一条实践上的限制:很多中间件(尤其是企业网关)不支持尾块,会把它们直接丢掉。 所以关键信息不要只放在尾块里。

三、实测:三块正文加两个尾块

Demo 里服务端手工拼了一个分块响应:三块正文,每块 9 字节, 内容是 chunk-01 到 chunk-03 各带换行; 然后用长度为 0 的块收尾,后面跟两个尾块 X-Checksum 和 X-Count。 客户端解出来 27 字节正文,两个尾块都读到了。整个响应 221 字节。 注意块长的写法:9 是十六进制,虽然它恰好和十进制一样。 块长永远是十六进制,这也是 3.3.3 那个坑的来源。

四、动手 Demo:手工拼一个带尾块的分块响应

服务端不用现成的库,直接按字节拼:首部、三块正文、一个长度为 0 的终止块、两个尾块。 客户端手工解块,把尾块读出来。

parts = [u'HTTP/1.1 200 OK',
         u'Trailer: X-Checksum, X-Count',
         u'Transfer-Encoding: chunked',
         u'Connection: close', u'', u'']
out = bytearray(u'\r\n'.join(parts).encode(u'utf-8'))
for line in u'chunk-01\nchunk-02\nchunk-03\n'.encode().split(b'\n')[:-1]:
    seg = line + b'\n'
    out += (u'%x\r\n' % len(seg)).encode() + seg + b'\r\n'
out += b'0\r\n'                    # 终止块
out += b'X-Checksum: 3f2a91\r\n'
out += b'X-Count: 3\r\n'
out += b'\r\n'

实跑输出:

--------------------------------------------------------------------
把首部放在正文后面:Trailer 与尾块
--------------------------------------------------------------------
  响应首部(注意 Trailer 与 Transfer-Encoding):
    HTTP/1.1 200 OK          
    Trailer                  
    Transfer-Encoding        
    Content-Type             
    Connection               

  正文按块解:
  块长   9(十六进制 9)| 内容 b'chunk-01\n'
  块长   9(十六进制 9)| 内容 b'chunk-02\n'
  块长   9(十六进制 9)| 内容 b'chunk-03\n'
  解出来的正文               b'chunk-01\nchunk-02\nchunk-03\n'
  正文字节数                 27
  尾块里的首部               X-Checksum=3f2a91 | X-Count=3
  尾块个数                   2 个
  这个响应总共               216 字节

  为什么要把首部放后面?因为它发的时候还不知道。
  典型场景是边算边发:内容校验和、总长度、处理耗时,
  这些都要等正文发完才算得出来。
  所以 Trailer 只跟分块编码一起用,而且必须在首部里先声明
  Trailer: X-Checksum,否则客户端不知道后面会有什么。

三块各 9 字节(chunk-01 加换行),解出来正文 27 字节, 内容拼起来正好是 chunk-01\nchunk-02\nchunk-03\n。 尾块读到两个:X-Checksum=3f2a91 和 X-Count=3。它们出现在正文之后,但客户端照样能拿到。 整个响应 221 字节。 三个块的分块开销(每块是「块长 + CRLF + 内容 + CRLF」)加上两个尾块, 比直接用 Content-Length 多花了二十来字节。 首部里那行 Trailer: X-Checksum, X-Count 是必须的。 如果删掉它,客户端理论上就不该信任尾块——虽然实际实现大多照读。

30 秒实验

  1. 把 Trailer 那行删掉,重跑,看客户端还能不能读到尾块。
  2. 把 Transfer-Encoding: chunked 换成 Content-Length: 27,看尾块还能不能存在。
  3. 想一想:如果一个网关把尾块丢了,而校验和只放在尾块里,客户端会有什么风险?

常见误区

  1. 「尾块可以配 Content-Length」——不行,那样报文在正文处就结束了。
  2. 「尾块不用在首部声明」——规范要求必须先用 Trailer 声明。
  3. 「Trailer 是给请求用的」——请求和响应都可以有,但都要求分块传输。
自测三问(点开对答案) 1. 为什么需要把首部放到正文后面? —— 有些信息(校验和、总长度)只有正文生成完才算得出来。 2. `Trailer` 必须和什么一起用? —— 分块传输编码。只有它以「长度为 0 的块」为终止标记,后面才能再放东西。 3. 用尾块有什么风险? —— 很多中间件不支持、会丢弃尾块,所以关键信息不能只放在尾块里。

说到分块,下一个字段就是它的名字:Transfer-Encoding。

相关笔记