node原生分析文件上传的过程

305 阅读8分钟

让你明明白白学知识,有代码,有讲解,抄的走,学的会!

我们知道,在node中,如果你想要获取到http请求中的响应体,必须监听 data和 end事件, 具体示例如下:

router.post('/api/t', (req, res) {
    let data = ""
    req.on('data', (chunk)= {
        // 将请求体片段,都存储起来
        data += chunk
    })
    
    req.on('end', ()=> {
        // 在这里,你已经获取到所有的请求体数据
        console.log(data)
        
        // 异步 将数据形成实体文件
        fs.writeFile('./1.txt', data, (err)=> {
            if(err) throw err
            console.log('文件写入成功')
        })
    })
})

思考几个问题

  • 没有文件上传的情况下,响应体是什么样子?
  • 有文件上传的情况下,响应体是什么样子
  • 普通数据+文件上传时,响应体是什么样子?
  • 如何处理二进制形式的响应体?

以下图片,都是将请求体生成实体的txt文件

图-无文件上传时

图-只存在文件上传的情况

图-文件上传+普通数据 的情况

前面3个问题已经回答完毕,我们从图片中看到,其实响应体的内容是存在规律的,所以,我们来具体分析第4个问题

响应体规律

  • 每个字段都是以 ------WebKitFormBoundaryl8dFcQjQhK8gHvKY 开头
  • 内容体和前面的内容,以空行分割
  • 内容体、Content-Disposition: form-data; name="name" 都存在换行
  • 如果请求体中包含【文件】,则描述行,多一行描述,对文件类型的描述 Content-Type: image/png
------WebKitFormBoundaryl8dFcQjQhK8gHvKY【换行符】
Content-Disposition: form-data; name="img"; filename="error-stack.png"【换行符】
Content-Type: image/png 【换行符】
空行【换行符】
具体的图片二进制数据【换行符】
------WebKitFormBoundaryl8dFcQjQhK8gHvKY--【换行符】
空行

前端代码

前端就没什么处理,直接一个表单即可, 记得指定input 的name, 后台是通过name去取值的, 这里使用了bootstrap美化一下表单,其他没有任何过多的代码

<form enctype="multipart/form-data" id='multy-form' method="post" action='/cors/multy-upload'>
    <div class="form-group">
      <label for="">姓名:</label><input class="form-control" type="text" name='name' id='name'>
    </div>
    <div class="form-group">
      <label for="">年龄:</label><input class="form-control" type="text" name='age' id='age'>
    </div>
    <div class="form-group">
      <label for="">介绍:</label><input type="text" class="form-control" name='desc' id='desc'>
    </div>
    <div class="form-group">
      <label for="">上传照片</label>
      <div>
        <input type="file" name='img' id='file-upload' multiple>
      </div>
    </div>
    <div>
      <button type="submit" class="btn btn-primary">提交表单</button>
    </div>
</form>

前端效果图

后台处理请求

这里使用 express起一个简单的服务,我们可以很轻松处理静态文件的问题,纯原生 node需要自己处理文件读写并返回,比较繁琐,我们的重点在 文件上传

express 服务

const express = require('express')
const path = require('path')
const logger = require('morgan')
const bodyParser = require('body-parser')

const app = express()
const router = express.Router()

// 记录请求日志的 日志中间件
app.use(logger('dev'))
app.use(express.static(path.join(__dirname, 'public')))

// 就写一个接口服务
router.post('/cors/multy-upload', (req, res) => {
    resoveFile(req, res)
})

app.use(router)

app.listen(3000, ()=> {
  console.log('http://localhost:3000')
})

// 解析请求体
function resoveFile(req, res) {
    // .... 看下面的分析过程
}

解析请求体

现在,我们重点在 resoveFile 这个函数上

我们从请求头中会得到如下信息:

接受到前端发送的表单请求,HTTP中的请求体 长下面这样

------WebKitFormBoundarygD6nnN7FoXJAn7oh
Content-Disposition: form-data; name="age"

12
------WebKitFormBoundarygD6nnN7FoXJAn7oh
Content-Disposition: form-data; name="img"; filename="error-stack.png"
Content-Type: image/png

�PNG
这些都是文件类型,我删除了,观察更直观&
------WebKitFormBoundarygD6nnN7FoXJAn7oh--

我们发现,表单中的请求头中的 Conent-Type中,有一个boundary,这里的内容,不正是我们的【二进制数据转换成字符串】文件的分隔符吗,只是分割符号比我们多了2个 --

下面这个图中的红色的都是 \r\n 换行符

图1

分隔符切割请求体

使用 ------WebKitFormBoundaryIvpVAvwvkcjmd139 作为分隔符号,切割buffer

分隔符示例:

let a = "-1-2-3-4-"
console.log( a.split('-') ) // 返回数组 [ '', '1', '2', '3', '4', '' ]

我们使用分割符号,切割以后 就是一个数组

图2
这个图中,我们可以看到,分割以后,数组的头部和尾部,其实是没有内容的,是空的 ,这不是我们所需要的,直接使用 pop和shift 去掉

在HTTP请求体中,一行完整的数据,是以\r\n作为结束符,去掉

// 3、丢弃掉每个buffer片段前后的\r\n
arr = arr.map(buffer => buffer.slice(2, buffer.length - 2))

值后面都有一个回车换行符

这里,一定要明白,我们现在的数据是一个数组,下面的图清晰的标出了哪些数据,是一个整体

处理属性与属性值

我们从上图看出,前端传过来的name和值,在数组中,是一起的,他们的分隔符 \r\n\r\n

  1. 循环遍历
  2. 在遍历的项中找到 \r\n\r\n 的下标索引位置n
  3. 分割\r\n\r\n前面的内容,就可以获取到属性名 name
  4. 对于普通数据,对于内容属性的描述,就一个name,但是对于file类型的数据,有2行
// 普通数据
disposition Content-Disposition: form-data; name="name"

// 文件上传的数据
Content-Disposition: form-data; name="img"; filename="error-stack.png"
Content-Type: image/png

所以,我们的代码中需要有所区分

if (disposition.indexOf('\r\n') == -1) {
    // 没找到,说明只有一行,那就是普通数据
} else {
    // 文件上传的
}
  1. 分离内容

// 实体内容与 表单的key 之间存在一行空行\r\n
// 表单的属性name结束行会有\r\n, 所以n+4就可以截取到内容
let content = bufferItem.slice(n + 4)

n是前面的 \r\n\r\n 的索引位置

  1. 从字符串中分离普通数据的属性名 Content-Disposition: form-data; name="age"
 ... 前面省略的代码
if (disposition.indexOf('\r\n') == -1) {
    // Content-Disposition: form-data; name="age"
    let name = disposition.split("; ")[1].split("=")[1]
    // 去掉key的前后引号
    name = name.substring(1, name.length - 1);
    
    // content怎么获取,上面5有介绍
    post[name] = content
}

  1. 针对存在文件上传的数据,处理属性,分离文件类型

// 文件类型的数据
/* 
Content-Disposition: form-data; name="img"; filename="error-stack.png"  \r\n
Content-Type: image/png
*/
// 文件类型,会有2行的描述, 第一行有文件名filename,前端传过来的name
// 第二行是文件类型的描述
let [line1, line2] = disposition.split('\r\n')

// 抽出name,filename
let [, name, filename] = line1.split('; ')
let type = line2.split(': ')[1]


name = name.split('=')[1]
// 去掉引号
name = name.substring(1, name.length - 1)

filename = filename.split('=')[1]
// 去掉引号-- error-stack.png
filename = filename.substring(1, filename.length - 1)

至此, 我们已经完整的剥离了文件上传中,请求体的内容,这只是分享文件上传的过程,但是作为实际项目开发,这部分的逻辑实在是繁杂而臃肿,还是需要使用 multer 这种第三方的包去实现快速开发

完整的 resoveFile 代码



function resoveFile (req, res) {
  let chunks = []
  let num = 0;

  req.on('data', (chunk) => {
    chunks.push(chunk)
    num += chunk.length
  })

  req.on('end', (err) => {
    
    console.log('内容体长度--》', num)

    // 最终流的内容体
    let buffer = Buffer.concat(chunks)

    console.log(buffer)

    // 第一个阶段的buffer--log
    writeTempBufferData(buffer, 1, '原始请求体')

    // 解析字符串数据
    let post = {}
    let files = {}

    if (req.headers['content-type']) {
      //  'content-type': 'multipart/form-data; boundary=----WebKitFormBoundaryNDLBdEgBhssBJUQd',
      // 获取到后面需要用到的解析二进制数据的分隔符
      let str = req.headers['content-type'].split('; ')[1]
      // ------WebKitFormBoundaryIvpVAvwvkcjmd139
      if (str) {
        let boundary = '--' + str.split('=')[1]

        // 1、使用分隔符去切割整个请求体数据
        let arr = buffer.split(boundary)

        // 第2个阶段的buffer--log
        writeTempBufferData(arr, 2)

        // 2、丢掉头部和尾部, 因为切割后的数组,头部和剩下的就是一个 \r\n 没内容的,直接删除
        arr.shift()
        arr.pop()

        writeTempBufferData(arr, 3)

        // 3、丢弃掉每个buffer片段前后的\r\n
        arr = arr.map(buffer => buffer.slice(2, buffer.length - 2))

        // 第2个阶段的buffer--log
        writeTempBufferData(arr, 4)

        console.log("arr-->",arr.length)
        // 4、每个数据在第一个 \r\n\r\n 处切割
        arr.forEach(bufferItem => {
          let n = bufferItem.indexOf('\r\n\r\n')
          // 拿到有属性的数据-- Content-Disposition: form-data; name="age"
          let disposition = bufferItem.slice(0, n)

          // 实体内容与 表单的key 之间存在一行空行\r\n,表单的属性name结束行会有\r\n, 所以n+4就可以截取到内容
          let content = bufferItem.slice(n + 4)

          writeTempBufferData(content, 5, '内容体')

          console.log('disposition',disposition, disposition.indexOf('\r\n'))
          writeTempBufferData(disposition, 7, 'disposition')

          if (disposition.indexOf('\r\n') == -1) {
            // 转化成普通数据 Content-Disposition: form-data; name="age"
            content = content.toString()

            let name = disposition.split("; ")[1].split("=")[1]
            // 去掉key的前后引号
            name = name.substring(1, name.length - 1);

            post[name] = content
          
          } else {
            // 文件类型的数据
            /* 
            Content-Disposition: form-data; name="img"; filename="error-stack.png"  \r\n
            Content-Type: image/png
            */
            // 文件类型,会有2行的描述, 第一行有文件名filename,前端传过来的name
            // 第二行是文件类型的描述
            let [line1, line2] = disposition.split('\r\n')

            // 抽出name,filename
            let [, name, filename] = line1.split('; ')
            let type = line2.split(': ')[1]


            name = name.split('=')[1]
            // 去掉引号
            name = name.substring(1, name.length - 1)

            filename = filename.split('=')[1]
            // 去掉引号-- error-stack.png
            filename = filename.substring(1, filename.length - 1)


            // 文件内容的实体,我们在上面已经切出啦了 content

            fs.writeFile('./public/temp/' + filename, content, err => {
              if (err) throw err
              console.log(`写入的文件的数据`)
              console.log('type-->', type)
              console.log('filename-->', filename)
              files[name] = {
                filename,
                type,
                path: './public/temp/' + filename
              }
            })
          }

        })

        // 上面读写文件 都是异步的,下面直接响应前端,断开HTTP连接
        res.json({
          msg: 'ok'
        })
      }
    }
  })
}

// 写入每个阶段,截取出来的Buffer数据,并查看是什么样子的
function writeTempBufferData (data, type, desc = '') {
  let basePath = './public/temp/' + type + '--' + desc + '.txt'

  fs.writeFile(basePath, data, (err) => {
    console.log('写入成功')
  })
}

注意事项:

 let buffer = Buffer.concat(chunks)

上面代码中 这个buffer是一个Buffer类型,然后下面使用了Buffer中没有提供的split方法,我代码里面跟下面的链接的split是一模一样的

Node中 Buffer 利用 slice + indexOf 生成 split 方法

源代码地址

http学习