之前做了大文件上传,于是对文件上传这个概念进行了深入的了解了一下。
原理
由于浏览器本身的限制,浏览器是不能直接操作文件系统的,需要通过浏览器所暴露出来的统一接口,由用户主动授权发起来访问文件动作,然后读取文件内容进指定内存里,最后执行提交请求操作,将内存里的文件内容数据上传到服务端,最后服务端解析前端传来的数据信息后存入文件里。
最简单的上传实现
这里利用form表单标签和类型为file的Input标签来完成上传,要将表单数据编码格式置为 multipart/form-data 类型,这个编码类型会对文件内容在上传时进行处理,以便服务端处理程序解析文件类型与内容,完成上传操作。
<form method="POST" enctype="multipart/form-data">
<input type="file" name="file" value="请选择文件"><br />
<input type="submit">
</form>
如果单单对于前端来讲,上面的代码就够了,但是为了了解上传的本质,这里从全栈的角度来看看文件上传。先以 Nodejs作为服务端,提供一个上传接口给前端,来看看上面的前端代码与后端是怎么传递文件数据的。
//上传接口逻辑
if(url ==='/upload' && method === 'POST') {
// 定义一个缓存区
const arr = []
req.on('data', buffer => {
// 将前端传来的数据进行存储进缓存区
arr.push(buffer);
})
req.on('end', () => {
// 前端请求结束后进行数据解析 处理
const buffer = Buffer.concat(arr);
// 将数据变成string类型
const content = buffer.toString();
// 从传来的数存进test的文件里
fileStream('test').write(buffer);
// 返回前端请求完成
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('上传完成');
})
}
这里的服务端代码先将前端上传的数据内容毫不处理直接写入一个名为test的文件内,以便我们查看前端到底传来了什么样的数据。
在前端发起一次上传操作请求,获取部分请求头信息。
再来看看从前端传来的被上传到服务端的文件数据。
------WebKitFormBoundary7YGEQ1Wf4VuKd0cE
Content-Disposition: form-data; name="file"; filename="index.html"
Content-Type: text/html
<html>
<head>
<title>上传文件</title>
</head>
<body>
<form method="POST" enctype="multipart/form-data">
<input type="file" name="file" value="请选择文件"><br />
<input type="submit">
</form>
</body>
</html>
------WebKitFormBoundary7YGEQ1Wf4VuKd0cE--
从上面被上传到服务端的数据可以看出相比于客户端本地的文件中多了几行内容,先是第一行和最后一行的WebKitFormBoundary 码,第二行的ContentDisposition,该行包含一些文件基本信息,还有第三行文件内容类型,所以后端如果要获取到正确的文件内容则需要自己去除由浏览器在上传时所添加的进来的几行内容,而保留有效文件内容后进行写文件操作,完成上传目的。
从上面的最简单的实现中可以看出以下几个点 。
- 前端文件上传实际是文件内容的传递,是数据的传递,并非我们最常使用的文件拷贝与复制操作。
- 传递过程中要进行编码来制定发送的文件数据规则,以便于后端能够实现一套对应的解析规则。
- 传递的数据规则里包含所传递文件的基本信息 ,如文件名与文件类型,以便后端写出正确格式的文件。
最常用的上传实现
上面利用了form表单的能力来上传本地文件,但是由于form表单提交操作网页会造成整体刷新,所以一般比较少用,而是利用熟悉的异步请求操作AJAX来完成上传动作,而一个新的问题出现了,不使用form表单,那文件编码该怎么处理呢?接下来看看下面的代码。
<div>
<input id="file" type="file" />
<input type="button" value="文件上传" οnclick="uploadFile()" />
</div>
<script>
function uploadFile() {
const file = document.getElementById('file').files[0];
const xhr = new XMLHttpRequest();
xhr.open('POST', 'http://127.0.0.1:8000/upload', true);
xhr.onreadystatechange = function() {
if (xhr.readyState == 4 && xhr.status == 200) {
alert(xhr.responseText);
}
};
xhr.send(file);
}
</script>
上面的相比于Form表单的提交,使用了浏览器的XMLHttpRequest自定义的提交方式,也就是俗称的AJAX技术。但是使用这种提交方式没有设置编码 enctype=“multipart/form-data” 类型,如果直接将文件内容上传,会导致后端在解析Form表单上传的文件时与Ajax上传的不一致,所以为了后端能够使用相同的代码就能解析前端这两种提交方式,所以前端需要自行格式化文件内容。在格式化的过程中,则需要通过浏览器自身提供的FormData构造函数来实例化的一个文件fd,然后使用实例的append方法将文件内容插入进去,最后利用XMLHttpRequest的实例做出发送动作。所以最终上传部分应为如下代码:
function uploadFile() {
const file = document.getElementById('file').files[0];
const xhr = new XMLHttpRequest();
const fd = new FormData();
fd.append('file', file);
xhr.open('POST', 'http://127.0.0.1:8000/upload', true);
xhr.onreadystatechange = function() {
if (xhr.readyState == 4 && xhr.status == 200) {
alert(xhr.responseText);
}
};
xhr.send(fd);
}
从前端发起一次请求观察请求信息,可以看出已经成功的变成了FormData的编码类型。
在后端也收到的数据和上面Form表单一样的内容,为了能够真正的体验上传过程,接下来我们在服务端做一个解析器来解出正在的文件内容,并且写进文件里,完成上传目标。
先看看上传的文件内容,它的特点前面已经描述过了,其实在真正的文件内容外多了几行文件信息,所以我们解析器的目的就是去掉这几行内容,并且在这几行简要信息里摘出文件名,以便写文件。
实现思路:将前端传来的文件按行分成数组,数组的第一个第二第三个和最后一个元素删除,并且在第二个元素里匹配出文件名。代码如下:
/**
* @step1 过滤第一行
* @step2 过滤最后一行
* @step3 过滤最先出现Content-Disposition的一行
* @step4 过滤最先出现Content-Type:的一行
*/
const decodeContent = content => {
let lines = content.split('\n');
const findFlagNo = (arr, flag) => arr.findIndex(o => o.includes(flag));
// 查找 ----- Content-Disposition Content-Type 位置并且删除
const startNo = findFlagNo(lines, '------');
lines.splice(startNo, 1);
const ContentDispositionNo = findFlagNo(lines, 'Content-Disposition');
lines.splice(ContentDispositionNo, 1);
const ContentTypeNo = findFlagNo(lines, 'Content-Type');
lines.splice(ContentTypeNo, 1);
// 最后的 ----- 要在数组末往前找
const endNo = lines.length - findFlagNo(lines.reverse(), '------') - 1;
// 先反转回来
lines.reverse().splice(endNo, 1);
return Buffer.from(lines.join('\n'));
}
一个简单的解析器完成了,一般情况下你所使用的框架会解决解码这一部分问题,无论是Nodejs或是Java,他们的本质都是摘出有效的文件内容然后写进新文件里,从而达到文件上传的目的。
最终的服务端代码如下:
if(url ==='/upload' && method === 'POST') {
//文件类型
const arr = []
req.on('data', (buffer) => {
arr.push(buffer);
})
req.on('end', () => {
const buffer = Buffer.concat(arr);
const content = buffer.toString();
const result = decodeContent(content);
const fileName = content.match(/(?<=filename=").*?(?=")/)[0];
fileStream(fileName).write(result);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('上传完成')
})
}
Form表单的意义
首先来想一想,Form表单对文件上传的文件内容做了什么,它格式化了文件内容,在请求时以特定的格式发送了数据至服务器,像下面的格式这样。
------WebKitFormBoundary7YGEQ1Wf4VuKd0cE
Content-Disposition: form-data; name="file"; filename="index.html"
Content-Type: text/html
...
文件内容
...
------WebKitFormBoundary7YGEQ1Wf4VuKd0cE--
再思考一下,这样的格式化的目的又是什么?先看看格式化后的内容,它包含了一个文件的全部信息,如格式,文件名,文件内容均已特定的字段或者位置出现,所以格式化的目的就是在制定一种规范,一种约定俗成的规范,无论哪一个项目或是那一个网站它的文件上传如果选择Form表单编码类型,它均是一种输出。
无Form表单的文件上传
接下来看看没有Form这种规范,又该如何上传文件。前面已经说清楚了,文件上传的实质是上传文件的内容以及文件的格式,当我们使用HTML提供的Input上传文件的时候,它将文件的内容读进内存里,那我们直接将内存里的数据当成普通的数据提交到服务端可以么?看下面的例子。
<!-- 前端代码:-->
<div>
<input id="file" type="file" />
<input type="button" value="文件上传" οnclick="uploadFile()" />
</div>
<script>
function uploadFile() {
const file = document.getElementById('file').files[0];
const xhr = new XMLHttpRequest();
xhr.open('POST', `http://127.0.0.1:8000/upload?name=${file.name}`, true);
xhr.onreadystatechange = function() {
if (xhr.readyState == 4 && xhr.status == 200) {
alert(xhr.responseText);
}
}
xhr.send(file);
}
</script>
先分析一下代码,先使用Input的type为file类型建立一块文件上传区域,页面上绑定一个uploadFile执行的click事件,uploadFile里先获取了上传区域的文件内容,然后构建Ajax直接提交数据,很简单,文件就被上传到服务器上了,当然前提是 http://127.0.0.1:8000/upload 这个API知道你传的是什么?然后再去解析存储。
相信上面这种方式,很多人对这个file变量到底是什么还是比较含糊的,接下里看看这个file是个啥。
上面这些数字其实就是文件的内容,大家都知道数据是0,1组成的世界,而ArrayBuffer则是更多的数字来体现的数据世界,它和二进制的目的是一样的,它被用来表示通用的、固定长度的原始二进制数据缓冲区。说到这里则必须要提起一个新的概念,浏览器的提供的Blob接口。
Blob对象
Blob 对象表示一个不可变、原始数据的类文件对象。上面的file变量的构造函数File就是继承与基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。看看下面的Blob与File的示例。
上面我先打印了一下file与浏览器提供的构造函数File和Blob的关系,然后自行构建了自定义的myfile对象和myblob的对象,看得出自行构建的File对象下会多出一些文件相关的属性,而Blob对象则只是基本的size与type属性。当打印arrayBuffer函数的返回值时发现其内容也是完全一致的。