前言
平常在写业务的时候,对于POST
中常用的表单提交、JSON
提交大家都会觉得比较容易,而对【文件上传】这个步骤可能会有些许害怕。因为大家对它的细节并不是怎么熟悉,而浏览器Network
对它也没有详细记录。我们老是无法确定,关于文件上传到底是前端写得有问题呢,还是后端有问题,然后花费了大量的时间在不断的修改和尝试上。那么我们如何避免这种情况呢?要对上传这一块足够熟悉,才能不以猜的方式去写代码。希望你阅读完这篇文章你将收获自信,面对【上传】的时候可以了然于胸。
本文采用自顶向下的方式,所有示例会先展现出你熟悉的方式,再一层层往下,先从请求端是怎么发送文件的,再到接收端是怎么解析文件的。
前置知识
什么是 multipart/form-data?
multipart/form-data
最初由 [1]《RFC 1867: Form-based File Upload in HTML》文档提出。
由于文件上传功能将使许多应用程序受益,因此建议对HTML进行扩展,以允许信息提供者统一表达文件上传请求,并提供文件上传响应的MIME兼容表示。
就是原先的规范不满足啦,扩充规范了。
文件上传为什么要用 multipart/form-data?
1867文档中也写了为什么要新增一个类型,而不使用旧有的application/x-www-form-urlencoded
:因为此类型不适合用于传输大型二进制数据或者包含非ASCII字符的数据。平常我们使用这个类型都是把表单数据使用url编码后传送给后端,二进制文件当然没办法一起编码进去了。所以multipart/form-data
就诞生了,专门用于有效的传输文件。
也许你有疑问?那可以用 application/json
吗?
其实无论你用什么都可以传,只不过要综合考虑一些因素的话,multipart/form-data
更好。例如我们知道了文件是以二进制的形式存在,application/json
是以文本形式进行传输,那么某种意义上我们确实可以将文件转成例如文本形式的 Base64
形式。但是呢,转成这种形式后,后端也需要按照这种传输的形式,做特殊的解析。并且文本在传输过程中是相比二进制效率低的,那么对于我们动辄几十M几百M的文件来说是速度是更慢的。
我还可以举个例子,例如你在中国,想要去美洲,我们的multipart/form-data
相当于是选择飞机,而application/json
相当于高铁。但是中国和美洲之间没有高铁啊,你执意要坐高铁去的话,必须花昂贵的代价(后端额外解析你的文本)造高铁去美洲。但是你明明有更加廉价的方式坐飞机(使用multipart/form-data
)去美洲(去传输文件)。你图啥?
multipart/form-data规范是什么?
摘自 [2]《RFC 1867: Form-based File Upload in HTML》 6.Example
Content-type: multipart/form-data, boundary=AaB03x
--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain
... contents of file1.txt ...
--AaB03x--
简单解释一些,首先第一行是请求类型,然后是一个boundary
(分隔符),因为可能有多文件多字段,每个字段/文件之间,需要由分隔符来对这个字段/文件是从开始到截止来进行划分。
后面就演示了field
这个字段,声明内容的形式是 form-data 类型,字段名以及字段内容;
然后如果是文件的话,除了内容形式、字段名,还得有filename
即文件名,还有这个文件的类型(text/plain
) 。
后面我们会讲到如果这些没有声明的时候,会发生什么?
好了接下来要进入我们的主题了。面对File、formData、 Blob、Base64、ArrayBuffer
,到底该怎么做?还有文件上传不仅仅是前端的事。服务端也可以进行文件上传(例如我们利用某云,把静态资源上传到 OSS 对象存储)。服务端和客户端也有各种类型,Buffer、Stream、Base64....
头秃,怎么搞?我将上传文件的一方称为请求端,接受文件一方称为接收方。然后通过请求端各种上传方式,接收端如何解析我们的文件以及杀手锏调试工具-wireshark来进行讲解。
请求端
浏览端
File
我们先写最简单的表单提交方式。
然而我们选择文件后上传,发现后端返回了文件不存在。
不用着急,熟悉的同学可能立马知道原因是啥了。
我们打开控制台,由于表单提交会进行网页跳转,因此我们勾选preserve log
来进行日志追踪。
可以发现FormData
中file
字段显示的是文件名,并没有将真正的内容进行传输。再看请求头。
请求头规范和预期不符,也印证了application/x-www-form-urlencoded
无法进行文件上传。
我们加上请求头,再次请求。
文件上传成功,简单的表单上传就是像以上一样简单。但是你得熟记文件上传的格式以及类型。
FormData
我随便写了以下几种方式:
以上几种方式都是可以的。但是请求库这么多,随便在 npm 上一搜就有几百个请求相关的库。
因此掌握请求库的写法并不是我们的目标,目标只有一个,还是掌握文件上传的请求头和请求内容。
Blob
Blob
对象表示一个不可变、原始数据的类文件对象。Blob 表示的不一定是JavaScript原生格式的数据。[3] File 接口基于Blob
,继承了 blob 的功能并将其扩展以使其支持用户系统上的文件。
如果我们遇到 Blob 文件上方式不用害怕,可以用以下两种处理:
1.直接使用 blob 上传
const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
const formData = new FormData();
formData.append('file', blob, '1.json');
axios.post('http://localhost:7787/files', formData);
2.使用 File 对象,再进行一次包装(File 兼容性可能会差一些 https://caniuse.com/#search=File)
const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
const file = new File([blob], '1.json');
formData.append('file', file);
axios.post('http://localhost:7787/files', formData)
ArrayBuffer
ArrayBuffer
对象用来表示通用的、固定长度的原始二进制数据缓冲区。
虽然用得比较少,但它是最贴近文件流的方式了。
在浏览器中它的每个字节以十进制的形式存在。以下我提前准备了一张图片:
const bufferArrary = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,1,3,0,0,0,37,219,86,202,0,0,0,6,80,76,84,69,0,0,255,128,128,128,76,108,191,213,0,0,0,9,112,72,89,115,0,0,14,196,0,0,14,196,1,149,43,14,27,0,0,0,10,73,68,65,84,8,153,99,96,0,0,0,2,0,1,244,113,100,166,0,0,0,0,73,69,78,68,174,66,96,130];
const array = Uint8Array.from(bufferArrary);
const blob = new Blob([array], {type: 'image/png'});
const formData = new FormData();
formData.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', formData)
这里需要注意的是new Blob([typedArray.buffer], {type: 'xxx'})
,第一个参数是由一个数组包裹。里面是typedArray
类型的 buffer。
Base64
const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEUAAP+AgIBMbL/VAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==';
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const array = Uint8Array.from(byteNumbers);
const blob = new Blob([array], {type: 'image/png'});
const formData = new FormData();
formData.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', formData);
关于 base64 的转化和原理可以看这两篇 [4]【base64 原理】 和
[5]【原来浏览器原生支持JS Base64编码解码】
小结
对于浏览器端的文件上传,可以归结出一个套路,所有的核心思路就是构造出 File
对象。然后观察请求Content-Type
,再看请求体是否有信息缺失。以上这些二进制数据类型的转化可以看以下表。
图片来源:[6]【浏览器中的二进制以及相关转换#数据输入】
服务端
服务器端和浏览器不同的是,服务端上传有两个难点:
- 服务端没有原生
formData
,也不会像浏览器一样帮我们转成二进制形式。 - 服务端没有可视化的
Network
调试器。
Buffer
1. Request
首先我们通过最简单的示例来进行演示,然后一步一步深入。相关文档可以查看 ➡️ https://github.com/request/request#multipartform-data-multipart-form-uploads
// request-error.js
const fs = require('fs');
const path = require('path');
const request = require('request');
const stream = fs.readFileSync(path.join(__dirname, '../1.png'));
request.post({
url: 'http://localhost:7787/files',
formData: {
file: stream,
}
}, (err, res, body) => {
console.log(body);
})
发现报了一个错误,服务端报错该怎么办?这个时候就拿出我们的利器 -- wireshark
:
打开 wireshark
(如果没有或者不会的可以查看教程 ➡️ 【Wireshark抓取本地Tcp包(任何数据包)】)
设置配置 tcp.port == 7787
,这个是我们后端的端口。
运行上述文件node request-error.js
来找到我们发送的这条http
的请求报文。中间那堆乱七八糟的就是我们的文件内容。
POST /files HTTP/1.1
host: localhost:7787
content-type: multipart/form-data; boundary=--------------------------437240798074408070374415
content-length: 305
Connection: close
----------------------------437240798074408070374415
Content-Disposition: form-data; name="file"
Content-Type: application/octet-stream
.PNG
.
...
IHDR.............%.V.....PLTE......Ll..... pHYs..........+.....
IDAT..c`.......qd.....IEND.B`.
----------------------------437240798074408070374415--
在上述报文可以看到内容请求头Content-Type: application/octet-stream
有错误,我们上传的是图片格式应该是image/png
,并且也少了文件名filename="1.png"
。
我们来思考一下,fs.readFileSync(path.join(__dirname, '../1.png'))
这个函数返回的是Buffer
,Buffer
是什么样的呢?就是像下面这样,不会包含任何文件相关的信息,只有二进制流。
所以需要指定文件名以及文件格式,幸好request
也给我们提供了这个选项。
key: {
value: fs.createReadStream('/dev/urandom'),
options: {
filename: 'topsecret.jpg',
contentType: 'image/jpeg'
}
}
可以指定options
,因此正确的代码应该如下(省略不重要的代码)
...
request.post({
url: 'http://localhost:7787/files',
formData: {
file: {
value: stream,
options: {
filename: '1.png',
contentType: 'image/png'
}
},
}
});
我们通过抓包进行分析可以看出文件上传的要点还是【规范】,大部分的问题都可以通过规范模板来进行排查,是否构造出了规范要求的样子。
2. Form-data
我们再深入一些,来看看request
的源码是怎么实现Node
端的数据传输的。
打开源码很容易就可以找到关于 formData 相关的内容 https://github.com/request/request/blob/3.0/request.js#L21
就是利用form-data
,我们先来看看formData
的方式。
const path = require('path');
const FormData = require('form-data');
const fs = require('fs');
const http = require('http');
const form = new FormData();
form.append('file', fs.readFileSync(path.join(__dirname, '../1.png')), {
filename: '1.png',
contentType: 'image/jpeg',
});
const request = http.request({
method: 'post',
host: 'localhost',
port: '7787',
path: '/files',
headers: form.getHeaders()
});
form.pipe(request);
request.on('response', function(res) {
console.log(res.statusCode);
});
3. 原生 Node
看完formData
,感觉这个封装还是太高层了,于是打算对照规范手动来构造multipart/form-data
请求方式来进行讲解。我们再来回顾一下规范。
Content-type: multipart/form-data, boundary=AaB03x
--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain
... contents of file1.txt ...
--AaB03x--
模拟上方,用原生Node
来写一个multipart/form-data
请求的方式。
主要分为4个部分
- 构造请求header
- 构造内容header
- 写入内容
- 写入结束分隔符
const path = require('path');
const fs = require('fs');
const http = require('http');
// 定义一个分隔符,要确保唯一性
const boundaryKey = '-------------------------461591080941622511336662';
const request = http.request({
method: 'post',
host: 'localhost',
port: '7787',
path: '/files',
headers: {
'Content-Type': 'multipart/form-data; boundary=' + boundaryKey, // 在请求头上加上分隔符
'Connection': 'keep-alive'
}
});
// 写入内容头部
request.write(`--${boundaryKey}\r\nContent-Disposition: form-data; name="file"; filename="1.png"\r\nContent-Type: image/jpeg\r\n\r\n`
);
// 写入内容
const fileStream = fs.createReadStream(path.join(__dirname, '../1.png'));
fileStream.pipe(request, { end: false });
fileStream.on('end', function () {
// 写入尾部
request.end('\r\n--' + boundaryKey + '--' + '\r\n');
});
request.on('response', function(res) {
console.log(res.statusCode);
});
至此,已经实现服务端上传文件的方式。
Stream、Base64
由于这两块就是和Buffer
的转化,比较简单,就不再重复描述了。
// base64 to buffer
const b64string = /* whatever */;
const buf = Buffer.from(b64string, 'base64');
// stream to buffer
function streamToBuffer(stream) {
return new Promise((resolve, reject) => {
const buffers = [];
stream.on('error', reject);
stream.on('data', (data) => buffers.push(data))
stream.on('end', () => resolve(Buffer.concat(buffers))
});
}
小结
由于服务端没有像浏览器那样formData
的原生对象,因此核心思路为构造出文件上传的格式(header、filename等),然后写入buffer
。然后别忘了用wireshark
进行验证。
接收端
这一部分会针对Node
进行讲解,如果你平常用惯了koa-body
等,可能不太清楚整个过程发生了什么?一旦ctx.request.files
不存在,就会懵逼了,也不清楚它到底做了什么,文件流又是怎么解析的。
还是要说到规范,请求端是按照规范来构造请求,那么接收端自然是按照规范来解析请求了。
Koa-body
const koaBody = require('koa-body');
// multipart 参数表示是否解析 FormData 形式的表单数据,即处理 Content-Type 为 multipart/formdate 的请求,上传文件必须为 true
app.use(koaBody({ multipart: true }));
我们来看看最常用的koa-body
,它的使用方式非常简单,短短几行,就能让我们享受到文件上传的简单与快乐,(对其他源码库用一样的思路去寻找问题的本源) 可以带着一个问题去阅读,为什么用了它就能解析出文件?
打开koa-body
的源码,只有很少的211行:https://github.com/dlau/koa-body/blob/v4.1.1/index.js#L125 可以发现它用了一个叫做formidable
的库来解析files
。并且把解析好的files
对象赋值到了ctx.req.files
。(所以说大家要注意查看文档,因为今天用koa-body
是ctx.request.files
,明天换个库可能就是ctx.request.body
了)
因此我们得出结论,koa-body
的核心方法是formidable
。
Formidable
那么让我们继续深入,来看看formidable
做了什么,我们首先来看它的目录结构。
├── lib
│ ├── file.js
│ ├── incoming_form.js
│ ├── index.js
│ ├── json_parser.js
│ ├── multipart_parser.js
│ ├── octet_parser.js
│ └── querystring_parser.js
根据这个目录,我们大致可以梳理出这样的关系。
index.js
|
incoming_form.js
|
type
?
|
1.json_parser
2.multipart_parser
3.octet_parser
4.querystring_parser
由于源码分析比较枯燥。因此只摘录比较重要的片段。由于是分析文件上传,所以我们只需要关心multipart_parser
这个文件。
https://github.com/node-formidable/formidable/blob/v1.2.1/lib/multipart_parser.js#L72
...
MultipartParser.prototype.write = function(buffer) {
console.log(buffer);
var self = this,
i = 0,
len = buffer.length,
prevIndex = this.index,
index = this.index,
state = this.state,
...
我们将它的buffer
打印看看。
144
106
我们来看wireshark
抓到的包:
我用红色进行了分割标记,对应的就是formidable
所分割的片段 ,所以说这个包主要是将大段的buffer
进行分割,然后循环处理。
这里我还要补充一下,可能你对以上表非常陌生。左侧是二进制流,每1个代表1个字节,1字节=8位。上面的 2d 其实就是16进制的表示形式,用二进制表示就是 0010 1101。右侧是 ascii 码用来可视化,但是 assii 分可显和非可显。有部分是无法可视的。比如你所看到文件中有些小点,就是不可见字符。
你可以对照[7]【ascii表对照表】来看。
下图总结了formidable
对于文件的处理流程。
原生 Node
我们已经知道了文件处理的流程,那么自己来写一个吧。
const fs = require('fs');
const http = require('http');
const querystring = require('querystring');
const server = http.createServer((req, res) => {
if (req.url === "/files" && 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 list = body.split(boundary);
let contentType = '';
let fileName = '';
for (let i = 0; i < list.length; i++) {
if (list[i].includes('Content-Disposition')) {
const data = list[i].split('\r\n');
for (let j = 0; j < data.length; j++) {
// 从头部拆分出名字和类型
if (data[j].includes('Content-Disposition')) {
const info = data[j].split(':')[1].split(';');
fileName = info[info.length - 1].split('=')[1].replace(/"/g, '');
console.log(fileName);
}
if (data[j].includes('Content-Type')) {
contentType = data[j];
console.log(data[j].split(':')[1]);
}
}
}
}
// 去除前面的请求头
const start = body.toString().indexOf(contentType) + contentType.length + 4; // 有多\r\n\r\n
const startBinary = body.toString().substring(start);
const end = startBinary.indexOf("--" + boundary + "--") - 2; // 前面有多\r\n
// 去除后面的分隔符
const binary = startBinary.substring(0, end);
const bufferData = Buffer.from(binary, "binary");
fs.writeFile(fileName, bufferData, function(err) {
res.end("sucess");
});
;
})
}
server.listen(7787)
总结
相信有了以上的介绍,你对文件上传整个过程都会比较清晰了。
再次回顾下我们的重点:
请求端出问题,浏览器端打开 Network 查看格式是否正确(请求头,请求体)。如果数据不够详细,打开 wireshark,对照我们的规范标准,看下格式(请求头,请求体)。
接收端出问题,情况一就是请求端缺少信息,参考上面请求端出问题的情况,情况二请求体内容错误,如果说请求体内容是请求端自己构造的,那么需要检查请求体是否是正确的二进制流(例如上面的blob构造的时候,我一开始少了一个[],导致内容主体错误)。
其实讲这么多就两个字:[8] 【规范】,所有的生态都是围绕它而展开的。
参考
https://juejin.im/post/5c9f4885f265da308868dad1
https://my.oschina.net/bing309/blog/3132260
https://segmentfault.com/a/1190000020654277
本文分享整理自微信公众号 - 前端迷(love_frontend)
原文出处及转载信息见文内详细说明,如有侵权,请联系我删除。
原始发表时间:2020-04-06