弄明白文件上传
先从一个例子开始,看一下掘金上传头像接口。
请求头:
注意看图片中的content-type
,后面会解释:
content-type: multipart/form-data; boundary=----WebKitFormBoundarycA7SgHXGF2nIiW3S
再看一下请求携带的参数(接口中还带了一大串查询参数,这不是重点,重点是form-data参数):
从上图中提炼出form-data参数的格式:
------WebKitFormBoundarycA7SgHXGF2nIiW3S
Content-Disposition: form-data; name="avatar"; filename="blob"
Content-Type: image/png
...图片数据
------WebKitFormBoundarycA7SgHXGF2nIiW3S--
即:
--分隔符换行符
Content-Disposition; name=参数名; filename=文件名 换行符
Content-Type换行符
参数名对应的参数值
--分隔符--换行符
这里的分隔符跟前面请求头中content-type
后跟的boundary
的值对应。
为什么会采用这种格式,因为这就是multipart/form-data
的标准,下面是标准中给出的例子:
6. Examples
Suppose the server supplies the following HTML:
<FORM ACTION="http://server.dom/cgi/handle"
ENCTYPE="multipart/form-data"
METHOD=POST>
What is your name? <INPUT TYPE=TEXT NAME=submitter>
What files are you sending? <INPUT TYPE=FILE NAME=pics>
</FORM>
and the user types "Joe Blow" in the name field, and selects a text
file "file1.txt" for the answer to 'What files are you sending?'
The client might send back the following 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--
If the user also indicated an image file "file2.gif" for the answer
to 'What files are you sending?', the client might client might send
back the following data:
Content-type: multipart/form-data, boundary=AaB03x
--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"
Content-type: multipart/mixed, boundary=BbC04y
--BbC04y
Content-disposition: attachment; filename="file1.txt"
Content-Type: text/plain
... contents of file1.txt ...
--BbC04y
Content-disposition: attachment; filename="file2.gif"
Content-type: image/gif
Content-Transfer-Encoding: binary
...contents of file2.gif...
--BbC04y--
--AaB03x--
multipart/form-data
诞生的初衷就是为了满足文件上传需求,这在标准开篇也有说明,所以上传文件时,请求头中content-type
首选是multipart/form-data
。
那如果不使用multipart/form-data
能实现上传文件吗?答案是能。
简单点的你可以把文件转换为base64,以字符串的形式传给服务端,服务端拿到数据后再解码转换为文件。
复杂点的,你可以自己订一套标准,并实现它,用于文件上传。
文件上传实例项目概览
下面是例子的项目目录,用node做服务端。
代码目录:
|-- node_upload
|-- index.js
|-- back
|-- front
|-- upload
front
文件夹下放前端页面,back
文件夹下放接口代码,upload
文件夹存放上传的文件。
在index.js
中,为了行文方便,提前把后面才用到的接口引入了,后面会实现这些接口:
var http = require("http"),
url = require("url"),
path = require("path"),
fs = require("fs")
port = process.argv[2] || 80;
// 接口
var upload = require('./back/upload');
var uploadBlob = require('./back/uploadBlob');
var mimeTypes = {
"htm": "text/html",
"html": "text/html",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"js": "text/javascript",
"css": "text/css"
};
http.createServer(function(request, response) {
// 接口逻辑
if (request.url == '/upload' && request.method.toLowerCase() == 'post') {
upload(request, response);
return;
}
if (request.url == '/uploadBlob' && request.method.toLowerCase() == 'post') {
uploadBlob(request, response);
return;
}
/***以下代码是为了返回页面、静态资源 */
var uri = url.parse(request.url).pathname
// process.cwd() 当前工作目录
, filename = path.join(process.cwd(), uri)
, root = uri.split("/")[1];
fs.exists(filename, function(exists) {
if(!exists) {
response.writeHead(404, {"Content-Type": "text/plain"});
response.write("404 Not Found\n");
response.end();
console.error('404: ' + filename);
return;
}
if (fs.statSync(filename).isDirectory()) filename += '/index.html';
fs.readFile(filename, "binary", function(err, file) {
if(err) {
response.writeHead(500, {"Content-Type": "text/plain"});
response.write(err + "\n");
response.end();
console.error('500: ' + filename);
return;
}
var mimeType = mimeTypes[path.extname(filename).split(".")[1]];
response.writeHead(200, {"Content-Type": mimeType});
response.write(file, "binary");
response.end();
console.log('200: ' + filename + ' as ' + mimeType);
});
});
}).listen(parseInt(port, 10));
console.log("Static file server running at\n => http://localhost:" + port + "/\nCTRL + C to shutdown");
运行node index.js
启动服务器,默认是监听80端口,如果80端口被占用,可以指定端口号启动,比如3000端口:node index.js 3000
。
不使用multipart/form-data
上传文件
如果不使用multipart/form-data
上传文件,那肯定不能使用表单的形式提交数据了,好在XMLHttpRequest.send()
支持传入Blob
,ArrayBuffer
等类型的数据,所以在例子中我们对可以把获取到的文件直接传给后端。
在front
文件夹下创建upload-ajax-blob.html
文件,并添加如下代码:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>上传文件</title>
</head>
<body>
<input type="file" id="picker" />
<img id="image" />
<pre id="output"></pre>
<script>
window.onload = function() {
var output = document.getElementById("output");
document.getElementById("picker").onchange = function () {
var file = this.files[0];
console.log(file);
output.innerText = '';
if(file.size > 1024*1024) {
output.innerText = 'file size great than 1M';
return;
}
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.onload = function(d) {
output.innerText = JSON.stringify(xhr.response);
document.getElementById("image").setAttribute('src',xhr.response.data);
};
xhr.onerror = function(err) {
output.innerText = 'fail:' + err.toString();
};
xhr.open('POST', '/uploadBlob');
xhr.setRequestHeader("Contnet-type", 'application/octet-stream');
xhr.setRequestHeader("file-info", file.type + ';name=' + encodeURIComponent(file.name));
xhr.send(file);
// printFile(file,function(res) {
// xhr.send(res);
// });
};
};
// 测试传输ArrayBuffer
function printFile(file,callback) {
var reader = new FileReader();
reader.onload = function (evt) {
console.log(evt.target.result);
callback(reader.result);
};
reader.readAsArrayBuffer(file);
}
</script>
</body>
</html>
上面代码中接口地址是/uploadBlob
,稍后会实现这个接口。
先看看Contnet-type
。Contnet-type
的值设置为了application/octet-stream
,表示请求主体是二进制数据。
另外代码中又设置了一个自定义请求头file-info
,其值包含了上传文件的MIME类型和文件的名字。
接下来实现/uploadBlob
接口。在back
文件夹下创建uploadBlob.js
文件,并添加以下代码:
const fs = require("fs");
function setResponseDate(res, data) {
res.write(JSON.stringify(data));
}
function uploadBlob(req, res) {
req.setEncoding("binary"); // 设置字符编码为二进制
let fileInfo = req.headers["file-info"].split(';');
let body = "";
let fileName = decodeURIComponent(fileInfo[1].split('=')[1]);
// 接收数据
req.on("data", function(chunk) {
body += chunk;
});
req.on("end", function() {
const bufferData = Buffer.from(body, "binary");
// 保存接收到的文件
fs.writeFile(process.cwd() + '/upload/' + fileName, bufferData, function(err) {
// res.end('sucess'),等价于res.write('sucess')+res.end()。
var resData = {
code: 200,
msg: '',
data: '/upload/' + fileName
};
resData.msg = 'success';
if(err) {
resData.msg = err.message;
resData.code = 0;
resData.data = null;
}
setResponseDate(res,resData);
res.end();
});
});
}
module.exports = uploadBlob;
根据前端请求接口时设置的请求头,提取出上传文件的名字,然后保存文件到upload
文件夹下。
下面是运行例子的结果:
文件上传时的请求头:
上传文件时的请求报文主体:
请求的响应:
例子中包含了上传
ArrayBuffer
数据的逻辑,可以试一下。
使用multipart/form-data
上传文件
使用multipart/form-data
上传文件时,可以直接使用form
元素,也可以使用FormData
。
首先实现/upload
接口。
在back
文件夹下创建upload.js
文件,然后添加以下代码:
const fs = require("fs");
function setResponseDate(res, data) {
res.write(JSON.stringify(data));
}
function parseFormData(dataBody,boundary) {
var formList = dataBody.split('--' + boundary);
var res = {};
for (let i = 0; i < formList.length; i++) {
var keyReg = /Content-Disposition: form-data;\s(name\=\"(\w+)\")/g
var keyExecRes = keyReg.exec(formList[i]);
if(keyExecRes) {
res[keyExecRes[2]] = {
content: formList[i]
};
var contentTypeReg = /Content-Type: ([a-zA-Z0-9_\-]+\/[a-zA-Z0-9_\-]+)\r\n\r\n/g;
var fileNameReg = /Content-Disposition: form-data;\sname\=\"\w+\";\sfilename=\"([\w\.\-]+)\"/g;
var fileNameExecRes = fileNameReg.exec(formList[i]);
var contentTypeExecRes = contentTypeReg.exec(formList[i]);
if(fileNameExecRes) { // 文件
res[keyExecRes[2]]['filename'] = fileNameExecRes[1];
}
if(contentTypeExecRes) { // 文件媒体类型
res[keyExecRes[2]]['contentType'] = contentTypeExecRes[1];
res[keyExecRes[2]]['binaryStart'] = contentTypeReg.lastIndex;
}
}
}
return res;
}
function upload(req, res) {
req.setEncoding("binary"); // 设置字符编码为二进制
let body = "";
// 边界字符
// req.headers --- 请求头
let boundary = req.headers["content-type"]
.split("; ")[1]
.replace("boundary=", "");
// 接收数据
req.on("data", function(chunk) {
body += chunk;
});
req.on("end", function() {
let contentType = "";
let fileName = "";
let fileExtension = "";
let binary; // 文件数据
var formData = parseFormData(body,boundary);
for(let key in formData) {
if(formData.hasOwnProperty(key)) {
// file字段的值是文件
if(key === 'file') {
fileName = formData[key]['filename'];
contentType = formData[key]['contentType'];
binary = formData[key]['content'].substring(formData[key]['binaryStart'], formData[key]['content'].length - 2);
}
// fileExtension 字段值是文件扩展名 (可选)
if(key === 'fileExtension') {
let reg = /\r\n\r\n(\w+)\r\n/g
fileExtension = reg.exec(formData[key]['content'])[1];
}
}
}
if(fileExtension) {
fileName = fileName + "." + fileExtension;
}
const bufferData = Buffer.from(binary, "binary");
// 保存接收到的文件
fs.writeFile(process.cwd() + '/upload/' + fileName, bufferData, function(err) {
// res.end('sucess'),等价于res.write('sucess')+res.end()。
var resData = {
code: 200,
msg: '',
data: '/upload/' + fileName
};
resData.msg = 'success';
if(err) {
resData.msg = err.message;
resData.code = 0;
resData.data = null;
}
setResponseDate(res,resData);
res.end();
});
});
}
module.exports = upload;
upload.js
中根据multipart/form-data
的格式对请求主体进行解析,获取到文件数据,然后把文件保存到upload
文件夹。
使用form元素上传
在front
文件夹下创建upload-form.html
里添加以下代码:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>上传文件</title>
</head>
<body>
<form action="/upload" enctype="multipart/form-data" method="post">
<input type="file" name="file" /><br>
<input type="submit" value="Upload" />
</form>
</body>
</html>
访问upload-form.html
页面:
文件上传时的请求头:
上传文件时的请求报文主体(在chrome浏览器中看不到,在firefox中可以看到):
下面是响应数据,图片上传成功了:
使用form
表单上传就是这么简单,前端不需要写任何逻辑,前提是该设置的类型要设置对,并且后端要按对应类型的格式解析数据。
使用FormData
上传
使用FormData
上传时需要使用AJAX。不管是用jQuery
,axios
,XMLHttpRequest
,fetch
,还是其他的请求库,重点不是各种请求库的api怎么使用,而是如何处理文件数据。
下面的例子中使用的是XMLHttpRequest
。
FormData
的api文档可以看这里。
这里主要用到append()
方法。
FormData
接口的 append()
方法会添加一个新值到 FormData
对象内的一个已存在的键中,如果键不存在则会添加该键。
formData.append(name, value);
// 或者
formData.append(name, value, filename);
参数
- name value 中包含的数据对应的表单名称。
- value 表单的值。可以是USVString 或 Blob (包括子类型,如 File)。
- filename 可选 传给服务器的文件名称 (一个 USVString), 当一个 Blob 或 File 被作为第二个参数的时候, Blob 对象的默认文件名是 "blob"。 File 对象的默认文件名是该文件的名称。
注意 表单的字段名可以是
name[]
形式,为了与 PHP 数组命名习惯一致,方便PHP后端获取到数据后遍历。
主要看第二个参数,第二个参数支持Blob
(File
是Blob
子类),所以我们可以直接把input
元素获取的文件添加到FormData
,也可以通过Blob
或者File
创建一个文件添加到FormData
。
下面看例子。
例子1 直接把input
元素获取的文件添加到FormData
再上传。
在front
文件夹下创建upload-ajax-file.html
里添加以下代码:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>上传文件</title>
</head>
<body>
<input type="file" id="picker" />
<img id="image" />
<pre id="output"></pre>
<script>
window.onload = function() {
var output = document.getElementById("output");
document.getElementById("picker").onchange = function () {
var file = this.files[0];
output.innerText = '';
if(file.size > 1024*1024) {
output.innerText = 'file size great than 1M';
return;
}
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.onload = function() {
output.innerText = JSON.stringify(xhr.response);
document.getElementById("image").setAttribute('src',xhr.response.data);
};
xhr.onerror = function(err) {
output.innerText = 'fail:' + err.toString();
};
xhr.open('POST', '/upload');
var fd = new window.FormData();
// 跟后端约定 file字段的值是文件
fd.append('file', file);
xhr.send(fd);
};
};
</script>
</body>
</html>
访问upload-ajax-file.html
页面:
请求报文头部:
请求报文主体:
响应:
例子2 使用Blob
创建文件上传
在front
文件夹中创建upload-ajax-file-blob.html
文件,并添加以下代码:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>上传文件</title>
</head>
<body>
<button id="blob-test">测试上传</button>
<pre id="output"></pre>
<script>
window.onload = function() {
var output = document.getElementById("output");
document.getElementById("blob-test").addEventListener('click', function () {
var file = new Blob(['<a id="a"><b id="b">name="rrrttt"</b></a>'],{
type: 'text/html'
});
var fileExtension = 'html';
output.innerText = '';
if(file.size > 1024*1024) {
output.innerText = 'file size great than 1M';
return;
}
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.onload = function(d) {
output.innerText = JSON.stringify(xhr.response);
};
xhr.onerror = function(err) {
output.innerText = 'fail:' + err.toString();
};
xhr.open('POST', '/upload');
var fd = new window.FormData();
fd.append('file', file);
fd.append('fileExtension', fileExtension);
xhr.send(fd);
});
};
</script>
</body>
</html>
点击页面上的测试上传
按钮,会使用Blob
创建一个文件上传,并使用fileExtension
告知服务端文件的扩展名。
访问页面:
上传时的请求头:
请求参数:
响应:
自己构造符合标准的请求报文上传文件
前面的例子都是通过浏览器上传文件,multipart/form-data
格式的请求报文都是浏览器帮我们构建的,接下来看一个例子,不使用浏览器,通过node.js
自己构造请求报文上传文件。
创建一个server_upload.js
文件,添加以下代码:
const path = require("path");
const fs = require("fs");
const http = require("http");
// 定义一个分隔符,要确保唯一性
const boundaryKey = "-------------------------461591080941622511336662";
const request = http.request({
method: "post",
host: "localhost",
port: "3000",
path: "/upload",
headers: {
"Content-Type": "multipart/form-data; boundary=" + boundaryKey, // 在请求头上加上分隔符
Connection: "keep-alive",
},
});
// 写入内容头部
request.write(
`--${boundaryKey}\r\nContent-Disposition: form-data; name="file"; filename="9.png"\r\nContent-Type: image/png\r\n\r\n`
);
// 写入内容
const fileStream = fs.createReadStream(path.join(__dirname, "../img/9.png"));
fileStream.pipe(request, { end: false });
fileStream.on("end", function() {
// 写入尾部
// request.end(data) 如果指定了 data,则相当于调用 request.write(data, encoding) 后跟 request.end(callback)。
request.end("\r\n--" + boundaryKey + "--" + "\r\n");
});
request.on("response", function(res) {
let body = '';
res.on('data', function(chunk) {
body += chunk;
});
res.on('end', function() {
console.log(res.headers);
console.log(body.toString());
});
});
代码中用于上传的文件是写死的,需要在server_upload.js
所在的目录下创建一个img
文件夹,然后在该img
文件夹下添加一张png
格式的图片,并命名为9.png
。
运行node server_upload.js
命令,可以在命令行工具中看到以下输出:
{
date: 'Mon, 27 Nov 2023 09:03:43 GMT',
connection: 'keep-alive',
'keep-alive': 'timeout=5',
'transfer-encoding': 'chunked'
}
{"code":200,"msg":"success","data":"/upload/9.png"}