在我们日常开发中一般使用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请求。
我们拷出浏览器发送的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-encoding
和content-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-type
为application/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>
当我们提交的时候,请求的报文头有一行关键信息如下所示:
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');
});