1. 程式人生 > >【Node.js】 bodyparser實現原理解析

【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輸出。

資料處理流程

  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測網速

【完】

&n