注意:この記事は、以前CodeZineに投稿した「TCPを利用した複数クライアント接続可能なチャットアプリケーションの作成」を基にしています。
ここでは、複数のクライアントが同時に接続できる、TCPを利用したクライアントサーバー型チャットアプリケーション(僭越ながら、「DOBON Chat」と命名させていただきます)のサンプルを示し、その要点を解説します。
「DOBON Chat」の実行ファイル(バイナリファイル)とソースファイル(プロジェクトファイル)は、記事の最後(こちら)からダウンロードできます。
サンプルはVisual Studio .NET 2003で作成され、.NET Framework 1.1で動作確認をしていますが、.NET Framework 1.0でも問題なく動作するでしょう。
補足:この記事では、基本的には、.NET Framework 2.0以降については触れません。
.NET FrameworkではTCPを利用したデータ通信を行うためのクラスとして、TcpClientクラス及びTcpListenerクラス(共にSystem.Net.Sockets名前空間)が用意されています。これらのクラスは内部でSocketクラス(System.Net.Sockets名前空間)を使用しており、Socketクラスをより簡単に扱えるようにするためのクラスであると言えます。しかしSocketクラスを直接扱う場合と比べて機能的に劣り、しかも取り扱いの難しさもそれほど変わるとは思えません。そこでここでは、TcpClientとTcpListenerクラスを使わずに、Socketクラスを使ってサーバーとクライアントを作成しています。(TcpClientとTcpListenerを使用した簡単なサンプルは、「TCPクライアント・サーバープログラムを作成する」にあります。)
クライアントは、サーバーに接続し、データの送受信を行います。Socketクラスの場合、データの送信にはSend、受信にはReceiveメソッドを使えば簡単です(「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 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
//非同期データ受信のための状態オブジェクト 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); }
ここで注意しなければならないのが、コールバックメソッドは、初めに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 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
//クライアントの接続待ちスタート 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); }
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を利用した複数クライアントが接続可能なチャットアプリケーションの作り方を説明しました。ポイントをまとめると次のようになります。