7.6 データカプセル化の子

ところで、データをカプセル化するとはどういうことでしょうか。最も単純なケースでは、識別情報かパケット長のどちらか、あるいは両方を含むヘッダーをそこに貼り付けるということです。

ヘッダーはどのようなものにすべきでしょうか?まあ、プロジェクトを完成させるために必要だと思うものを表すバイナリデータに過ぎません。

うわー。漠然としてますね。

なるほど。例えば、SOCK_STREAM を使用したマルチユーザチャットプログラムがあるとします。ユーザが何かをタイプする("発言する")と、2つの情報がサーバに送信される必要があります: 何を発言したか、誰が発言したかです。

ここまでは良いですか?"では何が問題なのか?" とあなたは聞いているのでしょう。

問題は、メッセージの長さがまちまちであることです。ある人は "tom" と名乗り、ある人は "Benjamin" と名乗り、"Hey guys what is up?" と言うかもしれません。

そこで、これらのものが入ってきたときに send() でクライアントに送るわけです。送信するデータストリームは次のようになります。

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 ?

といった具合に。あるメッセージが始まり、別のメッセージが止まったとき、クライアントはどうやって知るのでしょうか?もし望むなら、すべてのメッセージを同じ長さにして、上で実装した sendall() を呼び出すだけでよいでしょう。しかし、それはバンド幅を浪費します!"tom" が "Hi" と言うために 1024 バイトも send() したくはないでしょう。

そこで、データを小さなヘッダーとパケット構造でカプセル化します。クライアントもサーバも、このデータをどのようにパックしたりアンパックしたりするか("マーシャル"、"アンマーシャル" と呼ばれることもあります)知っています。今は見ないでください、私たちは、クライアントとサーバがどのように通信するかを記述するプロトコルを定義し始めているのです

この場合、ユーザ名は固定長で8文字、パディングは '\0' とします。そして、データは最大 128 文字の可変長とします。このような場合に使用するパケット構造の例を見てみましょう。

  1. len (1 byte, unsigned)---8 バイトのユーザ名とチャットデータをカウントしたパケットの総長です。

  2. name (8 bytes)---ユーザの名前。必要なら NUL パッドされます。

  3. chatdata (n-bytes) --- データ自体で、128 バイト以下です。パケットの長さは、このデータの長さに 8(上記の名前フィールドの長さ)を加えたものとします。

なぜ、8 バイトと 128 バイトというフィールドの制限を選んだのか?十分な長さがあると思い、思いつきで選んだのです。しかし、8 バイトでは制約が多すぎる、30 バイトの名前フィールドがあってもいい、などということもあるかもしれません。選択はあなた次第です。

上記のパケット定義を用いると、最初のパケットは以下の情報(16 進数、ASCII)で構成されることになります。

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

そして、2つ目も同様です。

   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  ...

(もちろん、長さはネットワークバイトオーダーで格納されます。この場合は 1 バイトだけなので問題ないですが、一般的にはパケット内の 2 進整数はすべてネットワークバイトオーダーで格納されるようにしたいです。)

このデータを送信するときには、安全策をとって上記の sendall() のようなコマンドを使うべきです。そうすれば、たとえすべてのデータを送信するために send() を複数回呼び出す必要があったとしても、すべてのデータが送信されたことを確認できます。

同様に、このデータを受信するときにも、少し余分な作業をする必要があります。念のため、部分的なパケットを受け取るかもしれないことを想定しておく必要があります(例えば、上の Benjamin から "18 42 65 6E 6A" を受け取るかもしれませんが、この recv() のコールではそれがすべてです。)。パケットが完全に受信されるまで、何度も何度も recv() を呼び出す必要があります。

でも、どうやって?さて、私たちはパケットが完成するために必要な合計バイト数を知っています。その数はパケットの前面に貼られているからです。 また、パケットの最大サイズは 1+8+128、つまり 137 バイトであることも知っています(パケットの定義がそうなっているからです)。

実は、ここでできることがいくつかあるんです。すべてのパケットが長さで始まることを知っているので、パケットの長さを取得するためだけに recv() を呼び出すことができます。そして、一度それを手に入れたら、パケットの残りの長さを正確に指定して(おそらくすべてのデータを得るために繰り返し)、完全なパケットを手に入れるまで再びそれを呼び出すことができます。この方法の利点は、1つのパケットに対して十分な大きさのバッファが必要なことで、欠点は、すべてのデータを取得するために少なくとも2回 recv() を呼び出す必要があることです。

もう一つの方法は、単に recv() を呼び出して、受信してもよい量をパケットの最大バイト数として言うことです。そして、受け取ったものはすべてバッファの後ろに貼り付け、最後にパケットが完了したかどうかを確認します。もちろん、次のパケットの一部を受け取るかもしれないので、そのためのスペースが必要です。

そこで、2つのパケットに十分な大きさの配列を宣言します。これは作業用の配列で、到着したパケットを再構築します。

データを recv() するたびに、ワークバッファにデータを追加し、パケットが完成したかどうかチェックします。すなわち、バッファ内のバイト数がヘッダで指定された長さ以上であること(ヘッダの長さには長さ自体のバイト数は含まれないので、+1)です。バッファ内のバイト数が1より小さい場合、明らかにパケットは完全ではありません。この場合、特別なケースを作る必要があります。しかし、最初の 1 バイトはゴミなので、正しいパケット長を知るためにそれを当てにすることはできないからです。

パケットを完成させたら、あとは好きなように使ってください。使って、ワークバッファから削除してください。

ふぅ〜。もう頭の中でグルグルしてますか?さて、ここでワンツーパンチの2つ目です。1回の recv() 呼び出しで、あるパケットの終わりを越えて次のパケットを読んでしまったかもしれません。つまり、一つの完全なパケットと、次のパケットの不完全な部分を持つワークバッファを持っているのです!なんてこったい。(しかし、このような事態を想定して、ワークバッファを2つのパケットを保持できるような大きさにしたのです!)

最初のパケットの長さはヘッダからわかっているし、ワークバッファのバイト数も記録しているので、ワークバッファのバイト数のうち何バイトが2番目の(不完全な)パケットに属しているかを差し引いて計算することができるのです。最初のパケットを処理したら、それをワークバッファから取り除き、部分的な2番目のパケットをバッファの先頭に移動させ、次の recv() のためにすべての準備をすることができます。

(読者の中には、実際に部分的な2番目のパケットをワークバッファの先頭に移動させるのに時間がかかることを指摘する人もいるでしょう。プログラムは、循環バッファを使用することによって、これを必要としないようにコード化することができます。残念ながら、円形バッファに関する議論は、この記事の範囲外です。それでも気になるなら、データ構造の本を読んでみてください。)

簡単だとは言っていません。なるほど、簡単だとは言いました。練習すれば、すぐに自然にできるようになりますよ。エクスカリバーに誓って!