一文了解文件上传全过程(项目中碰到的难点)(2)

请求端


浏览端

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')) 这个函数返回的是 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/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前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

既有适合小白学习的零基础资料,也有适合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开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值