【Node.js系列(8)】文件上传原理解析与实现

2,948 阅读5分钟

前言

大家上传文件时或许只是获取文件后将内容发送给了后端,至于后端是如何解析的相信大部分同学也都是一知半解。这样会造成很多问题,比如文件上传失败了,当我们定位问题的时候无从下手,后端同学说是前端的问题,而我们又找不到理由去反驳它们,最终我们只能依靠百度。

所以此篇文章会详细介绍文件上传的原理,并利用 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,有任何不懂的地方可以直接进行骚扰,大家一起学习,一起进步。