最近优化了几个新人写出的动态表格文件下载接口的性能瓶颈,感觉非常有必要总结一篇文章作为文档来抛砖引玉,以促进大家学习一起写出更专业的代码。
讲具体问题之前需要先了解一些 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 -> userData
return 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);
},
};
多数人业务初期做需求时,考虑到数据量还不是很多,排期紧任务重,都像上面这样实现:
exceljs
库,但却没有用其提供的流 API最简单的策略就是将几 w 条数据库数据按每组 1w 条分组,分批次处理,有很多优秀的开源库以供使用比如 async 。
简单代码示意:
// 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);
},
};
源码中出现先 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 流处理等等大家可以自行去了解一番。
本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/4q3R4Cz-rYzNdGYrKjudQA
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。