文件上传与小应用

效果

原生, 没有package.json

将图片文件上传到电脑上, 项目地址

上传25张

前端显示

后端显示

之后用这个上传了几百张, 超喜欢的图嘿嘿😆

文件上传

平时会碰到上传文件或者用浏览器处理文件等情况, 所以从前后端来写各自的原理与解决方法。

后端的上传处理 - nodejs

原生的

从 nodejs 原生说起

详细可以阅读 http:文件上传背后发生了什么?

  1. 服务器接收到文件数据, 下文的 req 便是 http.createSever(function(req, res) {}) 里往匿名回调中传入的参数
1
2
3
4
5
req.on('data', function(chunk) {
console.log(chunk)
body += chunk;
}); //目测是按 ascii 输出, 因为没有出现中文乱码 Эg0RÇÄ+ÓG\I.PÒUeOÓZáQµ(Wk
// 当然 每次在 data 事件中触发抓获的数据块是一个 Buffer 或 string, http.incomingMessage 继承了 stream, 这里是 string, 可以用 typeof 看

post数据的传输是可能分包的,因此采用监听方式
使用 node –inspect 查看的内容如下

chunk 内容

  1. 对数据进行处理
  1. 将请求报文转为对象
  2. 判断图片文件, 提取信息
  3. 获取文件二进制数据
    http 请求报文结构为: 请求行(request line)、消息头部(header) 、空行(CRLF) 、请求正文, 详细了解 HTTP消息
    http 报文内容

使用 querystring.parse 获取信息

解析出来的 file 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
req.on('end', function() {
var file = querystring.parse(body, '\r\n', ':');
// console.log(file);将整个 Http 请求报文解析为一个对象

// 只处理图片文件
if (file['Content-Type'].indexOf('image') !== -1) {
//获取文件名
var fileInfo = file['Content-Disposition'].split('; ');
for (value in fileInfo) {
if (fileInfo[value].indexOf('filename=') != -1) {
fileName = fileInfo[value].substring(10, fileInfo[value].length - 1);

if (fileName.indexOf('\\') != -1) {
fileName = fileName.substring(fileName.lastIndexOf('\\') + 1);
}
// console.log('文件名: ' + fileName);
}
}

// 获取图片类型(如:image/gif 或 image/png))
var entireData = body.toString(),
contentType = file['Content-Type'].substring(1);

//获取文件二进制数据开始位置,即contentType的结尾
var upperBoundary = entireData.indexOf(contentType) + contentType.length;
var shorterData = entireData.substring(upperBoundary);

// 替换开始位置的空格
var binaryDataAlmost = shorterData.replace(/^\s\s*/, '').replace(/\s\s*$/, '');

// 去除数据末尾的额外数据,即: "--"+ boundary + "--"
var binaryData = binaryDataAlmost.substring(
0,
binaryDataAlmost.indexOf('--' + boundary + '--')
);
}

http 上传文件, 实际上是由协议 RFC1867 对 form 表单扩展而来的, 如果有兴趣模拟 http 上传文件, 可以看这篇文章, 作者用的是 c# - 模拟HTML表单上传文件(RFC 1867)

  1. 将数据写入文件
1
2
3
4
5
6
7
fs.writeFile('./image/' + fileName, binaryData, 'binary', function(err) {
// res.writeHead(302, {
// Location: '/' 文件的写入需要buffer类型的数据
// });
res.writeHead(200); //没写end导致变成了持久连接
res.end('123');
});

如果只接收信息不返回的话, 会成为持续连接, 由于浏览器对连接数有最大限制, 所以会出现同时上传文件超过6个很慢的 bug 原文出处 - Max parallel http connections in a browser?

框架的

将来用到再说, 除了原理或者有趣的东西, api 调用什么的是不会调查的, 当然用过的话会写就是了

前端

首先, 上传文件肯定用的是 post , 因为 get 传参放在 url 里有最大值, 而 post 放在 request.body 中没有限制。

form enctype

首先说明一下 enctype, 用来指示 form 数据形式, 它可以有三种值:

  1. application/x-www-form-urlencoded 正常形式
  2. multipart/form-data 用来传文件
  3. text/plain 不用管

其中 application/x-www-form-urlencoded 是在 headers 部分添加 Entity headers (如果请求中没有 body,则不会包含。), body 是正常 url 编码的值 传达的形式如下:

1
2
3
4
5
6
POST / HTTP/1.1
[[ Less interesting headers ... ]]
Content-Type: application/x-www-form-urlencoded
Content-Length: 51

text1=text+default&text2=a%CF%89b&file1=a.txt&file2=a.html&file3=binary

而 multipart/form-data 则会在添加 boundary 来区分, 并在最后的 boundary 添加两个连号代表结尾

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST / HTTP/1.1
[[ Less interesting headers ... ]]
Content-Type: multipart/form-data; boundary=---------------------------735323031399963166993862150

-----------------------------735323031399963166993862150
Content-Disposition: form-data; name="text1"

text default
-----------------------------735323031399963166993862150
Content-Disposition: form-data; name="file1"; filename="a.txt"
Content-Type: text/plain

Content of a.txt.

-----------------------------735323031399963166993862150
Content-Disposition: form-data; name="file2"; filename="a.html"
Content-Type: text/html

<!DOCTYPE html><title>Content of a.html.</title>

-----------------------------735323031399963166993862150--

详细可以看这里 What does enctype=’multipart/form-data’ mean?

历史演变

submit form -> XMLHttpRequest form

form 直接 submit

  1. form 提交会默认跳转新页面, 默认使用 GET, 如果用 POST, 那么请求报文中的 content-type 默认为 x-www-form-urlencoded
  2. 提交需要使用设置了 type=”submit” <input><button> 当然 js 也可以

详细提交方式可以看 - form表单提交方式

另外插件 jquery.form.js 不跳转上传, 原理为: ajax + 取消默认事件如果不支持 XMLHttpRequest 2级使用 target + iframe

由于懒, 不想用 postman 之类的, 直接用 node 来看请求报文, 使用 nodejs 监听源自本机上所有的访问次端口的请求, 并打印请求中的信息, 原文来自 - HTTP test server accepting GET/POST requests

ajax 上传

到了 ajax 的时候, 可以用 FormData 对象管理表单数据, FormData 类型其实是在 XMLHttpRequest 2级定义的, 可以利用 HTMLFormElement 对象初始化来获得与表单相同的值, 相比一个个获取表单值再上传方便

1
2
3
4
5
6
7
var req = new XMLHttpRequest();
req.open('post', '/uploadUrl');
var test = new FormData()
test.append("k1", "v1");
req.send(test); //自动设置 content-type: multipart/form-data
req.open('post', '/uploadUrl');
req.send(123) //text/plain

content-type 自动设置

FormData 会自动在上传的时候设置 content-type, 原文来自 - axios的content-type是自动设置的吗?

应用

最后来写下我的应用:

场景: 手机上有很多好看的图片, 但是我平时欣赏图片一般是电脑上, 所以想把图片传上来
方案: 手机 usb 连电脑, 手机浏览器上传
优缺点分析: usb 连接慢, 并且图片分散在不同文件夹

应该够清楚了吧, 接下来贴代码啦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
//node环境 文件名 uploadUrl.js
var http = require('http');
var fs = require('fs');
var querystring = require('querystring');

const portal = 8888;
console.log(`服务开始,打开localhost:${portal}即可访问`);
http
.createServer(function(req, res) {
var arg = req.url;
if (arg === '/uploadUrl') {
console.log('上传文件服务');
parseFile(req, res);
} else {
fs.readFile('./test.html', 'utf-8', function(err, data) {
//读取内容
if (err) throw err;
res.writeHead(200, { 'Content-Type': 'text/html' }); //注意这里
res.write(data);
res.end();
});
}
})
.listen(portal);

function parseFile(req, res) {
req.setEncoding('binary');
var body = ''; // 文件数据
var fileName = ''; // 文件名
// 边界字符串
// console.log(req.headers['content-type'] + '\n\n\n\n\n\n\n');
var boundary = req.headers['content-type'].split('; ')[1].replace('boundary=', '');
req.on('data', function(chunk) {
console.log(typeof chunk);
body += chunk;
});
req.on('end', function() {
var file = querystring.parse(body, '\r\n', ':');
// console.log(file);

// 只处理图片文件
if (file['Content-Type'].indexOf('image') !== -1) {
//获取文件名
var fileInfo = file['Content-Disposition'].split('; ');
for (value in fileInfo) {
if (fileInfo[value].indexOf('filename=') != -1) {
fileName = fileInfo[value].substring(10, fileInfo[value].length - 1);

if (fileName.indexOf('\\') != -1) {
fileName = fileName.substring(fileName.lastIndexOf('\\') + 1);
}
// console.log('文件名: ' + fileName);
}
}

// 获取图片类型(如:image/gif 或 image/png))
var entireData = body.toString(),
contentType = file['Content-Type'].substring(1);

//获取文件二进制数据开始位置,即contentType的结尾
var upperBoundary = entireData.indexOf(contentType) + contentType.length;
var shorterData = entireData.substring(upperBoundary);

// 替换开始位置的空格
var binaryDataAlmost = shorterData.replace(/^\s\s*/, '').replace(/\s\s*$/, '');

// 去除数据末尾的额外数据,即: "--"+ boundary + "--"
var binaryData = binaryDataAlmost.substring(
0,
binaryDataAlmost.indexOf('--' + boundary + '--')
);
// 保存文件
// res.write('<head><meta charset="utf-8"/></head>');
// res.end('123');
fs.writeFile('./image/' + fileName, binaryData, 'binary', function(err) {
// res.writeHead(302, {
// Location: '/'
// //add other headers here...
// });
res.writeHead(200); //原来没写end导致变成了持久连接
res.end('123');
});
} else {
res.end('just pic allowed');
}
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<!-- 文件名 test.html -->
<form id="form" action="/uploadUrl" enctype="multipart/form-data" method="post">
<input type="file" name="upload" multiple />
<input type="button" value="upload" onclick="handler(this)" />
</form>
<script>
var files;
function handler(e) {
var trueForm = document.getElementById('form');
var form = new FormData(trueForm);
files = form.getAll('upload'); //利用只读 formdata 获得全部文件信息
iterate(0);
}
function iterate(num) {
Promise.all(
//6 的原因是同时存在的连接最大数
files.slice(num, num + 6).map(
(i) =>
new Promise((res, rej) => {
upload(i, res);
})//这里没有大括号, 依据箭头函数是直接返回的, 注意 Promise.all 的使用
)
)
.then((result) => {
if (num >= files.length - 6) return;
iterate(num + 6);
})
.catch((result) => {
if (num >= files.length - 6) return;
iterate(num + 6);
});
}
function upload(i, res) {
var temForm = new FormData();
temForm.append('file', i);
var req = new XMLHttpRequest();
req.onreadystatechange = function() {
if (req.status == 200) {
res(); // 更改 Promise 状态
}
};
req.open('post', '/uploadUrl');
req.send(temForm);
}
</script>

参考文章:

文章作者:
文章链接: https://luckyray-fan.github.io/2019/10/27/文件上传与小应用/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 luckyray