From 表单解析

这篇文章给学校组织上课的一部分,同时也是为了帮我补充以前没注意的问题。

网上有许多解析 From 表单的教程,但是他们要么太复杂,要么没法跟着完成,所以这里就从零开始解析一下 From 表单,当然实际使用还是用 mscdex/busboy 之类成熟的库。

🕵️‍♂️试一下

我们先来测试一下 form 表单上传服务器端会拿到什么东西。

const http = require('http');
const fs = require('fs');
let server = http.createServer((req,res) => {
  let str = '';
  req.on('data',(data) => {
    str+=data;
  });
  req.on('end',() => {
    console.log('|start|')
    console.log(str);
    console.log('|end|')
    console.log(`content-type:`, req.headers['content-type'])
    res.end();
  })
})

server.listen(8001, () => {
  console.log('Runing at: http://localhost:8001');
});

输出的结果:(是一个 1.txt 文件,里面的内容是 “redrock 测试”)

Runing at: http://localhost:8001
|start|
----------------------------083754191852957706053326
Content-Disposition: form-data; name="file"; filename="1.txt"
Content-Type: text/plain

redrock 测试
----------------------------083754191852957706053326
Content-Disposition: form-data; name="test"

testValue
----------------------------083754191852957706053326--

|end|
content-type: multipart/form-data; boundary=--------------------------083754191852957706053326

🔎 分析 FORM 表单内容

这里我们有两个问题,form 表单可以有多个属性,比如你可以上传头像,同时上传你的个人信息。所以怎么样将多个属性分出来,然后对是否是文件进行判断。

  • 多个属性分出来:在这里我们可以看到是比较有规律的,我们可以通过请求头里面的 content-type 字段中的 boundary 来对进行分割
  • 区分文件:

    Content-Disposition 是否有两行来进行判别。

    • 文件
    Content-Disposition: form-data; name="file"; filename="1.txt"
    Content-Type: text/plain
    • 普通
    Content-Disposition: form-data; name="test"

上面的 from 表单部分我们就先转换成

--<分隔符>
Content-Disposition: form-data; name="file"; filename="1.txt"
Content-Type: text/plain

redrock 测试
--<分隔符>
Content-Disposition: form-data; name="test"

testValue
--<分隔符>--

把换行转换一下就成了这样

--<分隔符>\r\nContent-Disposition: form-data; name="file"; filename="1.txt"\r\nContent-Type: text/plain\r\n\r\nredrock 测试\r\n--<分隔符>\r\nContent-Disposition: form-data; name="test"\r\n\r\ntestValue\r\n--<分隔符>--\r\n

image-20200313201311847

简化一下就是

--<分隔符>\r\n数据描述1\r\n数据描述2\r\n\r\n<文件内容>\r\n
--<分隔符>\r\n数据描述\r\n\r\n数据值\r\n
--<分隔符>--

我们学过 js 中有些内建类型中有 split方法能分割

[ 
  空,
  \r\n数据描述1\r\n数据描述2\r\n\r\n<文件内容>\r\n,
  \r\n数据描述\r\n\r\n数据值\r\n,
  --\r\n
]

将数组两头的东西拿掉

[ 
  \r\n数据描述1\r\n数据描述2\r\n\r\n<文件内容>\r\n,
  \r\n数据描述\r\n\r\n数据值\r\n
]

观察一下数组中每个两头都有 \r\n

[ 
  数据描述1\r\n数据描述2\r\n\r\n<文件内容>,
  数据描述\r\n\r\n数据值
]

再在 \r\n\r\n 分割一下

文件数据:[数据描述1\r\n数据描述2, <文件内容>]
  或
普通数据:[数据描述, 数据值]

📊 完整代码

const http = require('http'),
  URL = require('url'),
  fs = require('fs');

Buffer.prototype.split = Buffer.prototype.split || function (b) {
  let arr = [];

  let cur = 0;
  let n = 0;
  while ((n = this.indexOf(b, cur)) != -1) {
    arr.push(this.slice(cur, n));
    cur = n + b.length;
  }

  arr.push(this.slice(cur));

  return arr;
};

/**
 * 显示文件
 * @param {String} pathname
 * @return {Promise<fs.ReadStream>}
 */
const fileShow = pathname =>
  new Promise((reslove, reject) => {
    fs.stat(pathname, (err, status) => {
      if (err) reject(err);
      if (!status.isFile()) reslove(false);
      else reslove(fs.createReadStream(pathname, { encoding: 'utf-8', flag: 'r' }));
    });
  });

/**
 * 上传文件
 * @param {http.IncomingMessage} req
 * @return {Promise}
 */
const uploadFile = req => new Promise((reslove, reject) => {
  let arr = []; // 文件数据
  req.on("data", function (chunk) {
    // Buffer
    arr.push(chunk)
  });
  req.on("end", function () {
    try {
      let data = Buffer.concat(arr);
      if (!req.headers['content-type']) reject()
      // req.headers['content-type'] ----> multipart/form-data; boundary=----WebKitFormBoundarys4qd7J4TILGjv4KE
      let str = req.headers['content-type'].split('; ')[1];
      // str ----> boundary=----WebKitFormBoundarys4qd7J4TILGjv4KE
      if (str) reject();
      // content-type 和 form-data 有两个 -- 的差距, 抹平差异
      let boundary = '--' + str.split('=')[1];
      //1.用"分隔符切分整个数据"
      let arr = data.split(boundary);

      //2.丢弃头尾两个数据
      arr.shift();
      arr.pop();

      //3.丢弃掉每个数据头尾的"\r\n"
      arr = arr.map(buffer => buffer.slice(2, buffer.length - 2));

      //4.每个数据在第一个"\r\n\r\n"处切成两半
      arr.forEach(buffer => {
        let n = buffer.split('\r\n\r\n');

        let disposition = n[0];
        let content = n[1];

        disposition = disposition.toString();

        if (disposition.indexOf('\r\n') === -1) {
          //普通数据
          // Content-Disposition: form-data; name="user"
        } else {
          //文件数据
          /*Content-Disposition: form-data; name="file"; filename="发现_1.png"        
            Content-Type: image/png*/
          let [line1, line2] = disposition.split('\r\n');
          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];
          filename = filename.substring(1, filename.length - 1);

          let path = `upload/${filename}`;

          fs.writeFile(path, content, err => {
            if (err) {
              reject(err)
            } else {
              reslove()
            }
          });
        }
      });
    } catch (error) {
      reject(error)
    }
  })
});

const server = http.createServer(async (req, res) => {
  const { url, method } = req;
  const { pathname } = URL.parse(url);
  if (method.toUpperCase() === 'GET') {
    try {
      const data = await fileShow('.' + pathname);
      if (!data) {
        res.writeHead(404);
        res.end('404 not found');
      } else {
        res.writeHead(200);
        data.pipe(res);
      }
    } catch (error) {
      console.log(error);
      res.writeHead(500);
      res.end('500 Internal Server Error');
    }
  } else if (method.toUpperCase() === 'POST') {
    try {
      await uploadFile(req)
      res.end();
    } catch (error) {
      console.log(error);
      res.writeHead(500);
      res.end('500 Internal Server Error');
    }
  } else {
    // https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status/405
    res.writeHead(405);
    res.end('405 Method Not Allowed');
  }
});

server.listen(8000);
console.log('Runing at: http://localhost:8000')