はじめに
ここでは、複数のクライアントが同時に接続できるTCPを利用したクライアントサーバー型チャットアプリケーション(僭越ながら、「DOBON Chat」と命名させていただきます)のサンプルを示し、その要点を解説します。
.NET FrameworkではTCPを利用したデータ通信を行うためのクラスとして、TcpClient
及びTcpListener
クラス(共にSystem.Net.Sockets
名前空間)が用意されています。これらのクラスは内部でSocket
クラス(System.Net.Sockets
名前空間)を使用しており、Socket
クラスをより簡単に扱えるようにするためのクラスであると言えます。しかしSocket
クラスを直接扱う場合と比べて機能的に劣り、しかも取り扱いの難しさもそれほど変わるとは思えません。そこでここでは、TcpClient
とTcpListener
クラスを使わずに、Socket
クラスを使ってサーバーとクライアントを作成しています(TcpClient
とTcpListener
を使用した簡単なサンプルは、「DOBON.NET .NET Tips - TCPクライアント・サーバープログラムを作成する」にあります)。
対象読者
.NETでのネットワークプログラミング(特にソケットを使ったTCP非同期通信)に興味がある方を対象としています。ただし、ここではネットワークや.NETプログラミングの基本的な事柄については説明しませんので、不明な点はMSDNライブラリ等で調べてください。
必要な環境
サンプルはVisual Studio .NET 2003で作成され、.NET Framework 1.1で動作確認をしていますが、.NET Framework 1.0でも問題なく動作するでしょう。
クライアントの作成
クライアントはサーバーに接続し、データの送受信を行います。Socket
クラスの場合、データの送信にはSend
、受信にはReceive
メソッドを使えば簡単です(「DOBON.NET .NET Tips - Socketを使ってファイルをダウンロードし表示」に一例があります)。しかしチャットアプリケーションの場合、少なくともReceive
メソッドをそのまま呼び出す訳にはいきません。Receive
メソッドはデータを受信するまでスレッドをブロックするため(ブロッキングモードの場合)、その間データの送信はおろか、何もできなくなります。いつデータが送られて来るか分からないチャットでは常にデータの受信を待機していなければならないため、これは致命的です。
データの受信を待機しつつ、送信もできるようにするには、ポーリングによる方法(参考資料3)や、Socket
クラスの非同期メソッドによる方法(参考資料1,2)があります。ポーリングによる方法では、Socket.Poll
メソッド(または、Available
プロパティ)でデータの読み取りが可能かループやタイマーで監視し、読み取れると判断できれば、Receive
メソッドで受信するようにします。ポーリングはこのように無駄なループを繰り返すため、CPUに負担をかけるという欠点があります。
一方非同期メソッドによる方法では、Socket
のBeginReceive
とEndReceive
メソッドを使用します。「DOBON Chat」ではこの方法を採用しています。
非同期受信では、まずBeginReceive
メソッドを呼び出すことにより、データの受信が開始されます。Receive
メソッドではデータを受信しない限り処理が次へ進みませんが、BeginReceive
メソッドはすぐに終了し、ブロックしません。BeginReceive
を呼び出した後にデータを受信すると、指定したコールバックメソッドが実行されます。このコールバックメソッドでEndReceive
メソッドを呼び出し、データを受信し、再びBeginReceive
メソッドを呼び出して非同期受信を再開するようにします。
以下に非同期メソッドを使ってデータを受信する簡単な例を示します。Connect
メソッドを使ってすでにサーバーと接続しているSocket
をこのStartReceive
メソッドに渡すことにより、データの非同期受信が開始され、データを受信するとUTF-8でデコードし、コンソールに出力しています。
//非同期データ受信のための状態オブジェクト private class AsyncStateObject { public System.Net.Sockets.Socket Socket; public byte[] ReceiveBuffer; public System.IO.MemoryStream ReceivedData; public AsyncStateObject(System.Net.Sockets.Socket soc) { this.Socket = soc; this.ReceiveBuffer = new byte[1024]; this.ReceivedData = new System.IO.MemoryStream(); } } //データ受信スタート private static void StartReceive(System.Net.Sockets.Socket soc) { AsyncStateObject so = new AsyncStateObject(soc); //非同期受信を開始 soc.BeginReceive(so.ReceiveBuffer, 0, so.ReceiveBuffer.Length, System.Net.Sockets.SocketFlags.None, new System.AsyncCallback(ReceiveDataCallback), so); } //BeginReceiveのコールバック private static void ReceiveDataCallback(System.IAsyncResult ar) { //状態オブジェクトの取得 AsyncStateObject so = (AsyncStateObject) ar.AsyncState; //読み込んだ長さを取得 int len = 0; try { len = so.Socket.EndReceive(ar); } catch (System.ObjectDisposedException) { //閉じた時 System.Console.WriteLine("閉じました。"); return; } //切断されたか調べる if (len <= 0) { System.Console.WriteLine("切断されました。"); so.Socket.Close(); return; } //受信したデータを蓄積する so.ReceivedData.Write(so.ReceiveBuffer, 0, len); if (so.Socket.Available == 0) { //最後まで受信した時 //受信したデータを文字列に変換 string str = System.Text.Encoding.UTF8.GetString( so.ReceivedData.ToArray()); //受信した文字列を表示 System.Console.WriteLine(str); so.ReceivedData.Close(); so.ReceivedData = new System.IO.MemoryStream(); } //再び受信開始 so.Socket.BeginReceive(so.ReceiveBuffer, 0, so.ReceiveBuffer.Length, System.Net.Sockets.SocketFlags.None, new System.AsyncCallback(ReceiveDataCallback), so); }
'非同期データ受信のための状態オブジェクト Private Class AsyncStateObject Public Socket As System.Net.Sockets.Socket Public ReceiveBuffer() As Byte Public ReceivedData As System.IO.MemoryStream Public Sub New(ByVal soc As System.Net.Sockets.Socket) Me.Socket = soc Me.ReceiveBuffer = New Byte(1023) {} Me.ReceivedData = New System.IO.MemoryStream End Sub End Class 'データ受信スタート Private Shared Sub StartReceive _ (ByVal soc As System.Net.Sockets.Socket) Dim so As New AsyncStateObject(soc) '非同期受信を開始 soc.BeginReceive(so.ReceiveBuffer, 0, so.ReceiveBuffer.Length, _ System.Net.Sockets.SocketFlags.None, _ New System.AsyncCallback(AddressOf ReceiveDataCallback), so) End Sub 'BeginReceiveのコールバック Private Shared Sub ReceiveDataCallback _ (ByVal ar As System.IAsyncResult) '状態オブジェクトの取得 Dim so As AsyncStateObject _ = CType(ar.AsyncState, AsyncStateObject) '読み込んだ長さを取得 Dim len As Integer = 0 Try len = so.Socket.EndReceive(ar) Catch ex As System.ObjectDisposedException '閉じた時 System.Console.WriteLine("閉じました。") Return End Try '切断されたか調べる If len <= 0 Then System.Console.WriteLine("切断されました。") so.Socket.Close() Return End If '受信したデータを蓄積する so.ReceivedData.Write(so.ReceiveBuffer, 0, len) If so.Socket.Available = 0 Then '最後まで受信した時 '受信したデータを文字列に変換 Dim str As String = _ System.Text.Encoding.UTF8.GetString( _ so.ReceivedData.ToArray()) '受信した文字列を表示 System.Console.WriteLine(str) so.ReceivedData.Close() so.ReceivedData = New System.IO.MemoryStream End If '再び受信開始 so.Socket.BeginReceive(so.ReceiveBuffer, 0, _ so.ReceiveBuffer.Length, _ System.Net.Sockets.SocketFlags.None, _ New System.AsyncCallback(AddressOf ReceiveDataCallback), so) End Sub
ここで注意しなければならないのが、コールバックメソッドは、初めにBeginReceive
を呼び出したスレッドとは別のスレッドで実行されるということです。つまり、スレッドの同期が必要になるケースが十分あり得ます。例えばSocket
クラスはスレッドセーフではありませんので、上記のような非同期受信を行いながらSendメソッドなどを呼び出す場合には、lock
(VB.NETでは、SyncLock
)等を使用してスレッドの同期を行う必要があるということになります(実際にこの様にしているサンプルはほとんど見ませんが)。また、受信したデータをテキストボックスに表示する場合など、コールバックメソッドからコントロールのメソッドを呼び出す時は、Invoke
メソッド(またはBeginInvoke
メソッド)を使用してコントロールのスレッドにマーシャリングします。
Socket
クラスにはデータの受信以外に、リモートホストへの接続(BeginConnect
、EndConnect
メソッド)、データの送信(BeginSend
、EndSend
メソッド)のための非同期メソッドも用意されています。
サーバーの作成
サーバーでは、Socket.Bind
メソッドでバインドし、Listen
メソッドでクライアントの接続を待機し、Accept
メソッドで接続を受け入れるというのが基本的な流れです。しかし、Accept
メソッドは接続要求がない限りブロックし続けてしまいます。
この対処法としては、先ほどと同様に、ポーリング(参照資料3)、非同期メソッド(参照資料2)、さらに、スレッド化(参照資料1)といった方法が考えられます。ポーリングでは、先と同じく、Poll
メソッドで接続の要求がないかループで監視し、あればAccept
メソッドで受け入れるようにします。スレッド化による方法では、Accept
メソッドの呼び出しを別のスレッドで行うようにします(この方法では接続の待機を中止するために、スレッドセーフでないSocket
クラスのClose
メソッドを別のスレッドから呼び出すことになります)。
「DOBON Chat」では、非同期メソッドのBeginAccept
、EndAccept
メソッドを使用しています。以下にBeginAccept
メソッドを使った簡単な例を示します。Listen
メソッドがすでに呼び出されているSocket
をこのStartAccept
メソッドに渡すことにより、クライアントからの接続を非同期で待機します。
//クライアントの接続待ちスタート private static void StartAccept(System.Net.Sockets.Socket server) { //接続要求待機を開始する server.BeginAccept( new System.AsyncCallback(AcceptCallback), server); } //BeginAcceptのコールバック private static void AcceptCallback(System.IAsyncResult ar) { //サーバーSocketの取得 System.Net.Sockets.Socket server = (System.Net.Sockets.Socket) ar.AsyncState; //接続要求を受け入れる System.Net.Sockets.Socket client = null; try { //クライアントSocketの取得 client = server.EndAccept(ar); } catch { System.Console.WriteLine("閉じました。"); return; } //クライアントが接続した時の処理をここに書く //ここでは文字列を送信して、すぐに閉じている client.Send(System.Text.Encoding.UTF8.GetBytes("こんにちは。")); client.Shutdown(System.Net.Sockets.SocketShutdown.Both); client.Close(); //接続要求待機を再開する server.BeginAccept( new System.AsyncCallback(AcceptCallback), server); }
'クライアントの接続待ちスタート Private Shared Sub StartAccept( _ ByVal server As System.Net.Sockets.Socket) '接続要求待機を開始する server.BeginAccept(New System.AsyncCallback( _ AddressOf AcceptCallback), server) End Sub 'BeginAcceptのコールバック Private Shared Sub AcceptCallback(ByVal ar As System.IAsyncResult) 'サーバーSocketの取得 Dim server As System.Net.Sockets.Socket = _ CType(ar.AsyncState, System.Net.Sockets.Socket) '接続要求を受け入れる Dim client As System.Net.Sockets.Socket = Nothing Try 'クライアントSocketの取得 client = server.EndAccept(ar) Catch System.Console.WriteLine("閉じました。") Return End Try 'クライアントが接続した時の処理をここに書く 'ここでは文字列を送信して、すぐに閉じている client.Send(System.Text.Encoding.UTF8.GetBytes("こんにちは。")) client.Shutdown(System.Net.Sockets.SocketShutdown.Both) client.Close() '接続要求待機を再開する server.BeginAccept(New System.AsyncCallback( _ AddressOf AcceptCallback), server) End Sub
EndAccept
メソッドにより、接続したクライアントとの通信に使用するSocket
オブジェクトが返されます。上記のコードでは、接続したクライアントに文字列をUTF-8でエンコードしたデータを送信し、すぐに接続を閉じています。
チャットアプリケーションのサーバーでは、あるクライアントから受信したメッセージを接続中のすべてのクライアントに送信する必要があります。これは単純に、クライアントからデータを受信したら、接続中の一つ一つのクライアントのSocket.Send
(または、BeginSend
)メソッドを呼び出してデータを送信するようにすればよいでしょう。
独自のアプリケーションプロトコルの定義
以上で、チャットアプリケーションを作成するための知識が揃いました。これで当初の目的には達したと言っていいでしょう。
しかし文字列のやり取りができるようになったとはいえ、これだけでは実用的なチャットアプリケーションには程遠いです。現状では、クライアント側で現在チャットに参加しているメンバーを表示することも、送られてきたメッセージを表示するときにその送信者を併記することもできないのです。さらに、意欲的な読者の中には、プライベートメッセージ(指定した人にしかメッセージを送信しない)などの機能はどのように実装すればよいのか知りたいという方もいらっしゃるでしょう。
このように様々な機能を持つチャットアプリケーションを作成するためには、あらかじめサーバーとクライアントの間で機能に応じた「決まり」を用意しておく必要があります。これは、アプリケーション層プロトコルと呼ばれるものです。
この「決まり」はごく簡単なもので構いません(ちゃんとしたものを作るならば、RFC、特にチャットアプリケーションではIRCが参考になるでしょう)。よくある「決まり」の形式は、文字列のコマンドと一つ以上のパラメータから成り、それぞれをスペース文字で区切り、「CR-LF(キャリッジリターン+ラインフィード)」で終わるというものです。
例えば、クライアントがプライベートメッセージを送信する時は、
PRIVMSG "メッセージの送信先" "メッセージの内容"CR-LF
という内容の文字列をUTF-8でエンコードしたデータを送信すると決めておきます。この様にしておくことにより、サーバーは「PRIVMSG」で始まり「CR-LF」で終わる文字列を受信した時に、それがクライアントがプライベートメッセージの送信を要求している合図であると分かり、メッセージの内容と送信先を正しく理解することができます。
同様に、サーバーからクライアントへのプライベートメッセージの送信でも、
PRIVMSG "メッセージの送信者" "メッセージの内容"CR-LF
という決まりにしておけば、クライアントが「PRIVMSG」で始めるデータを受信した時、そのデータが自分だけに送られてきたプライベートメッセージであり、誰がどのようなメッセージを送ってきたのかが分かります。
このようなコマンド(上記の例では「PRIVMSG」)を増やしていくことにより、機能を拡張することができます。
まとめ
この記事では、TCPを利用した複数クライアントが接続可能なチャットアプリケーションの作り方を説明しました。ポイントをまとめると次のようになります。
- チャットアプリケーションでは長時間ブロックするメソッドの使用は厳禁であるため(例外あり)、非同期メソッドの活用が有効である。
- 非同期メソッドにより呼び出されるコールバックメソッドは個別のスレッドで実行されるため、スレッドの同期等の対策が必要となる。
- 既存のアプリケーション層プロトコルを利用しないならば、独自のアプリケーション層プロトコルを定義しなければならない。
参考資料
- MSDNライブラリ 『Creating a Multi-User TCP Chat Application』 Rockford Lhotka著、2001年8月
- CodeGuru 『Asynchronous Socket Programming in C#』 Jayan Nair著、2005年3月
- CSharpFriends.com 『Non-blocking Sockets (A Chat Program)』
- MSDNライブラリ 『非同期クライアント ソケットの使用』
- MSDNライブラリ 『非同期サーバー ソケットの使用』
- MSDNライブラリ 『非同期クライアント ソケットの例』
- MSDNライブラリ 『非同期サーバー ソケットの例』