ゲーム開発者向けのネットワークプログラミング。 パート2:データパケットの送受信

翻訳者から:これは、「 ゲームプログラマー向けネットワーキング」シリーズの 2番目の記事の翻訳です。 私は一連の記事全体が本当に好きで、それに加えて私は常に翻訳者として自分自身を試してみたかったです。 おそらく、この記事は経験豊富な開発者にはあまりにも明白に思えるかもしれませんが、私の意見では、どのような場合でも役立つでしょう。

最初の記事-http://habrahabr.ru/post/209144/




データパケットの送受信



はじめに


こんにちは、私の名前はグレンフィードラーです。シリーズ「ゲーム開発者向けのネットワークプログラミング」の2回目の記事であなたを歓迎します。









前の記事で 、ネットワークを介してコンピューター間でデータを転送するさまざまな方法について説明し、最終的にTCPではなくUDPプロトコルを使用することにしました。 パケットの再送信の待機に関連する遅延なしでデータを送信できるようにするために、UDPを使用することにしました。



次に、実際にUDPを使用してパケットを送受信する方法を説明します。



BSDソケット


最新のオペレーティングシステムのほとんどには、BSDソケット(バークレーソケット)に基づいた何らかのソケット実装があります。



BSDソケットは、「socket」、「bind」、「sendto」、「recvfrom」などの単純な関数で動作します。 もちろん、これらの関数に直接アクセスできますが、この場合、コードはプラットフォームに依存します。これは、異なるOSでの実装がわずかに異なる場合があるためです。



したがって、BSDソケットとの相互作用の最初の簡単な例を挙げますが、将来それらを直接使用することはありません。 代わりに、基本的な機能を習得した後、ソケットでのすべての動作を抽象化するいくつかのクラスを作成し、将来的にコードがプラットフォームに依存しないようにします。



さまざまなOSの機能


まず、ソケットの動作の違いを考慮できるように、現在のOSを決定するコードを記述します。



// platform detection #define PLATFORM_WINDOWS 1 #define PLATFORM_MAC 2 #define PLATFORM_UNIX 3 #if defined(_WIN32) #define PLATFORM PLATFORM_WINDOWS #elif defined(__APPLE__) #define PLATFORM PLATFORM_MAC #else #define PLATFORM PLATFORM_UNIX #endif
      
      





次に、ソケットの操作に必要なヘッダーファイルを接続します。 必要なヘッダーファイルのセットは現在のOSに依存するため、ここでは、上記の#defineコードを使用して、接続するファイルを決定します。



  #if PLATFORM == PLATFORM_WINDOWS #include <winsock2.h> #elif PLATFORM == PLATFORM_MAC || PLATFORM == PLATFORM_UNIX #include <sys/socket.h> #include <netinet/in.h> #include <fcntl.h> #endif
      
      





UNIXシステムでは、ソケット関数は標準システムライブラリに含まれているため、この場合、サードパーティライブラリは必要ありません。 ただし、Windowsでは、これらの目的のためにwinsockライブラリを接続する必要があります。



以下に、プロジェクトまたはメイクファイルを変更せずにこれを実行する方法を示します。



  #if PLATFORM == PLATFORM_WINDOWS #pragma comment( lib, "wsock32.lib" ) #endif
      
      





怠け者だからこのテクニックが好きです。 もちろん、ライブラリをプロジェクトまたはメイクファイルに接続できます。



ソケットの初期化


ほとんどのUnixライクなオペレーティングシステム(macosxを含む)では、ソケットを操作する機能を初期化するための特別な手順は必要ありませんが、Windowsでは最初にいくつかの手順を実行する必要があります。 -「WSACleanup」を呼び出します。



2つの新しい機能を追加しましょう。



  inline bool InitializeSockets() { #if PLATFORM == PLATFORM_WINDOWS WSADATA WsaData; return WSAStartup( MAKEWORD(2,2), &WsaData ) == NO_ERROR; #else return true; #endif } inline void ShutdownSockets() { #if PLATFORM == PLATFORM_WINDOWS WSACleanup(); #endif }
      
      





これで、プラットフォームに依存しないソケットの初期化および完了コードができました。 初期化を必要としないプラットフォームでは、このコードは何もしません。



ソケットを作成する


これで、UDPソケットを作成できます。 これは次のように行われます。



  int handle = socket( AF_INET, SOCK_DGRAM, IPPROTO_UDP ); if ( handle <= 0 ) { printf( "failed to create socket\n" ); return false; }
      
      





次に、ソケットを特定のポート番号(30000など)にバインドする必要があります。 新しいパケットが到着すると、ポート番号によって転送先のソケットが決まるため、各ソケットには独自のポートが必要です。 1024より小さいポート番号は使用しないでください-それらはシステムによって予約されています。



ソケットに使用するポート番号を気にしない場合は、関数に「0」を渡すだけで、システム自体が使用されていないポートを提供します。



  sockaddr_in address; address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; address.sin_port = htons( (unsigned short) port ); if ( bind( handle, (const sockaddr*) &address, sizeof(sockaddr_in) ) < 0 ) { printf( "failed to bind socket\n" ); return false; }
      
      





これで、ソケットはデータパケットを送受信する準備ができました。



しかし、この神秘的な「htons」関数はコードで何と呼ばれていますか? これは、現在の(リトルエンディアンまたはビッグエンディアン)からビッグエンディアンにネットワーク通信に使用される16ビット整数のバイト順を変換する小さなヘルパー関数です。 ソケットを直接操作するときに整数を使用するたびに呼び出す必要があります。



この記事では、「htons」関数とその32ビットのダブル「htonl」がさらに何度か表示されるので、注意してください。



ソケットを非ブロックモードにする


デフォルトでは、ソケットはいわゆる「ブロッキングモード」にあります。 つまり、「recvfrom」を使用してデータを読み取ろうとした場合、読み取り可能なデータを含むパケットをソケットが受信するまで、関数は値を返しません。 この動作は、私たちにはまったく適していません。 ゲームは、毎秒30〜60フレームの速度を備えたリアルタイムアプリケーションであり、ゲームを停止してデータパケットが到着するまで待つことはできません。



この問題は、作成後にソケットを「非ブロックモード」にすることで解決できます。 このモードでは、「recvfrom」関数は、ソケットから読み取るデータがない場合、ソケットにデータが表示されたときに再度呼び出す必要があることを示す特定の値をすぐに返します。



次のようにソケットを非ブロックモードにできます。



  #if PLATFORM == PLATFORM_MAC || PLATFORM == PLATFORM_UNIX int nonBlocking = 1; if ( fcntl( handle, F_SETFL, O_NONBLOCK, nonBlocking ) == -1 ) { printf( "failed to set non-blocking socket\n" ); return false; } #elif PLATFORM == PLATFORM_WINDOWS DWORD nonBlocking = 1; if ( ioctlsocket( handle, FIONBIO, &nonBlocking ) != 0 ) { printf( "failed to set non-blocking socket\n" ); return false; } #endif
      
      





ご覧のとおり、Windowsには「fcntl」関数がないため、一緒に「ioctlsocket」を使用します。



パッケージの送信


UDPはコネクションレスプロトコルであるため、パケットを送信するたびに、受信者アドレスを指定する必要があります。 同じUDPソケットを使用して、異なるIPアドレスにパケットを送信できます。ソケットの反対側に1台のコンピューターが存在する必要はありません。



次のように、パケットを特定のアドレスに転送できます。



  int sent_bytes = sendto( handle, (const char*)packet_data, packet_size, 0, (sockaddr*)&address, sizeof(sockaddr_in) ); if ( sent_bytes != packet_size ) { printf( "failed to send packet: return value = %d\n", sent_bytes ); return false; }
      
      





sendto関数によって返される値は、パケットがローカルコンピューターから正常に送信されたかどうかのみを示すことに注意してください。 しかし、パケットが受信者によって受け入れられたかどうかは表示されません! UDPには、パケットが目的の宛先に到着したかどうかを判断する手段はありません。



上記のコードでは、「sockaddr_in」構造体を宛先アドレスとして渡します。 この構造をどのように取得しますか?



アドレス207.45.186.98 ∗ 0000にパケットを送信するとします。



アドレスは次の形式で記述します。



  unsigned int a = 207; unsigned int b = 45; unsigned int c = 186; unsigned int d = 98; unsigned short port = 30000;
      
      





そして、sendtoが理解できる形式にするために、さらにいくつかの変換を行う必要があります。



  unsigned int destination_address = ( a << 24 ) | ( b << 16 ) | ( c << 8 ) | d; unsigned short destination_port = port; sockaddr_in address; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl( destination_address ); address.sin_port = htons( destination_port );
      
      





ご覧のとおり、最初に数字a、b、c、d(範囲[0、255]にある)を組み合わせて、各バイトが元の数字の1つである単一の整数にします。 次に、「htonl」および「htons」関数を使用してバイト順を変換することを忘れずに、宛先アドレスとポートで「sockaddr_in」構造を初期化します。



それとは別に、パケットを自分に転送する必要がある場合を強調する価値があります。ローカルマシンのIPアドレスを調べる必要はありませんが、アドレス(ローカルループのアドレス)として127.0.0.1を使用するだけで、パケットはローカルコンピューターに送信されます。



パケットを受信する


UDPソケットをポートにバインドすると、ソケットのIPアドレスとポートに到着するすべてのUDPパケットがキューに入れられます。 そのため、パケットを受信するには、エラーをスローするまでループで「recvfrom」を呼び出すだけです。これは、読み取るパケットがなくなることを意味します。



UDPは接続をサポートしていないため、パケットはネットワーク上のさまざまなコンピューターから送信される可能性があります。 パケットを受信するたびに、「recvfrom」関数は送信者のIPアドレスとポートを提供するため、このパケットの送信者がわかります。



ループでパケットを受信するためのコード:



  while ( true ) { unsigned char packet_data[256]; unsigned int maximum_packet_size = sizeof( packet_data ); #if PLATFORM == PLATFORM_WINDOWS typedef int socklen_t; #endif sockaddr_in from; socklen_t fromLength = sizeof( from ); int received_bytes = recvfrom( socket, (char*)packet_data, maximum_packet_size, 0, (sockaddr*)&from, &fromLength ); if ( received_bytes <= 0 ) break; unsigned int from_address = ntohl( from.sin_addr.s_addr ); unsigned int from_port = ntohs( from.sin_port ); // process received packet }
      
      





受信バッファのサイズより大きいパケットは、単に静かにキューから削除されます。 したがって、上記の例のように256バイトのバッファーを使用し、誰かが300バイトのパケットを送信すると、それは破棄されます。 パケットから最初の256バイトだけを取得することはできません。



ただし、独自のプロトコルを作成しているため、これは問題にはなりません。 常に注意して、受信バッファのサイズが十分に大きく、送信できる最大のパケットを収容できることを確認してください。



ソケットを閉じる


ほとんどのUnixライクなシステムでは、ソケットはファイル記述子であるため、使用後に標準の「閉じる」関数を使用してソケットを閉じることができます。 ただし、Windowsはいつものように際立っており、その中で「closesocket」を使用する必要があります。



  #if PLATFORM == PLATFORM_MAC || PLATFORM == PLATFORM_UNIX close( socket ); #elif PLATFORM == PLATFORM_WINDOWS closesocket( socket ); #endif
      
      





Windowsを続けてください!



ソケットクラス


そのため、ソケットの作成、ポートへのバインド、非ブロッキングモードへの切り替え、パケットの送受信、最後にソケットのクローズなど、すべての基本操作を把握しました。



しかし、ご覧のとおり、これらの操作はすべてプラットフォームごとにわずかに異なります。もちろん、ソケットを操作するときは常に、異なるプラットフォームの機能を覚えてこれらすべてを#ifdefで記述することは困難です。



したがって、これらのすべての操作に対して「ソケット」ラッパークラスを作成します。 また、「アドレス」クラスを作成して、IPアドレスを簡単に操作できるようにします。 これにより、パケットを送受信するたびに「sockaddr_in」を使用してすべての操作を実行することができなくなります。



したがって、Socketクラス:



  class Socket { public: Socket(); ~Socket(); bool Open( unsigned short port ); void Close(); bool IsOpen() const; bool Send( const Address & destination, const void * data, int size ); int Receive( Address & sender, void * data, int size ); private: int handle; };
      
      





そしてAddressクラス:



  class Address { public: Address(); Address( unsigned char a, unsigned char b, unsigned char c, unsigned char d, unsigned short port ); Address( unsigned int address, unsigned short port ); unsigned int GetAddress() const; unsigned char GetA() const; unsigned char GetB() const; unsigned char GetC() const; unsigned char GetD() const; unsigned short GetPort() const; bool operator == ( const Address & other ) const; bool operator != ( const Address & other ) const; private: unsigned int address; unsigned short port; };
      
      





次のように、受信と送信に使用します。



  // create socket const int port = 30000; Socket socket; if ( !socket.Open( port ) ) { printf( "failed to create socket!\n" ); return false; } // send a packet const char data[] = "hello world!"; socket.Send( Address(127,0,0,1,port), data, sizeof( data ) ); // receive packets while ( true ) { Address sender; unsigned char buffer[256]; int bytes_read = socket.Receive( sender, buffer, sizeof( buffer ) ); if ( !bytes_read ) break; // process packet }
      
      





ご覧のとおり、これはBSDソケットを直接操作するよりもはるかに簡単です。 また、プラットフォームに依存するすべての機能はSocketクラスとAddressクラス内にあるため、このコードはすべてのOSで同じになります。



おわりに


これで、UDPパケットを送受信するためのプラットフォームに依存しないツールができました。



UDPは接続をサポートしていません。これを明確に示す例を作成したいと思います。 そのため、テキストファイルからIPアドレスのリストを読み取り、パケットを1秒間に1回送信する小さなプログラムを作成しました。 プログラムはパケットを受信するたびに、送信側コンピューターのアドレスとポート、および受信したパケットのサイズをコンソールに表示します。



プログラムを簡単に構成して、ローカルマシン上でも複数のノードが互いにパケットを交換できるようにすることができます。 これを行うには、たとえば次のように、プログラムインスタンスごとに異なるポートを指定するだけです。



>ノード30000

>ノード30001

>ノード30002

など...



各ノードは、他のすべてのノードにパケットを転送し、ミニピアツーピアシステムのようなものを形成します。



このプログラムはMacOSXで開発しましたが、UnixライクなOSおよびWindowsでコンパイルする必要がありますが、これを改善する必要がある場合はお知らせください。



All Articles