弄明白文件上传

先从一个例子开始,看一下掘金上传头像接口。

请求头:

掘金上传头像请求头

注意看图片中的content-type,后面会解释:

content-type: multipart/form-data; boundary=----WebKitFormBoundarycA7SgHXGF2nIiW3S

再看一下请求携带的参数(接口中还带了一大串查询参数,这不是重点,重点是form-data参数):
掘金上传头像form-data参数 1
掘金上传头像form-data参数 2

从上图中提炼出form-data参数的格式:

------WebKitFormBoundarycA7SgHXGF2nIiW3S
Content-Disposition: form-data; name="avatar"; filename="blob"
Content-Type: image/png

...图片数据
------WebKitFormBoundarycA7SgHXGF2nIiW3S--

即:

--分隔符换行符
Content-Disposition; name=参数名; filename=文件名 换行符
Content-Type换行符

参数名对应的参数值
--分隔符--换行符

这里的分隔符跟前面请求头中content-type后跟的boundary的值对应。

为什么会采用这种格式,因为这就是multipart/form-data标准,下面是标准中给出的例子:

6. Examples

   Suppose the server supplies the following HTML:

     <FORM ACTION="http://server.dom/cgi/handle"
           ENCTYPE="multipart/form-data"
           METHOD=POST>
     What is your name? <INPUT TYPE=TEXT NAME=submitter>
     What files are you sending? <INPUT TYPE=FILE NAME=pics>
     </FORM>

   and the user types "Joe Blow" in the name field, and selects a text
   file "file1.txt" for the answer to 'What files are you sending?'

   The client might send back the following data:

        Content-type: multipart/form-data, boundary=AaB03x

        --AaB03x
        content-disposition: form-data; name="field1"

        Joe Blow
        --AaB03x
        content-disposition: form-data; name="pics"; filename="file1.txt"
        Content-Type: text/plain

         ... contents of file1.txt ...
        --AaB03x--

   If the user also indicated an image file "file2.gif" for the answer
   to 'What files are you sending?', the client might client might send
   back the following data:

        Content-type: multipart/form-data, boundary=AaB03x

        --AaB03x
        content-disposition: form-data; name="field1"

        Joe Blow
        --AaB03x
        content-disposition: form-data; name="pics"
        Content-type: multipart/mixed, boundary=BbC04y

        --BbC04y
        Content-disposition: attachment; filename="file1.txt"
        Content-Type: text/plain

        ... contents of file1.txt ...
        --BbC04y
        Content-disposition: attachment; filename="file2.gif"
        Content-type: image/gif
        Content-Transfer-Encoding: binary

          ...contents of file2.gif...
        --BbC04y--
        --AaB03x--

multipart/form-data诞生的初衷就是为了满足文件上传需求,这在标准开篇也有说明,所以上传文件时,请求头中content-type首选是multipart/form-data

那如果不使用multipart/form-data能实现上传文件吗?答案是能。

简单点的你可以把文件转换为base64,以字符串的形式传给服务端,服务端拿到数据后再解码转换为文件。

复杂点的,你可以自己订一套标准,并实现它,用于文件上传。

文件上传实例项目概览

下面是例子的项目目录,用node做服务端。

代码目录:

|-- node_upload
    |-- index.js
    |-- back
    |-- front
    |-- upload

front文件夹下放前端页面,back文件夹下放接口代码,upload文件夹存放上传的文件。

index.js中,为了行文方便,提前把后面才用到的接口引入了,后面会实现这些接口:

var http = require("http"),
    url = require("url"),
    path = require("path"),
    fs = require("fs")
    port = process.argv[2] || 80;
// 接口
var upload = require('./back/upload');
var uploadBlob = require('./back/uploadBlob');

var mimeTypes = {
    "htm": "text/html",
    "html": "text/html",
    "jpeg": "image/jpeg",
    "jpg": "image/jpeg",
    "png": "image/png",
    "gif": "image/gif",
    "js": "text/javascript",
    "css": "text/css"
};

http.createServer(function(request, response) {

    // 接口逻辑
    if (request.url == '/upload' && request.method.toLowerCase() == 'post') {
        upload(request, response);
        return;
    }
    if (request.url == '/uploadBlob' && request.method.toLowerCase() == 'post') {
        uploadBlob(request, response);
        return;
    }

    /***以下代码是为了返回页面、静态资源 */
    var uri = url.parse(request.url).pathname
        // process.cwd() 当前工作目录
        , filename = path.join(process.cwd(), uri)
        , root = uri.split("/")[1];

    fs.exists(filename, function(exists) {
        if(!exists) {
            response.writeHead(404, {"Content-Type": "text/plain"});
            response.write("404 Not Found\n");
            response.end();
            console.error('404: ' + filename);
            return;
        }

        if (fs.statSync(filename).isDirectory()) filename += '/index.html';

        fs.readFile(filename, "binary", function(err, file) {
            if(err) {        
                response.writeHead(500, {"Content-Type": "text/plain"});
                response.write(err + "\n");
                response.end();
                console.error('500: ' + filename);
                return;
            }

            var mimeType = mimeTypes[path.extname(filename).split(".")[1]];
            response.writeHead(200, {"Content-Type": mimeType});
            response.write(file, "binary");
            response.end();
            console.log('200: ' + filename + ' as ' + mimeType);
        });
    });
}).listen(parseInt(port, 10));

console.log("Static file server running at\n  => http://localhost:" + port + "/\nCTRL + C to shutdown");

运行node index.js启动服务器,默认是监听80端口,如果80端口被占用,可以指定端口号启动,比如3000端口:node index.js 3000

不使用multipart/form-data上传文件

如果不使用multipart/form-data上传文件,那肯定不能使用表单的形式提交数据了,好在XMLHttpRequest.send()支持传入BlobArrayBuffer 等类型的数据,所以在例子中我们对可以把获取到的文件直接传给后端。

front文件夹下创建upload-ajax-blob.html文件,并添加如下代码:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>上传文件</title>
</head>
<body>
<input type="file" id="picker" />

<img id="image" />

<pre id="output"></pre>
<script>
    window.onload = function() {
        var output = document.getElementById("output");
        document.getElementById("picker").onchange = function () {
            var file = this.files[0];
            console.log(file);
            output.innerText = '';
            if(file.size > 1024*1024) {
                output.innerText = 'file size great than 1M';
                return;
            }
            var xhr = new XMLHttpRequest();
            xhr.responseType = 'json';
            xhr.onload = function(d) {
                output.innerText = JSON.stringify(xhr.response);
                document.getElementById("image").setAttribute('src',xhr.response.data);
            };
            xhr.onerror = function(err) {
                output.innerText = 'fail:' + err.toString();
            };
            xhr.open('POST', '/uploadBlob');
            xhr.setRequestHeader("Contnet-type", 'application/octet-stream');
            xhr.setRequestHeader("file-info", file.type + ';name=' + encodeURIComponent(file.name));
            xhr.send(file);
            // printFile(file,function(res) {
            //     xhr.send(res);
            // });
        };
    };
    // 测试传输ArrayBuffer
    function printFile(file,callback) {
        var reader = new FileReader();
        reader.onload = function (evt) {
            console.log(evt.target.result);
            callback(reader.result);
        };
        reader.readAsArrayBuffer(file);
    }

</script>

</body>
</html>

上面代码中接口地址是/uploadBlob,稍后会实现这个接口。

先看看Contnet-typeContnet-type的值设置为了application/octet-stream,表示请求主体是二进制数据。

另外代码中又设置了一个自定义请求头file-info,其值包含了上传文件的MIME类型和文件的名字。

接下来实现/uploadBlob接口。在back文件夹下创建uploadBlob.js文件,并添加以下代码:

const fs = require("fs");

function setResponseDate(res, data) {
    res.write(JSON.stringify(data));
}

function uploadBlob(req, res) {
    req.setEncoding("binary"); // 设置字符编码为二进制
    let fileInfo = req.headers["file-info"].split(';');
    let body = "";
    let fileName = decodeURIComponent(fileInfo[1].split('=')[1]);
    // 接收数据
    req.on("data", function(chunk) {
        body += chunk;
    });
    req.on("end", function() {
        const bufferData = Buffer.from(body, "binary");
        // 保存接收到的文件
        fs.writeFile(process.cwd() + '/upload/' + fileName, bufferData, function(err) {
            // res.end('sucess'),等价于res.write('sucess')+res.end()。
            var resData = {
                code: 200,
                msg: '',
                data: '/upload/' + fileName
            };
            resData.msg = 'success';
            if(err) {
                resData.msg = err.message;
                resData.code = 0;
                resData.data = null;
            }
            setResponseDate(res,resData);
            res.end();
        });
    });
}

module.exports = uploadBlob;

根据前端请求接口时设置的请求头,提取出上传文件的名字,然后保存文件到upload文件夹下。

下面是运行例子的结果:
非form表单上传 页面
文件上传时的请求头:
非form表单上传 请求报文
上传文件时的请求报文主体:
非form表单上传 请求报文主体
请求的响应:
非form表单上传 请求报文主体

例子中包含了上传ArrayBuffer数据的逻辑,可以试一下。

使用multipart/form-data上传文件

使用multipart/form-data上传文件时,可以直接使用form元素,也可以使用FormData

首先实现/upload接口。
back文件夹下创建upload.js文件,然后添加以下代码:

const fs = require("fs");

function setResponseDate(res, data) {
    res.write(JSON.stringify(data));
}
function parseFormData(dataBody,boundary) {
    var formList = dataBody.split('--' + boundary);
    var res = {};
    for (let i = 0; i < formList.length; i++) {
        var keyReg = /Content-Disposition: form-data;\s(name\=\"(\w+)\")/g
        var keyExecRes = keyReg.exec(formList[i]);

        if(keyExecRes) {
            res[keyExecRes[2]] = {
                content: formList[i]
            };
            var contentTypeReg = /Content-Type: ([a-zA-Z0-9_\-]+\/[a-zA-Z0-9_\-]+)\r\n\r\n/g;
            var fileNameReg = /Content-Disposition: form-data;\sname\=\"\w+\";\sfilename=\"([\w\.\-]+)\"/g;

            var fileNameExecRes = fileNameReg.exec(formList[i]);
            var contentTypeExecRes = contentTypeReg.exec(formList[i]);

            if(fileNameExecRes) { // 文件
                res[keyExecRes[2]]['filename'] = fileNameExecRes[1];
            }
            if(contentTypeExecRes) { // 文件媒体类型
                res[keyExecRes[2]]['contentType'] = contentTypeExecRes[1];
                res[keyExecRes[2]]['binaryStart'] = contentTypeReg.lastIndex;
            }
        }
    }

    return res;
}
function upload(req, res) {
    req.setEncoding("binary"); // 设置字符编码为二进制
    let body = "";
    // 边界字符
    // req.headers --- 请求头
    let boundary = req.headers["content-type"]
      .split("; ")[1]
      .replace("boundary=", "");

    // 接收数据
    req.on("data", function(chunk) {
        body += chunk;
    });
    req.on("end", function() {
        let contentType = "";
        let fileName = "";
        let fileExtension = "";
        let binary; // 文件数据
   
        var formData = parseFormData(body,boundary);
        for(let key in formData) {
            if(formData.hasOwnProperty(key)) {
                // file字段的值是文件
                if(key === 'file') {
                    fileName = formData[key]['filename'];
                    contentType = formData[key]['contentType'];
                    binary = formData[key]['content'].substring(formData[key]['binaryStart'], formData[key]['content'].length - 2);
                }
                // fileExtension 字段值是文件扩展名 (可选)
                if(key === 'fileExtension') {
                    let reg = /\r\n\r\n(\w+)\r\n/g
                    fileExtension = reg.exec(formData[key]['content'])[1];
                }
            }
        }
        if(fileExtension) {
            fileName = fileName + "." + fileExtension;
        }

        const bufferData = Buffer.from(binary, "binary");
        // 保存接收到的文件
        fs.writeFile(process.cwd() + '/upload/' + fileName, bufferData, function(err) {
            // res.end('sucess'),等价于res.write('sucess')+res.end()。
            var resData = {
                code: 200,
                msg: '',
                data: '/upload/' + fileName
            };
            resData.msg = 'success';
            if(err) {
                resData.msg = err.message;
                resData.code = 0;
                resData.data = null;
            }
            setResponseDate(res,resData);
            res.end();
        });
    });
}

module.exports = upload;

upload.js中根据multipart/form-data的格式对请求主体进行解析,获取到文件数据,然后把文件保存到upload文件夹。

使用form元素上传

front文件夹下创建upload-form.html里添加以下代码:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>上传文件</title>
</head>
<body>

<form action="/upload" enctype="multipart/form-data" method="post">
    <input type="file" name="file" /><br>
    <input type="submit" value="Upload" />
</form>
</body>
</html>

访问upload-form.html页面:
form表单上传 页面
文件上传时的请求头:
form表单上传 请求报文
上传文件时的请求报文主体(在chrome浏览器中看不到,在firefox中可以看到):
form表单上传 请求报文主体
下面是响应数据,图片上传成功了:
form表单上传 请求报文主体

使用form表单上传就是这么简单,前端不需要写任何逻辑,前提是该设置的类型要设置对,并且后端要按对应类型的格式解析数据。

使用FormData上传

使用FormData上传时需要使用AJAX。不管是用jQueryaxiosXMLHttpRequestfetch,还是其他的请求库,重点不是各种请求库的api怎么使用,而是如何处理文件数据。

下面的例子中使用的是XMLHttpRequest

FormData的api文档可以看这里

这里主要用到append()方法。
FormData 接口的 append() 方法会添加一个新值到 FormData 对象内的一个已存在的键中,如果键不存在则会添加该键。

formData.append(name, value);
// 或者
formData.append(name, value, filename);

参数

  1. name value 中包含的数据对应的表单名称。
  2. value 表单的值。可以是USVString 或 Blob (包括子类型,如 File)。
  3. filename 可选 传给服务器的文件名称 (一个 USVString), 当一个 Blob 或 File 被作为第二个参数的时候, Blob 对象的默认文件名是 "blob"。 File 对象的默认文件名是该文件的名称。

注意 表单的字段名可以是name[]形式,为了与 PHP 数组命名习惯一致,方便PHP后端获取到数据后遍历。

主要看第二个参数,第二个参数支持BlobFileBlob子类),所以我们可以直接把input元素获取的文件添加到FormData,也可以通过Blob或者File创建一个文件添加到FormData

下面看例子。

例子1 直接把input元素获取的文件添加到FormData再上传。

front文件夹下创建upload-ajax-file.html里添加以下代码:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>上传文件</title>
</head>
<body>
<input type="file" id="picker" />

<img id="image" />

<pre id="output"></pre>
<script>
    window.onload = function() {
        var output = document.getElementById("output");
        document.getElementById("picker").onchange = function () {
            var file = this.files[0];
            output.innerText = '';
            if(file.size > 1024*1024) {
                output.innerText = 'file size great than 1M';
                return;
            }
            var xhr = new XMLHttpRequest();
            xhr.responseType = 'json';
            xhr.onload = function() {
                output.innerText = JSON.stringify(xhr.response);
                document.getElementById("image").setAttribute('src',xhr.response.data);
            };
            xhr.onerror = function(err) {
                output.innerText = 'fail:' + err.toString();
            };
            xhr.open('POST', '/upload');
            var fd = new window.FormData();
            // 跟后端约定 file字段的值是文件
            fd.append('file', file);
            xhr.send(fd);
        };

    };
</script>

</body>
</html>

访问upload-ajax-file.html页面:
AJAX file上传 请求报文主体
请求报文头部:
AJAX file上传 请求报文主体
请求报文主体:
AJAX file上传 请求报文主体
响应:
AJAX file上传 请求报文主体

例子2 使用Blob创建文件上传

front文件夹中创建upload-ajax-file-blob.html文件,并添加以下代码:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>上传文件</title>
</head>
<body>
<button id="blob-test">测试上传</button>

<pre id="output"></pre>
<script>
    window.onload = function() {
        var output = document.getElementById("output");
        document.getElementById("blob-test").addEventListener('click', function () {
            var file = new Blob(['<a id="a"><b id="b">name="rrrttt"</b></a>'],{
                type: 'text/html'
            });
            var fileExtension = 'html';
            output.innerText = '';
            if(file.size > 1024*1024) {
                output.innerText = 'file size great than 1M';
                return;
            }
            var xhr = new XMLHttpRequest();
            xhr.responseType = 'json';
            xhr.onload = function(d) {
                output.innerText = JSON.stringify(xhr.response);
            };
            xhr.onerror = function(err) {
                output.innerText = 'fail:' + err.toString();
            };
            xhr.open('POST', '/upload');
            var fd = new window.FormData();
            fd.append('file', file);
            fd.append('fileExtension', fileExtension);
            xhr.send(fd);
        });

    };
</script>

</body>
</html>

点击页面上的测试上传按钮,会使用Blob创建一个文件上传,并使用fileExtension告知服务端文件的扩展名。

访问页面:
AJAX file blob上传 请求报文主体
上传时的请求头:
AJAX file blob上传 请求报文主体
请求参数:
AJAX file blob上传 请求报文主体
响应:
AJAX file blob上传 请求报文主体

自己构造符合标准的请求报文上传文件

前面的例子都是通过浏览器上传文件,multipart/form-data格式的请求报文都是浏览器帮我们构建的,接下来看一个例子,不使用浏览器,通过node.js自己构造请求报文上传文件。

创建一个server_upload.js文件,添加以下代码:

const path = require("path");
const fs = require("fs");
const http = require("http");
// 定义一个分隔符,要确保唯一性
const boundaryKey = "-------------------------461591080941622511336662";
const request = http.request({
  method: "post",
  host: "localhost",
  port: "3000",
  path: "/upload",
  headers: {
    "Content-Type": "multipart/form-data; boundary=" + boundaryKey, // 在请求头上加上分隔符
    Connection: "keep-alive",
  },
});
// 写入内容头部
request.write(
  `--${boundaryKey}\r\nContent-Disposition: form-data; name="file"; filename="9.png"\r\nContent-Type: image/png\r\n\r\n`
);

// 写入内容
const fileStream = fs.createReadStream(path.join(__dirname, "../img/9.png"));
fileStream.pipe(request, { end: false });
fileStream.on("end", function() {
  // 写入尾部
  // request.end(data) 如果指定了 data,则相当于调用 request.write(data, encoding) 后跟 request.end(callback)。
  request.end("\r\n--" + boundaryKey + "--" + "\r\n");
});
request.on("response", function(res) {
    let body = '';
    res.on('data', function(chunk) {

        body += chunk;
    });
    res.on('end', function() {
        console.log(res.headers);
        console.log(body.toString());
    });
});

代码中用于上传的文件是写死的,需要在server_upload.js所在的目录下创建一个img文件夹,然后在该img文件夹下添加一张png格式的图片,并命名为9.png

运行node server_upload.js命令,可以在命令行工具中看到以下输出:

{
  date: 'Mon, 27 Nov 2023 09:03:43 GMT',
  connection: 'keep-alive',
  'keep-alive': 'timeout=5',
  'transfer-encoding': 'chunked'
}
{"code":200,"msg":"success","data":"/upload/9.png"}

参考资料

  1. 一文了解文件上传全过程(1.8w 字深度解析,进阶必备)
  2. https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4
  3. Form-based File Upload in HTML
  4. https://github.com/ktont/javascript-file-upload
  5. 文件上传
posted @ 2023-11-28 09:42  Fogwind  阅读(168)  评论(0编辑  收藏  举报