【Node.js】 bodyparser實現原理解析

  • 2019 年 10 月 3 日
  • 筆記

為什麼我們需要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輸出。

數據處理流程

  1. 在request的data事件觸發時候,收集Buffer對象,將其放到一個命名為chunks的數組中
  2. 在request的end事件觸發時,通過Buffer.concat(chunks)將Buffer數組整合成單一的大的Buffer對象
  3. 解析請求首部的Content-Encoding,根據類型,如gzip,deflate等調用相應的解壓縮函數如Zlib.gunzip,將2中得到的Buffer解壓,返回的是解壓後的Buffer對象
  4. 解析請求的charset字元編碼,根據其類型,如gbk或者utf-8,調用iconv庫提供的decode(buffer, charset)方法,根據字元編碼將3中的Buffer轉換成字元串
  5. 最後,根據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測網速

【完】