Excel 是常用的办公软件,会用它来做数据的整理。
后台管理系统一般都会支持从 Excel 导入数据,或者导出数据到 Excel 文件:
那这种功能是如何实现的呢?
在 Node 和浏览器里,如何解析、生成 Excel 文件呢?
一般会用 exceljs 这个包来做。
在 npm 官网可以看到,这个包每周有 30w+ 的下载量,用的还是很多的:
具体写代码试试:
mkdir exceljs-test
cd exceljs-test
npm init -y安装 exceljs:
npm install --save exceljs把刚才这个 excel 文件复制过来:
在代码里读取出来看看:
const { Workbook } = require('exceljs');
async function main(){
const workbook = new Workbook();
const workbook2 = await workbook.xlsx.readFile('./data.xlsx');
workbook2.eachSheet((sheet, index1) => {
console.log('工作表' + index1);
sheet.eachRow((row, index2) => {
const rowData = [];
row.eachCell((cell, index3) => {
rowData.push(cell.value);
});
console.log('行' + index2, rowData);
})
})
}
main();工作表就是这个东西:
每个工作表下都是独立的表格。
也就是 workbook(工作簿) > worksheet(工作表) > row(行) > cell(列)这样的层级关系。
每一层都可以遍历:
所以遍历 sheet、row、cell 这几层,就能拿到所有的数据。
跑下看看:
确实都拿到了。
这样就是数据导入,从 excel 文件里解析出数据,然后存入数据库。
exceljs 还提供了简便的方法,可以直接调用 worksheet 的 getSheetValues 来拿到表格数据,不用自己遍历:
解析 excel 文件还是很简单的。
导入数据的时候,按照格式从中解析数据然后存入数据库就行。
有同学可能会说,那如果 excel 的格式不符合要求呢?
一般都会提供一个 excel 模版,用这个模版来填数据,然后再导入。
excel 解析会了,再来看下 excel 的生成:
const { Workbook } = require('exceljs');
async function main(){
const workbook = new Workbook();
const worksheet = workbook.addWorksheet('guang111');
worksheet.columns = [
{ header: 'ID', key: 'id', width: 20 },
{ header: '姓名', key: 'name', width: 30 },
{ header: '出生日期', key: 'birthday', width: 30},
{ header: '手机号', key: 'phone', width: 50 }
];
const data = [
{ id: 1, name: '光光', birthday: new Date('1994-07-07'), phone: '13255555555' },
{ id: 2, name: '东东', birthday: new Date('1994-04-14'), phone: '13222222222' },
{ id: 3, name: '小刚', birthday: new Date('1995-08-08'), phone: '13211111111' }
]
worksheet.addRows(data);
workbook.xlsx.writeFile('./data2.xlsx');
}
main();相当简单,也是按照层次结构,先 addWorkSheet、然后 addRows,之后写入文件。
可以看到 worksheet 的名字,还有每行的数据都是对的。
右边手机号那一列最宽,因为设置了 width 是 50。
excel 是可以设置格式的,比如字体、背景色等,在代码里同样可以。
就是遍历 row、cell,根据行数设置 style 就好了:
worksheet.eachRow((row, rowIndex) => {
row.eachCell(cell => {
if(rowIndex === 1) {
cell.style = {
font: {
size: 10,
bold: true,
color: { argb: 'ffffff' }
},
alignment: { vertical: 'middle', horizontal: 'center' },
fill: {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: '000000' }
},
border: {
top: { style: 'dashed', color: { argb: '0000ff' } },
left: { style: 'dashed', color: { argb: '0000ff' } },
bottom: { style: 'dashed', color: { argb: '0000ff' } },
right: { style: 'dashed', color: { argb: '0000ff' } }
}
}
} else {
cell.style = {
font: {
size: 10,
bold: true,
},
alignment: { vertical: 'middle', horizontal: 'left' },
border: {
top: { style: 'dashed', color: { argb: '0000ff' } },
left: { style: 'dashed', color: { argb: '0000ff' } },
bottom: { style: 'dashed', color: { argb: '0000ff' } },
right: { style: 'dashed', color: { argb: '0000ff' } }
}
}
}
})
})跑下看看:
这样,就完成了数据的导出。
而且,exceljs 这个库可以直接在浏览器里用。
试试:
创建 index.html,引入 exceljs 包。
添加一个 file 类型的 input,onchange 的时候解析其中的内容,解析逻辑和之前一样,只是从 readFile 换成 load。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://unpkg.com/exceljs@4.4.0/dist/exceljs.min.js"></script>
</head>
<body>
<input id="fileInput" type="file"/>
<script>
const fileInput = document.getElementById('fileInput');
fileInput.onchange = async () => {
const file = fileInput.files[0];
const { Workbook } = ExcelJS;
const workbook = new Workbook();
const workbook2 = await workbook.xlsx.load(file);
workbook2.eachSheet((sheet, index1) => {
console.log('工作表' + index1);
const value = sheet.getSheetValues();
console.log(value);
})
}
</script>
</body>
</html>起个静态服务:
npx http-server .浏览器访问下:
可以看到,同样解析出了 excel 的内容。
然后再试试生成 excel:
前面的逻辑一样,只是把 writeFile 换成了 writeBuffer。
这里我创建了一个 10M 的 ArrayBuffer 来写入数据,之后再读取。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://unpkg.com/exceljs@4.4.0/dist/exceljs.min.js"></script>
</head>
<body>
<script>
const { Workbook } = ExcelJS;
async function main(){
const workbook = new Workbook();
const worksheet = workbook.addWorksheet('guang111');
worksheet.columns = [
{ header: 'ID', key: 'id', width: 20 },
{ header: '姓名', key: 'name', width: 30 },
{ header: '出生日期', key: 'birthday', width: 30},
{ header: '手机号', key: 'phone', width: 50 }
];
const data = [
{ id: 1, name: '光光', birthday: new Date('1994-07-07'), phone: '13255555555' },
{ id: 2, name: '东东', birthday: new Date('1994-04-14'), phone: '13222222222' },
{ id: 3, name: '小刚', birthday: new Date('1995-08-08'), phone: '13211111111' }
]
worksheet.addRows(data);
worksheet.eachRow((row, rowIndex) => {
row.eachCell(cell => {
if(rowIndex === 1) {
cell.style = {
font: {
size: 10,
bold: true,
color: { argb: 'ffffff' }
},
alignment: { vertical: 'middle', horizontal: 'center' },
fill: {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: '000000' }
},
border: {
top: { style: 'dashed', color: { argb: '0000ff' } },
left: { style: 'dashed', color: { argb: '0000ff' } },
bottom: { style: 'dashed', color: { argb: '0000ff' } },
right: { style: 'dashed', color: { argb: '0000ff' } }
}
}
} else {
cell.style = {
font: {
size: 10,
bold: true,
},
alignment: { vertical: 'middle', horizontal: 'left' },
border: {
top: { style: 'dashed', color: { argb: '0000ff' } },
left: { style: 'dashed', color: { argb: '0000ff' } },
bottom: { style: 'dashed', color: { argb: '0000ff' } },
right: { style: 'dashed', color: { argb: '0000ff' } }
}
}
}
})
})
const arraybuffer = new ArrayBuffer(10 * 1024 * 1024);
const res = await workbook.xlsx.writeBuffer(arraybuffer);
console.log(res.buffer);
}
main();
</script>
</body>
</html>跑下试试:
可以看到,确实有数据了。
那有了 arraybuffer 的数据,如何触发下载呢?
创建一个 a 标签,设置 download 属性,然后触发点击就可以了。
也就是这样:
function download(arrayBuffer) {
const link = document.createElement('a');
const blob = new Blob([arrayBuffer]);
const url = URL.createObjectURL(blob);
link.href = url;
link.download = 'guang.xlsx';
document.body.appendChild(link);
link.click();
link.addEventListener('click', () => {
link.remove();
});
}跑一下:
可以看到,生成了 excel 并且触发了下载。
打开文件,可以看到和 node 里生成的一样。
Excel 的导入导出是后台管理系统的常见功能,一般用 exceljs 来实现。
excel 文件分为 workbook、worksheet、row、cell 这 4 层,解析和生成都是按照这个层次结构来。
解析就是 readFile 之后,遍历 worksheet、row,拿到 cell 中的数据 。
生成就是 addWorkSheet、addRow 添加数据,然后 writeFile 来写入文件。
如果是在浏览器里,就把 readFile 换成 load,把 writeFile 换成 writeBuffer 就好了。
浏览器里生成 excel 之后,可以通过 a 标签触发下载,设置 download 属性之后,触发点击就好了。
这样,就分别在 node 和浏览器里完成了 excel 的解析和生成。
假设有一天,你需要整理一份中国所有大学信息的 ppt。
大学的信息是能搜到的,但是一份份整理到 ppt 里也太麻烦了。
能不能用代码自动生成 PPT呢?
自然是可以的。
这里大学的信息可以从中国大学 MOOC这里抓取:
用 puppeteer 来爬取大学的校徽、名字、介绍,然后用这些信息来生成 ppt 等。
创建个 Nest 项目:
nest new ppt-generate安装 puppeteer:
npm install --save puppeteer然后在 AppService 里引入下:
import { Injectable } from '@nestjs/common';
import puppeteer from 'puppeteer';
let cache = null;
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
async getUniversityData() {
if(cache) {
return cache;
}
const browser = await puppeteer.launch({
headless: true,
defaultViewport: {
width: 0,
height: 0
}
});
const page = await browser.newPage();
await page.goto('https://www.icourse163.org/university/view/all.htm');
await page.waitForSelector('.u-usitys');
const universityList = await page.$eval('.u-usitys', el => {
return [...el.querySelectorAll('.u-usity')].map(item => {
return {
name: item.querySelector('img').alt,
img: item.querySelector('img').src,
link: item.getAttribute('href')
}
})
});
await browser.close();
cache = universityList;
return universityList;
}
}这里用 puppeteer 抓取中国大学 mooc 的学校列表的信息。
headless 指定 true,不用看界面了。
然后简单在内存做了下 cache,没用 redis。
在 AppController 里加个路由:
@Get('list')
async universityList() {
return this.appService.getUniversityData();
}把服务跑起来:
npm run start:dev试一下:
然后继续点进详情页,拿到学校的描述:
抓取每个学校数据的时间太长,用 SSE(server sent event) 的方式返回数据。
Server Sent Event 就是服务端返回的 Content-Type 是 text/event-stream,这是一个流,可以多次返回内容,通过这种方式来随时推送数据。
SSE 类似这样用:
改下 AppController
@Sse('list')
async universityList() {
return this.appService.getUniversityData();
}还有 AppService
import { Injectable } from '@nestjs/common';
import puppeteer from 'puppeteer';
import { Observable, Subscriber } from 'rxjs';
let cache = null;
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
async getUniversityData() {
if(cache) {
return cache;
}
async function getData(observer: Subscriber<Record<string, any>>) {
const browser = await puppeteer.launch({
headless: true,
defaultViewport: {
width: 0,
height: 0
}
});
const page = await browser.newPage();
await page.goto('https://www.icourse163.org/university/view/all.htm');
await page.waitForSelector('.u-usitys');
const universityList: Array<Record<string, any>> = await page.$eval('.u-usitys', el => {
return [...el.querySelectorAll('.u-usity')].map(item => {
return {
name: item.querySelector('img').alt,
img: item.querySelector('img').src,
link: item.getAttribute('href')
}
})
});
for(let i = 0; i < universityList.length; i ++) {
const item = universityList[i];
await page.goto('https://www.icourse163.org' + item.link);
await page.waitForSelector('.m-cnt');
const content = await page.$eval('.m-cnt p', el => el.textContent);
item.desc = content;
observer.next({data: item});
}
await browser.close();
cache = universityList;
}
return new Observable((observer) => {
getData(observer);
});
}
}主要是返回一个 rxjs 的 Observable 然后不断用 observer.next 返回数据。
试一下:
SSE 和爬虫简直是绝配!
接下来生成 ppt,用 pptxgenjs 这个包。
用法很简单:
new 一个实例,添加一个 Slide,然后添加 text image 等内容,最后写入文件。
先测试下:
npm install --save pptxgenjs新建 test.js
const pptxgen = require('pptxgenjs');
const ppt = new pptxgen();
const slide = ppt.addSlide();
slide.addText('北京大学', { x: '10%', y: '10%', color: '#ff0000', fontSize: 30, align: ppt.AlignH.center,});
slide.addImage({
path: "https://nos.netease.com/edu-image/F78C41FA9703708FB193137A688F7195.png?imageView&thumbnail=150y150&quality=100",
x: '42%',
y: '25%',
});
slide.addText(`北京大学创办于1898年,初名京师大学堂,是中国第一所国立综合性大学,也是当时中国最高教育行政机关。辛亥革命后,于1912年改为现名。 学校为教育部直属全国重点大学,国家“211工程”、“985工程”建设大学、C9联盟,以及东亚研究型大学协会、国际研究型大学联盟、环太平洋大学联盟、东亚四大学论坛的重要成员。`,
{ x: '10%', y: '60%', color: '#000000', fontSize: 14,}
);
ppt.writeFile({
fileName: '中国所有大学.pptx'
})分别指定文字和图片的 x、y,对齐方式 align。
跑一下:
node ./test.js打开看一下:
没问题。
然后在 list 接口里加一下这个:
顺便替换下校徽图片,之前取的这个:
换成这里的:
import { Injectable } from '@nestjs/common';
import puppeteer from 'puppeteer';
import { Observable, Subscriber } from 'rxjs';
const pptxgen = require('pptxgenjs');
let cache = null;
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
async getUniversityData() {
if(cache) {
return cache;
}
async function getData(observer: Subscriber<Record<string, any>>) {
const browser = await puppeteer.launch({
headless: true,
defaultViewport: {
width: 0,
height: 0
}
});
const page = await browser.newPage();
await page.goto('https://www.icourse163.org/university/view/all.htm');
await page.waitForSelector('.u-usitys');
const universityList: Array<Record<string, any>> = await page.$eval('.u-usitys', el => {
return [...el.querySelectorAll('.u-usity')].map(item => {
return {
name: item.querySelector('img').alt,
link: item.getAttribute('href')
}
})
});
const ppt = new pptxgen();
for(let i = 0; i < universityList.length; i ++) {
const item = universityList[i];
await page.goto('https://www.icourse163.org' + item.link);
await page.waitForSelector('.m-cnt');
const content = await page.$eval('.m-cnt p', el => el.textContent);
item.desc = content;
item.img = await page.$eval('.g-doc img', el => el.getAttribute('src'));
observer.next({data: item});
const slide = ppt.addSlide();
slide.addText(item.name, { x: '10%', y: '10%', color: '#ff0000', fontSize: 30, align: ppt.AlignH.center,});
slide.addImage({
path: item.img,
x: '42%',
y: '25%',
});
slide.addText(item.desc,
{ x: '10%', y: '60%', color: '#000000', fontSize: 14,}
);
}
await browser.close();
await ppt.writeFile({
fileName: '中国所有大学.pptx'
})
cache = universityList;
}
return new Observable((observer) => {
getData(observer);
});
}
}跑一下:
跑完之后可以看到,动态生成了 400 多张 ppt:
使用 puppeteer 抓取了大学的信息,用 SSE 的方式创建了接口,不断返回爬取到的数据。
然后用 pptxgenjs 来生成了 ppt。
这样,400 多张 PPT 瞬间就生成了,不用自己手动搞。
GIF 压缩是一个常见需求,例如公众号图片最大支持 10M,而录制出来的 GIF 经常超过这个限制。
比如这样一个图片:
在编辑器上传会提示超过 10 M 了:
这时候就需要 GIF 压缩,不然文章发不了。
于是我在百度搜索 GIF 压缩,就找到了一个工具:
它确实能解决我的问题:
但是要花钱:
一年 148 呢,对一个小工具来说还是挺贵的。
但没办法,这对我是刚需,总不能不发文章了吧。
于是去年年底我就开了一年的会员:
但最近发现有 npm 包可以做这个,没必要买这种网站的会员。
当时我的心情是这样的:
这个 npm 包就是 sharp,它是用来处理各种图片的:
它可以用来调整图片的大小,对图片做旋转、颜色调整、合成图片等。
这些功能我用不到,我就关心它的 gif 压缩功能。
看了下文档,大概这样用:
const sharp = require('sharp');
sharp('1.image.gif', {
animated: true,
limitInputPixels: false
}).gif({
colours: 10
}).toFile('2.image.gif')先试试看:
node 执行这个文件,可以看到产生了 2.image.gif,只有 2.7 M
要知道之前的 1.image.gif 可是有 21M 啊:
然后打开它看看:
没啥问题。
回过头来,再来看看这段代码:
animated 设为 true 是读取所有的帧,不然默认只会读取 gif 的第一帧。
limitInputPixels 设为 false 是不限制大小,默认太大的图片是会报错的。
然后是输出参数:
colours 是颜色的数量,默认是 256。
一般色彩不丰富的图片,可以把 colours 设置的小一点。
当把 colours 设置为 2,图片就变成这样了:
图片也更小了一些:
具体怎么设置压缩级别和颜色数量,还是看需求。
总之,完全可以用 sharp 来自己做 gif 压缩,没必要买这种工具网站的会员。
不过体验上还是网页更好一点,也来写个这种网页:
用 create-vite 创建个项目:
npm create vite@latest gif-compression-frontend -- --template react进入项目目录,安装 antd:
npm install --save antd修改下 App.js
import { DatePicker } from 'antd';
function App() {
return (
<div>
<DatePicker/>
</div>
);
}
export default App;然后把开发服务跑起来:
npm run dev浏览器访问下:
antd 引入成功了。
然后来写下上传文件的 UI:
import React from 'react';
import { InboxOutlined } from '@ant-design/icons';
import { message, Upload } from 'antd';
const { Dragger } = Upload;
const props = {
name: 'file',
action: 'http://localhost:3005',
onChange(info) {
const { status } = info.file;
if (status === 'done') {
message.success(`${info.file.name} 文件上传成功`);
} else if (status === 'error') {
message.error(`${info.file.name} 文件上传失败`);
}
}
};
const App = () => (
<Dragger {...props}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或拖拽文件到这个区域来上传</p>
</Dragger>
);
export default App;大概是这样的:
antd 会 post 方式请求 action 对应的接口,带上上传的文件:
再用 nest 写个后端服务接收下:
nest new gif-compression-backend创建个 nest 项目:
修改 main.ts,启用跨域支持,并修改启动端口为 3005
把它跑起来:
npm run start:dev浏览器访问 http://localhost:3005 可以看到 hello world,说明 nest 服务跑成功了:
然后来添加下文章上传的接口:
安装需要的 ts 类型的包:
npm install -D @types/multer在 AppController 里添加这样一个路由:
@Post('upload')
@UseInterceptors(FileInterceptor('file', {
dest: 'uploads'
}))
uploadFile(@UploadedFile() file: Express.Multer.File) {
console.log('file', file);
}这段代码是提取 file 参数的内容,保存到 dest 目录下,然后把文件对象传入该方法。
然后改下前端代码的上传接口:
测试下上传:
提示文件上传成功了,然后在服务端控制台也打印了文件信息,并且在 uploads 目录下可以看到这个文件:
这些文件在浏览器打开,可以看到就是上传的 gif:
然后把文件路径返回就好了:
现在上传文件成功之后就可以拿到这个文件在服务端的路径了:
然后再实现下压缩,在 AppController 增加一个接口:
@Get('compression')
compression(@Query('path') filePath: string, @Query('color', ParseIntPipe) color:number) {
console.log(filePath, color);
if(!existsSync(filePath)) {
throw new BadRequestException('文件不存在');
}
return 'success';
}接收 path、level、color 的 query 参数,分别是文件路径、压缩级别、颜色数量的意思。
其中 color 要使用 ParseIntPipe 转成 int 类型。
测试下:
访问 http://localhost:3005/compression?path=uploads/xxx&color=10
提示文件不存在:
服务端接收到了传过来的参数:
然后换一个真实存在的路径,返回 success:
说明服务端找到了这个路径的文件:
接下来安装 sharp 来实现压缩:
npm install --save sharp修改下 compression 方法:
调用 sharp 来压缩 gif 图片,并注入 response 对象来返回文件下载响应:
@Get('compression')
async compression(
@Query('path') filePath: string,
@Query('level', ParseIntPipe) level: number,
@Query('color', ParseIntPipe) color:number,
@Res() res: Response
) {
if(!existsSync(filePath)) {
throw new BadRequestException('文件不存在');
}
const data = await sharp(filePath, {
animated: true,
limitInputPixels: false
}).gif({
compressionLevel: level,
colours: color
}).toBuffer();
res.set('Content-Disposition', `attachment; filename="dest.gif"`);
res.send(data);
}测试下:
访问这个接口,带上文件路径和压缩的参数,会返回压缩后的文件:
然后在前端页面上加一个表单来填参数,然后访问这个接口压缩文件:
代码如下:
import React, { useState } from 'react';
import { InboxOutlined } from '@ant-design/icons';
import { message, Upload, Form, Input, Button } from 'antd';
const { Dragger } = Upload;
const App = () => {
const [form] = Form.useForm();
const [filePath, setFilePath] = useState('');
const [fileName, setFileName] = useState('');
const compress = async (values) => {
console.log(values);
console.log(filePath);
};
const props = {
name: 'file',
action: 'http://localhost:3005/upload',
onChange(info) {
const { status } = info.file;
if (status === 'done') {
setFilePath(info.file.response);
setFileName(info.file.name);
message.success(`${info.file.name} 文件上传成功`);
} else if (status === 'error') {
message.error(`${info.file.name} 文件上传失败`);
}
}
};
return <div>
<Form style={{width: 500, margin: '50px auto'}}form={form} onFinish={compress}>
<Form.Item
label="颜色数量"
name="color"
>
<Input />
</Form.Item>
<Form.Item>
<Dragger {...props}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或拖拽文件到这个区域来上传</p>
</Dragger>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">压缩</Button>
</Form.Item>
</Form>
</div>
}
export default App;用 filePath 的 state 来保存上传后的文件路径,用 fileName 保存文件名。
在点击压缩的时候打印下表单的值和 filePath。
试试看:
3 个参数都拿到了,然后调用下压缩接口。
安装 axios:
npm install --save axios修改下 compress 方法:
const compress = async (values) => {
const res = await axios.get('http://localhost:3005/compression', {
params: {
color: values.color || 256,
level: values.level || 9,
path: filePath
},
responseType: 'arraybuffer'
});
const blob = new Blob([res.data], { type: 'image/gif' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
message.success('压缩成功');
};访问 compression 接口,传入参数,指定返回数据的类型为 arraybuffer。
然后用 URL.createObjectURL 创建 blob 的 url,设置为 a 标签的 href,指定 download 属性的值也就是文件名,然后触发点击。
这样,就能把返回的 arraybuffer 作为文件下载了。
试试看:
整个流程都跑通了!
试下刚开始那个 21M 的文件,压缩之后下载的是 2.7M。
和用这个网站压缩的差不多:
更重要的是不用每年 148 的会员费。
压缩 gif 图片是我的刚需,之前都是买某网站的 148 的年度会员,直到我发现了 sharp 这个包。
它是用来处理各种图片的,调整大小、旋转等等,只用它的 gif 压缩的功能。
然后也做了一个网站,前端 react + antd,后端 nest + sharp。
后端提供一个 /upload 接口用于上传文件,返回文件路径。
又提供了一个 /compression 接口用来压缩 gif,返回压缩后的文件。
整个流程如下:
其实最好再做一步: 把这个应用通过 dockerfile 来 build 成 docker 镜像,随时用,随时跑。
再需要压缩图片的时候,不用花钱买会员了,直接用自己的压缩工具就好了。
文件上传、文件下载都是常见的需求。
大文件上传会通过分片上传来优化。
那大文件下载如何优化呢?
答案也是分片下载,或者叫流式传输。
试一下:
nest new download-test创建个 Nest 项目。
在 AppController 里添加个 download 的路由:
import { Controller, Get, Res } from '@nestjs/common';
import { AppService } from './app.service';
import { Response } from 'express';
import * as fs from 'fs';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Get('download')
download(@Res() res: Response) {
const content = fs.readFileSync('package.json');
res.set('Content-Disposition', `attachment; filename="guang.json"`);
res.end(content);
}
}把服务跑起来:
npm run start:dev浏览器访问下:
可以看到,触发了下载。
在 devtools 里可以看到正确设置了 header:
header 通过 @Header 装饰器加也可以:
@Get('download')
@Header('Content-Disposition', `attachment; filename="guang.json"`)
download(@Res() res: Response) {
const content = fs.readFileSync('package.json');
res.end(content);
}效果一样。
但是,这样文件是全部读取出来返回,如果文件大了,比如好几个 G,会占用很大的内存。
当大文件下载的时候,能不能读出一部分返回一部分,也就是流式的下载呢?
可以的,http 有这个功能。
就是 transfer-encoding:chunked
这个是面试常考题。
从服务器下载一个文件的时候,如何知道文件下载完了呢?
有两种方式:
一种是 header 里带上 Content-Length,浏览器下载到这个长度就结束。
另一种是设置 transfer-encoding:chunked,它是不固定长度的,服务器不断返回内容,直到返回一个空的内容代表结束。
比如这样:
5
Hello
1
,
5
World
1
!
0这里分了 “Hello” “,” “World”“!” 这 4 个块,长度分别为 5、1、5、1
最后以一个长度为 0 的块代表传输结束。
这样,不管内容多少都可以分块返回,就不用指定 Content-Length 了。
这就是大文件的流式传输的原理,就是 transfer-encoding:chunked。
然后在代码里实现下:
@Get('download2')
@Header('Content-Disposition', `attachment; filename="guang.json"`)
download2(@Res() res: Response) {
const stream = fs.createReadStream('package.json');
stream.pipe(res);
}node 的 stream 本来就是分块读取内容的,这里配合流式返回数据很合适。
现在就不再返回 Content-Length 了,而是返回了 Transfer-Encoding:chunked:
这就是流式传输。
不过在 nest 里最好不要直接用 node 的 stream api。
因为它有很多事件,比如 data、error、end 等,自己处理还是挺麻烦的。
可以直接用 Nest 封装的一个类 StreamableFile:
@Get('download3')
download3() {
const stream = fs.createReadStream('package.json');
return new StreamableFile(stream, {
disposition: `attachment; filename="guang.json"`
});
}试一下:
效果一样。
只是这里的 Content-Type 默认是 application/octet-stream 二进制流:
你也可以改一下:
@Get('download3')
download3() {
const stream = fs.createReadStream('package.json');
return new StreamableFile(stream, {
type: 'text/plain',
disposition: `attachment; filename="guang.json"`
});
}这样就实现了流式传输了。
相比大文件上传需要自己实现分片,大文件下载这个,浏览器和 http 内置了支持,直接指定对应 header 就行,自己不用做很多事情。
然后具体的 http 响应体是什么样的呢?
用 wireshark 抓包看一下:
在 wireshark 官网下载安装包:
安装后把它跑起来:
选择 loopback 这个网卡,本地回环地址,可以抓到 localhost 的包:
输入过滤器 port 3000,也就是过滤 3000 端口的数据包。
然后回车就会进入抓包界面:
这时候再访问下 http://localhost:3000/download3
可以看到抓到了几个 tcp 的包:
这两个分别是请求和响应:
如果多了找不到,点一下 protocol 会排序: 可以看到,确实是分块传输的:
这里有一个数据块,然后一个空块结尾。
再访问下 http://localhost:3000/download 接口对比下:
和上面的对比下,这就是没有分块的响应。
当然,现在的文件比较小,可以找一个大一点的文件试一下:
可以看到,现在分片就多了:
大概是 65536 一个分块,也就是 64k。
每个分块都有 chunk size 和 chunk data:
确实是分块了。
大文件上传的优化是分片上传,大文件下载的优化是分片下载。
只不过这个分片下载 http 帮你做了,你只要指定 transfer-encoding:chunked 就行,也叫流式传输。
在 Nest 里可以用 fs.createReadStream 获取文件流,然后返回 StreamableFile 的对象就可以了。
返回的响应就是流式的,通过 wireshark 抓包证实了这点。
每个分块都有 chunk size、chunk data 的信息。
以后面试官再问你大文件下载优化或者问 transfer-encoding:chunked,你就可以大胆的说你用 wireshark 抓包验证过了。