Shoeisha Technology Media

CodeZine(コードジン)

記事種別から探す

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

SocketクラスによるTCP非同期通信の方法

  • LINEで送る
  • このエントリーをはてなブックマークに追加
2005/03/04 00:00

ダウンロード バイナリ (33.4 KB)
ダウンロード ソース (108.7 KB)

複数のクライアントが同時に接続できるTCPを利用したクライアントサーバー型チャットアプリケーションをTcpClientとTcpListenerクラスを使わずに、Socketクラスを使って作る方法を紹介。

複数のクライアントが同時に接続できる「DOBON Chat」
複数のクライアントが同時に接続できる「DOBON Chat」

はじめに

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

 .NET FrameworkではTCPを利用したデータ通信を行うためのクラスとして、TcpClient及びTcpListenerクラス(共にSystem.Net.Sockets名前空間)が用意されています。これらのクラスは内部でSocketクラス(System.Net.Sockets名前空間)を使用しており、Socketクラスをより簡単に扱えるようにするためのクラスであると言えます。しかしSocketクラスを直接扱う場合と比べて機能的に劣り、しかも取り扱いの難しさもそれほど変わるとは思えません。そこでここでは、TcpClientTcpListenerクラスを使わずに、Socketクラスを使ってサーバーとクライアントを作成しています(TcpClientTcpListenerを使用した簡単なサンプルは、「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に負担をかけるという欠点があります。

 一方非同期メソッドによる方法では、SocketBeginReceiveEndReceiveメソッドを使用します。「DOBON Chat」ではこの方法を採用しています。

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

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

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);
}
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

 ここで注意しなければならないのが、コールバックメソッドは、初めに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メソッドに渡すことにより、クライアントからの接続を非同期で待機します。

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);
}
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

 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』 Rockford Lhotka著、2001年8月
  2. CodeGuru 『Asynchronous Socket Programming in C#』 Jayan Nair著、2005年3月
  3. CSharpFriends.com 『Non-blocking Sockets (A Chat Program)』
  4. MSDNライブラリ 『非同期クライアント ソケットの使用』
  5. MSDNライブラリ 『非同期サーバー ソケットの使用』
  6. MSDNライブラリ 『非同期クライアント ソケットの例』
  7. MSDNライブラリ 『非同期サーバー ソケットの例』
  • LINEで送る
  • このエントリーをはてなブックマークに追加

修正履歴

  • 2006/02/12 01:51 ソースとバイナリを更新。

著者プロフィール

  • どぼん!(ドボン!)

    DOBON.NET内で.NET Frameworkの機能を紹介したWebサイト.NET Tipsやメールマガジン「.NETプログラミング研究」の発行人。

All contents copyright © 2006-2017 Shoeisha Co., Ltd. All rights reserved. ver.1.5