深入浅出 multipart/form-data

5,145 阅读4分钟

在前端开发过程中,不可避免地会遇到表单,即 multipart/form-data,你是否知道:

  • HTTP 如何传输 multipart/form-data
  • 服务端如何解析 multipart/form-data
  • 浏览器如何组装 multipart/form-data

我们来看一个简单的 form 表单:

<form action="/submit" method="POST" enctype="multipart/form-data">
    <input type="text" name="username"><br>
    <input type="text" name="password"><br>
    <button>提交</button> 
</form>

当提交的时候,查看浏览器的网络请求:

请求头:

POST /submit HTTP/1.1
Host: localhost:3000
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------340073633417401055292887335273
Content-Length: 303

请求体:

-----------------------------340073633417401055292887335273
Content-Disposition: form-data; name="username"

张三
-----------------------------340073633417401055292887335273
Content-Disposition: form-data; name="password"

123456
-----------------------------340073633417401055292887335273--

这就是 multipart/form-data 的传输过程了,但是这里面有三个大坑:

  • 请求头 Content-Type 里面的 boundary 分隔符比请求体用的分隔符少了两个杠(-)

    从请求头中取分隔符之后,一定要在之前加两个 - 再对请求体进行分割

  • 请求头 Content-Length 的换行用的是 \r\n 而不是 \n

    请求体的真实面目是下面的字符串: "-----------------------------340073633417401055292887335273\r\nContent-Disposition: form-data; name="username"\r\n\r\n张三\r\n-----------------------------340073633417401055292887335273\r\nContent-Disposition: form-data; name="password"\r\n\r\n123456\r\n-----------------------------340073633417401055292887335273--\r\n"

  • 请求头 Content-Length 的值表示字节的长度,而不是字符串的长度

    因为字节的长度跟编码无关,而字符串的长度往往跟编码有关,举个例子,在 utf8 编码下:

    console.log('a1'.length) // 2
    console.log(Buffer.from('a1').length) // 2
    console.log('张三'.length) // 2
    console.log(Buffer.from('张三').length) // 6
    

如果仅仅是基本的字符串类型,完全可以用 www-form-urlencoded 来进行传输,multipart/form-data 强大的地方是其能够传输二进制文件的能力,我们看一下如果包含二进制文件的话应该如何处理。我们增加一个 file 类型的 input,上传一张图片作为头像,发现请求体多出了一部分:

-----------------------------114007818631328932362459060915
Content-Disposition: form-data; name="avatar"; filename="1.jpg"
Content-Type: image/jpeg

xxxxxx文件的二进制数据xxxxx

可以发现,文件类型的 part 跟之前字符串的格式有所不同了,head 部分有两个头字段,多出一个 Content-Type 头,而且 Content-Disposition 头多出来 filename 字段,body 部分是文件的二进制数据。

了解这这些规律之后,接下来就可以在服务端对 multipart/form-data 进行解码了:

const http = require('http')
const fs = require('fs')
http
  .createServer(function (req, res) {
    // 获取 content-type 头,格式为: multipart/form-data; boundary=--------------------------754404743474233185974315
    const contentType = req.headers['content-type']
    const headBoundary = contentType.slice(contentType.lastIndexOf('=') + 1) // 截取 header 里面的 boundary 部分
    const bodyBoundary = '--' + headBoundary // 前面加两个 - 才是 body 里面真实的分隔符
    const arr = [], obj = {}
    req.on('data', (chunk) => arr.push(chunk))
    req.on('end', function () {
      const parts = Buffer.concat(arr).split(bodyBoundary).slice(1, -1) // 根据分隔符进行分割
      for (let i = 0; i < parts.length; i++) {
        const { key, value } = handlePart(parts[i])
        obj[key] = value
      }
      res.end(JSON.stringify(obj))
    })
  })
  .listen(3000)

其中关键的就是 handlePart 部分,即对分隔出来的每一部分单独处理,如果是二进制的就保存到文件,是字符串就返回键值对:

function handlePart(part) {
  const [head, body] = part.split('\r\n\r\n') // buffer 分割
  const headStr = head.toString()
  const key = headStr.match(/name="(.+?)"/)[1]
  const match = headStr.match(/filename="(.+?)"/)
  if (!match) {
    const value = body.toString().slice(0, -2) // 把末尾的 \r\n 去掉
    return { key, value }
  }
  const filename = match[1]
  const content = part.slice(head.length + 4, -2) // 文件二进制部分是 head + \r\n\r\n 再去掉最后的 \r\n
  fs.writeFileSync(filename, content)
  return { key, value: filename }
}

这里面涉及到 buffer 的分割,nodejs 中并没有提供 split 方法,可根据 slice 方法自己实现:

Buffer.prototype.split = function (sep) {
  let sepLength = sep.length, arr = [], offset = 0, currentIndex = 0
  while ((currentIndex = this.indexOf(sep, offset)) !== -1) {
    arr.push(this.slice(offset, currentIndex))
    offset = currentIndex + sepLength
  }
  arr.push(this.slice(offset))
  return arr
}