formidable处理文件上传的细节
阅读原文时间:2023年07月09日阅读:1

koa在请求体的处理方面依赖于通用插件koa-bodyparser或者koa-body,前者比较小巧,内部使用了co-body库,可以处理一般的x-www-form-urlencoded格式的请求,但不能处理文件上传;但后者则内置了formidable库,在应对文件上传方面得心应手,本文就formidable文件上传的细节进行了一些分析。

koa-body的一般使用

const Koa = require('koa2')
const koaBody = require('koa-body');

const app = new Koa()

app.use(koaBody({
  multipart: true,
  formidable: {
    // multiples: 接受多文件上传,默认为true
    // uploadDir: os.tmpDir() 文件上传路径,默认为系统临时文件夹
    keepExtensions: true // 保留文件原本的扩展名,否则没有扩展名,默认为false
  }
}))

app.use(ctx => {
  ctx.body = `Request Body: ${JSON.stringify(ctx.request.body)}, ${JSON.stringify(ctx.request.files)}`;
})

app.listen(3003, () => console.log('server running on port 3003'))

在koaBody的选项中开启multipart即可,非常简便,其中的formidable可以指定设置,具体配置见文档

文件form和普通form的区别

普通form

下面是一个普通form的例子:

<form action="http://localhost:3003" method="POST">
  <p>
    姓名:<input type="text" name="name">
  </p>
  <p>
    年龄:<input type="number" name="age">
  </p>
  <p>
    <input type="submit" value="提交">
  </p>
</form>

在填写表单点击提交按钮时,它的请求报文格式是:

// 省略了一些头字段
POST http://localhost:3003/ HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded
...

name=MickFone&age=58

Content-Type是application/x-www-form-urlencoded,请求体是标准的form请求体格式,键值之间使用=连接,field之间则使用&

文件form

<form action="http://localhost:3003" method="POST" enctype="multipart/form-data">
  <p>
    姓名:<input type="text" name="name">
  </p>
  <p>
    年龄:<input type="number" name="age">
  </p>
  <p>
    资料:<input type="file" multiple name="materials">
  </p>
  <p>
    <input type="submit" value="提交">
  </p>
</form>

它的请求报文格式是:

// 省略了一些头字段,所有的换行均为\\r\\n
POST http://localhost:3003/ HTTP/1.1
...
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryI58Bh9EERAVK3lE7
...

------WebKitFormBoundaryI58Bh9EERAVK3lE7
Content-Disposition: form-data; name="name"

MickFone
------WebKitFormBoundaryI58Bh9EERAVK3lE7
Content-Disposition: form-data; name="age"

58
------WebKitFormBoundaryI58Bh9EERAVK3lE7
Content-Disposition: form-data; name="materials"; filename="sign.png"
Content-Type: image/png

PNG信息(乱码)
------WebKitFormBoundaryI58Bh9EERAVK3lE7
Content-Disposition: form-data; name="materials"; filename="文本.txt"
Content-Type: text/plain

两个黄鹂鸣翠柳,
一行白鹭上青天。
------WebKitFormBoundaryI58Bh9EERAVK3lE7--

上传文件时的Conent-Type是multipart/form-data,这种情况下就不能再简单地使用=, &连接项,因为可能上传了二进制文件,而且文件上传需要携带它们的描述信息,比如mime类型、文件名、编码方式这些,所以multipart/form-data类型的表单格式会变得比较复杂。

来看看具体的格式,首先在请求头中Content-Type不仅指定了mime类型,还指定了一个boundary,以这个作为请求体中每个field之间的界线,每两个boundary之间的部分都是一个表单项,或者是一个文件。每个部分都有Content-Disposition头,其中的name属性指定了文件在form表单中的name;如果是文件,则有额外的filename属性表示上传文件名(带扩展名)和Content-Type头描述其mime类型。描述信息结束后是一个空行,紧接着的是表单项的值或者文件的内容,最后以一个boundary加上--作为请求体的结尾。

请求体格式可以大概表示为:

--${boundary}
${描述头1 Content-Disposition: form-data; ...}
${描述头2}
..
// 空白行
${内容}
if (end) --${boundary}--

那么koa-body是怎么处理这种上传格式的呢?koa-body内部使用了formidable,这是个专门解决这类问题的库,我们来分析一下它的细节处理。

这里需要注意的一点是,Content-Disposition这个头字段如果出现在响应头中,则表明浏览器应该如何去呈现这个文件,
如:Content-Disposition: inline表明浏览器默认应当在页面中打开它,而 Content-Disposition: attachment 则会使
浏览器打开一个“另存为”的对话框保存文件。由此可见其与请求体中这个字段的含义有较大的区别,不能混淆这个头字段在请求
和响应两种场景的含义。

formidable对文件上传的处理

  1. 首先从请求头的Content-Type检测出multipart/form-data类型,并且读取boundary
  2. 创建了一个Multiple parser(一个对象模式的Transform流)来用来解析ctx.req,使用boundary初始化它
  3. Multiple parser开始处理请求体,它内部的实现是一个自动机,通过它提取出文件头信息(mime、编码、文件名等)
  4. 头信息提取完后,根据文件信息新建File可读流和一个part工具流,随着parser的解析触发part的data事件,将ctx.req暂停,然后往File可读流中写入内容,接着恢复ctx.req的流动性

下面是一个简单版本的实现,只能处理单文件上传,省略了解析字符串的自动机部分

// middleware.js
const { Transform, Stream } = require('stream')
const fs = require('fs')
const path = require('path')

const dir = path.dirname(process.argv[1])
const LF = 10 // '\n'
const CR = 13 // '\r'
const HYPHEN = 45 // '-'

module.exports = async function middleWare(ctx, next) {
  return new Promise((resolve, reject) => {
    let part
    let File, filepath

    // 1. 从Content-Type拿到boundary
    const contentType = ctx.headers['content-type']
    const boundaryReg = contentType.match(/boundary=(.*)/)
    const boundary = Buffer.from('--' + boundaryReg[1])

    // 用来收集普通的表单元素
    let fields = {}
    // 是否碰到文件的标记
    let fileFlag = false

    // 2. 创建转换流
    const transformer = new Transform({
      // 运行在对象模式
      objectMode: true,
      transform(buffer, encoding, callback) {
        let prevIndex = 0
        let fieldBegin = 0
        let fieldEnd = 0

        for (let i = 0, l = buffer.length; i < l; i++) {
          const c = buffer[i]
          // 检测到空行
          if (!fieldBegin && c === LF && buffer[i-1] == CR && buffer[i-2] === c) {
            // 第一部分头信息,可以使用utf8编码提取文件信息
            let fileInfoBuffer = buffer.slice(prevIndex, i+1)
            let fileInfoString = fileInfoBuffer.toString('utf8')

            // 这里只简单地用正则去匹配了文件名
            const filenameReg = /name="([^"]+)"(; filename="([^"]+)")?/

            if (fileInfoString.match(filenameReg)) {
              let name = RegExp.$1
              let filename = RegExp.$3
              // 3. 获取文件名
              if (filename) {
                this.push({ name: 'filename', buffer: Buffer.from(filename) })
                fieldBegin = i + 1
                fileFlag = true
              }
              // 获取表单name属性
              else if (name) {
                fieldBegin = i + 1
                this.push({ name: 'fieldname', buffer: Buffer.from(name) })
              }
            }
          }
          // 简单地用校验开头和结尾匹配的方式判断表单值或文件的结尾
          else if (fieldBegin && c === LF && buffer[i+1] === HYPHEN && buffer[i+2] === HYPHEN) {
            let j = i + boundary.length
            if (buffer[j] === boundary[boundary.length - 1]) {
              fieldEnd = i - 1
              let fileBuffer = buffer.slice(fieldBegin, fieldEnd)
              this.push({ name: 'fielddata', buffer: fileBuffer })
              fieldBegin = fieldEnd = 0
              prevIndex = i + 1
            }
          }
        }

        callback()
      }
    })

    // 让转换流开始流动
    let currentFieldName = ''
    transformer.on('data', ({ name, buffer }) => {
      if (name === 'fieldname') {
        currentFieldName = buffer.toString()
      }
      else if (name === 'filename') {
        // 这是个无关紧要的工具流
        part = new Stream()
        part.readable = true
        // 4. 创建待写入的文件流
        filepath = path.resolve(dir, buffer.toString())
        File = fs.createWriteStream(filepath)

        // 这里只简单使用了工具流的EventEmitter特征
        part.on('data', (chunk) => {
          ctx.req.pause()
          File.write(chunk, () => {
            ctx.req.resume()
          })
        })

        // 写入后再执行后续操作
        part.on('end', () => {
          File.end('', async () => {
            ctx.fields = fields
            ctx.file = filepath
            console.log(filepath)

            fileFlag = false
            resolve()
          })
        })
      }
      else if (name === 'fielddata') {
        if (fileFlag) {
          // 如果是文件,向工具流发送数据
          part.emit('data', buffer)
        }
        // 否则是普通的表单值
        else if (currentFieldName) {
          let field = fields[currentFieldName]
          if (typeof field === 'string') {
            fields[currentFieldName] = [field, buffer.toString()]
          } else if (Array.isArray(field)) {
            field.push(buffer.toString())
          } else {
            fields[currentFieldName] = buffer.toString()
          }
          currentFieldName = ''
        }
      }
    })

    transformer.on('end', () => {
      if (part instanceof Stream) {
        part.emit('end')
      }
    })

    ctx.req.on('end', () => {
      transformer.end()
    })

    ctx.req.pipe(transformer)
  }).then(next)
}

// app.js
const Koa = require('koa2')
const middleWare = require('./middleware')

const app = new Koa()

app.use(middleWare)

app.use(ctx => {
  ctx.body = `Request Fields: ${JSON.stringify(ctx.fields)}, Request Body: ${ctx.file}`;
})

if (!module.parent) {
  app.listen(3003, () => console.log('server running on port 3003...'))
}

如果接收这样的表单:

<form action="http://localhost:3003" method="POST" enctype="multipart/form-data">
  <p>
    姓名:<input type="text" name="name">
  </p>
  <p>
    年龄:<input type="number" name="age">
  </p>
  <p>
    爱好1:<input type="text" name="hobit">
  </p>
  <p>
    爱好2:<input type="text" name="hobit">
  </p>
  <p>
    资料:<input type="file" multiple name="materials">
  </p>
  <p>
    <input type="submit" value="提交">
  </p>
</form>

响应结果为:

Request Fields: {"name":"MickFone","age":"58","hobit":["pingpong","video games"]}, Request Body: ...\upload\instance.png

总结

  1. multipart/form-data的表单格式与一般的x-www-form-urlencoded有很大区别,在读取数据上要麻烦一些
  2. formidable大致使用了四个步骤读取了multipart/form-data中的普通表单字段和文件,并把文件保存到本地
  3. 通过了解formidable的基本实现,需要掌握Node.js自定义流的用法