Skip to content

文件下载的奥秘

下载方式

前端的文件下载主要是通过 <a>,再加上 download 属性

  • 此属性仅适用于同源 URL
  • 尽管 HTTP URL 需要位于同一源中,但是可以使用 blob:URL(URL.createObjectURL()) 和 data:URL(data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D) 以方便用户下载使用 JavaScript 生成的内容

因此下载 url 主要有三种方式

image-20240118092427939

返回文件流

能够让浏览器自动下载文件,主要有两种情况:

  1. 使用了 Content-Disposition 属性

    部分浏览器可能会出现 content-disposition 匹配不到,最好做一下判断看 content-dispositionContent-Disposition 哪个能取到

    Content-Disposition 响应头指示回复的内容该以何种形式展示,是以 内联 的形式(即网页或者页面的一部分),还是以 附件 的形式下载并保存到本地

    text
    Content-Disposition: inline;
    Content-Disposition: attachment
    # 只要设置如下形态就能成功下载文件
    Content-Disposition: attachment; filename="xxx.jpg"
  2. 浏览器无法识别的类型

    例如:输入 http://127.0.0.1:8080/xxx.sh,浏览器无法识别该类型,就会自动下载

    不过 nginx 少配置了 include mime.types;mime.types 文件被恶意修改了会导致 js、css 也能自动下载(默认走了 application/octet-stream

    nginx
    http {
      include mime.types;
      default_type application/octet-stream;
    }

注意: 跨域情况下,浏览器处于安全考虑不让自定义响应头通过 JS 获取(详见: JS 无法获取响应 header 的 Content-Disposition 字段

  • 也就是说 Content-Disposition 前端在 Network 里是能看到的,但是无法通过 JS 获取到,这里后端需要将其暴露出去(Access-Control-Expose-Headers
java
ctx.set({
  'Access-Control-Expose-Headers': 'Content-Disposition'
})
  • 跨域情况默认只暴露:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma 六个属性
js
// 后端代码
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))
})

直接将后端返回的文件流以新的窗口打开,即可直接下载了

js
// 前端代码
const windowOpenClick = async () => {
  window.open('http://127.0.0.1:8080/api/download/download?filename=origin.png', '_blank')
}

返回静态站点地址

通过静态站点下载,这里主要分为两种情况

  1. 该服务器自带静态目录,即为同源情况。可以使用 a 标签进行下载
  2. 使用第三方静态存储平台,比如阿里云、腾讯云之类的进行托管。可以使用 blob:URLdata:URL 形式下载

image-20240123111857608

前提:后端直接返回同源静态目录

js
// 后端代码
router.get('/downloadUrl', async ctx => {
  const { filename } = ctx.query
  ctx.body = { ...SUCCESS, data: { url: `http://127.0.0.1:8080/${filename}` } }
})

前端直接根据返回的 URL 直接下载即可

js
// 前端代码
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 内容是一张图片或一个音频

  1. 默认情况下 axios 不会处理二进制数据,即请求可以正常被浏览器接收,但 axios 不会去处理。需要在请求的时候设置 responseType: 'blob' 才可以

  2. 拿到文件流之后,需要生成一个 URL 才可以下载,可以 通过URL.createObjectURL()方法生成一个链接

  3. a 标签添加文件名

    正常情况下,通过 window.location = url 就可以下载文件。浏览器判断这个链接是一个资源而不是页面的时候,就会下载文件。但是通过文件流生成的 url 对应的资源是没有文件名的,需要添加文件名。这时候可以用到 download 属性指定下载的文件名

js
// 后端代码
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: 协议起作用

image-20240123144543044

js
// 前端代码
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下载

有时候我们也会遇到一些新手后端返回字符串的情况,这种情况很少见,但是来了我们也不慌

image-20240125103851905

js
// 后端代码
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)
    }
  }
})

前端处理需要多两个步骤

  1. 需要将我们的 base64 字符串转化为二进制流。可以使用 JS 内置函数 atob 将 Base64 转换为原始的二进制字符串

    atob 可以理解 A to B,A 指的是 Base64,B 指的是普通字符

    一篇文章彻底弄懂Base64编码原理

  2. 再存储每个字符的 Unicode 编码值,最后使用 Uint8Array.from() 转换为无符号 8 位整数数组 buffer

js
// 前端代码
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 的请求

  • 这样分片加载资源,对于体验或流量节省都是非常大的帮助

Range MDN

The Range 是一个请求首部,告知服务器返回文件的哪一部分。在一个 Range首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回

  • 如果服务器返回的是范围响应,需要使用 206 Partial Content 状态码
  • 假如所请求的范围不合法,那么服务器会返回 416 Range Not Satisfiable 状态码,表示客户端错误
  • 服务器允许忽略 Range 首部,从而返回整个文件,状态码用 200

image-20240125093911731

js
// 后端代码
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 方式下载即可

image-20240125103803594

js
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 以及设置的分片大小,来计算每个分片是滑动距离

js
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

cpp
// Chromium 源码
int g_max_sockets_per_group[] = {
    6,   // NORMAL_SOCKET_POOL
    255  // WEBSOCKET_SOCKET_POOL
};

不过经反复测试,直接下载和并发分片下载速度是差不多的(在文件不大的情况下),和预期不符合

image-20240125154520461

image-20240125151914990

由于我们的服务器是一根大水管,流速是一定的,并且我们客户端没有限制。如果是单个下载的话,那么会跑满用户的最大的速度。如果是并行下载的呢,以 3 个线程为例子的话,相当于每个线程都跑了原先线程三分之一的速度

  • 可以在 nginx 上加入 limit_rate 1M 进行测试,这下基本上速度已经是正常了,并行下载比直接下载快了很快

image-20240125153016285

关于 HTTP/1.1 同一站点只能并发 6 个请求,多余的请求会放到下一个批次。但是 HTTP/2.0 不受这个限制,多路复用代替了 HTTP/1.x序列和阻塞机制

  • 使用自签的 ssl 证书
bash
# 生成私钥(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
nginx
server {
  listen 8080 ssl http2;
  ssl_certificate cert/server.crt;
  ssl_certificate_key cert/server.key;
}

常备不懈,才能有备无患