发现文件上传成功,简单的表单上传就是像以上一样简单。但是你得熟记文件上传的格式以及类型。
FormData
formData 的方式我随便写了以下几种方式。
上传
以上几种方式都是可以的。但是呢,请求库这么多,我随便在 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/binary-in-frontend/#%E6%95%B0%E6%8D%AE%E8%BE%93%E5%85%A5[6])
服务端
讲完了浏览器端,现在我们来讲服务器端,和浏览器不同的是,服务端上传有两个难点。
1.浏览器没有原生 formData
,也不会想浏览器一样帮我们转成二进制形式。
2.服务端没有可视化的 Network
调试器。
Buffer
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);
})
发现报了一个错误,正像上面所说,浏览器端报错,可以用NetWork
。那么服务端怎么办?这个时候我们拿出我们的利器 – wireshark
我们打开 wireshark
(如果没有或者不会的可以查看教程 https://blog.csdn.net/u013613428/article/details/53156957)
设置配置 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
是什么样的呢?就是下面的形式,不会包含任何文件相关的信息,只有二进制流。
<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/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);
});
原生 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
??? 如果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-body/blob/v4.1.1/index.js#L125 很容易地发现它其实是用了一个叫做formidable
的库来解析files
的。并且把解析好的files
对象赋值到了 ctx.req.files
。(所以说大家不要一味死记 ctx.request.files
, 注意查看文档,因为今天用 koa-body
是 ctx.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-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
打印看看.
<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
好了,我们已经知道了文件处理的流程,那么我们自己来写一个吧。
最后
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。
因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!
如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
你可以对照,ascii表对照表[7]来看。
我来总结一下formidable
对于文件的处理流程。
原生 Node
好了,我们已经知道了文件处理的流程,那么我们自己来写一个吧。
最后
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。
因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
[外链图片转存中…(img-LwbBxYXT-1715800916846)]
[外链图片转存中…(img-5CxDEA7B-1715800916846)]
[外链图片转存中…(img-QZWsXs1A-1715800916846)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!
如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!