QA@IT

ストリーム型ソケットにおけるデータ(メッセージ)の受信について

14665 PV

初めまして

ストリーム型のソケットプログラミングにおいて、送受信するアプリケーションデータの
取り扱いについて、ご教示いただきたいことがございます。

ストリーム型ソケットを使用する場合、各データは区切りなく送受信されるため、
データにヘッダーを設け、そのデータ長を記載して送受信するかと思います。
(データの最後に区切りコードを付ける方法もありますが、データ長を記載する方法で考えています。)

送信側で、送信するデータのデータ長(Length)が正しく設定されていれば
問題はないかと思いますが、送信側で誤って、実際に送信するデータサイズと異なる
データ長(Length)を設定した場合に、受信側でどのように扱えばよいか苦慮しています。

実際に送信するデータサイズより小さな値が設定された場合は、
次のデータのヘッダー部分を正しく受信することができませんし、
また、大きな値が設定された場合は、次のデータのヘッダー部分まで
受信してしまい、いずれにしても次のデータを適切に送受信できなくなってしまいます。

一般的に、ヘッダーに設定されているデータ長(Length)が正しく設定されていない
データを受信した場合、次のデータを適切に扱うため、どのように判断すればよいのか
お知恵を拝借いたしたくご質問させていただきました。

よろしくお願いいたします。

回答

一般にストリームの場合はヘッダのLengthフィールドでパケット境界を教えるか、パケットの区切りコードでパケット境界を示すかは、プロトコル設計者がどっちか片方を選択するんじゃないかと思います。いずれにせよ送信側は正しいものをセットすることがコミュニケーションの前提条件になると思います。ただ受信側はLengthフィールドを一応信用しつつもパケットのsanity checkを行ったほうがよいと思います。

RADIUS over TLS (RFC 6614)やRADIUS over TCP (RFC 6613)の場合は、ヘッダのLengthフィールドを使用します。

パケット境界の考え方についてはRFC 6614の下の箇所が参考になります。

3.4. RADIUS Datagram Considerations
(1) ...
Instead, packet boundaries of RADIUS packets that arrive in the
stream are calculated by evaluating the packet's Length field.
Special care needs to be taken on the packet sender side that
the value of the Length field is indeed correct before sending
it over the TLS tunnel, because incorrect packet lengths can no
longer be detected by a differing datagram boundary. See
Section 2.6.4 of [RFC6613] for more details.

パケットのsanity checkについては上で言及されているRFC 6613の下記の箇所が参考になります。Lengthフィールドが誤っていたような場合、パケット境界を厳密に決定することはできないと思いますが、あからさまな間違いは検出できると思います。また「1個前の」パケットがmalformedで「次の」パケットが切り出せないときは、コネクションを切断すべきであるようです。

2.6.4. Malformed Packets and Unknown Clients
...
When TCP is used as a transport, decoding the "next" packet on a
connection depends on the proper decoding of the previous packet. As
a result, the behavior with respect to discarded packets has to
change.
...

RADIUSの場合は、ヘッダの固定長部分とAttributeのリストからなる可変長部分がありますが、AttributeはTLVの形式で、個々のAttributeがL(ength)を持つので、リストを舐めながらその和をとって固定長部分と足せば、パケット全体長がわかるはずと思います。それとヘッダ内のLengthを比較すれば、ヘッダとボディ部分とで辻褄が合っているかどうかを確認できるのではないかと思いますので、ヘッダ内のLengthの間違いはある程度は検出できそうな気がします。

編集 履歴 (0)
  • ご返信が長くなりましたので、回答で記載させていただきました。 -

誤解があったようですので、ご説明させていただきます。

ご存じかと思いますが、UDPはその名のとおり、データグラム単位で送受信を行うプロトコルです。これに対して、TCPはバイトストーム型で、バイト単位で送受信を行うプロトコルです。

UDPではデータグラム単位(意味をなすデータの塊)で送受信することができます。
例を挙げますと、アプリケーションが「AAAA」と「BBBB」という2つの意味をなす塊であるデータグラムをUDPを使用して、2回送信した場合、受信側でも2回受信することにより、
「AAAA」、「BBBB」という2つの意味をなす塊として、それぞれ受信することができます。

これに対してTCPでは意味をなす塊であるデータグラムという単位は意識されず、単にバイト列として扱われます。受信側でも同様に単にバイト列として扱われます。
例を挙げますと、アプリケーションが「AAAA」と「BBBB」という2つの意味をなす塊であるデータグラムをTCPを使用して2回にわたり送信した場合、受信側では1回の受信で「AAAABBBB」という単位で受信する場合があり、また、3回の受信で「AAA」、「ABB」、「BB」と単位で受信する場合もあります。つまり、単なるバイト列として送受信され、送信回数と受信回数には何ら関係はないのです。意味をなす塊で2回送信したので、2回受信すれば、意味をなす塊でそれぞれ受信できるとは言えないのです。
TCPでは、「AAAA」、「BBBB」という本来意味をなす塊(データグラム)に関する情報はなく、
このため、受信側アプリケーションでは受信されたバイト列を意味をなす塊(データグラム)に分ける必要があります。

TCP、UDPともに下位のレイヤーとしてIPを使用しますので、IP層でのフラグメント化やフラグメント化されたパケットの再構築については基本的には同様です。(少し荒っぽいですが。)
送信側では意味のある塊が意識できているので問題はないのですが、受信側で意味のある塊として受信できるかが異なります。
これは、受信側でどのような状態で受信されるかということかと思います。
実装上のお話になりますが、UDPでは各ソケットは受信バッファを持ち、
ソケットに対して到着した「データグラム」はそのソケットの受信バッファに格納され、
プロセスがrecvfrom(C言語)を呼び出すと、このバッファからFIFO順序で次のデータグラム(意味をなす塊)が返されます。
TCPでも同様に各ソケットは受信バッファを持ち、ソケットに対して到着したデータがそのソケットの受信バッファに格納されます。プロセスがrecv(C言語)を呼び出すとその時点で受信バッファに格納されているバイト列が返されます。つまり、意味をなす塊では返されません。

長文になってしましましたが、以下の文言の意味するところをご理解いただけましたでしょうか。

UDPを使用して送受信してしたメッセージの塊をTCPを使用して送受信した場合、
その塊の区切りは判断できないと思っています。

また、ご意見をいただきました以下のような発想でもありません。

通常、TCP(STREAM)の方が信頼がおける通信であり、UDP(DGRAM)の方が整合性を
失いやすい通信です。なぜUDPだと可能なものがTCPだと無理だと思われたのでしょうか
(普通は逆ですね)

UDP、TCPはそれぞれ固有の特性をもっています。それぞれの特性に応じた使い方をすれば良いと思っています。例えば、今日のインターネットの基盤を支えるDNSへの問合せでは、ご存じのとおりUDPが使用されています。TCPがUDPよりも優れているという発想もありません。
単にTCPを使用する際は、バイトストリーム型であるため、その中から意味のある塊を適切に取り出す、あるいは不適切なものを排除する適切な方法がないかと思いお知恵を拝借した次第です。

以下、ご質問をいただいております回答になりましたでしょうか。
今回、ご質問をさせていただいた主旨ですが、UDPでもフラグメンテーションはされますので、小分けでしか送れないという点ではありません。誤解を招いたのであれば申し訳ありませんでした。

ですから「UDPだと塊で送れて安心、TCPだと塊は送れない小分けでしか送れないから
どうしよう」という発端であればそれがそもそも勘違いではないかと思うのですが
どうでしょう。

編集 履歴 (0)
  • 丁寧な説明ありがとうございます。あらたに回答をおこしましたが、そこに書いた以上のことは出せないと思いますので一旦この辺りで締めさせてもらえればと思います。 -
  • 最後までお付き合いをいただき、ありがとうございました。
    幅広い知識をお持ちのようですね。
    更なるご活躍をされることをお祈りいたします。
    -

なるほど、わかりました。

ちなみに誤解なきように付け加えておきますがTCPの方が優れていると言っているつもりはありません。信頼性が高いというだけですね。
(動画で過ぎたフレームが再送されても…とか書いていたんですが、長かったので消してしまってたのでTCP推しに見えてしまいましたね。)

たしかにUDPは送信と受信が1対1になりますが、再構築されたパケットロスや順番が違っていて例えばデータ長の部分がおかしくなっている可能性もありますよね。
(元々は送信データが間違っている可能性が…という話でしたが、UDPだと通信内容がおかしいリスクもありますね。詳しい様ですので説明する必要も無さそうですが。)

TCPではたしかに送信と受信は1対1になりません。そこはたしかに考慮漏れてました。
ただ、区切りを明確にしたいのであればソケットを閉じればいいとも思っていました。
たとえば「Webページ」を構成するすべての情報(HTML、CSS、js、画像)を取得するにあたってはブラウザは複数のコネクションを同時に接続して取得します。各ファイルごとにソケットは閉じて次のリソースのために再度接続からやりなおします。これであればファイルという塊をきちんと区別して取得する事ができますね。
(各リソースのHTTPヘッダとボディの間にCRLFという区切り文字があることはもちろん知っています。そういう意味ではHTTPでは区切り文字も使い、レスポンスボディの長さ(ContentLength)もセットされていて、ファイルの区別はソケットを使い回さないことで(ソケットを分ける事で)データを区別するので、固定長以外の簡易な手法は一通りでてきてるかもしれません)

ソケットの使い回しを考えなければUDPのデータグラムの制限よりも大きなデータは送信可能ですし、送りたいだけ送ってしまえばよく、明確に分けたいデータを送る時が来たらそれは別のソケットを使う…とすれば塊は表現できるとおもいます。
(今回のコードでは明確な終了データらしきものを送信させましたが、TCPで使い回しを考えないケースではrecvを呼びつづけて、最初にrecv長が0になったらデータが最後まで来た(FINの後である)としてソケットを閉じるケースが多いと思います)

塊はそれで表現できますからセットされているデータ長が正しいかどうかも比較できますので元の質問にあったような設定されているデータ長が誤っている場合も対処できます。
(実際HTTPはContentLengthが正しいかどうかチェックされることがありますね)
クロスチェックとしてチェックデータつけたい場合も固定長なら最後でも平気そうですね。
(最初はWebSocketのフレームとか別の事と思ってましたもので…)

実際にはデータの設計などが決まってから適切なものを選択する事になると思います(どれが正しいという事はないと思います)が、参考になれば幸いです。

編集 履歴 (0)
  • 貴重なノウハウをご教示(公開)いただき、ありがとうございました。
    ご教示いただきました事項を参考にさせていただきます。
    -

ストリーム「型」ソケット、INET STREAMのストリームですね、ちょっとストリームデータと勘違いしていました。
いずれにせよ、以下の発言が気になります。

UDPを使用して送受信してしたメッセージの塊をTCPを使用して送受信した場合、その塊の区切りは判断できないと思っています。

通常、TCP(STREAM)の方が信頼がおける通信であり、UDP(DGRAM)の方が整合性を失いやすい通信です。なぜUDPだと可能なものがTCPだと無理だと思われたのでしょうか(普通は逆ですね)

C じゃないですが、python 3.4.2 で簡単なUDPとTCPの実装を書くと以下の様になります。
C のソケットライブラリを使っても流れは大体同じになるかと思います。
マジックコメントつけてないので試してみるときは全角文字をソースに含めないようにしてください。Win 8.1でためしましたが大したことしてないんでどのpython 3系ならどの環境でも動くかと思います。

※ 説明のためにソース書いたんですが、書きながら質問者さんの疑問がなにか考えながら直していたらソースはあまり関係なくなってしまいました。
まったく無関係にはなってないですが、ソース後のコメントだけ読んでもらって結構です。

TCP Server / Client

serv.py

import socket
import threading
import time

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('', 41234)) ; s.listen(2)  # allow 2 connections

def listen_thread(s_sock, num):
    print("listen_thread No.{0} start.".format(num))
    (c_sock, addr) = s_sock.accept() 
    print("accept : No.{0} from {1}".format(num, addr))
    try:
        while True:
            data = c_sock.recv(10)
            if not data:
                continue
            print("recv: thread No.{0}: {1}".format(num, data))
            if data.decode('ascii') == b'end'.decode('ascii'):
                print("recv: receive terminate request. thread No.{0}".format(num))
                return
    finally:
        c_sock.close()

th1 = threading.Thread(target=listen_thread, name="th1", args=(s, 1))
th2 = threading.Thread(target=listen_thread, name="th2", args=(s, 2))
th1.start() ; th2.start()

try:
    while th1.isAlive() and th2.isAlive():
        time.sleep(1)
finally:
    s.close()

cli.py

import socket
import time
server_address = ('localhost', 41234)
sock1 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

sock1.connect(server_address)
sock2.connect(server_address)
print("sock1 sockname = {0}".format(sock1.getsockname()))
print("sock2 sockname = {0}".format(sock2.getsockname()))

st = ed = time.clock()
try:
    while int(ed - st) < 3:
        sock1.sendall(b'a'*3) ; sock2.sendall(b'bbb')
        ed = time.clock() ; time.sleep(1)
finally:
    sock1.sendall(b'end')
    sock2.sendall(b'end')
sock1.close()
sock2.close()

実行結果(サーバー出力のみ)

listen_thread No.1 start.
listen_thread No.2 start.
accept : No.1 from ('127.0.0.1', 50144)
accept : No.2 from ('127.0.0.1', 50145)
recv: thread No.1: b'aaa'
recv: thread No.2: b'bbb'
~省略~
recv: thread No.1: b'end'
recv: receive terminate request. thread No.1
recv: thread No.2: b'end'
recv: receive terminate request. thread No.2

サーバーは最大 2接続を許可するようにしています(そのためスレッドも利用しています)。
クライアントはソケットを 2個作ってサーバーに接続しにいきます。

このときサーバーでacceptした段階で各クライアント接続用のソケット(c_sock)が作成されます。
2つ接続されるので、2つのc_sockが作成されそれぞれのクライアントの通信は区別されて通信できます。

UDP Server / Client

一方UDPではそういうことにはなりません。

udp_serv.py

import socket

udp_s_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_s_sock.bind(('', 41235))

while True:
    (data, addr) = udp_s_sock.recvfrom(10)
    print("recv: {0} from {1}".format(data, addr))
    if data and data.decode('ascii') == b'end'.decode('ascii'):
        print("recv: udp socket receive terminate request from {0}".format(addr))
        break

udp_s_sock.close()

udp_cli.py

TCPのクライアントの先頭 5行を以下に書き換えます(変更があるのは3,4,5行目)

import socket
import time
server_address = ('localhost', 41235)
sock1 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock2 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

6行目以降は同じ。

実行結果(サーバー出力)

recv: b'aaa' from ('127.0.0.1', 61491)
recv: b'bbb' from ('127.0.0.1', 61492)
~省略~
recv: b'end' from ('127.0.0.1', 61491)
recv: udp socket receive terminate request from ('127.0.0.1', 61491)

※ こちらは先にendを受け取った段階でUDPソケットをクローズするので、2つ目のendは受信されない(さらにUDPだから受信確認も接続確認もないので2つ目のendがロストしていてもだれもエラーを出さない)。

UDPではサーバーは単純に待ち受けて飛んできたデータを順次処理していくだけです。
さらに、TCPの場合はサーバーが起動していないとクライアントはエラーとなりますが、UDPの場合はサーバーが起動していなくてもデータを送信します。受信されたかどうかの確認もされません。

と、こういった基本部分はご存知かもしれませんが、

UDPを使用して送受信してしたメッセージの塊をTCPを使用して送受信した場合、その塊の区切りは判断できないと思っています。

という点をみるところ、データグラム(つまり塊)を受け取るUDPに対して
TCPは小刻みに受け取るじゃないかという話なんでしょうか。

上記のプログラムの各クライアントにおいて
sock1.sendall(b'a'*3) という部分で 3byteの送信を行っています。
ではこれを
sock1.sendall(b'a'*65536)
とした場合何が起こると思いますか?

TCPは正常に送信できます。
UDPは…エラーとなります(正確には環境にもよりますが)。
うちのWindows環境では

OSError: [WinError 10040] データグラム ソケットで送信されたメッセージが、内部のメッセージのバッファーまたはほかのネットワークの制限を超えています。または、データグラムの受信に使われるバッファーがデータグラムより小さく設定されています。

というエラーになります。

UDPはデータグラムを送信します。イーサネットフレームのサイズなどIPフラグメンテーションが発生すれば分割しなければなりませんがそれはIPの仕事です。IPがデータグラムまでは戻してくれますがUDPから2回送信したデータの届いた順番が正しいのかはUDPもIPも知りません。

分けずに塊で送るから安心という考えなのだと思いますが、データグラムのサイズには 65515 だったか、16bit長からヘッダサイズを除いたサイズという限界があります。さらに環境によってはフラグメンテーションが許されずそもそも大きなデータグラムが送れないことがあり、その場合はUDPは分割できないのでエラーになります。

TCPの場合はストリーム通信で受取先で組み立てられるのでその制限はありません。
数GBでも転送できます。それを小分けに送ってもとの数GBまで組み立てるまでがTCPの仕事です。
塊を送れないのではなくて、塊をばらして送って塊に戻すところまでやってくれるのです。

ですから「UDPだと塊で送れて安心、TCPだと塊は送れない小分けでしか送れないからどうしよう」という発端であればそれがそもそも勘違いではないかと思うのですがどうでしょう。

編集 履歴 (0)
  • ご質問に対する回答が長くなりましたので、回答として記載させていただきました。 -

はじめに、お時間を割いていただきRFCをご確認いただいたのでしょうか。
心から感謝申し上げます。

明確なご回答をいただき、誠にありがとうございます。

RADIUS over TCP(RFC6613)では、以下の記載があります。

2.6.5. Limitations of the ID Field
The RADIUS ID field is one octet in size. As a result, any one TCP
connection can have only 256 "in flight" RADIUS packets at a time.

上記を実装する場合、Lengthフィールドのみで、一つのTCPコネクションの中で、複数のメッセージを適切に扱わなければならいことになります。

RADIUS over TCP(RFC6613)では、また、以下のような記載があります。

2.6.4. Malformed Packets and Unknown Clients
 ・・・・
That is, the TCP connection MUST be closed if any of
the following circumstances are seen:
 ・・・
 * Packet where the attributes do not exactly fill the packet
 ・・・

Lengthフィールドが誤っている場合は、適切に属性データが送られているか判断できないと考えています。
RADIUS over TCP(RFC6613)に限ったことではなく、多くのメッセージフォーマットでLengthフィールドが使われていますが、一般的にLengthフィールドのみでどのようにメッセージを扱われているか、お知恵を拝借させていただきました。

Lengthフィールド値が適切であることが大前提であることは理解していますが、フェールセーフの観点から、明確かつ効率よくMalformed Packetを排除する方法があればと思った次第です。
第三者が故意にMalformed Packetを送ってこないとも限りませんので、当該レイヤーで可能限り対策ができないかと。

非常に勉強になりました。ありがとうございました。

編集 履歴 (0)

ご回答いただき、ありがとうございます。

独自にパケットのフォーマットを規定できるのであれば
ご教示いただきました方法等を考えることができるのですが
区切りコードのないデータ長を使用するプロトコルの場合は
一般的にどのように扱われるのでしょうか。

例えば、RADIUSパケットのフォーマットは、
以下のようになっています。

Code: 1オクテット
Identifier: 1オクテット
Length: 2オクテット
Autenticator:16オクテット
Attribute: 任意のオクテット

このパケットのフォーマットでは、「Length」のみでパケット長に合わないパケットは破棄しなければなりません。
(区切りコード等がありません。)

RADIUSは、UDPを使用するのでパケットは一つの塊として送受信されるので、
上記のようなフォーマットでパケットを判断できるというのは理解できるのですが
RADIUSに付随した「RADIUS over TCP」というRFCがあり、
その中で「RADIUSパケットフォーマットに変更はない」と記載されています。

SCTPを使用する方法があるかとは思いますが、TCPで扱うことができればと思っています。

編集 履歴 (0)
  • もしかして、今回は独自の通信プロトコルを作ることに関する質問じゃなくて、「RADIUS over TCP」の仕組みに関する質問なんですか? -
  • 「RADIUS over TCP」の仕組みは、例としてあげさせていただきました。TCP等のストリームソケットにおいて、区切りコードを使用せず、データ長(Length)のみを使用して、どのように単一パケットを取り出されているか、お知恵をお借りしたいと思い質問させていただきました。 -
  • その「区切りコードが使えない」という前提条件はどこから出てきたものなのでしょう?区切りコードを使えば解決することは、もう分かっていると思いますが? -
  • ちなみに、UDPの1パケットは、TCPでも1パケットです。 -
  • パケットという表現が不適切でした。UDPを使用して送受信してしたメッセージの塊をTCPを使用して送受信した場合、その塊の区切りは判断できないと思っています。 -
  • ご存じでしたら、「RADIUS over TCP」では、どのようにこれまでのRADIUSパケットを送受信しているか教えていただないでしょうか。やはり区切りコードを追加しているのでしょうか。
    -
  • 追伸:区切りコードを付加することでメッセージの塊を判断できることは理解していますが、標準プロトコル等のメッセージフォーマットを変えることになるため、ご質問をさせていただきました。 -

「送信側でデータ長を間違える可能性がある」のであればヘッダが信用できない場合があるという事なので、区切りコードか何かをセットしないと難しいんじゃないでしょうか。

データの前に固定サイズのチェックサムなどがあったとしても、データ長が誤りであったと気付けるだけでどこで区切れば次のデータの始まりかは判断でき無い様に思います(データの後だと同じ理由でチェック用データを取得できないかもしれません)。

BASE64の様にデータ部には特定の文字しか含めずにそれ以外のコードが来た場合は少なくともデータではないと判断できるようにするとか、
データ中には絶対に現れないバイトデータをヘッダの前か後(ヘッダが固定長なら後でも逆算できます)につけるなどの方が簡単ではないかと思います。

信用できる部分、信用できないことがある部分、救いたい部分の範囲(再送が要求できればいいのか、再送は許されず既にもらったデータでなんとかリカバリーできなければいけないのかなど)も整理した方がいいかもしれません。

編集 履歴 (0)
  • ご回答いただき、ありがとうございます。
    返信が長くなりましたので、回答として記載させていただきます。
    -

データの末尾にCRCを付加してみてはどうでしょう

編集 履歴 (0)
ウォッチ

この質問への回答やコメントをメールでお知らせします。