文件上传那点事儿

前言

日常在写业务的时候经常会用的到的是 GET, POST请求去请求接口,GET 相关的接口会比较容易基本不会出错,而对于 POST中经常使用的 表单提交,JSON提交也比较容易,可是对于文件上传呢?你们可能对这个步骤会比较惧怕,由于可能你们对它并非怎么熟悉,而浏览器Network对它也没有详细的进行记录,所以它成为了咱们心中的一根刺,咱们总是没法肯定,关于文件上传究竟是我写的有问题呢?仍是后端有问题,固然,咱们通常都比较谦虚, 老是会在本身身上找缘由,但是每每实事呢?可能就出在后端身上,多是他接受写的有问题,致使你换了各类请求库去尝试,axiosrequestfetch 等等。那么咱们如何避免这种状况呢?咱们自身要对这一块够熟悉,才能不以猜的方式去写代码。若是你以为我以上说的你有同感,那么你阅读完这篇文章你将收获自信,你将不会质疑本身,不会以猜的方式去写代码。css

本文比较长可能须要花点时间去看,须要有耐心,我采用自顶向下的方式,全部示例会先展示出你熟悉的方式,再一层层往下, 先从请求端是怎么发送文件的,再到接收端是怎么解析文件的。前端

前置知识

什么是 multipart/form-data?

multipart/form-data 最初由 《RFC 1867: Form-based File Upload in HTML》[1]文档提出。node

Since file-upload is a feature that will benefit many applications, this proposes an extension to HTML to allow information providers to express file upload requests uniformly, and a MIME compatible representation for file upload responses.

因为文件上传功能将使许多应用程序受益,所以建议对HTML进行扩展,以容许信息提供者统一表达文件上传请求,并提供文件上传响应的MIME兼容表示。ios

总结就是原先的规范不知足啦,我要扩充规范了。git

文件上传为何要用 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.

1867文档中也写了为何要新增一个类型,而不使用旧有的application/x-www-form-urlencoded:由于此类型不适合用于传输大型二进制数据或者包含非ASCII字符的数据。日常咱们使用这个类型都是把表单数据使用url编码后传送给后端,二进制文件固然没办法一块儿编码进去了。因此multipart/form-data就诞生了,专门用于有效的传输文件。github

也许你有疑问?那能够用 application/json吗?express

其实我认为,不管你用什么均可以传,只不过会要综合考虑一些因素的话,multipart/form-data更好。例如咱们知道了文件是以二进制的形式存在,application/json 是以文本形式进行传输,那么某种意义上咱们确实能够将文件转成例如文本形式的 Base64 形式。可是呢,你转成这样的形式,后端也须要按照你这样传输的形式,作特殊的解析。而且文本在传输过程当中是相比二进制效率低的,那么对于咱们动辄几十M几百M的文件来讲是速度是更慢的。npm

以上为何文件传输要用multipart/form-data 我还能够举个例子,例如你在中国,你想要去美洲,咱们的multipart/form-data至关因而选择飞机,而application/json至关于高铁,可是呢?中国和美洲之间没有高铁啊,你执意要坐高铁去,你能够花昂贵的代价(后端额外解析你的文本)造高铁去美洲,可是你有更加廉价的方式坐飞机(使用multipart/form-data)去美洲(去传输文件)。你图啥?(若是你有钱有时间,抱歉,打扰了,老子给你道歉)json

multipart/form-data规范是什么?

摘自 《RFC 1867: Form-based File Upload in HTML》[2] 6.Exampleaxios

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 (分割符),这个东西是干啥的呢?其实看名字就知道,分隔符,当时分割做用,由于可能有多文件多字段,每一个字段文件之间,咱们没法准确地去判断这个文件哪里到哪里为截止状态。所以须要有分隔符来进行划分。而后再接下来就是声明内容的描述是 form-data 类型,字段名字是啥,若是是文件的话,得知道文件名是啥,还有这个文件的类型是啥,这个也很好理解,我上传一个文件,我总得告诉后端,我传的是个啥,是图片?仍是一个txt文本?这些信息确定得告诉人家,别人才好去进行判断,后面咱们也会讲到若是这些没有声明的时候,会发生什么?

好了讲完了这些前置知识,咱们接下来要进入咱们的主题了。面对 File, formData,Blob,Base64,ArrayBuffer 到底怎么作?还有文件上传不只仅是前端的事。服务端也能够文件上传(例如咱们利用某云,把静态资源上传到 OSS 对象存储)。服务端和客户端也有各类类型,Buffer,Stream,Base64....头秃,怎么搞?不急,就是由于上传文件不仅仅是前端的事,因此我将如下上传文件的一方称为请求端,接受文件一方称为接收方。我会以请求端各类上传方式,接收端是怎么解析咱们的文件以及咱们最终的杀手锏调试工具 -wireshark 来进行讲解。如下是讲解的大纲,咱们先从浏览器端上传文件,再到服务端上传文件,而后咱们再来解析文件是如何被解析的。

请求端

前端

File

首先咱们先写下最简单的一个表单提交方式。

<form action="http://localhost:7787/files" method="POST">
 <input name="file" type="file" id="file">
 <input type="submit" value="提交">
</form>

咱们选择文件后上传,发现后端返回了文件不存在。

不用着急,熟悉的同窗可能立马知道是啥缘由了。嘘,知道了也听我慢慢叨叨。

咱们打开控制台,因为表单提交会进行网页跳转,所以咱们勾选preserve log 来进行日志追踪。

咱们能够发现其实 FormDatafile 字段显示的是文件名,并无将真正的内容进行传输。再看请求头。

发现是请求头和预期不符,也印证了 application/x-www-form-urlencoded 没法进行文件上传。

咱们加上请求头,再次请求。

<form action="http://localhost:7787/files" enctype="multipart/form-data" method="POST">
 <input name="file" type="file" id="file">
 <input type="submit" value="提交">
</form>

发现文件上传成功,简单的表单上传就是像以上同样简单。可是你得熟记文件上传的格式以及类型。

FormData

formData 的方式我随便写了如下几种方式。

<input type="file" id="file">
<button id="submit">上传</button>
<script src="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script>
submit.onclick = () => {
 const file = document.getElementById('file').files[0];
 var form = new FormData();
 form.append('file', file);
 
 // type 1
 axios.post('http://localhost:7787/files', form).then(res => {
 console.log(res.data);
 })
 // type 2
 fetch('http://localhost:7787/files', {
 method: 'POST',
 body: form
 }).then(res => res.json()).tehn(res => {console.log(res)});
 // type3
 var xhr = new XMLHttpRequest();
 xhr.open('POST', 'http://localhost:7787/files', true);
 xhr.onload = function () {
 console.log(xhr.responseText);
 };
 xhr.send(form);
}
</script>

以上几种方式都是能够的。可是呢,请求库这么多,我随便在 npm 上一搜就有几百个请求相关的库。

所以,掌握请求库的写法并非咱们的目标,目标只有一个仍是掌握文件上传的请求头和请求内容。

Blob

Blob 对象表示一个不可变、原始数据的类文件对象。Blob 表示的不必定是JavaScript原生格式的数据。File[3] 接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

所以若是咱们遇到 Blob 方式的文件上方式不用惧怕,能够用如下两种方式:

1.直接使用 blob 上传

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
 
const form = new FormData();
form.append('file', blob, '1.json');
axios.post('http://localhost:7787/files', form);

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');
form.append('file', file);
axios.post('http://localhost:7787/files', form)

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 form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form)

这里须要注意的是 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 form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form);

关于 base64 的转化和原理能够看这两篇 base64 原理[4] 和

原来浏览器原生支持JS Base64编码解码[5]

小结

对于浏览器端的文件上传,能够归结出一个套路,全部东西核心思路就是构造出 File 对象。而后观察请求 Content-Type,再看请求体是否有信息缺失。而以上这些二进制数据类型的转化能够看如下表。

图片来源 (https://shanyue.tech/post/bin...[6])

服务端

讲完了浏览器端,如今咱们来说服务器端,和浏览器不一样的是,服务端上传有两个难点。

1.浏览器没有原生 formData,也不会想浏览器同样帮咱们转成二进制形式。

2.服务端没有可视化的 Network 调试器。

Buffer

Request

首先咱们经过最简单的示例来进行演示,而后一步一步深刻。相信文档能够查看 https://github.com/request/re...

// 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);
})

发现报了一个错误,正像上面所说,浏览器端报错,能够用NetWork。那么服务端怎么办?这个时候咱们拿出咱们的利器 -- wireshark

咱们打开 wireshark (若是没有或者不会的能够查看教程 https://blog.csdn.net/u013613...

设置配置 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')) 这个函数返回的是 BufferBuffer是什么样的呢?就是下面的形式,不会包含任何文件相关的信息,只有二进制流。

<Buffer 01 02>

因此我想到的是,须要指定文件名以及文件格式,幸亏 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'
 }
 },
 }
});

咱们经过抓包能够进行分析到,文件上传的要点仍是规范,大部分的问题,均可以经过规范模板来进行排查,是否构造出了规范的样子。

Form-data

咱们再深刻一些,来看看 request 的源码, 他是怎么实现Node端的数据传输的。

打开源码咱们很容易地就能够找到关于 formData 这块相关的内容 https://github.com/request/re...

就是利用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);
});

原生 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}rnContent-Disposition: form-data; name="file"; filename="1.png"rnContent-Type: image/jpegrnrn`
);
// 写入内容
const fileStream = fs.createReadStream(path.join(__dirname, '../1.png'));
fileStream.pipe(request, { end: false });
fileStream.on('end', function () {
 // 写入尾部
 request.end('rn--' + boundaryKey + '--' + 'rn');
});
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 ??? 若是ctx.request.files 不存在,就会懵逼了,可能也不太清楚它到底作了什么,文件流又是怎么解析的。

我仍是要说到规范...请求端是按照规范来构造请求..那么咱们接收端天然是按照规范来解析请求了。

Koa-body

const koaBody = require('koa-body');
app.use(koaBody({ multipart: true }));

咱们来看看最经常使用的 koa-body,它的使用方式很是简单,短短几行,就能让咱们享受到文件上传的简单与快乐(其余源码库同样的思路去寻找问题的本源) 能够带着一个问题去阅读,为何用了它就能解析出文件?

寻求问题的本源,咱们固然要打开 koa-body的源码,koa-body 源码不多只有211行,https://github.com/dlau/koa-b... 很容易地发现它实际上是用了一个叫作formidable的库来解析files 的。而且把解析好的files 对象赋值到了 ctx.req.files。(因此说你们不要一味死记 ctx.request.files, 注意查看文档,由于今天用 koa-bodyctx.request.files 明天换个库可能就是 ctx.request.body 了)

所以看完koa-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-formi...

...
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 打印看看.

<Buffer 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 36 36 ... >
144
<Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 00 01 00 00 00 01 01 03 00 00 00 25 db 56 ca 00 00 00 06 50 4c 54 45 00 00 ff 80 80 80 4c 6c bf ... >
106
<Buffer 0d 0a 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 ... >

咱们来看wireshark 抓到的包

我用红色进行了分割标记,对应的就是formidable所分割的片断 ,因此说这个包主要是将大段的 buffer 进行分割,而后循环处理。

这里我还能够补充一下,可能你对以上表很是陌生。左侧是二进制流,每1个表明1个字节,1字节=8位,上面的 2d 其实就是16进制的表示形式,用二进制表示就是 0010 1101,右侧是ascii 码用来可视化,可是 assii 分可显和非可显示。有部分是没法可视的。好比你所看到文件中有须要小点,就是不可见字符。

你能够对照,ascii表对照表[7]来看。

我来总结一下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('rn');
            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; // 有多rnrn
    const startBinary = body.toString().substring(start);
    const end = startBinary.indexOf("--" + boundary + "--") - 2; // 前面有多rn
    // 去除后面的分隔符
    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构造的时候,我一开始少了一个[],致使内容主体错误)。

其实讲这么多就两个字:  规范,全部的生态都是围绕它而展开的。

相关文章
相关标签/搜索