前言
大家上传文件时或许只是获取文件后将内容发送给了后端,至于后端是如何解析的相信大部分同学也都是一知半解。这样会造成很多问题,比如文件上传失败了,当我们定位问题的时候无从下手,后端同学说是前端的问题,而我们又找不到理由去反驳它们,最终我们只能依靠百度。
所以此篇文章会详细介绍文件上传的原理,并利用 Node 去实现一个上传文件的 http 服务,纯手动实现,不依赖任何第三方模块。
如果大家觉得此篇文章还不错,希望也能看一下我的其它文章,绝对都是干货。
请求报文分析
当上传文件时,Content-Type
字段值会变为 multipart/form-data
,至于为什么会是这个值,可以看一下《RFC 1867: Form-based File Upload in HTML》 文档定义。
Since file-upload is a feature that will benefit many applications, this proposes an extension to HTML to allow information providers to express file upload requests uniformly, and a MIME compatible representation for file upload responses.
大概意思是说,由于文件上传是一个普遍的需求,为了在目前 html 的 form 表单上增加此需求,特意增加了一个 mime 类型。这里的 mime 类型就是指multipart/form-data
。
我们可以简单粗暴的理解为,只要是文件上传,Content-Type
必须为 multipart/form-data
。
现在我们写一个简单的 demo,真实的看一下文件上传的请求头与请求体。
html 代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<form>
<p>file: <input name="file" type="file" id="fileInput"></p>
<p><button type="button" id="submit">提交</button></p>
</form>
</body>
</html>
<script>
const formData = new FormData()
formData.append('sth', '111')
fileInput.addEventListener('change', (e) => {
console.log(fileInput.files);
const file = fileInput.files[0]
formData.append(file.name, file)
})
submit.addEventListener('click', () => {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/file')
xhr.send(formData)
})
</script>
这里利用常见的 FormData 来存储文件内容并上传。使用 ajax 上传 FormData 对象时,浏览器会自动帮我们设置 Content-Type 为 multipart/form-data。
请求头
重点看一下 Content-Type
字段:
ContentType: multipart/form-data; boundary=----WebKitFormBoundaryGBVZwk1PFwCSPfCh
boundary=----WebKitFormBoundaryGBVZwk1PFwCSPfCh
代表分隔符,用来分隔多个表单项。我们这里上传数据用的是 FormData
,其本质还是构造了一个表单数据。每次 formData.apend(key, value)
,就是构建一个表单项的数据结构,每个表单项都用boundary
分割。其构建的数据结构如下所示。
请求体
请求体会分为多段内容,每段内容都是用 boundary
进行分割,每段内容的结构等同于请求报文的结构。 每段内容会分为 head 和 body 两部分,head 与 body 之间用两个换行符(\r\n\r\n)做分割。 看到这,我们对 multipart/form-data
应该有种恍然大悟的感觉,multipart form data,多个表单数据。so dei si nai~~~
Content-Disposition
代表字段描述,这里的 name
即字段名。当有文件时会出现filename
字段。
Content-Type
当内容为文件时会出现此字段,含义等同于请求头中的 Content-Type
文件上传解析
理论分析完了,此时,我们就应该可以手写一个文件上传的服务。
实现思路就是拿到请求报文,然后用请求头中的分隔符对请求体进行解析,最终得到文件名与文件内容,并将文件写入到磁盘中。
启动服务器
首先,我们用 http 模块启动一个服务,当访问 /file
路径时开始解析文件。
const fs = require('fs')
const path = require('path')
const http= require('http')
// 创建一个服务器
const server= http.createServer((req, res) => {
if (req.url === '/file') {
// 拿到请求头中的分隔符
// 在请求体中的分隔符会多两个 --
const separator = `--${req.headers['content-type'].split('boundary=')[1]}`
// 创建一个 0 字节的内存,用来存储请求体的内容
let data = Buffer.alloc(0)
// req 是一个可读流
req.on('data', (chunk) => {
data = Buffer.concat([data, chunk])
})
req.on('end', () => {
// 解析文件
parseFile(data, separator)
res.end()
})
}
})
server.listen(3000, () => {
console.log('server start up 3000')
})
- 这里 req 对象是一个可读流,所以读取请求体的时候必须以流的形式读取,对流不了解的同学可以在我的主页看一下《手写文件流》这篇文章。
- 流里每次读取的数据都是 buffer 对象,每次读取的时候都拼接成一个 buffer。
- 请求体中的分隔符有 6 个
-
,而请求头中只有 4 个-
,所以这里我们手动加两个。 - req 读取完毕后调用
parseFile
开始解析文件。
我们打印一下 data
,看看具体接收到的数据。
直接打印是一个二进制的 buffer,我们转成字符串看一下:
解析文件
接下来就是解析请求体,拿到文件内容与文件名,写入文件到磁盘就大功告成了。
function parseFile(data, separator) {
// 利用分隔符分割data
// split 等同于数组的 split
const bufArr = split(data, separator).slice(1, -1)
bufArr.forEach(item => {
// 分割 head 与 body
const [head, body] = split(item, '\r\n\r\n')
// 可能会存在两行 head,所以用换行符 '\r\n' 分割一下
// 这里的第一个元素是截取后剩下空 buffer,所以要剔除掉
const headArr = split(head, '\r\n').slice(1)
// head 的第一行肯定是 Content-Disposition
// 通过这个字段肯定能拿到文件名
// 通过parseHeader解析head
const headerVal = parseHeader(headArr[0].toString())
// 如果 head 内存在 filename 字段,则代表是一个文件
if (headerVal.filename) {
// 写入文件到磁盘
fs.writeFile(path.resolve(__dirname, `./public/${headerVal.filename}`), body.slice(0, -2), (err) => {
if (err) {
console.log(err)
}
})
}
})
}
- 首先用分隔符对
data
进行分割,得到如下内容:
[ '\r\nContent-Disposition: form-data; name="sth"\r\n\r\n111\r\n', '\r\n' + 'Content-Disposition: form-data; name="a.txt"; filename="a.txt"\r\n' + 'Content-Type: text/plain\r\n' + '\r\n' + 'hello world\r\n']
- 遍历分割后的内容,解析出 head 与 body,head 与 body 之间存在两个换行符(\r\n\r\n),所以利用这一点进行解析。
- 解析 head 得到文件名。
- 用此文件名将 body 写入到磁盘中去
parseHead
解析请求体中的 head
function parseHeader(header) {
const [name, value] = header.split(': ')
const valueObj = {}
value.split('; ').forEach(item => {
const [key, val = ''] = item.split('=')
valueObj[key] = val && JSON.parse(val)
})
return valueObj
}
split
分割buffer,等同于数组的 split
方法
function split(buffer, separator) {
const res = []
let offset = 0;
let index = buffer.indexOf(separator, 0)
while (index != -1) {
res.push(buffer.slice(offset, index))
offset = index + separator.length
index = buffer.indexOf(separator, index + separator.length)
}
res.push(buffer.slice(offset))
return res
}
总结
至此,我们从原理到实现完完整整的走了一遍文件上传的流程,有些地方会涉及到 Node.js 核心模块的使用,有不了解的同学可以在我的主页看一下相关文章。
这里的主要难点可能就是操作 buffer 这一块,但是大家多思考一下也没啥大问题。
希望此文能够帮助到大家,感谢阅读~~~
最后
不要忘记关注我的公众号:前端superYue,关注有惊喜哦。
也可以添加我的个人微信 hdsm26,有任何不懂的地方可以直接进行骚扰,大家一起学习,一起进步。