请求端
浏览端
File
首先我们先写下最简单的一个表单提交方式。
我们选择文件后上传,发现后端返回了文件不存在。
不用着急,熟悉的同学可能立马知道是啥原因了。嘘,知道了也听我慢慢叨叨。
我们打开控制台,由于表单提交会进行网页跳转,因此我们勾选preserve log
来进行日志追踪。
我们可以发现其实 FormData
中 file
字段显示的是文件名,并没有将真正的内容进行传输。再看请求头。
发现是请求头和预期不符,也印证了 application/x-www-form-urlencoded
无法进行文件上传。
我们加上请求头,再次请求。
发现文件上传成功,简单的表单上传就是像以上一样简单。但是你得熟记文件上传的格式以及类型。
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
最后
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。
因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!
如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
│ ├── 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
最后
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。
因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
[外链图片转存中…(img-ci5tVBkf-1715800881907)]
[外链图片转存中…(img-E4WTAeeV-1715800881907)]
[外链图片转存中…(img-scG2K6wE-1715800881907)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!
如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!