封包組合與剖析器設計@node.js (II) 封包剖析

一、前言

上一篇「封包組合與剖析器設計@node.js (I) 封包組合」,我們示範了如何使用 concentrate 模組,依照我們「自己訂的格式」,將一個名為 person 的資料物件轉換為 buffer。在這篇文章,我們要示範如何使用 dissolve 模組來將這個 buffer 依照自訂義的格式規則來剖析回原本的資料物件。

  像這類「自己訂的格式」,也就是俗稱的 Domain-specific language (DSL),例如某公司為他們的產品,使用了他們自己所訂的封包格式來進行通訊(傳控制碼、狀態碼、資料等等),這就可稱為 DSL。但在我的觀念中,DSL 是比較廣義的,倒不一定就是指文章中所提的封包格式定義。即便我們是使用 json 資料物件來進行通訊、採用自己定義的物件欄位(或者說介面也可以),都可稱為 DSL。DSL 在不同的情境下,可能會被理解為不同意思,但是很歹勢啦,我實在舉不出甚麼好例子啦!呵呵~不是重點。在比較高的應用層,其實蠻少碰到二進位封包 DSL 的組合跟剖析。假使你只是需要應用層級的協定,不管是資料或介面,我是建議都可以用 json 來完成,端看你怎麼定義物件裡那一坨欄位的意義囉~ 假使覺得 json 的 overhead 有點大,也可以考慮使用 msgpack 這樣的格式來打包物件,你可以在 msgpack 的官網上看到它為不同語言所提供的函式庫。像我最喜歡的 node,就可以直接用 Collina 的 msgpack5 模組。

二、範例程式

我將連同上一次的範例,一起放在 git 上面。大家如果有需要可以直接到這裡下載,或是 clone 回去並 npm install 一下(會安裝 concetrate, dissolve, 與 dissolve-chunks 三個 modules):
$ git clone https://github.com/simenkid/packet_demo.git
$ cd packet_demo
~/packet_demo$ npm install

三、目標封包

這邊小小回顧一下我們要處理的封包格式,採用上一篇文章中 personBuf_01.js 的例子如下:
var person = {
    sex: 1,        // [無號整數, uint8 ]: 0 表示女生, 1 表示男生
    name: 'simen', // [字串, utf8]: 長度不一定
    age: 37,       // [無號整數, uint8 ]: 年齡數字
    height: 17200  // [無號整數, uint16]: 單位是 mm
}


組合出來的 buffer 如下
 <Buffer 01 05 73 69 6d 65 6e 25 30 43> 

四、Dissolve 模組的 APIs 介紹

這裡我先簡單介紹一下 dissolve 模組的 APIs。

4.1 Numeric Parsing Methods

連結到 dissolve 的說明頁面,我們拉到最下面看 Numeric Methods,它提供了一堆如 int8(key_name), uint8(key_name), uint16le(key_name), int32be(key_name) 的剖析方法,其中 key_name 是字串,指的是你要將剖析結果放到結果物件中的 'key' 的名稱是什麼。它的意思超直覺的,例如 int8(key_name) 是說

「哦!我要抓取 buffer 接下來 1 個 byte(因為是int8),然後將它辨識為有號整數。將辨識完的結果,放到結果物件中的 key_name 欄位。」

又以 uint16le(key_name) 為例:

「哦!我要抓取接下來的連續 2 個 bytes(因為是uint16),而且它們是用小端排列(Littile endian) 的阿!所以請用小端的方式將這兩個 bytes 辨識為無號整數 (uint16le)。將辨識完的結果,放到結果物件中的 key_name 欄位。」

再以 int32be(key_name) 為例,現在你知道,這就是在告訴 dissolve 接下來抓取 buffer 接下來的 4 個 bytes 並視為大端方式辨識為有號整數。

4.2 Buffer/String Parsing Methods

這裡有兩支 APIs,意思也很好懂
  • buffer(key_name, length) - binary slice
    • 接下來,要抓 length 這麼多個 bytes,將它視為獨立一段 buffer,掛到結果物件中的 key_name 鍵底下
  • string(key_name, length) - utf8 string slice
    • 接下來,(預設)用 utf8 的編碼方式,總共要抓出 length 這麼多個字元出來,成為一條字串,掛到結果物件中的 key_name 鍵底下
    • 支援的編碼方式,如'utf8', 'ascii' 等請參考 node.js 官方文件

4.3 Tap Method

  這支方法就重要了, very 之常用。因為封包格式的定義五花八門,我們經常不能夠直接以 .uint8(), .unt16() 這些方法一連串地持續剖析下去,必須在剖析過程中,加入一些處理邏輯來告訴 Dissolve:

「接下來沒有這麼簡單哦!你要先這樣、再那樣,然後就怎樣....才能辨識出我要的東西哦!」

  那要如何告訴 Dissolve 呢?我們只要在剖析方法的串鏈過程中,在需要特殊處理的地方用 .tap() 插入我們的判斷邏輯即可。API 的介面如下,callback 就是我們要告訴 Dissolve 如何處理的函式,key_name 就是將這一段特殊邏輯剖析出來的結果掛到結果物件的 key_name 鍵底下
  • tap(key_name, callback)
  • 小提示
    • 當你放 key_name 的時候,剖析出來的小結果,就會掛在結果物件的 key_name 這個鍵底下。舉例來說,如果這段邏輯會剖析出一個物件 { foo: 3, bar: [ 5, 6, 7] },我們調用 tap('kerker', function () {...}) 所剖析後的結果物件會長這樣
{
    kerker: {
        foo: 3,
        bar: [5, 6, 7]
    }
} 
    • 當你不放 key_name 的時候,我們調用 tap(function () {...}) 所剖析後的結果物件會長這樣,所有 sub-keys 都會直接掛在結果物件之上
{
    foo: 3,
    bar: [5, 6, 7]
} 
  • 結果物件:this.vars
    • Dissolve 在剖析過程中,會將剖析完的結果暫存在 this.vars 所指到的物件之下。因此,我們在 tap() 時,可以從 this.vars 身上去挖出前面已剖析好的東西,來協助完成接下來要處理的邏輯。
    • 當剖析器全部寫完之後,最後一步就是要掛一個 tap(),調用 this.push(this.vars) 將 結果物件(根物件) 的 this.vars 內容給 push 出去,並將它清空為一個空物件。
      • 注意,這裡的 push 方法是 Stream 的方法,不是 Array 的 push(),意思是將結果推出去,此時 Stream 物件就會引發 'readable' 事件,你可以寫個 listener 來監聽該事件並將結果拿出來,又或者將它 pipe 到一個 writtable 的 Stream 物件。
    • 在 push(this.vars) 之前,我們可以先修整一下 this.vars 的內容,將一些中間產物先刪除掉再 push() 出去。
      • 我們會在本文例子的剖析字串片段,看到這一點。
    • this.vars 的 this 並不總是指 "根" 部的 Dissolve 實例,this 所指向的物件是依據 tap 深度的不同,而有不同的 context。關於這一點,我就不多加說明了!在真正實作中,你一定會遇到這個問題,而且這個問題有時候會給開發者帶來一些困擾。
  • 我們馬上就碰到的問題:剖析字串!
    • 要解出字串,我要先找出它到底有多長 (len 欄位)
    • 所以呢!我要在解析出 len 之後,插入一個 tap,在當前已剖析好的結果物件中,撈出 len 的值,這樣我才能告訴 Dissolve 接下來要 .string() 繼續頗析出多長的字串出來

4.4 Loop Method

  當我們遇到需要「重複性」頗析的時候,例如「格式」都相同的陣列元素,就可以使用 loop 來協助我們。Loop 會丟一個 end 函式做為 callback 的參數,當你完成重複性剖析的最後一個項目時,就可以在 callback 中調用 end(),這樣 Dissolve 就會停止重複性的剖析,並將剖析結果掛到你所指定的 key_name 之下,這個規則跟 tap() 一樣。它的 API 介面如下:
  • loop(key_name, callback)
由於 Dissolve 進行完一次完整的剖析後 (自 Dissolve() 的實例開始執行剖析,到 push(this.vars) 出去為止),就形同工作結束。它的內部是使用一個 jobs list 來列出每項剖析工作,每執行完一個 job,那個 job 就會從 job list 中被拿掉,直掉 jobs list 中沒有 job 了,就代表剖析完全結束。此時,如果你再 write 一段 buffer 給 parser,你會發現 parser 就完全不執行剖析啦 (因為 jobs list是空的)!
  這時候,loop() 就派上用場啦,我只要在 parser 撰寫的一開始 用 loop() 包裹整套剖析邏輯,在一次完整的剖析結束後,Dissolve 會將依連串的 jobs 再重新填回 jobs list 中哦!
  這很重要!舉個例子,假如你現在要 parse 的 buffer 是從 UART 接回來的,那當然希望從 UART 一直收、一直剖析吧!要是不用 loop(),你會發現從 UART 收回來的 buffer 都只被剖析一次,就整個停掉啦!

五、開始剖析 personBuf


  直接上程式碼 (personParser_01.js),你可以看到我們在 uint8('len') 找出字串長度後,插入一個 tap(),在裡面,我們可以從 this.vars.len 找出字串長度到底是多長,然後在調用 .string() 時,告訴它要剖析出多長的字串。最後一個 tap 的任務就是將最後的結果給 push 出去。
  我們用 parse.on() 監聽 'readable' 事件,當此事件被引發時,代表 parser 的剖析結果已經可以讀取了,調用 parser.read() 去讀取即可。
  最後,parser.write() 就是實際將 buffer 推入 paser 的時刻,parser 開始正式勤奮工作!執行看看,光榮的時刻來臨了,為我們偉大的結果歡呼吧!(有沒有這麼誇張~~~)

var Dissolve = require('dissolve');
// <Buffer 01 05 73 69 6d 65 6e 25 30 43>
var personBuf = new Buffer([
    0x01, 0x05, 0x73, 0x69, 0x6d,
    0x65, 0x6e, 0x25, 0x30, 0x43
]);

var parser = Dissolve().uint8('sex').uint8('len')
    .tap(function () {
        this.string('name', this.vars.len);
    }).uint8('age').uint16le('height')
    .tap(function () {
        this.push(this.vars);
        this.vars = {};
    });

parser.on('readable', function() {
  var e;
  while (e = parser.read()) {
    console.log(e);
  }
});

parser.write(personBuf); 

這支程式最後 parse 出來的結果如下,但事情可還沒完~
{ sex: 1, len: 5, name: 'simen', age: 37, height: 17200 } 
結果多出了一個欄位 'len',這個前導欄位的中繼功能已經完成了它的職責,所以,我們可以在 push 之前先清理一下,將它刪除。在程式中加入這一行:

// ...
    .tap(function () {
        this.string('name', this.vars.len);
        delete this.vars.len;
    }).uint8('age').uint16le('height')

再執行一次,你可以看到這個作為 meta 功能的 len 欄位就不見啦!Bravo!!
{ sex: 1, name: 'simen', age: 37, height: 17200 } 

六、Loop

  現在我們再做一件事,就是在程式碼的最後一行再將 buffer 寫入一次、兩次或三次,隨便~

var Dissolve = require('dissolve');
// ...
    console.log(e);
  }
});

parser.write(personBuf);
parser.write(personBuf);
parser.write(personBuf);
parser.write(personBuf);

執行看看,結果如下。你發現什麼!我依序一直寫入那麼多 buffer,parser 竟然只剖析了 1 次阿!這就是我在 4.4 小節中所說的事情~
{ sex: 1, name: 'simen', age: 37, height: 17200 } 

用 loop() 包裹剖析工作!將程式碼另存為 personParser_02.js,內容如下:
var Dissolve = require('dissolve');
var personBuf = new Buffer([
    0x01, 0x05, 0x73, 0x69, 0x6d,
    0x65, 0x6e, 0x25, 0x30, 0x43
]);

var parser = Dissolve().loop(function (end) {
    this.uint8('sex').uint8('len')
        .tap(function () {
            this.string('name', this.vars.len);
            delete this.vars.len;
        }).uint8('age').uint16le('height')
        .tap(function () {
            this.push(this.vars);
            this.vars = {};
        });
});

parser.on('readable', function() {
  var e;
  while (e = parser.read()) {
    console.log(e);
  }
});

parser.write(personBuf);
parser.write(personBuf);
parser.write(personBuf);
parser.write(personBuf);

最後執行一下!結果會怎樣咧~
「各!位!觀!眾!........   黑桃...... .  A斯!」
{ sex: 1, name: 'simen', age: 37, height: 17200 }
{ sex: 1, name: 'simen', age: 37, height: 17200 }
{ sex: 1, name: 'simen', age: 37, height: 17200 }
{ sex: 1, name: 'simen', age: 37, height: 17200 } 

很好!這一刻起,你已經入門了!接下來有件事情要你試試看(假如你有空,而且沒有弄得很賭爛的話....)

七、牛刀小試

假設現在我有一個物件 data1 長得像這樣
var data1 = {
    x: 100,
    y: 'hello',
    z: {
        z1: 30,
        z2: 'world!',
        z3: [ 1, 2, 3, 4, 5 ]
    },
    m: [ 'It ', 'makes ', 'my ', 'life ', 'easier.' ]
};

我使用以下的格式來編出封包的二進位 buffer
其中 format 列中的 x(1),(1) 指的是它占用 1 byte;y(len) 是它占用 len 個 bytes,而 len 的值是由它的前導欄位所決定

依照此規則編出來的 buffer 如下:
var data1_buf = new Buffer([
    0x64, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x1e, 
    0x06, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x21,
    0x05, 0x01, 0x02, 0x03, 0x04, 0x05,
    0x05, 0x03, 0x49, 0x74, 0x20, 
    0x06, 0x6d, 0x61, 0x6b, 0x65, 0x73, 0x20,
    0x03, 0x6d, 0x79, 0x20,
    0x05, 0x6c, 0x69, 0x66, 0x65, 0x20,
    0x07, 0x65, 0x61, 0x73, 0x69, 0x65, 0x72, 0x2e
]);

挑戰一下!將這段 buffer 給 parse 回 data1 吧!科科.... 如果你 parse 的出來,就表示你完全OK了!不蓋你!

八、小結

這系列目前為止,我們學會如何使用 concentrate 與 dissolve 來組合與剖析封包!其實你已經可以動手規劃屬於自己的 Monitor and Test Instruction Sets 了 (MT指令集),制定一些控制碼欄位、狀態碼欄位,在你的 node 跟單晶片上施展神奇魔法!如果你是 Maker,我相信這點小知識或許可以讓你更上一層樓哦!試試看,用 Node 跟你自己的單晶片應用程式透過 UART 進行 RPC (remote process communication)!
 
  如果第七節的挑戰,你實在搞不出來!沒關係!下一篇文章,我會介紹如何使用小弟所寫的 dissolve-chunks 模組,用宣告式的方式來撰寫 parser!事情會容易許多!
 
 
 
 
simen

An enthusiastic engineer with a passion for learning. After completing my academic journey, I worked as an engineer in Hsinchu Science Park. Later, I ventured into academia to teach at a university. However, I have now returned to the industry as an engineer, again.

1 Comments

Post a Comment
Previous Post Next Post