7.5. 資料封裝

不管怎樣,你的意思真的是指封裝資料嗎?

以最簡單的例子而言,這表示你會需要增加一個 header(標頭),用來代表識別的資訊或封包長度,或者都有。

你的 header 看起來像什麼呢?

好的,它就只是某個用來表示你覺得完成專案會需要的二進位資料。

哇,真是抽象。

Okay,舉例來說,咱們說你有一個使用 SOCK_STREAM 的多重使用者聊天程式。當某個使用者輸入["says"]某些字,會有兩筆資訊要傳送給 server:

"是誰" 以及 "說了什麼"。

到目前為止都還可以嗎?

你問了:"會有什麼問題嗎?"

問題是訊息的長度是會變動的。一個叫做 "tom" 的人可能會說 "Hi(嗨)",而另一個叫做"Benjamin(班傑明)"的人可能說:"Hey guys what is up?(嘿!兄弟最近你好嗎?)"

所以你在收到全部的資料之後,將它全部 send() 給 clients。你輸出的 data stream(資料串流)類似這樣:

t o m H i B e n j a m i n H e y g u y s w h a t i s u p ?

類似這樣。那 client 要如何知道訊息何時開始與結束呢?

如果你願意,是可以的,只要讓全部的訊息都一樣長,並只要呼叫我們之前實作的 sendall() 就行了。但是這樣會浪費頻寬(bandwidth)!我們並不想用 send() 送出了 1024 個 bytes 的資料,卻只有攜帶了 "tom" 說了 "Hi" 這樣的有效資訊。

所以我們以小巧的 header 與封包結構封裝(encapsulate)資料。Client 與 server 都知道如何封裝(pack)與解封裝(unpack)這筆資料[有時候稱為 "marshal"與 "unmarshal"]。現在先不要想太多,我們會開始定義一個協定(protocol),用來描述 client 與 server 是如何溝通的!

在這個例子中,咱們假設使用者的名稱是固定 8 個字元,並用 '\0' 結尾。然後接著讓我們假設資料的長度是變動的,最多高達 128 個字元。我們看個可能在這個情況會用到的封包結構範例。

  1. len[1 個 byte,unsigned(無號)]:封包的總長度,計算 8 個 bytes 的使用者名稱,以及聊天資料。

  2. name[8 個 bytes]:使用者名稱,如果有需要,結尾補上 NUL。

  3. chatdata[n 個 bytes]:資料本身,最多 128 bytes。封包的長度應該要以這個資料長度加 8 [上面的 name 欄位長度]來計算。

為什麼我選擇 8 個 bytes 與 128 個 bytes 長度的欄位呢?我假設這樣就已經夠用了,或許,8 個 bytes 對你的需求而言太少了,你也可以有 30 個 bytes 的 name 欄位,總之,你可以自己決定!

使用上列的封包定義,第一個封包由下列的資訊組成[以 hex 與 ASCII]:

   0A    74 6F 6D 00 00 00 00 00     48 69
(length) T  o  m    (padding)        H  i

而第二個也是差不多:

   18    42 65 6E 6A 61 6D 69 6E       48 65 79 20 67 75 79 73 20 77 ...
(length) B  e  n  j  a  m  i  n        H  e  y     g  u  y  s     w  ...

[長度(length)是以 Network Byte Order 儲存,當然,在這個例子只有一個 byte,所以沒差,但是一般而言,你會想要讓你全部的二進位整數能以Network Byte Order 儲存在你的封包中。]

當你傳送資料時,你應該要謹慎點,使用類似前面的 sendall() 指令,因而你可以知道全部的資料都有送出,即便要將資料全部送出會多花幾次的 send()。

同樣地,當你接收這筆資料時,你需要額外做些處理。如果要保險一點,你應該假設你可能只會收到部分的封包內容[如我們可能會從上面的班傑明那裡收到 "18 42 65 6E 6A"],但是我們這次呼叫 recv() 全部就只收到這些資料。我們需要一次又一次的呼叫 recv(),直到完整地收到封包內容。

可是要怎麼做呢?

好的,我們可以知道所要接收的封包它全部的 byte 數量,因為這個數量會記載在封包前面。我們也知道最大的封包大小是 1 + 8 + 128,或者 137 bytes[因為這是我們自己定義的]。

實際上你在這邊可以做兩件事情,因為你知道每個封包是以長度(length)做開頭,所以你可以呼叫 recv() 只取得封包長度。接著,你知道長度以後,你就可以再次呼叫 recv(),這時候你就可以正確地指定剩下的封包長度[或者重複取得全部的資料],直到你收到完整的封包內容為止。這個方法的優點是你只需有一個足以存放一個封包的緩衝區,而缺點是你為了要接收全部的資料,至少呼叫兩次的 recv()。

另一個方法是直接呼叫 recv(),並且指定你所要接收的封包之最大資料量。這樣的話,無論你收到多少,都將它寫入緩衝區,並最後檢查封包是否完整。當然,你可能會收到下一個封包的內容,所以你需要有足夠的空間。

你所能做的是宣告(declare)一個足以容納兩個封包的陣列,這是你在封包到達時,你可以重新建構(reconstruct)封包的地方。

每次你用 recv() 接收資料時,你會將資料接在工作緩衝區(work buffer)的後端,並檢查封包是否完整。在緩衝區中的資料數量大於或等於 封包 header 中所指定的長度時[+1,因為 header 中的長度沒有包含 length 本身的長度]。若緩衝區中的資料長度小於 1,那麼很明顯地,封包是不完整的。你必須針對這種情況做個特別處理,因為第一個 byte 是垃圾,而你不能用它來取得正確的封包長度。

一旦封包已經完整接收了,你就可以做你該做的處理,將資料拿來使用,並在用完之後將它移出工作緩衝區。

呼呼!Are you juggling that in your head yet?

好的,這裡是第二次的衝擊:你可能在一次的 recv() call 就已經讀到了一個封包的結尾,還讀到下一個封包的內容,即是你的工作緩衝區有一個完整的封包,以及下一個封包的一部分!該死的傢伙。[但是這就是為什麼你需要讓你的工作緩衝區可以容納兩個封包的原因,就是會發生這種情況!]

因為你從 header 得知第一個封包的長度,而你也有持續追蹤工作緩衝區的資料量,所以你可以相減,並且計算出工作緩衝區中有多少資料是屬於第二個[不完整的]封包的。當你處理完第一個封包後,你可以將第一個封包的資料從工作緩衝區中清掉,並將第二個封包的部分內容移到緩衝區的前面,準備進行下一次的 recv()。

[部分讀者會注意到,實際地將第二個封包的部份資料移動到緩衝區的開頭需要花費時間,而程式可以寫成利用環狀緩衝區(circular buffer),就不需要這樣做。如果你還是很好奇,可以找一本資料結構的書來讀。]

我從未說過這很簡單,好吧,我有說過這很簡單。而你所需要的只是多練習,然後很快的你就會習慣了。我發誓!

[34] http://beej.us/guide/bgnet/examples/pack2.c

[35] http://tools.ietf.org/html/rfc4506

Last updated