前言

最近优化了几个新人写出的动态表格文件下载接口的性能瓶颈,感觉非常有必要总结一篇文章作为文档来抛砖引玉,以促进大家学习一起写出更专业的代码。

HTTP 文件下载

讲具体问题之前需要先了解一些 HTTP 基础,下面简单介绍一下用 Node.js&Koa 怎么实现文件下载。

参考:

rfc2616 19.5.1 Content-Disposition

rfc1806

Node.js Stream

简单下载

最简单的情况就是服务器上文件系统已经存在了某个文件,客户端请求下载直接把文件读了吐回去即可:

import Koa from 'koa';
import Router from 'koa-router';
import * as fs from 'fs/promises';const app = new Koa();
const router = new Router();router.get('/download/simple', async (ctx) => {const file = await fs.readFile(`${__dirname}/1.txt`, 'utf-8');ctx.set({'Content-Disposition': `attachment; filename=1.txt`,});ctx.body = file;
});app.use(router.routes());
app.listen(80);

设置 Content-Disposition 头部为 attachment 是关键,告诉浏览器应该下载这个文件。

流式下载

简单下载在碰到大文件的情景就不够用了,因为 Node 无法将大文件一次性读取到进程内存里。这时候用流来解决:

router.get('/download/stream', async (ctx) => {const file = fs.createReadStream(`${__dirname}/1.txt`);ctx.set({'Content-Disposition': `attachment; filename=1.txt`,});ctx.body = file;
});

此例子不设置 Content-Disposition 头部也是会下载的,因为 Content-Type 被设置为了 application/octet-stream,浏览器认为其是一个二进制流文件所以默认下载处理了。

进度显示

当下载的文件特别大时,上个例子 Content-Length 正确设置时浏览器下载条里就能正常显示进度了,为了方便我们使用程序模拟一下:

router.get('/download/progress', async (ctx) => {const { enable } = ctx.query;const buffer = await fsp.readFile(`${__dirname}/1.txt`);const stream = new PassThrough();const l = buffer.length;const count = 4;const size = Math.floor(l / count);const writeQuarter = (i = 0) => {const start = i * size;const end = i === count - 1 ? l : (i + 1) * size;stream.write(buffer.slice(start, end));if (end === l) {stream.end();} else {setTimeout(() => writeQuarter(i + 1), 3000);}};if (!!enable) {ctx.set({'Content-Length': `${l}`,});}ctx.set({'Content-Type': 'plain/txt','Content-Disposition': `attachment; filename=1.txt`,Connection: 'keep-alive',});ctx.body = stream;writeQuarter();
});

这里利用了 PassThrough 流来替代 fs.createReadStream,故 Koa 不再知道文件大小和类型,并将文件分为 4 份,每份间隔 3 秒发送来模拟大文件下载。

当参数 enable 为真时,设置了 Content-Length 则会显示进度 (剩余时间),否则不显示:

断点续传

下载文件特别大时,常常也会因为网络不稳定导致下载中途断开而失败,这时候可以考虑支持断点续传:

function getStartPos(range = '') {var startPos = 0;if (typeof range === 'string') {var matches = /^bytes=([0-9]+)-$/.exec(range);if (matches) {startPos = Number(matches[1]);}}return startPos;
}router.get('/download/partial', async (ctx) => {const range = ctx.get('range');const start = getStartPos(range);const stat = await fsp.stat(`${__dirname}/1.txt`);const stream = fs.createReadStream(`${__dirname}/1.txt`, {start,highWaterMark: Math.ceil((stat.size - start) / 4),});stream.on('data', (chunk) => {console.log(`Readed ${chunk.length} bytes of data.`);stream.pause();setTimeout(() => {stream.resume();}, 3000);});console.log(`Start Pos: ${start}.`);if (start === 0) {ctx.status = 200;ctx.set({'Accept-Ranges': 'bytes','Content-Length': `${stat.size}`,});} else {ctx.status = 206;ctx.set({'Content-Range': `bytes ${start}-${stat.size - 1}/${stat.size}`,});}ctx.set({'Content-Type': 'application/octet-stream','Content-Disposition': `attachment; filename=1.txt`,Connection: 'keep-alive',});ctx.body = stream;
});

让我们来试验一下(Chrome 默认下载工具不支持断点续传):

curl -v http://127.0.0.1/download/partial -o 1.txt

此时我们趁传输间隙,将服务进程停止,这时可以看到 1.txt 文件仅仅只传了 18 bytes:

我们将服务恢复,恢复下载:

curl -v http://127.0.0.1/download/partial -o 1.txt -C -

可以看到剩下的部分也分 4 次传完了。

动态表格

在了解完上述关于文件下载实现的基础后,我们来看一个实际问题:根据请求参数条件读取数据库的某张表的全部记录并导出为表格。

参考:

exceljs

瓶颈

// Controller.js
const sequelize = new Sequelize(name, user, password, {dialect: 'mysql',host,port,
});
const model = sequelize.import('/path/to/model');
const { rows } = await model.findAndCountAll({where: conditions,attributes: ['f_user_id'],group: 'f_user_id',
});const list = awaitPromise.all(rows.map((item) => {const { f_user_id } = item;const userRows = await model.findAll({where: { ...conditions, f_user_id },// ordering, eager loading, ...});// formating userRows -> userDatareturn userData;})
);const headers = ['ID', /*...*/];
const sheetData = [headers, ...list];ctx.attachment(`${sheetName}.xlsx`);
ctx.body = await exportXlsx(sheetName, sheetData);
// xlsx
const ExcelJS = require('exceljs');
const fs = require('fs');module.exports = {exportXlsx: async (name = 'sheet', data) => {const tempFilePath = `./xlsx/${Date.now()}.xlsx`;const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ filename: tempFilePath }); // 创建一个流式写入器const sheet = workbook.addWorksheet('My Sheet'); // 添加工作表const { length } = data;for (let i = 0; i < length; i++) {sheet.addRow(data[i]);}sheet.commit(); // 提交工作表await workbook.commit(); // 交工作簿,即写入文件return fs.createReadStream(tempFilePath);},
};

多数人业务初期做需求时,考虑到数据量还不是很多,排期紧任务重,都像上面这样实现:

  • 不考虑数据量,当数据库表记录数超过 2w 时,内存就已经承受不住导致 Node 进程退出了

  • 没有考虑内存限制,找个成熟的 exceljs 库,但却没有用其提供的流 API

  • 数据查询逻辑实现完全不考虑性能,拿到 ORM 库就是调用查询,完全不考虑 SQL 查询并发数

优化

分段处理

最简单的策略就是将几 w 条数据库数据按每组 1w 条分组,分批次处理,有很多优秀的开源库以供使用比如 async。

简单代码示意:

let total = await model.count(/* ... */)
let page = 0;
const tasks = [];
const size = 10000;
while (total > 0) {tasks.push(() => queryModel({limit: size,offset: size * page}))page++;total -= size;
}
await async.series(tasks)

减少 SQL 查询数

源码中出现先 group by 查询出去重的 f_user_id 后,再来并发查询某一用户的所有记录。

这里应该用 SQL 中的 IN 先查完再匹配处理:

model.findAll({where: {...conditions,f_user_id: rows.map(x =>`${x.f_user_id}`)}
})

流处理

在上面的 xlsx.js 文件中,是先输出一个文件再使用 fs.createReadStream 流输出

exceljs 库提供了 API 来实现流写:

const workbook = new Excel.stream.xlsx.WorkbookWriter(options);
const sheet = workbook.addWorksheet('My Sheet');
// .,,
ctx.body = workbook.stream;

更多

当然除了上述提到的优化手段,还有离线生成、缓存等等诸多优化手段可用,这里不再展开。

总结

文件导出是最常见的需求之一,把功能实现好是专业素质最好的体现。

此文篇幅有限,原理性的细节如 Exceljs 的依赖里对 xlsx 规范的 zip 流处理等等大家可以自行去了解一番。

- END -

关于奇舞团

奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。

Node.js 动态表格大文件下载实践相关推荐

  1. Node.js之十大Web框架

    Node.js之十大Web框架 之前接触过Node.js是因为好奇大前端越来越能干了,连我后台的饭碗都要抢了,太嚣张了,于是我想打压打压它,然后就这样接触它了. 再到后来是因为Settings-Syn ...

  2. GMTC大会专访:Node.js 2018年大前端潮流解析

    近年来,随着一波又一波技术浪潮的更迭,分工的不断细化,以及和业务越来越紧密的联系,前端早已告别了切图的时代,迎来的是大前端之路.可以说,大前端已经是Web前端和客户端开发的趋势,加上Node的横空出世 ...

  3. 十个书写Node.js REST API的最佳实践(上)

    收录待用,修改转载已取得腾讯云授权 原文:10 Best Practices for Writing Node.js REST APIs 我们会通过本文介绍下书写Node.js REST API的最佳 ...

  4. node 升级_技术周刊( Node.js 12 性能大提升 2019-04-30)

    前端快爆 Node.js 12 发布,该版本带来了巨大的性能提升.V8 升级到 7.4 带来了诸多新特性,比如 Private Class Fields.Array#{flat,flatMap} 等: ...

  5. 关于Angular Js动态表格合并单元格的一点见解

    记第一篇笔记  hell OwO rld          最近公司有一点点小需求,做个另类的表格,要求动态的样式(PS:不能直接写死那种),没办法打工人只能硬着头皮去干.         网上传的一 ...

  6. html+css+js 动态表格改动

    html代码: <html lang="en"> <head><meta charset="UTF-8"><link ...

  7. 小女子需要各位博友帮忙—— 一个关于JS 动态表格合并拆分问题

    没有在首页上发过文章,今天是第一次,而且还是求助性的文章.希望大家帮我提供解决办法(*^__^*) . 现在正在做一个关于试验项目的插件. 基本功能:由用户自己来设计试验项目的表格框架以及计算公式(如 ...

  8. html js 动态表格数据,HTML+JS动态表格

    1. 设置固定列 ID日期星期 propName -- 属性名 valCalc -- 设置值时, 特定回调函数 2. 动态生成可变列 这里模拟服务器获取数据 // 返回数据的格式: /* { poin ...

  9. axios下载大文件_前端多线程大文件下载实践,提速10倍(拿捏百度云盘)

    背景 没错,你没有看错,是前端多线程,而不是Node.这一次的探索起源于最近开发中,有遇到视频流相关的开发需求发现了一个特殊的状态码,他的名字叫做 206~ 屏幕快照 2020-09-21 23.21 ...

最新文章

  1. Ubuntu安装wps Linux版
  2. mongodb 字符串 截取_Mongodb split字符串后分组统计
  3. 一发模拟水题但是RE,暑假抽个时间改一改、、
  4. iphone mac地址是否随机_iOS8随机MAC地址功能:需要满足前提要求
  5. AI一体机高速自由流收费稽核解决方案
  6. 云服务器定时启动程序_过去、现在和未来:开发一款微信小程序的技术迭代全过程...
  7. 使用navicat无法登陆oracle 数据库
  8. php libiconv close_PHP 5.3.10 编译安装时出现 undefined reference to `libiconv' 错误的解决方法...
  9. 运维工程师到底是做什么的?
  10. 清理注册表 php,如何彻底清理注册表?小编教你清理注册表操作方法
  11. 人工智能AI在智能交通领域中的应用
  12. 毕业设计-基于微信小程序的“掌上实验室” 安全教育与管理系统
  13. Mac远程Win桌面官方工具——Microsoft Remote Desktop for mac
  14. 【基本面的量化分析与投资的回报可能 - 上篇】
  15. js对内容进行编码(富文本编辑器使用居多)
  16. GSM与GPRS区别介绍
  17. STM32F4的DMA
  18. 广州刷脸支付骗局_刷脸支付骗局你仔细观察一下
  19. 看老程序员如何用Python的SimPy库简化复杂的编程模型的
  20. oracle 嵌套 哈希,Oracle-三种联接方法(哈希连接、嵌套连接、笛卡儿乘积)

热门文章

  1. android 报错:should pass resolved color instead of id here
  2. Nginx网站服务(一)
  3. 使用ARouter进行Android模块化开发,2021年Android春招面试经历
  4. word文档中有几页突然变得很宽
  5. Spring-aop 基于Aspectj 表达式配置AOP
  6. [模板] 快速沃尔什变换
  7. seo搜索优化技巧02-seo问答推广怎么做?
  8. Sabine, Eyring,Arau-Puchades的混响时间计算对比分析
  9. WeBankBlockchain-Data-Reconcile---基于区块链的对账组件
  10. 重磅!Redis 深度历险:核心原理和应用实践,GitHub标星65k!