本文主要讲述基于node如何实现文件上传和下载,分成原生node实现版、中间件实现版。
1、文件上传为什么需要使用multipart/form-data编码类型?
The encoding type application/x-www-form-urlencoded is inefficient for sending large quantities of binary data or text containing non-ASCII characters. Thus, a new media type,multipart/form-data, is proposed as a way of efficiently sending the values associated with a filled-out form from client to server.
文档大概意思是说application/x-www-form-urlencoded不适合用于传输大型二进制数据和包含非ASCII字符的文本,因此提出了一个新的MIME类型multipart/form-data,有效地将与填写的表单相关联的值从客户端发送到服务器。想了解很多MIME类型,点击查看
2、文件对象介绍
Document
文件(File)接口提供有关文件的信息,并允许网页中的 JavaScript 访问其内容。
File 对象是特殊类型的 Blob,且可以用在任意的 Blob 类型的 context 中。比如说, FileReader, URL.createObjectURL(), createImageBitmap(), 及 XMLHttpRequest.send() 都能处理 Blob 和 File。
另外还有一个blob对象,也附上一张chrome浏览器的截图。blob,二进制大文档存储
两个对象里面都有size和type,按照官方的文档,file集成了blob,而且可以使用slice方法.
slice方法可以在当前的blob数据中,取出一段数据,作为新的blob。常用的就是文件断点上传。
3、node中的buffer、Stream、fs模块介绍
buffer
JavaScript语言没有用于读取或处理二进制数据流的机制。
但在处理像TCP流或文件流时,必须使用到二进制数据。因此在 Node.js中,定义了一个 Buffer 类,该类用来创建一个专门存放二进制数据的缓存区。
在 Node.js 中,Buffer 类是随 Node 内核一起发布的核心库。Buffer 库为 Node.js 带来了一种存储原始数据的方法,可以让 Node.js 处理二进制数据,每当需要在 Node.js 中处理I/O操作中移动的数据时,就有可能使用 Buffer 库。原始数据存储在 Buffer 类的实例中。一个 Buffer 类似于一个整数数组,但它对应于 V8 堆内存之外的一块原始内存(buffer 是 C++ 层面分配的,所得内存不在 V8 内)。
const Buffer = require('buffer').Buffer
var buf1 = Buffer.from('tést');
var buf2 = Buffer.from('tést', 'latin1');
var buf3 = Buffer.from([1, 2, 3]);
console.log(buf1, buf2, buf3);
输出的结果为:
不是说node引入buffer是来处理二进制数据流吗?怎么转换成buffer对象打印出来却不是二进制,而是十六进制呢?
在计算机内使用二进制表示数据,一个存储空间叫做一个 bit ,只能存储 0 或是 1。 通常,计算机把 8 个bit作为一个存储的单位,称为一个 Byte。
于是一个 Byte 可以出现 256 种不同的情况。一个 Buffer 是一段内存,比如大小为 2(Byte)的buffer,一共有 16 bit ,比如是
00000001 00100011,可是这样显示太不方便。所以显示这段内存的数据的时候,用其对应的 16 进制就比较方便了,是01 23,之所以用 16 进制是因为转换比较方便。内存仅仅存储的是二进制的数据,但是如何解释就是我们人类自己的事情了。。。。比如
A在 内存中占用两个Byte,对应的内存状态是0000000 01000001,而uint16(JS不存在这个类型) 类型的65对应的存储内存的状态也是这个。如果输出 Buffer 那么nodejs 输出的是内存实际存储的值(因为你没有给出如何解释这段内存中的数据),可是二进制显示起来不方便看,所以转换为 16 进制方便人类阅读。
如果转换为数组,那么意思就是把这个 buffer 的每一个字节解释为一个数字(其实是10进制数字,这是人类最方便的),所以是 0~255 的 10 进制数字。
总之,这样转化的目的是方便显示和查看。
stream
Stream是一个抽象接口,Node中有很多对象实现了这个接口。例如,对http服务器发起请求的request对象和服务端响应对象response就是Stream,还有stdout(标准输出)。
你可以把流理解成一种传输的能力。通过流,可以以平缓的方式,无副作用的将数据传输到目的地。Stream表示的是一种传输能力,Buffer是传输内容的载体 (可以这样理解,Stream:外卖小哥哥, Buffer:你的外卖)。
流为什么这么好用还这么重要呢?
现在有个需求,我们要向客户端传输一个大文件。每次接收一个请求,就要把这个大文件读入内存,然后再传输给客户端。
const fs = require('fs');
const server = require('http').createServer();
server.on('request', (req, res) => {
fs.readFile('./big.file', (err, data) => {
if (err) throw err;
res.end(data);
});
});
server.listen(8000);
通过这种方式可能会产生以下三种后果:
- 内存耗尽
- 拖慢其他进程
- 增加垃圾回收器的负载
所以这种方式在传输大文件的情况下,不是一个好的方案。并发量一大,几百个请求过来很容易就将内存耗尽。
如果采用流呢?
const fs = require('fs');
const server = require('http').createServer();
server.on('request', (req, res) => {
const src = fs.createReadStream('./big.file');
src.pipe(res);
});
server.listen(8000);
采用这种方式,不会占用太多内存,可以将文件一点一点读到内存中,再一点一点返回给用户,读一部分,写一部分。(利用了 HTTP 协议的 Transfer-Encoding: chunked 分段传输特性),用户体验得到优化,同时对内存的开销明显下降。如果想在传输的过程中,想对文件进行处理,比如压缩、加密等等,也很好扩展。
fs文件模块
function readFile(path: PathLike | number, options: { encoding?: null; flag?: string; } | undefined | null, callback: (err: NodeJS.ErrnoException | null, data: Buffer) => void): void;
//当不指定encoding方式,默认返回buffer
function readFile(path: PathLike | number, options: { encoding: string; flag?: string; } | string, callback: (err: NodeJS.ErrnoException | null, data: string) => void): void;
//当指定encoding方式为string时,返回string数据
function createReadStream(path: PathLike, options?: string | {
flags?: string;
encoding?: string;
fd?: number;
mode?: number;
autoClose?: boolean;
start?: number;
end?: number;
highWaterMark?: number;
}): ReadStream;
//指定文件路径,将文件转化为可读流
4、原生node实现文件上传
运行以下程序看看二进制流数据传到服务端打印出来是怎么样子的
const fs = require('fs')
const http = require('http')
const server = http.createServer((req, res) => {
res.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE'
})
if (req.url === "/upload" && req.method.toLowerCase() === "get") {
res.writeHead(200, {
"content-type": "text/html"
})
res.end(
''
)
}
if (req.url === "/upload" && req.method.toLowerCase() === "post") {
// 上传接口
parseFile(req, res)
}
})
function parseFile(req, res) {
req.setEncoding("binary"); // 二进制编码
let body = ""; // 文件数据
req.on("data", function(chunk) {
body += chunk;
});
req.on("end", function() {
console.log(body);
})
}
server.listen(8888)
console.log('server is listening on 8888')
打印结果:
------WebKitFormBoundaryXTV5zMRqZzTOZtKa
Content-Disposition: form-data; name="firstname"
Mickey
------WebKitFormBoundaryXTV5zMRqZzTOZtKa
Content-Disposition: form-data; name="lastname"
Mouse
------WebKitFormBoundaryXTV5zMRqZzTOZtKa
Content-Disposition: form-data; name="file"; filename="bug.txt"
Content-Type: text/plain
1ã个人ä¸å¿æ°æ®æ¾ç¤ºnull è§£å³
2ã设置é»è®¤å°åé®é¢ è§£å³
3ãé¦é¡µååå¸å±é®é¢ï¼ä»·æ ¼æ¾ç¤ºé®é¢ è§£å³
4ãæç订åä»·æ ¼è®¡ç®é®é¢ è§£å³
5ãloginç»å
¥äºï¼ä½æ¯ä¸ªäººä¸å¿é¡µåè´ç©è½¦é¡µåºç°ç»å
¥æ ¡éªé®é¢ è§£å³
6ãå¤´åæ¾ç¤ºé®é¢
globalDataçææ¶é´æ®µ
1ãå¤ä¹ èè¯ppt 鿥 è§£å³
2ã妿åå°æå¡å¯å¨ï¼ä¿®æ¹å¥½è®¢åé¨åææçbug é
3ãè¿ä¸éé¡¹ç®æµç¨ï¼ççè¿æåªäºç¹éè¦æ¹å è½»
4ãæäº¤è®¢å页å°åéæ©è·³è½¬é®é¢ è§£å³
5ãè´ç©è½¦ååæ°éè¾å
¥æ¡æ ¡éª è§£å³
6ãè´ç©è½¦ååå¢å æ ¡éªé®é¢ è§£å³
7ãè´ç©è½¦å订åä»·æ ¼é®é¢ è§£å³
8ãæç订åä½é¢æ¾ç¤ºé®é¢ è§£å³
9ãä¸åæ¶åçä½é¢å个人ä¸å¿çä½é¢ä¸ä¸è´
10ãåºä»éé¢ãææ£éé¢éä¹±discounté®é¢
------WebKitFormBoundaryXTV5zMRqZzTOZtKa--

可以看到客户端传到服务端的数据被编码成二进制,而且request对象中找不到表单数据,因为数据位置在可读流中。
使用stream流模块中data、end事件来完成文件读写:
const fs = require('fs')
const http = require('http')
const querystring = require('querystring')
const server = http.createServer((req, res) => {
res.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE'
})
if (req.url === "/upload" && req.method.toLowerCase() === "get") {
res.writeHead(200, {
"content-type": "text/html"
})
res.end(
''
)
}
if (req.url === "/upload" && req.method.toLowerCase() === "post") {
// 上传接口
parseFile(req, res)
}
})
function parseFile(req, res) {
req.setEncoding("binary");
let body = ""; // 文件数据
let fileName = ""; // 文件名
// 边界字符串
let boundary = req.headers['content-type']
.split('; ')[1]
.replace("boundary=", "")
req.on("data", function(chunk) {
body += chunk;
});
req.on("end", function() {
// 字符串转化为对象
const file = querystring.parse(body, "\r\n", ":");
// 只处理图片文件;
if (file["Content-Type"].indexOf("image") !== -1) {
//获取文件名
var fileInfo = file["Content-Disposition"].split("; ");
for (value in fileInfo) {
if (fileInfo[value].indexOf("filename=") != -1) {
fileName = fileInfo[value].substring(10, fileInfo[value].length - 1);
if (fileName.indexOf("\\") != -1) {
fileName = fileName.substring(fileName.lastIndexOf("\\") + 1);
}
fileName = reconvert(fileName); // unicode转中文
console.log("文件名: " + fileName);
}
}
// 获取图片类型(如:image/gif 或 image/png))
const entireData = body.toString();
const contentTypeRegex = /Content-Type: image\/.*/;
contentType = file["Content-Type"].substring(1);
//获取文件二进制数据开始位置,即contentType的结尾
const upperBoundary = entireData.indexOf(contentType) + contentType.length;
const shorterData = entireData.substring(upperBoundary);
// 替换开始位置的空格
const binaryDataAlmost = shorterData
.replace(/^\s\s*/, "")
.replace(/\s\s*$/, "");
// 去除数据末尾的额外数据,即: "--"+ boundary + "--"
const binaryData = binaryDataAlmost.substring(
0,
binaryDataAlmost.indexOf("--" + boundary + "--")
);
// console.log("binaryData", binaryData);
const bufferData = new Buffer.from(binaryData, "binary");
console.log("bufferData", bufferData);
// fs.writeFile(fileName, binaryData, "binary", function(err) {
// res.end("sucess");
// });
fs.writeFile(fileName, bufferData, function(err) {
res.end("sucess");
});
} else {
res.end("reupload");
}
})
}
/**
* @description unicode转中文
* @param {String} str
*/
function reconvert(str){
str = str.replace(/(\\u)(\w{1,4})/gi,function($0){
return (String.fromCharCode(parseInt((escape($0).replace(/(%5Cu)(\w{1,4})/g,"$2")),16)));
});
str = str.replace(/()(\w{1,4});/gi,function($0){
return String.fromCharCode(parseInt(escape($0).replace(/(%26%23x)(\w{1,4})(%3B)/g,"$2"),16));
});
str = str.replace(/()(\d{1,6});/gi,function($0){
return String.fromCharCode(parseInt(escape($0).replace(/(%26%23)(\d{1,6})(%3B)/g,"$2")));
});
return str;
}
server.listen(8888)
console.log('server is listening on 8888')
5、使用中间件解析表单二进制数据流实现上传
有没有觉得原生node实现文件上传很费劲,需要自己去解析表单二进制数据流,有没有已经封装好的解析表单数据的库呢?答案肯定是有的,常见的解析表单数据的库有formidable、multer。以formidable为例来试试看:
npm上对formidable的描述如下:
A node.js module for parsing form data, especially file uploads.
const fs = require('fs')
const http = require('http')
const formidable = require('formidable')
const server = http.createServer((req, res) => {
/**
* @description unicode转中文
* @param {String} str
*/
function reconvert(str){
str = str.replace(/(\\u)(\w{1,4})/gi,function($0){
return (String.fromCharCode(parseInt((escape($0).replace(/(%5Cu)(\w{1,4})/g,"$2")),16)));
});
str = str.replace(/()(\w{1,4});/gi,function($0){
return String.fromCharCode(parseInt(escape($0).replace(/(%26%23x)(\w{1,4})(%3B)/g,"$2"),16));
});
str = str.replace(/()(\d{1,6});/gi,function($0){
return String.fromCharCode(parseInt(escape($0).replace(/(%26%23)(\d{1,6})(%3B)/g,"$2")));
});
return str;
}
if (req.url === "/upload" && req.method.toLowerCase() === "post") {
var form = new formidable.IncomingForm();
// 指定解析规则
form.encoding = 'utf-8'; // 设置编码
form.uploadDir = 'public/upload'; // 指定上传目录
form.keepExtensions = true; // 保留文件后缀
form.maxFieldsSize = 2 * 1024 * 1024; // 指定上传文件大小
form.parse(req, (err, fields, files) => {
// fields表单字段对象,files文件对象
if(err) throw err;
// 重命名文件,将unicode编码转化为中文
var oldPath = files.upload.path;
var newPath = oldPath.substring(0, oldPath.lastIndexOf('\\')) + '\\' + reconvert(files.upload.name);
fs.rename(oldPath, newPath, err => {
if(err) throw err;
res.writeHead(200, {"Content-Type": "text/html;charset=UTF8"});
res.end('上传成功!');
})
})
return;
}
res.writeHead(200, {
'content-type': 'text/html'
})
res.end(
''
)
})
server.listen(8889)
console.log('server is listening 8889')
无论是原生node事先文件上传还是使用中间件formidable实现文件上传,使用utf-8编码表单二进制数据时中文文件名变成类似于'身份证反面.jpg',中文被编码成Unicode,这就需要对文件进行重命名。
大家可以参考文章:字符编码-ASCII,Unicode 和 UTF-8
6、大文件上传、多文件上传、断点上传
7、总结
本文主要讲述了文件上传过程中表单编码为什么要采用multipart/form-data?服务端如何处理二进制数据流(buffer、stream)?原生node和中间件如何实现上传?有时间在了解一下大文件如何断点上传还有多文件上传。
参考文献:
buffer概述:
http://www.runoob.com/nodejs/nodejs-buffer.html
https://nodejs.org/api/buffer.html#buffer_buffer
buffer数据显示:
https://segmentfault.com/q/1010000009002065
stream运行机制:
https://www.php.cn/js-tutorial-412138.html
stream的理解:
https://www.jianshu.com/p/4eb9077a8956
原生node实现文件上传:
https://juejin.im/post/5d84ab33e51d4561b072ddd0#heading-4
formidable使用:
https://segmentfault.com/a/1190000004057022
https://github.com/node-formidable/node-formidable
https://segmentfault.com/a/1190000011424511


