初衷

关于 HTTP 的教程或者博客,网络上已经存在非常多了,不过想要了解 HTTP 数据包传输的始末,则需要一个比较系统的说明,所以这里想要表达的并不是 HTTP 的基础知识,而是 HTTP 数据包传输的相关分享。可能这是一些比较冷门的知识,但只要你能够上网,也就意味着你已经在使用到这些技术带来的便利了。

报文格式

为了更好的理解报文体传输,我们得先了解请求和响应的 HTTP 格式,共三个组成部分,分别是起始行、报文头、报文体,标准格式如下,这里着重讲解的是 message-body 部分,即报文体。更多可参考 RFC2616 HTTP Message 上的说明。

  1. generic-message = start-line
  2. *(message-header CRLF)
  3. CRLF
  4. [ message-body ]
  5. start-line = Request-Line | Status-Line

定长报文体

定长报文体的长度对应 HTTP Header 的 Content-Length。注意,这个是传输数据的最终长度,即如果报文体有经过压缩,那么 Content-Length 就应该是压缩后的大小,长度的单位是 byte。另外,GET 方法的请求,在规范中是不能带有报文体,即使带有报文体,服务器也可以选择忽略,而 HEAD 请求则表示响应中不能包含报文体,除非服务器的非标准实现要求。

以下是 GET 方法请求为例,其中 Content-Length: 93349 表示响应报文的报文体长度为 93349 B,约为 91.16 KB。

  1. $ curl -X GET -v https://www.icuter.cn
  2. ... SSL handshake ...
  3. > GET / HTTP/1.1
  4. > Host: www.icuter.cn
  5. > User-Agent: curl/7.54.0
  6. > Accept: */*
  7. >
  8. < HTTP/1.1 200 OK
  9. < Server: nginx/1.16.0
  10. < Content-Type: text/html; charset=UTF-8
  11. < Content-Length: 93349
  12. < Accept-Ranges: bytes
  13. < ETag: W/"16ca5-1707f7a8df3"
  14. <
  15. ... data not shown ...

定长的数据包的优点是简单直接,适用于短小报文体的请求和响应,如果是超大的报文体请求或响应,我们为了计算这个超大报文体不得不将其放到磁盘,为其创建临时文件。为什么要临时文件,而不是内存?这是因为我们的应用本身运行也需要内存,但为了完成计算 HTTP 请求和响应超大报文体的总长度而放到内存中,在 Java 中将提高了 Full GC 的频率,严重响应性能,甚至会造成应用内存溢出。如果非要使用定长方式传输超大报文体,则需要进行分片上传,但需要客户端和服务器协商相关的分片规则。

不定长报文体

结构

为了弥补定长报文体的不足,对于超大报文体数据,规范定义中可通过不定长报文体进行传输,对应的 Header 为 Transfer-Encoding: chunked,但不定长报文体,在 HTTP/1.0 中不支持。此时,我们不需要再需要计算资源的长度再发起请求或响应请求了,因此以流的方式(极大地减少内存消耗)请求或响应即可。不定长的报文体格式 Spec 如下,更多信息可参考 RFC2616 Transfer-Encoding 上的说明。

  1. Chunked-Body = *chunk
  2. last-chunk
  3. trailer
  4. CRLF
  5. chunk = chunk-size [ chunk-extension ] CRLF
  6. chunk-data CRLF
  7. chunk-size = 1*HEX
  8. last-chunk = 1*("0") [ chunk-extension ] CRLF
  9. chunk-extension= *( ";" chunk-ext-name [ "=" chunk-ext-val ] )
  10. chunk-ext-name = token
  11. chunk-ext-val = token | quoted-string
  12. chunk-data = chunk-size(OCTET)
  13. trailer = *(entity-header CRLF)

注意 chunk-size 是 16 进制,而 trailer 则是用于传输额外的 Header,譬如传输完成后计算传输报文体的 MD5 或 CRC,用于接收端的校验。其实 trailer 的使用算是比较少见,且请求头中必须指明 TE: trailers 表示客户端能够解析 Chunk-Body 中的 trailer 信息。chunk-extension 则是对 chunk-data 的额外参数描述,实际上也比较少见。

Transfer-Encoding 结合 Content-Encoding 一起使用,即可做到边传输边压缩的效果,对于超大报文体能极大的减少网络 IO 的消耗。以下是 chunked 方式传输数据的响应,通过响应头部的 Transfer-Encoding: chunked 和 Content-Encoding: gzip 表示服务器边压缩边发送数据给 HTTP 客户端。

请求

  1. GET /index.jsp HTTP/1.1
  2. Host: http-server-domain
  3. Connection: keep-alive
  4. Accept: text/html,application/xhtml+xml,application/xml;q=0.9
  5. Accept-Encoding: gzip, deflate
  6. Accept-Language: zh-CN,zh;q=0.9,en;q=0.8

注意,请求中的 Accept-Encoding 表示客户可以解析压缩后的 gzip / deflate 响应报文体。

响应

  1. HTTP/1.1 200 OK
  2. Server: nginx
  3. Transfer-Encoding: chunked
  4. Content-Encoding: gzip
  5. Vary: Accept-Encoding
  6. 1000\r\n16 进制)
  7. ... data(长度为 4096 chunk 数据) ...
  8. 5fa\r\n16 进制)
  9. ... data(长度为 1530 chunk 数据) ...
  10. 0\r\n
  11. \r\nchunk 结束的 CRLF 换行符)

Transfer-Encoding: chunked 表示不定长包体,Content-Encoding: gzip 则表示该包体通过 gzip 形式压缩。16 进制 1000 转为 10 进制的长度是 4096 (单位字节 byte),而 16 进制的 5fa 转为 10 进制的长度则是 1530,最后再以 0\r\n\r\n 结束。

如果同时存在 Transfer-Encoding 和 Content-Length,则 Transfer-Encoding 优先级更高,也就是说优先以不定长的方式解析报文体。

分片请求

有时候,我们并不希望获取资源的全部内容,可能是资源太大,也可能是为了用户体验,先返回部分资源进行初始化给用户正在处理的感觉,不至于产生系统无反应的错觉。所以,我们只希望服务器返回部分的资源,请求部分资源的应用也非常广泛,譬如,分片多线程下载,视频点播,音乐播放等。可以在 RFC 7233 规范中找到相应的定义,以下是对请求中的 Range 头部的详细描述。

Range: bytes=0-50      // 请求 0 到 50 bytes 的资源
       bytes=-50       // 最后 50 bytes 的资源
       bytes=50-       // 50 bytes 之后的资源
       bytes=0-50,-50  // 同时请求 0 到 50 bytes 的资源 和 最后 50 bytes 的资源
                       // 但这种格式不具备权威性,服务器不一定支持,如 Nginx 就不支持

服务器端必须返回 206 Partial Content 表示相应报文体是部分资源,且响应头部必须包含 Content-Range,如 Content-Range: bytes 0-50/97951 则表示返回资源的前 50 个字节,资源的总大小为 97951 字节。另外,值得注意的是,如果服务器端返回 Accept-Range: none 或不存在 Accept-Range 响应头部,则表示不支持部分资源请求,作为说明,以下将以分片下载为例。

分片下载

资源分片

首先,利用 HEAD 方法检查服务器是否支持部分资源响应,如果响应头部中存在 Accept-Ranges: none 则表示资源或服务器不支持部分内容响应,当 Accept-Ranges 头部不存在,Client 端也只能认为该资源或服务器不支持部分内容响应了。只有响应头部包含 Accept-Ranges: bytes 的时候才表示该资源支持部分内容响应,如下 cURL 所示。

$ curl -X HEAD -H "Range: bytes=0-0" -v "https://www.icuter.cn/index.html"

> HEAD /index.html HTTP/1.1
> Host: www.icuter.cn
> Range: bytes=0-0
>
< HTTP/1.1 206 Partial Content
< Accept-Ranges: bytes
< Content-Range: bytes 0-0/93349
< ETag: W/"16ca5-1707f7a8df3"
<

根据首次试探性的部分资源请求,我们将获得以下信息

  1. Accept-Ranges: bytes 该资源支持部分内容响应
  2. Content-Range: bytes 0-0/93349 该资源的总大小是 93349 字节(byte)
  3. ETag: W/“16ca5-1707f7a8df3” 该资源的标识串为 16ca5-1707f7a8df3,这是防止资源被改动后,Client 端能及时发现,并配合 If-Range 请求头部一起使用实现断点下载的校验

分片请求

然后,确定了资源支持部分内容响应及其总大小后,我们就需要根据资源总大小合理的划分一条线程的下载偏移及包大小了。为了简化例子,我这里分了两段,分别是

  • 0-46674 共 46675 个字节(从 0 开始)
  • 46675-93348,共 46674 个字节(等价于 46675-)

请求范围在 0-46674 的资源数据

$ curl -H "Range: bytes=0-46674" -H 'If-Range: W/"16ca5-1707f7a8df3"' -v \
"https://www.icuter.cn/index.html"

> GET /index.html HTTP/1.1
> Host: www.icuter.cn
> Range: bytes=0-46674
> If-Range: W/"16ca5-1707f7a8df3"
>
< HTTP/1.1 206 Partial Content
< Content-Length: 46675
< Accept-Ranges: bytes
< ETag: W/"16ca5-1707f7a8df3"
< Content-Range: bytes 0-46674/93349
<
... data ...

请求范围在 46675-93348 的资源数据

 $ curl -H "Range: bytes=46675-93349" -H 'If-Range: W/"16ca5-1707f7a8df3"' -v \
 "https://www.icuter.cn/index.html"

> GET /index.html HTTP/1.1
> User-Agent: curl/7.29.0
> Host: www.icuter.cn
> Accept: */*
> Range: bytes=46675-93349
> If-Range: W/"16ca5-1707f7a8df3"
>
< HTTP/1.1 206 Partial Content
< Server: nginx/1.16.0
< Content-Length: 46674
< ETag: W/"16ca5-1707f7a8df3"
< Content-Range: bytes 46675-93348/93349
<
... data ...

分片的请求头中,我们增加了 If-Range,这是服务器返回给 Client 端的 ETag 的值,用于服务器判断该分片请求的资源是否已经发生了变更。如果资源发生了变革,即 Client 端请求的 If-Range 与服务器返回的 ETag 不匹配,服务器将返回资源的所有数据。

或许你会存在疑问,为什么我们需要分片下载呢,一次性下载不可以吗?一次性下载肯定是可行的,但如果资源非常大,在传输的过程中恰好遇到了网络故障,那么资源就需要重新下载了,而且非常耗时。分片下载还能优化成多线程下载,通过 java.util.concurrent.CyclicBarrier 预先划分请求范围,请求完成后再整合,极大的提高了下载速度,当然了实际情况中会比这里的轻描淡写复杂一些。

参考资源

https://tools.ietf.org/html/rfc2616
https://tools.ietf.org/html/rfc7233
https://www.w3.org/Protocols/
https://developer.mozilla.org/zh-CN/docs/Web/HTTP