大文件上传采用的方式是分块,那大文件下载呢?答案也是分块!只是这次的分块任务交给了传输层(TCP)而已。因此我们需要完成分片(chunks)的接收。
什么是 chunk 传输?
分块传输编码(Chunked transfer encoding)是超⽂本传输协议(HTTP)中的⼀种数据传输机制,允许 HTTP 由应⽤服务器发送给客户端应⽤( 通常是⽹页浏览器)的数据可以分成多个部分。分块传输编码只在 HTTP 协议 1.1 版本(HTTP/1.1)中提供。
什么是 chunk?
chunk 编码格式如下:
code snippetCopytxt[chunk size][\r\n][chunk data][\r\n][chunk size][\r\n][chunk data][\r\n][chunk size = 0][\r\n][\r\n]
编码使用若干个 chunk 组成,由一个标明长度为 0 的 chunk 结束。每个 chunk 有两部分组成,第一部分是该 chunk 的长度,第二部分就是指定长度的内容,每个部分用 CRLF(\r\n) 隔开。在最后一个长度为 0 的 chunk 中的内容是称为 footer 的内容,是一些没有写的头部内容。
为什么需要它
如果要一边产生数据,一边发给客户端(即动态内容),服务器就需要使用分块传输。
通常,HTTP 应答消息中发送的数据是整个发送的,Content-Length 头部字段表⽰数据的长度。数据的长度很重要,因为客户端需要知道哪⾥是应答消息的结束,以及后续应答消息的开始。然⽽,使⽤分块传输编码,数据分解成⼀系列数据块,并以⼀个或多个块发送,这样服务器可以发送数据⽽不需要预先知道发送内容的总⼤⼩,非常适合大文件或者动态内容。
开启 chunk 传输
要使用分块传输编码,则需要在响应头配置 Transfer-Encoding 字段,并设置它的值为 chunked 或 gzip, chunked:
code snippetCopytxtTransfer-Encoding: chunked Transfer-Encoding: gzip, chunked
响应头 Transfer-Encoding 字段的值为 chunked,表示数据以一系列分块的形式进行发送。需要注意的是 Transfer-Encoding 和 Content-Length 这两个字段是互斥的,也就是说响应报文中这两个字段不能同时出现。
实现
使用 NodeJS 实现一个小 Demo。
服务端代码
code snippetCopyjavascriptconst http = require('http')
const fs = require('fs')
const path = require('path')
const server = http.createServer()
server.on('request', function (req, res) {
// 处理一下跨域
res.setHeader('Access-Control-Allow-Origin', '*')
if (req.url === '/') {
res.writeHeader(200, { 'Content-Type': 'text/html' })
res.write(fs.readFileSync(path.resolve(__dirname, 'demo.html')))
res.end()
} else if (req.url === '/download') {
// 设置为chunk传输(其实可以不用设置,流式传输自动开启)
res.setHeader('Transfer-Encoding', 'gzip, chunked')
const filePath = path.resolve(__dirname, 'hello.txt')
const r = fs.createReadStream(filePath)
// 注入内容
r.pipe(res)
} else res.end()
})
// 开启监听
server.listen(8080, function () {
console.log('Listen on http://localhost:8080')
})
客户端代码
只展示核心逻辑部分。
code snippetCopyjavascriptconst btn = document.querySelector('.btn')
btn.addEventListener('click', async function () {
const reader = (await fetch('http://localhost:8080/download')).body.getReader()
// 数据收集
let resultUint8Array = []
let bufferLen = 0
while (true) {
const readChunk = await reader.read()
if (readChunk.done) {
download(
new Blob([mergeUint8Array(resultUint8Array, bufferLen)], {
type: 'text/plain',
}),
'hello.txt',
)
break
} else {
bufferLen += readChunk.value.length
resultUint8Array.push(readChunk.value)
}
}
})
// 合并数据
function mergeUint8Array(arr, length) {
let mergedArray = new Uint8Array(length)
let offset = 0
arr.forEach((item) => {
mergedArray.set(item, offset)
offset += item.length
})
return mergedArray
}
// 下载文件
function download(blob, filename) {
const a = document.createElement('a')
a.download = filename
a.href = URL.createObjectURL(blob)
a.click()
URL.revokeObjectURL(a.href)
}
效果如下:
分析
手里正好有 WireShark,抓下包分析,下面是整个请求流程图:
14 到 16 正好是 TCP 三次握手(顺便复习了一下)。重点注意 18、19、21 号,这些都是服务器向客户端发送的消息。18 号报文:
可以很明显的看到这是一个 TCP 分片,里面包装了来自应用层的 http 数据包。TCP segment data (1460 bytes)表明包含的数据大小为 1460 字节,因为下面还有类似的分片,我们可以推断出最大传输单元字节数为1460。这是在 TCP 握手阶段第一个请求就确定好的(Maximum Segment Size)。19 号报文格式与 18 号报文相似,传输了剩下的 678 字节数据,这里有个小细节:
注意每个 chunk 数据后面都带了 CRLF(\r\n)!
到 21 号报文可知说有数据都传输完成,应该会有一个结束标志块:
没错,结束块就是:End of chunked encoding。同时这里可以看出传输的总数据大小为 2000 字节。