【Node.js】 bodyparser實現原理解析
為什麼我們需要body-parser
也許你第一次和bodyparser相遇是在使用Koa框架的時候。當我們嘗試從一個瀏覽器發來的POST請求中取得請求報文實體的時候,這個時候,我們想,這個從Koa自帶的ctx.body裡面取出來就可以了嘛! 唉!等等,但根據Koa文件,ctx.body等同於ctx.res.body,所以從ctx.body取出來的是空的響應報文,而不是請求報文的實體哦 於是這時候又打算從Node文件裡找找request物件有沒有可以提供查詢請求報文的屬性,結果自然是Node文件自然會告訴你結果——
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; }
來!傳送門
https://link.zhihu.com/?target=https%3A//www.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 })
專案的github和npm地址
https://github.com/penghuwan/body-parser-promise
https://www.npmjs.com/package/body-parser-promise
參考資料
Koa-bodyparser https://github.com/koajs/bodyparser
上一篇文章
如何用JavaScript測網速
【完】
&n