Node数据上传处理

100 阅读6分钟

在我们日常开发中一般使用Express或Koa之类的框架开发Node服务,这些框架都帮我们封装了数据处理的逻辑,本文主要讲解原生的Node应该如何处理上传的数据,有利于加深我们对底层的理解。

浏览器上传数据

首先我们构造一个上传数据的html文件:

<body>
    <form action="http://127.0.0.1:8111/upload" method="post">
        <label for="username">Please input your username:</label>
        <input type="text" id="username" name="username"/><br />
        <label for="password">Please input your password:</label>
        <input type="text" id="password" name="password"/><br />
        <input type="submit"/>
    </form>
</body>

当我们点击submitd的时候会向http://127.0.0.1:8111/upload发送一个post请求。

image.png

我们拷出浏览器发送的curl,如下图所示:

curl 'http://127.0.0.1:8111/upload' \
  -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' \
  -H 'Accept-Language: zh-CN,zh;q=0.9' \
  -H 'Cache-Control: max-age=0' \
  -H 'Connection: keep-alive' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -H 'Origin: null' \
  -H 'Sec-Fetch-Dest: document' \
  -H 'Sec-Fetch-Mode: navigate' \
  -H 'Sec-Fetch-Site: cross-site' \
  -H 'Sec-Fetch-User: ?1' \
  -H 'Upgrade-Insecure-Requests: 1' \
  -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36' \
  -H 'sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'sec-ch-ua-platform: "macOS"' \
  --data-raw 'username=pig&password=666' \
  --compressed

其中有一个比较关键的请求头Content-Type,该请求头指定了我们上传的报文体的格式。默认的form表单提交,它的格式为application/x-www-form-urlencoded,我们可以注意到里面有个urlencoded,该格式表明报文体的格式和我们url链接中的查询字符串相同。

--data-raw后面的就是我们上传的报文体username=pig&password=666

Node处理数据

那么我们的Node服务器是如何对这个报文进行处理的呢?

我们用http模块编写一个简单的处理请求的Node服务:

const http = require('http');

const hasBody = req => {
    return 'transfer-encoding' in req.headers || 'content-length' in req.headers;
};

const server = http.createServer((req, res) => {
    console.log('req.headers: ', req.headers);
    if (hasBody) {
        const buffer = [];
        req.on('data', chunk => {
            buffer.push(chunk);
        });
        req.on('end', () => {
            const body = Buffer.concat(buffer).toString();
            req.rawBody = body;
            handle(req, res);
        });
        return;
    }
    handle(req, res);
});

server.listen(8111, () => {
    console.log('Server is launched in 127.0.0.1:8111');
});

首先我们使用的http模块已经对报文做了一层处理,我们可以通过req.headers得到报文的请求头信息,如下所示:

req.headers: {
  host: '127.0.0.1:8111',
  'accept-encoding': 'deflate, gzip',
  accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
  'accept-language': 'zh-CN,zh;q=0.9',
  'cache-control': 'max-age=0',
  connection: 'keep-alive',
  'content-type': 'application/x-www-form-urlencoded',
  origin: 'null',
  'sec-fetch-dest': 'document',
  'sec-fetch-mode': 'navigate',
  'sec-fetch-site': 'cross-site',
  'sec-fetch-user': '?1',
  'upgrade-insecure-requests': '1',
  'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36',
  'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"',
  'sec-ch-ua-mobile': '?0',
  'sec-ch-ua-platform': '"macOS"',
  'content-length': '25'
}

我们定义了一个hasBody函数来判断是否有报文体,如果没有报文体,则不需要执行监听数据上传的逻辑,节约服务器性能。

const hasBody = req => {
    return 'transfer-encoding' in req.headers || 'content-length' in req.headers;
};

判断是否有报文体主要判断请求头中是否有transfer-encodingcontent-length两个字段。其中transfer-encoding可以理解为对报文体的编码格式,content-length为报文体的长度。

然后我们通过onData接收数据,我们接收到的是Buffer格式的数据流,然后在接收完数据后调用Buffer.concat(buffer).toString();得到完整的数据,然后再把这个挂到req对象上req.rawBody = body;,后续的业务逻辑就可以通过访问req.rawBody得到上传的数据了。

数据解析

通过上面的处理我们已经可以通过req.rawBody获取到上传的数据了,这个数据是username=pig&password=666这样的,还是不好直接使用。通过对content-type的判断,可以对不同的格式做不同的处理。

application/x-www-form-urlencoded

content-typeapplication/x-www-form-urlencoded的数据格式和url的请求字符串格式是一样的,我们可以通过Node的querystring库来处理:

const querystring = require('querystring');

const handle = (req, res) => {
    if (req.headers['content-type'] === 'application/x-www-form-urlencoded') {
        req.body = querystring.parse(req.rowBoday);
    }
}

通过querystring.parse

username=pig&password=666
解析成
{
    username: 'pig',
    password: '666'
}

JSON或者XML

同理我们可以通过content-type来处理其他格式的数据,例如JSON或者XML数据。

const handle = (req, res) => {
    const contentType = req.headers['content-type'];
    if (contentType === 'application/x-www-form-urlencoded') {
        req.body = querystring.parse(req.rowBody);
    } else if (contentType === 'application/json') {
        req.body = JSON.parse(req.rowBody);
    } else if (contentType === 'application/xml') {
        // 调用xml2js将xml转换为JSON
    }
}

附件上传

当我们要上传附件类型的时候,我们需要设置表单属性enctype为multipart/form-data,表示我们可以上传表单类型的控件。

对应的html如下:

<body>
    <form action="http://127.0.0.1:8111/upload" method="post" enctype="multipart/form-data">
        <label for="username">Please input your username:</label>
        <input type="text" id="username" name="username"/><br />
        <label for="password">Please input your password:</label>
        <input type="text" id="password" name="password"/><br />
        <label for="file">Please select your file:</label>
        <input type="file" id="file" name="file"/><br />
        <input type="submit"/>
    </form>
</body>

image.png 当我们提交的时候,请求的报文头有一行关键信息如下所示:

Content-Type: multipart/form-data; boundary=WebKitFormBoundaryVSWyi0hnDHyDaUxk

表示表单上传的类型为multipart/form-data,上传的字段之间用boundary分隔,WebKitFormBoundaryVSWyi0hnDHyDaUxk是随机生成的一串字符串。在每部分字段开始之前会使用--boundary开头,然后以--boundary--结尾。

请求的报文体如下所示:

--WebKitFormBoundaryVSWyi0hnDHyDaUxk
Content-Disposition: form-data; name="username"

111
--WebKitFormBoundaryVSWyi0hnDHyDaUxk
Content-Disposition: form-data; name="password"

222
--WebKitFormBoundaryVSWyi0hnDHyDaUxk
Content-Disposition: form-data; name="file"; filename="vue.config.js"
Content-Type: text/javascript

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true
})

--WebKitFormBoundaryVSWyi0hnDHyDaUxk--

第一部分上传了一个字段username: 111

--WebKitFormBoundaryVSWyi0hnDHyDaUxk
Content-Disposition: form-data; name="username"

111

第二部分上传了一个字段password: 222

--WebKitFormBoundaryVSWyi0hnDHyDaUxk
Content-Disposition: form-data; name="password"

222

第三部分上传了一个文件vue.config.js

--WebKitFormBoundaryVSWyi0hnDHyDaUxk
Content-Disposition: form-data; name="file"; filename="vue.config.js"
Content-Type: text/javascript

const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true
})

然后是结尾:

--WebKitFormBoundaryVSWyi0hnDHyDaUxk--

Node需要对上传的这个报文体进行解析,得到里面的数据。

formidable解析form-data报文体

社区有一个formidable库可以用来解析form-data类型的报文体:

const formidable = require('formidable');

if (contentType === 'multipart/form-data') {
        const form = new formidable.IncomingForm();
        form.parse(req, function (err, fields, files) {
            console.log('err: ', err);
            if (!err) {
                req.body = fields;
                req.files = files;
            };
            doOther(req, res);
        });
    }
}

formidable可以帮我们解析出上传的文件类型字段和非文件类型字段,在后续的代码里我们可以通过req.files访问上传的文件,通过req.body访问不是文件的字段。

同时formidable会帮我们处理接收数据的逻辑,我们不需要自己写req.on('data', () => {})的逻辑。

Node完整代码如下:

const http = require('http');
const querystring = require('querystring');
const formidable = require('formidable');

const hasBody = req => {
    return 'transfer-encoding' in req.headers || 'content-length' in req.headers;
};

const handle = (req, res) => {
    const contentType = req.headers['content-type']?.split('; ')[0];
    if (contentType === 'application/x-www-form-urlencoded') {
        req.body = querystring.parse(req.rawBody);
    } else if (contentType === 'application/json') {
        req.body = JSON.parse(req.rawBody);
    } else if (contentType === 'application/xml') {
        // 调用xml2js将xml转换为JSON
    } else if (contentType === 'multipart/form-data') {
        const form = new formidable.IncomingForm();
        form.parse(req, function (err, fields, files) {
            if (!err) {
                req.body = fields;
                req.files = files;
            };
            console.log('fields: ', fields);
            console.log('files: ', files);
            res.end(JSON.stringify(fields));
        });
    }
}

const server = http.createServer((req, res) => {
    console.log('req.headers: ', req.headers);
    if (hasBody) {
        const contentType = req.headers['content-type']?.split('; ')[0];
        // 接收数据的操作在formidable里完成
        if (contentType === 'multipart/form-data') {
            handle(req, res);
            return;
        }
        const buffer = [];
        req.on('data', chunk => {
            buffer.push(chunk);
        });
        req.on('end', () => {
            const rawBody = Buffer.concat(buffer).toString();
            req.rawBody = rawBody;
            handle(req, res);
        });
        return;
    }
    handle(req, res);
});

server.listen(8111, () => {
    console.log('Server is launched in 127.0.0.1:8111');
});