原生nodejs 处理文件上传

4,233 阅读4分钟

最近公司项目有需要处理文件上传的情况,本地开发遇到一些问题,后面经过排查解决掉。也因此对文件上传的原理产生一些兴趣,于是自己研究了下原生nodejs处理文件上传的情况。

几个重要概念

Content-Type

Content-Type 实体头部用于指示资源的MIME类型 media type 。
在响应中,Content-Type标头告诉客户端实际返回的内容的内容类型。
在请求中 (如POST 或 PUT),客户端告诉服务器实际发送的数据类型。
Content-Type 由三部分组成

  • media-type
    资源或数据的 MIME type。例如: application/json
  • charset
    字符编码标准。 例如: utf-8
  • boundary
    对于多部分实体,boundary 是必需的,其包括来自一组字符的1到70个字符。

Content-Disposition

在常规的 HTTP 应答中,Content-Disposition 响应头指示回复的内容该以何种形式展示,是以内联的形式(即网页或者页面的一部分),还是以附件的形式下载并保存到本地

在 multipart/form-data 类型的应答消息体中,Content-Disposition 消息头可以被用在 multipart 消息体的子部分中,用来给出其对应字段的相关信息。各个子部分由在Content-Type 中定义的分隔符分隔。用在消息体自身则无实际意义。

Content-Disposition 消息头最初是在 MIME 标准中定义的,HTTP 表单及 POST 请求只用到了其所有参数的一个子集。只有 form-data 以及可选的 name 和 filename 三个参数可以应用在HTTP场景中。

实际场景应用

在这里,我们以上传一个temp.txt文件举例,temp.txt文件内容为 test:::。

首先 上传文件 我们需要将传给服务端的消息的Content-Type设为 multipart/form-data,在表单中 我们可以指定enctype 值为multipart/form-data

    http
        .createServer(function(req, res) {
            if (req.url == "/upload" && req.method.toLowerCase() === "get") {
            
            //显示一个用于文件上传的form
            res.writeHead(200, { "content-type": "text/html" });
            res.end(
                '<form action="/upload" enctype="multipart/form-data" method="post">' +
                '<input type="file" name="upload" multiple="multiple" />' +
                '<input type="submit" value="Upload" />' +
                "</form>"
            );
            } 
        })
        .listen(3000);

这个时候 我们在浏览器中访问localhost:3000/upload 就可以访问到该表单。
form表单
然后nodejs 里面增加上传文件逻辑处理

    if (req.url == "/upload" && req.method.toLowerCase() === "get") {
    
      //显示一个用于文件上传的form
      res.writeHead(200, { "content-type": "text/html" });
      res.end(
        '<form action="/upload" enctype="multipart/form-data" method="post">' +
          // '<input type="text" name="description" value="multiple" />' +
          '<input type="file" name="upload" multiple="multiple" />' +
          '<input type="submit" value="Upload" />' +
          "</form>"
      );
    } else if (req.url == "/upload" && req.method.toLowerCase() === "post") {
      if (req.headers["content-type"].indexOf("multipart/form-data") !== -1)
        var body = ''
        req.on('data', (buffer) => {
            body += buffer
        })

        req.on('end', () => {
            res.end(JSON.stringify({
                contentType: req.headers['content-type'],
                content: body.toString()
            }));
        })

    }

点击选择文件选中temp.txt,然后点击upload。然后页面上显示如下

    {
        "contentType":"multipart/form-data; boundary=----WebKitFormBoundaryflF412CVstMQDH5m",
        "content":"------WebKitFormBoundaryflF412CVstMQDH5m\r\nContent-Disposition: form-data; name=\"upload\"; filename=\"temp.txt\"\r\nContent-Type: text/plain\r\n\r\ntest:::\r\n------WebKitFormBoundaryflF412CVstMQDH5m--\r\n"
    }

这里我们将req.headers['content-type']和实体内容打印出来了, 可以看到 contentType里面有个boundary,值和content里面的一串字符串是一样的。当我们上传文件时,我们接收到headers里面的content-type的值就会包含boundary,boundary是用来分隔表单字段的,当你的表单里面有多个内容时 content 里面就会有多个这个值。类似这样

    ------WebKitFormBoundarymrD3BuxNQlp4oQ2R
    Content-Disposition: form-data; name="description"

    multiple
    ------WebKitFormBoundarymrD3BuxNQlp4oQ2R
    Content-Disposition: form-data; name="upload"; filename="temp.txt"
    Content-Type: text/plain

    test:::
    ------WebKitFormBoundarymrD3BuxNQlp4oQ2R--

从上面的内容我们可以看到 test::: 是我们的文件内容。Content-Disposition 包含了我们的文件名。因此要实现我们的上传文件功能,我们只需要根据boundary 和 Content-Disposition 取到我们的文件名 和 文件内容 然后将文件内容写入到该文件名中即可。 下面是经整理过的完整代码

    const http = require("http");
    const fs = require("fs");
    const querystring = require("querystring");

    //用http模块创建一个http服务端
    http
        .createServer(function(req, res) {
            if (req.url == "/upload" && req.method.toLowerCase() === "get") {
            
                //显示一个用于文件上传的form
                res.writeHead(200, { "content-type": "text/html" });
                res.end(
                    '<form action="/upload" enctype="multipart/form-data" method="post">' +
                    '<input type="file" name="upload" multiple="multiple" />' +
                    '<input type="submit" value="Upload" />' +
                    "</form>"
                );
            } else if (req.url == "/upload" && req.method.toLowerCase() === "post") {
                if (req.headers["content-type"].indexOf("multipart/form-data") !== -1) {
                    parseFile(req, res)
                }
            } else {
                res.end("pelease upload file");
            }
        })
        .listen(3000);

    function parseFile(req, res) {
        req.setEncoding("binary");
        let body = ""; // 文件数据
        let fileName = ""; // 文件名
        
        // 边界字符串 boundary
        const boundary = req.headers["content-type"]
            .split("; ")[1]
            .replace("boundary=", "");
            
        console.log('req.headers["content-type"]', req.headers["content-type"]);
        req.on("data", function(chunk) {
            body += chunk;
        });
        
        req.on("end", function() {
            const file = querystring.parse(body, "\r\n", ":");
            console.log('file:::', JSON.stringify(file));

            var fileInfo = file["Content-Disposition"].split("; ");
            for (value in fileInfo) {
                if (fileInfo[value].indexOf("filename=") != -1) {
                    console.log('fileInfo[value]', fileInfo[value]);
                    fileName = fileInfo[value].substring(10, fileInfo[value].length - 1);

                    if (fileName.indexOf("\\") != -1) {
                        fileName = fileName.substring(fileName.lastIndexOf("\\") + 1);
                    }
                    console.log("文件名: " + fileName);
                }
            }

            const entireData = body.toString();

            contentType = file["Content-Type"].substring(1);

            //获取文件二进制数据开始位置,即contentType的结尾
            const upperBoundary = entireData.indexOf(contentType) + contentType.length;
            const shorterData = entireData.substring(upperBoundary);
            console.log('shorterData', shorterData);
            // 替换开始位置的空格
            const binaryDataAlmost = shorterData
                .replace(/^\s\s*/, "")
                .replace(/\s\s*$/, "");
            console.log('binaryDataAlmost', binaryDataAlmost);
            // 去除数据末尾的额外数据,即: "--"+ boundary + "--"
            const binaryData = binaryDataAlmost.substring(
                0,
                binaryDataAlmost.indexOf("--" + boundary + "--")
            );

            const bufferData = new Buffer.from(binaryData, "binary");
            console.log("bufferData", bufferData);

            fs.writeFile(fileName, bufferData, function(err) {
                res.end("sucess");
            });
        });
    }

总结

文件上传整个流程其实很好理解,前端上传的时候只需要设置content-type为multipart/form-data。然后服务端拿到headers 里面的boundary 在根据这个去处理得到文件内容即可。以上是nodejs 原生处理文件上传流程,略显复杂。现在koa 结合koa-body 处理文件上传很方便。后续有时间会补充上对应的处理流程

参考

content-type
content-disposition