需求背景: 项目中有多处下载数据的地方,有时候遇到几百万条数据,一口气返回的话,可能会导致内存不够用。

需求:是不是有一种方法,能让我循环每次取一点数据返回?

解决方案:目前想到两种——

一种是node端使用 stream 方式返回,前端用window.location.href的方式打开后端接口。

另一种浏览器端js主导的导出动态数据,是后端提供分页接口,前端使用 StreamSaver.js(文件大小无限制) 或 FileSaver.js(文件大小受限于前端可用内存和Blob允许的最大值即2G) 保存文件。

两种方法各有优势,按需选取。

服务端stream1. 只发起一次http请求。
2. 前后端总开发量少,基本是后端工作量
1. 如果接口有可能会返回json让前端判断是否下载,则前端会很难。
2. 假如运维不愿意加长网关超时,也是一个缺点
前端stream1. 前端可以做更细的判断
2. 总开发量大,基本是前端工作量
1. 会发起多次http请求,如果下载次数不多还行。

我个人还是偏向于前端Stream,因为可以满足更变态的需求,而且做过一次后,以后可以复用代码。

但本文标题是用node+koa以流的形式返回数据,所以本文先介绍第一种,另一种在这篇文章介绍:浏览器端js主导的导出动态数据

服务端stream

查阅koa的文档,只需要 ctx.body= 右边的值类型是 ReadableStream 即可。那么可以用 stream.Readable,由于我不习惯stream.Readable本身的用法,所以我封装了一个简易的函数:

/**
 * 创建一个可读 stream ,循环调用 getData 函数获取数据,当 该函数 返回 null 时结束,如果返回undefined,会认为是返回空字符串 * @param getData size参数是用于参考单次返回多少数据,不是说要严格按照这个。size单位应该是字节。必须返回的是 utf8 编码的 * */
 function createReadableStream(
  getData: (size: number) => Promise<string | null>
) {
  return new stream.Readable({
    async read(size) {
      while (true) {
        let data = null
        try {
          data = await getData(size)
        } catch (e) {
          console.error('[h-node-utils-error createReadable]:', e)
        }
        const goContinue = this.push(data, 'utf8')
        if (!goContinue || data === null) {
          break
        }
      }
    },
  })
}

使用方法:

ctx.set('Content-Type', 'text/csv; charset=utf-8')
// 中文必须用 encodeURIComponent 包裹,否则会报 Invalid character in header content ["Content-Disposition"]
ctx.set(
  'Content-Disposition',
  `attachment; filename=${encodeURIComponent('详细数据')}.csv`
)
let page = 0
ctx.body = createReadableStream(async () => {
  page += 1
  // 这里从数据库读一页数据,
  // 假如有数据,把数据转为字符串并返回,如果是csv则够用了,如果要用Excel,需要查查有没有方法可以用
  // 假如没有更多数据了,返回null
})

前端浏览器直接打开该接口地址即可下载

附:前端可以不用跳转到空白页面就能下载以上接口输出的方法:

// 其实就是创建一个不可见的iframe,让这个iframe跳转到下载接口
function download(url) {
  const iframe = document.createElement('iframe')
  iframe.style.display = 'none'
  function iframeLoad() {
    console.log('iframe onload')
    const win = iframe.contentWindow
    const doc = win.document
    if (win.location.href === url) {
      if (doc.body.childNodes.length > 0) {
        // response is error
      }
      iframe.parentNode.removeChild(iframe)
    }
  }
  if ('onload' in iframe) {
    iframe.onload = iframeLoad
  } else if (iframe.attachEvent) {
    iframe.attachEvent('onload', iframeLoad)
  } else {
    iframe.onreadystatechange = function onreadystatechange() {
      if (iframe.readyState === 'complete') {
        iframeLoad()
      }
    }
  }
  iframe.src = ''
  document.body.appendChild(iframe)
  setTimeout(function loadUrl() {
    iframe.contentWindow.location.href = url
  }, 50)
}
03-05 21:17