# 为什么我们需要 body-parser
Koa 集成了 cookies,fresh,cache 等库,但是并没有引入 body-parser
也许你第一次和 bodyparser 相遇是在使用 Koa 框架的时候。当我们尝试从一个浏览器发来的 POST 请求中取得请求报文实体的时候,这个时候,我们想,这个从 Koa 自带的 ctx.body 里面取出来就可以了嘛!
唉!等等,但根据 Koa 文档,ctx.body 等同于 ctx.res.body,所以从 ctx.body 取出来的是空的响应报文,而不是请求报文的实体哦
于是这时候又打算从 Node 文档里找找 request 对象有没有可以提供查询请求报文的属性,果自然是 Node 文档自然会告诉你结果 —— 沒有
# 所以,这个时候我们需要的是 ——bodyparser
bodyparser 是一类处理 request 的 body 的中间件函数,例如 Koa-bodyparser 就是和 Koa 框架搭配使用的中间件,帮助没有内置处理该功能的 Koa 框架提供解析 request.body 的方法,通过 app.use 加载 Koa-bodyparser 后,在 Koa 中就可以通过 ctx.request.body 访问到请求报文的报文实体啦!
# body-parser 代码逻辑
# 无论是 Node 的哪一款 body-parser,其原理都是类似的今天我们就编写一个 getRequestBody 的函数,解析出 request.body,以尽管中窥豹之理。
要编写 body-parser 的代码,首先要了解两个方面的逻辑:请求相关事件和数据处理流程
# 请求相关事件
- data 事件:当 request 接收到数据的时候触发,在数据传输结束前可能会触发多次,在事件回调里可以接收到 Buffer 类型的数据参数,我们可以将 Buffer 数据对象收集到数组里
- end 事件:请求数据接收结束时候触发,不提供参数,我们可以在这里将之前收集的 Buffer 数组集中处理,最后输出将 request.body 输出。
# 数据处理流程
- 在 request 的 data 事件触发时候,收集 Buffer 对象,将其放到一个命名为 chunks 的数组中
- 在 request 的 end 事件触发时,通过 Buffer.concat (chunks) 将 Buffer 数组整合成单一的大的 Buffer 对象
- 解析请求首部的 Content-Encoding,根据类型,如 gzip,deflate 等调用相应的解压缩函数如 Zlib.gunzip, 将 2 中得到的 Buffer 解压,返回的是解压后的 Buffer 对象
- 解析请求的 charset 字符编码,根据其类型,如 gbk 或者 utf-8, 调用 iconv 库提供的 decode (buffer, charset) 方法,根据字符编码将 3 中的 Buffer 转换成字符串
- 最后,根据 Content-Type, 如 application/json 或 'application/x-www-form-urlencoded' 对 4 中得到的字符串做相应的解析处理,得到最后的对象,作为 request.body 返回
下面展示下相关的代码
# 整体代码结构
// 根据 Content-Encoding 判断是否解压,如需则调用相应解压函数 | |
async function transformEncode(buffer, encode) { | |
// ... | |
} | |
//charset 转码 | |
function transformCharset(buffer, charset) { | |
// ... | |
} | |
// 根据 content-type 做最后的数据格式化 | |
function formatData(str, contentType) { | |
// ... | |
} | |
// 返回 Promise | |
function getRequestBody(req, res) { | |
return new Promise(async (resolve, reject) => { | |
const chunks = []; | |
req.on('data', buf => { | |
chunks.push(buf); | |
}) | |
req.on('end', async () => { | |
let buffer = Buffer.concat(chunks); | |
// 获取 content-encoding | |
const encode = req.headers['content-encoding']; | |
// 获取 content-type | |
const { type, parameters } = contentType.parse(req); | |
// 获取 charset | |
const charset = parameters.charset; | |
// 解压缩 | |
buffer = await transformEncode(buffer, encode); | |
// 转换字符编码 | |
const str = transformCharset(buffer, charset); | |
// 根据类型输出不同格式的数据,如字符串或 JSON 对象 | |
const result = formatData(str, type); | |
resolve(result); | |
}) | |
}).catch(err => { throw err; }) | |
} |
# Step0.Promise 的编程风格
function getRequestBody(req, res) { | |
return new Promise(async (resolve, reject) => { | |
// ... | |
} | |
} |
# Step1.data 事件的处理
const chunks = []; | |
req.on('data', buf => { | |
chunks.push(buf); | |
}) |
# Step2.end 事件的处理
const contentType = require('content-type'); | |
const iconv = require('iconv-lite'); | |
req.on('end', async () => { | |
let buffer = Buffer.concat(chunks); | |
// 获取 content-encoding | |
const encode = req.headers['content-encoding']; | |
// 获取 content-type | |
const { type, parameters } = contentType.parse(req); | |
// 获取 charset | |
const charset = parameters.charset; | |
// 解压缩 | |
buffer = await transformEncode(buffer, encode); | |
// 转换字符编码 | |
const str = transformCharset(buffer, charset); | |
// 根据类型输出不同格式的数据,如字符串或 JSON 对象 | |
const result = formatData(str, type); | |
resolve(result); | |
} |
# Step3. 根据 Content-Encoding 进行解压处理
Content-Encoding 可分为四种值:gzip,compress,deflate,br,identity
其中
- identity 表示数据保持原样,没有经过压缩
- compress 已经被大多数浏览器废弃,Node 没有提供解压的方法
所以我们需要处理解压的一共有三种数据类型
- gzip:采用 zlib.gunzip 方法解压
- deflate: 采用 zlib.inflate 方法解压
- br: 采用 zlib.brotliDecompress 方法解压
(** 注意!**zlib.brotliDecompress 方法在 Node11.7 以上版本才会支持,而且不要看到名字里有 compress 就误以为它是用来解压 compress 压缩的数据的,实际上它是用来处理 br 的)
代码如下,我们对 zlib.gunzip 等回调类方法通过 promisify 转成 Promise 编码风格
const promisify = util.promisify; | |
//node 11.7 版本以上才支持此方法 | |
const brotliDecompress = zlib.brotliDecompress && promisify(zlib.brotliDecompress); | |
const gunzip = promisify(zlib.gunzip); | |
const inflate = promisify(zlib.inflate); | |
const querystring = require('querystring'); | |
// 根据 Content-Encoding 判断是否解压,如需则调用相应解压函数 | |
async function transformEncode(buffer, encode) { | |
let resultBuf = null; | |
debugger; | |
switch (encode) { | |
case 'br': | |
if (!brotliDecompress) { | |
throw new Error('Node版本过低! 11.6版本以上才支持brotliDecompress方法') | |
} | |
resultBuf = await brotliDecompress(buffer); | |
break; | |
case 'gzip': | |
resultBuf = await gunzip(buffer); | |
break; | |
case 'deflate': | |
resultBuf = await inflate(buffer); | |
break; | |
default: | |
resultBuf = buffer; | |
break; | |
} | |
return resultBuf; | |
} |
# Step4. 根据 charset 进行转码处理
我们采用 iconv-lite 对 charset 进行转码,代码如下
const iconv = require('iconv-lite'); | |
//charset 转码 | |
function transformCharset(buffer, charset) { | |
charset = charset || 'UTF-8'; | |
//iconv 将 Buffer 转化为对应 charset 编码的 String | |
const result = iconv.decode(buffer, charset); | |
return result; | |
} |
来!传送门
iconv-litewww.npmjs.com/package/iconv-lite
# Step5. 根据 contentType 将 4 中得到的字符串数据进行格式化
具体的处理方式分三种情况:
- 对 text/plain 保持原样,不做处理,仍然是字符串
- 对 application/x-www-form-urlencoded,得到的是类似于 key1=val1&key2=val2 的数据,通过 querystring 模块的 parse 方法转成 {key:val} 结构的对象
- 对于 application/json,通过 JSON.parse (str)一波带走
代码如下
const querystring = require('querystring'); | |
// 根据 content-type 做最后的数据格式化 | |
function formatData(str, contentType) { | |
let result = ''; | |
switch (contentType) { | |
case 'text/plain': | |
result = str; | |
break; | |
case 'application/json': | |
result = JSON.parse(str); | |
break; | |
case 'application/x-www-form-urlencoded': | |
result = querystring.parse(str); | |
break; | |
default: | |
break; | |
} | |
return result; | |
} |
# 测试代码
服务端
下面的代码你肯定知道要放在哪里了
// 省略其他代码 | |
if (pathname === '/post') { | |
// 调用 getRequestBody, 通过 await 修饰等待结果返回 | |
const body = await getRequestBody(req, res); | |
console.log(body); | |
return; | |
} |
前端采用 fetch 进行测试
在下面的代码中,我们连续三次发出不同的 POST 请求,携带不同类型的 body 数据,看看服务端会输出什么
var iconv = require('iconv-lite'); | |
var querystring = require('querystring'); | |
var gbkBody = { | |
data: "我是彭湖湾", | |
contentType: 'application/json', | |
charset: 'gbk' | |
}; | |
// 转化为 JSON 数据 | |
var gbkJson = JSON.stringify(gbkBody); | |
// 转为 gbk 编码 | |
var gbkData = iconv.encode(gbkJson, "gbk"); | |
var isoData = iconv.encode("我是彭湖湾,这句话采用UTF-8格式编码,content-type为text/plain", "UTF-8") | |
// 测试内容类型为 application/json 和 charset=gbk 的情况 | |
fetch('/post', { | |
method: 'POST', | |
headers: { | |
"Content-Type": 'application/json; charset=gbk' | |
}, | |
body: gbkData | |
}); | |
// 测试内容类型为 application/x-www-form-urlencoded 和 charset=UTF-8 的情况 | |
fetch('/post', { | |
method: 'POST', | |
headers: { | |
"Content-Type": 'application/x-www-form-urlencoded; charset=UTF-8' | |
}, | |
body: querystring.stringify({ | |
data: "我是彭湖湾", | |
contentType: 'application/x-www-form-urlencoded', | |
charset: 'UTF-8' | |
}) | |
}); | |
// 测试内容类型为 text/plain 的情况 | |
fetch('/post', { | |
method: 'POST', | |
headers: { | |
"Content-Type": 'text/plain; charset=UTF-8' | |
}, | |
body: isoData | |
}); |
服务端输出结果
{ | |
data: '我是彭湖湾', | |
contentType: 'application/json', | |
charset: 'gbk' | |
} | |
{ | |
data: '我是彭湖湾', | |
contentType: 'application/x-www-form-urlencoded', | |
charset: 'UTF-8' | |
} | |
我是彭湖湾,这句话采用UTF-8格式编码,content-type为text/plain |
# 问题和后记
# Q1. 为什么要对 charset
其实本质上来说,charset 前端一般都是固定为 utf-8 的, 甚至在 JQuery 的 AJAX 请求中,前端请求 charset 甚至是不可更改,只能是 charset,但是在使用 fetch 等 API 的时候,的确是可以更改 charset 的,这个工作尝试满足一些比较偏僻的更改 charset 需求。
# Q2:为什么要对 content-encoding 做处理呢?
一般情况下我们认为,考虑到前端发的 AJAX 之类的请求的数据量,是不需要做 Gzip 压缩的。但是向服务器发起请求的不一定只有前端,还可能是 Node 的客户端。这些 Node 客户端可能会向 Node 服务端传送压缩过后的数据流。 例如下面的代码所示
const zlib = require('zlib'); | |
const request = require('request'); | |
const data = zlib.gzipSync(Buffer.from("我是一个被Gzip压缩后的数据")); | |
request({ | |
method: 'POST', | |
url: 'http://127.0.0.1:3000/post', | |
headers: {// 设置请求头 | |
"Content-Type": "text/plain", | |
"Content-Encoding": "gzip" | |
}, | |
body: data | |
}) |