7. 進階技術(續)

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 的資料,卻只有攜帶了 "tome"說了 "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),就不需要這樣做。如果你還是很好奇,可以找一本資料結構的書來讀。]

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

7.6. 廣播封包(Broadcast Packet):Hello World!

到了這裡,本文已經談了如何將資料從一台主機傳送到另一台主機。但是,我堅持你可能會需要究極的權力,同時將資料送給多個主機!

用 UDP[只能是 UDP,TCP 不行]與標準的 IPv4,可以透過一種叫作廣播(broadcasting)的機制達成。IPv6 不支援廣播,所以你必須要採用比較高級的技術-群播(multicasting),很遺憾地,我現在不會討論這個,我受夠了異想天開的未來,我們現在還停留在 32-bit 的 IPv4 世界呢!

可是,請等一下!不管你願不願意,你不能走呀,開始說說廣播吧。

你必須在將廣播封包送到網路之前,先設定 SO_BROADCAST socket 選項。這類似一個推送導彈開關的小塑膠蓋!就只是你的手上掌握了多少的權力。

不過認真說來,使用廣播封包是很危險的,因為每個收到廣播封包的系統都要撥開一層層的資料封裝,直到系統知道這筆資料是要送給哪個 port 為止。然後系統會開始處理這筆資料或者丟掉它。在另一種情況,對每部收到廣播封包的機器而言這很費工,因為他們都在同一個區域網路(local network),這樣會讓很多電腦做不少多餘的工作。當 Doom 遊戲出現時,就有人在說它的網路程式寫的不好。

現在,有很多方法可以解決這個問題 ...

等一下,真的有很多方法嗎?

那是什麼表情阿?哎呀,一樣阿,送廣播封包的方法很多。所以重點就是:你該如何指定廣播訊息的目地位址呢?

有兩種常見的方法:

1. 將資料送給子網路(subnet)的廣播位址,就是將 subnet's network(子網路網段)的 host(主機)那部分全部填 1,舉例來說,我家裡的網路是 192.168.1.0,而我的 netmask(網路遮罩)是 255.255.255.0,所以位址的最後一個 byte 就是我的 host number[因為依據 netmask,前三個 bytes 是 network number]。所以我的廣播位址就是 192.168.1.255。在 Unix 底下,ifconfig 指令實際上都會給你這些資料。[如果你有興趣,取得你廣播位址的邏輯運算方式是 network_number OR (Not netmask) ]。你可以用跟區域網路一樣的方式,將這類型的廣播封包送到遠端網路(remote network),不過風險是封包可能會被目地端的 router(路由器)丟棄。[如果 router 沒有將封包丟棄,那麼有個隨機的藍色小精靈會開始用廣播流量對它們的區域網路造成水災。]

2. 將資料送給 "global(全域的)"廣播位址,255.255.255.255,又稱為 INADDR_BROADCAST,很多機器會自動將它與你的 network number 進行 AND 位元運算,以轉換為網路廣播位址,但是有些機器不會這樣做。Routers 不會將這類的廣播封包轉送(forward)出你的區域網路,夠諷刺的。

所以如果你想要將資料送到廣播位址,但是沒有設定 SO_BROADCAST socket 選項時會怎樣呢?好,我們用之前的 talkerlistener 來炒冷飯,然後看看會發生什麼事情。
$ talker 192.168.1.2 foo
sent 3 bytes to 192.168.1.2
$ talker 192.168.1.255 foo
sendto: Permission denied
$ talker 255.255.255.255 foo
sendto: Permission denied
是的,沒有很順利 ... 因為我們沒有設定 SO_BROADCAST socket 選項,設定它,然後現在你就可以用 sendto() 將資料送到你想送的地方了!

事實上,這就是 UDP 應用程式能不能廣播的差異點。所以我們改一下舊的 talker 應用程式,設定 SO_BROADCAST socket 選項。這樣我們就能呼叫 broadcaster.c 程式了 [36]:
/*
** broadcaster.c -- 一個類似 talker.c 的 datagram "client",
** 差異在於這個可以廣播
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>

#define SERVERPORT 4950 // 所要連線的 port

int main(int argc, char *argv[])
{
  int sockfd;
  struct sockaddr_in their_addr; // 連線者的位址資訊
  struct hostent *he;
  int numbytes;
  int broadcast = 1;
  //char broadcast = '1'; // 如果上面這行不能用的話,改用這行

  if (argc != 3) {
    fprintf(stderr,"usage: broadcaster hostname message\n");
    exit(1);
  }

  if ((he=gethostbyname(argv[1])) == NULL) { // 取得 host 資訊
    perror("gethostbyname");
    exit(1);
  }

  if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
    perror("socket");
    exit(1);
  }

  // 這個 call 就是要讓 sockfd 可以送廣播封包
  if (setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &broadcast,
    sizeof broadcast) == -1) {
    perror("setsockopt (SO_BROADCAST)");
    exit(1);
  }

  their_addr.sin_family = AF_INET; // host byte order
  their_addr.sin_port = htons(SERVERPORT); // short, network byte order
  their_addr.sin_addr = *((struct in_addr *)he->h_addr);
  memset(their_addr.sin_zero, '\0', sizeof their_addr.sin_zero);

  if ((numbytes=sendto(sockfd, argv[2], strlen(argv[2]), 0,
          (struct sockaddr *)&their_addr, sizeof their_addr)) == -1) {
    perror("sendto");
    exit(1);
    }

  printf("sent %d bytes to %s\n", numbytes,
      inet_ntoa(their_addr.sin_addr));

  close(sockfd);

  return 0;
}
這個跟 "一般的" UDP client/server 有什麼不同呢?

沒有![除了 client 可以送出廣播封包]

同樣地,我們繼續,並在其中一個視窗執行舊版的 UDP listener 程式,然後在另一個視窗執行 broadcaster,你應該可以順利執行了。
$ broadcaster 192.168.1.2 foo
sent 3 bytes to 192.168.1.2
$ broadcaster 192.168.1.255 foo
sent 3 bytes to 192.168.1.255
$ broadcaster 255.255.255.255 foo
sent 3 bytes to 255.255.255.255
而你應該會看到 listener 回應說它已經收到封包。[如果 listener 沒有回應,可能是因為它綁到 IPv6 位址了,試著將 listener.c 中的 AF_UNSPEC 改成 AF_INET,強制使用 IPv4]。

好,真令人興奮,可是現在要在同一個網路上的另一台電腦執行 listener,所以你會有兩個複本正在執行,每個機器上各有一個,然後再次用你的廣播位址來執行 broadcaster ... 嘿!你只有呼叫一次 sendto(),但是兩個 listeners 都收到了你的封包。酷喔!

如果 listener 收到你直接送給它的資料,但不是在廣播位址沒有資料,可能是因為你本機(local machine)上有防火牆(firewall)封鎖了這些封包。[是的,謝謝 Pat 與 Bapper 的說明,讓我知道為什麼我的範例程式無法運作。我跟你們說過我會在文件中提到你們,就是這裡了,感恩。]

再次提醒,使用廣播封包一定要小心,因為 LAN 上面的每台電腦都會被迫處理這類封包,無論它們有沒有用 recvfrom() 接收,這類封包會造成整個電腦網路相當大的負擔,所以一定要謹慎、適當地使用廣播。

[34] http://beej.us/guide/bgnet/examples/pack2.c
[35] http://tools.ietf.org/html/rfc4506
[36] http://beej.us/guide/bgnet/examples/broadcaster.c