文件下载的奥秘
下载方式
前端的文件下载主要是通过 <a>
,再加上 download
属性
- 此属性仅适用于同源 URL
- 尽管 HTTP URL 需要位于同一源中,但是可以使用
blob:URL
(URL.createObjectURL()
) 和data:URL
(data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D
) 以方便用户下载使用 JavaScript 生成的内容
因此下载 url 主要有三种方式
返回文件流
能够让浏览器自动下载文件,主要有两种情况:
使用了
Content-Disposition
属性部分浏览器可能会出现
content-disposition
匹配不到,最好做一下判断看content-disposition
和Content-Disposition
哪个能取到Content-Disposition
响应头指示回复的内容该以何种形式展示,是以 内联 的形式(即网页或者页面的一部分),还是以 附件 的形式下载并保存到本地textContent-Disposition: inline; Content-Disposition: attachment # 只要设置如下形态就能成功下载文件 Content-Disposition: attachment; filename="xxx.jpg"
浏览器无法识别的类型
例如:输入
http://127.0.0.1:8080/xxx.sh
,浏览器无法识别该类型,就会自动下载不过 nginx 少配置了
include mime.types;
或mime.types
文件被恶意修改了会导致 js、css 也能自动下载(默认走了application/octet-stream
)nginxhttp { include mime.types; default_type application/octet-stream; }
注意: 跨域情况下,浏览器处于安全考虑不让自定义响应头通过 JS 获取(详见: JS 无法获取响应 header 的 Content-Disposition 字段 )
- 也就是说
Content-Disposition
前端在 Network 里是能看到的,但是无法通过 JS 获取到,这里后端需要将其暴露出去(Access-Control-Expose-Headers
)
ctx.set({
'Access-Control-Expose-Headers': 'Content-Disposition'
})
- 跨域情况默认只暴露:
Cache-Control
、Content-Language
、Content-Type
、Expires
、Last-Modified
、Pragma
六个属性
// 后端代码
const pathResolve = dir => path.resolve(__dirname, '../../static/', dir)
router.get('/download', async ctx => {
const { filename } = ctx.query
const fStats = fs.statSync(pathResolve(filename))
ctx.set({
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename=${filename}`,
'Content-Length': fStats.size
})
ctx.body = fs.readFileSync(pathResolve(filename))
})
直接将后端返回的文件流以新的窗口打开,即可直接下载了
// 前端代码
const windowOpenClick = async () => {
window.open('http://127.0.0.1:8080/api/download/download?filename=origin.png', '_blank')
}
返回静态站点地址
通过静态站点下载,这里主要分为两种情况
- 该服务器自带静态目录,即为同源情况。可以使用
a
标签进行下载 - 使用第三方静态存储平台,比如阿里云、腾讯云之类的进行托管。可以使用
blob:URL
、data:URL
形式下载
前提:后端直接返回同源静态目录
// 后端代码
router.get('/downloadUrl', async ctx => {
const { filename } = ctx.query
ctx.body = { ...SUCCESS, data: { url: `http://127.0.0.1:8080/${filename}` } }
})
前端直接根据返回的 URL 直接下载即可
// 前端代码
const downloadByUrl = url => {
const aLink = document.createElement('a')
aLink.download = url.split('/').pop()
aLink.href = url
aLink.click()
}
const downloadClick = async () => {
const res = await request({
method: 'get',
url: '/download/downloadUrl',
params: { filename: 'pig.jpg' }
})
downloadByUrl(res.data.url)
}
通过Blob下载
Blob 通常用于存储大文件,典型的 Blob 内容是一张图片或一个音频
默认情况下 axios 不会处理二进制数据,即请求可以正常被浏览器接收,但 axios 不会去处理。需要在请求的时候设置
responseType: 'blob'
才可以拿到文件流之后,需要生成一个 URL 才可以下载,可以 通过
URL.createObjectURL()
方法生成一个链接a 标签添加文件名
正常情况下,通过
window.location = url
就可以下载文件。浏览器判断这个链接是一个资源而不是页面的时候,就会下载文件。但是通过文件流生成的 url 对应的资源是没有文件名的,需要添加文件名。这时候可以用到 download 属性指定下载的文件名
// 后端代码
router.get('/downloadUrl', async ctx => {
const { filename } = ctx.query
ctx.body = { ...SUCCESS, data: { url: `http://127.0.0.1:8080/${filename}` } }
})
注意:download
只在同源 URL 或 blob:
、data:
协议起作用
// 前端代码
const downloadByBlob = (content, fileName, type) => {
const blob = new Blob([content], { type })
const aLink = document.createElement('a')
aLink.download = fileName
aLink.href = URL.createObjectURL(blob)
aLink.click()
URL.revokeObjectURL(aLink.href)
}
const downloadClick = async () => {
const resUrl = await request({
method: 'get',
url: '/download/downloadUrl',
params: { filename: 'pig.jpg' }
})
const download = async (url, fileName) => {
const res = await request({
method: 'get',
url,
responseType: 'blob'
})
downloadByBlob(res, fileName)
}
const url = resUrl.data.url
if (url) download(url, url.split('/').pop())
}
通过Base64下载
有时候我们也会遇到一些新手后端返回字符串的情况,这种情况很少见,但是来了我们也不慌
// 后端代码
const mime = require('mime') // need lower v3
router.get('/downloadBase64', async ctx => {
const { filename } = ctx.query
const content = fs.readFileSync(pathResolve(filename))
ctx.body = {
...SUCCESS,
data: {
base64: content.toString('base64'),
filename,
type: mime.getType(filename)
}
}
})
前端处理需要多两个步骤
需要将我们的
base64
字符串转化为二进制流。可以使用 JS 内置函数atob
将 Base64 转换为原始的二进制字符串atob
可以理解 A to B,A 指的是 Base64,B 指的是普通字符再存储每个字符的 Unicode 编码值,最后使用
Uint8Array.from()
转换为无符号 8 位整数数组buffer
// 前端代码
const base64ToBlob = (base64, type) => {
const byteChars = atob(base64)
const byteArray = new Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) {
byteArray[i] = byteChars.charCodeAt(i)
}
const buffer = Uint8Array.from(byteArray)
const blob = new Blob([buffer], { type })
return blob
}
const downloadByBase64 = ({ base64, filename, type }) => {
const blob = base64ToBlob(base64, type)
downloadByBlob(blob, filename, type)
}
const base64Click = async () => {
const { data } = await request({
method: 'get',
url: '/download/downloadBase64',
params: { filename: 'pig.jpg' }
})
downloadByBase64({ base64: data.base64, filename: data.filename, type: data.type })
}
大文件的分片下载
很多视频网站在加载 mp4 文件时,会有这样的现象:不需要将整个 mp4 下载完才进行播放,而且还伴随很多状态码为 206 的请求
- 这样分片加载资源,对于体验或流量节省都是非常大的帮助
The Range
是一个请求首部,告知服务器返回文件的哪一部分。在一个 Range
首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回
- 如果服务器返回的是范围响应,需要使用 206
Partial Content
状态码 - 假如所请求的范围不合法,那么服务器会返回 416
Range Not Satisfiable
状态码,表示客户端错误 - 服务器允许忽略
Range
首部,从而返回整个文件,状态码用 200
// 后端代码
exports.getRange = function getRange(range) {
const match = /bytes=([0-9]*)-([0-9]*)/.exec(range)
const requestRange = {}
if (match) {
if (match[1]) requestRange.start = Number(match[1])
if (match[2]) requestRange.end = Number(match[2])
}
return requestRange
}
router.get('/rangeFile', async ctx => {
const { filename } = ctx.query
const range = ctx.headers['range']
const file = pathResolve(filename)
const { size } = fs.statSync(file)
if (!range) {
ctx.set('Accept-Ranges', 'bytes')
ctx.set({
'Content-Disposition': `attachment; filename=${filename}`,
'Content-Length': size
})
ctx.body = fs.readFileSync(file)
return
}
const { start, end } = getRange(range)
if (start >= size || end >= size) {
ctx.status = 416
ctx.body = ''
return
}
ctx.status = 206
ctx.set({
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${start}-${end ? end : size - 1}/${size}`
})
ctx.body = fs.createReadStream(file, { start, end })
})
nginx 版本 v1.9.8 后,(加上 ngx_http_slice_module)默认自动支持,可以将 max_ranges
设置为 0
的来取消这个设置。
直接下载
直接去请求以 blob
方式下载即可
const serialClick = async () => {
console.time('直接下载')
const filename = 'big.png'
const res = await request({
method: 'get',
url: '/download/rangeFile',
params: { filename },
responseType: 'blob'
})
downloadByBlob(res.data, filename)
console.timeEnd('直接下载')
}
并发分片下载
首先发送一个 head 请求,来获取文件的大小,然后根据 length 以及设置的分片大小,来计算每个分片是滑动距离
const downloadRange = (filename, start, end, i) => {
return new Promise((resolve, reject) => {
request({
method: 'get',
url: '/download/rangeFile',
params: { filename },
responseType: 'blob',
headers: { range: `bytes=${start}-${end}` }
})
.then(res => resolve({ i, buffer: res.data }))
.catch(reject)
})
}
const parallelClick = async () => {
console.time('并行下载')
const filename = 'big.png'
const res = await request({
method: 'head',
url: '/download/rangeFile',
params: { filename }
})
const size = Number(res.headers['content-length'])
const limit = 1024 * 700
let length = parseInt(size / limit)
const arr = []
for (let i = 0; i <= length; i++) {
let start = i * limit
let end = i == length ? size - 1 : (i + 1) * limit - 1
arr.push(downloadRange(filename, start, end, i))
}
if (length === 0) {
arr.push(downloadRange(filename, 0, size - 1, 0))
}
Promise.all(arr).then(res => {
const buffers = res.map(item => item.buffer)
const mergeBlob = new Blob([...buffers])
downloadByBlob(mergeBlob, filename)
console.timeEnd('并行下载')
})
}
谷歌浏览器在 HTTP/1.1 对于单个域名有所限制,单个域名最大的并发量是 6
// Chromium 源码
int g_max_sockets_per_group[] = {
6, // NORMAL_SOCKET_POOL
255 // WEBSOCKET_SOCKET_POOL
};
不过经反复测试,直接下载和并发分片下载速度是差不多的(在文件不大的情况下),和预期不符合
由于我们的服务器是一根大水管,流速是一定的,并且我们客户端没有限制。如果是单个下载的话,那么会跑满用户的最大的速度。如果是并行下载的呢,以 3 个线程为例子的话,相当于每个线程都跑了原先线程三分之一的速度
- 可以在 nginx 上加入
limit_rate 1M
进行测试,这下基本上速度已经是正常了,并行下载比直接下载快了很快
关于 HTTP/1.1
同一站点只能并发 6 个请求,多余的请求会放到下一个批次。但是 HTTP/2.0
不受这个限制,多路复用代替了 HTTP/1.x
的 序列和阻塞机制
- 使用自签的 ssl 证书
# 生成私钥(key)
$ openssl genpkey -algorithm RSA -out server.key
# 生成证书签发请求(CSR)
$ openssl req -new -key server.key -out server.csr
# 使用私钥和CSR生成自签名证书
$ openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt
- 配置 nginx
server {
listen 8080 ssl http2;
ssl_certificate cert/server.crt;
ssl_certificate_key cert/server.key;
}