DOBON.NET プログラミング道: .NET Framework, VB.NET, C#, Visual Basic, Visual Studio, インストーラ, ...

TCPを利用した複数クライアント接続可能なチャットアプリケーションを作る

注意:この記事は、以前CodeZineに投稿した「TCPを利用した複数クライアント接続可能なチャットアプリケーションの作成」を基にしています。

ここでは、複数のクライアントが同時に接続できる、TCPを利用したクライアントサーバー型チャットアプリケーション(僭越ながら、「DOBON Chat」と命名させていただきます)のサンプルを示し、その要点を解説します。

「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のBeginReceiveEndReceiveメソッドを使用します。DOBON Chatではこの方法を採用しています。

非同期受信では、まずBeginReceiveメソッドを呼び出すことにより、データの受信が開始されます。Receiveメソッドではデータを受信しない限り処理が次へ進みませんが、BeginReceiveメソッドはすぐに終了し、ブロックしません。BeginReceiveを呼び出した後にデータを受信すると、指定したコールバックメソッドが実行されます。このコールバックメソッドでEndReceiveメソッドを呼び出し、データを受信し、再びBeginReceiveメソッドを呼び出して非同期受信を再開するようにします。

以下に、非同期メソッドを使ってデータを受信する簡単な例を示します。Connectメソッドを使ってすでにサーバーと接続しているSocketをこのStartReceiveメソッドに渡すことにより、データの非同期受信が開始され、データを受信するとUTF-8でデコードし、コンソールに出力しています。

VB.NET
コードを隠すコードを選択
'非同期データ受信のための状態オブジェクト
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
C#
コードを隠すコードを選択
//非同期データ受信のための状態オブジェクト
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クラスにはデータの受信以外に、リモートホストへ接続するための非同期メソッド(BeginConnectEndConnectメソッド)や、データを送信するための非同期メソッド(BeginSendEndSendメソッド)も用意されています。

サーバーの作成

サーバーでは、Socket.Bindメソッドでバインドし、Listenメソッドでクライアントの接続を待機し、Acceptメソッドで接続を受け入れるというのが基本的な流れです。しかし、Acceptメソッドは接続要求がない限りブロックし続けてしまいます。

この対処法としては、先ほどと同様に、ポーリング(参照資料3)、非同期メソッド(参照資料2)、さらに、スレッド化(参照資料1)といった方法が考えられます。ポーリングでは、先と同じく、Pollメソッドで接続の要求がないかループで監視し、あればAcceptメソッドで受け入れるようにします。スレッド化による方法では、Acceptメソッドの呼び出しを別のスレッドで行うようにします(この方法では接続の待機を中止するために、スレッドセーフでないSocketクラスのCloseメソッドを別のスレッドから呼び出すことになります)。

DOBON Chatでは、非同期メソッドのBeginAcceptEndAcceptメソッドを使用しています。以下にBeginAcceptメソッドを使った簡単な例を示します。Listenメソッドがすでに呼び出されているSocketをこのStartAcceptメソッドに渡すことにより、クライアントからの接続を非同期で待機します。

VB.NET
コードを隠すコードを選択
'クライアントの接続待ちスタート
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
C#
コードを隠すコードを選択
//クライアントの接続待ちスタート
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を利用した複数クライアントが接続可能なチャットアプリケーションの作り方を説明しました。ポイントをまとめると次のようになります。

  • チャットアプリケーションでは長時間ブロックするメソッドの使用は厳禁であるため(例外あり)、非同期メソッドの活用が有効である。
  • 非同期メソッドにより呼び出されるコールバックメソッドは個別のスレッドで実行されるため、スレッドの同期等の対策が必要となる。
  • 既存のアプリケーション層プロトコルを利用しないならば、独自のアプリケーション層プロトコルを定義しなければならない。

参考資料

  1. MSDN : Creating a Multi-User TCP Chat Application(リンク切れのため、Internet Archiveのキャッシュへのリンク)
  2. CodeGuru : Asynchronous Socket Programming in C#
  3. CSharpFriends.com : Non-blocking Sockets (A Chat Program)(リンク切れのため、Internet Archiveのキャッシュへのリンク)
  4. MSDN : 非同期クライアント ソケットの使用
  5. MSDN : 非同期サーバー ソケットの使用
  6. MSDN : 非同期クライアント ソケットの例
  7. MSDN : 非同期サーバー ソケットの例

ファイルのダウンロード

  • 履歴:
  • 2016/5/17 リンク切れを修正。
  • 2016/9/18 今までCodeZineへのリンクだけだったものを、CodeZineに投稿した原稿を基にした内容に替えた。

注意:この記事では、基本的な事柄の説明が省略されているかもしれません。初心者の方は、特に以下の点にご注意ください。

  • このサイトで紹介されているコードの多くは、例外処理が省略されています。例外処理については、こちらをご覧ください。
  • .NET Tipsをご利用いただく際は、注意事項をお守りください。