.NET Remotingでチャットアプリケーションを実装する

今までチャットアプリケーションを作った事が無かったので、ためしに作ってみた。
.NETでこういうクライアント⇔サーバー間で通信をするアプリケーションを作る場合、.NET Remotingを使うのが普通。いまさらソケット開いて、がちゃがちゃリスニングしたりするのなんてめんどくさくてやってられないからね。

プロジェクト構成

NChat.Client
クライアントアプリケーション(WinExe)
NChat.Service
サーバーアプリケーション(WinService)
NChat.Interface
クライアントとサーバーで共有するライブラリ(Lib)

画面イメージ

クライアントのUIはこんな感じにする。

実装

まずサーバーアプリケーション側で、「ChatManager」というクラスを用意する。

public class ChatManager : MarshalByRefObject, IChatManager {
}

リモーティングを可能にするために、「MarshalByRefObject」から継承する。そしてこのクラスのインターフェース情報をクライアントに伝えるために、「IChatManager」というインターフェースを定義し、それを実装する。

public interface IChatManager {
}

「IChatManager」にはサーバーにメッセージを通知するための「SendMessage」メソッドを定義する。

void SendMessage(string message, string userName);

クライアントを識別するために、「userName」でユーザを渡しておく。
次にクライアントから送信されたメッセージを他のクライアントが受信するために、イベントを定義する。

まずはイベント引数クラス

RecievedMessageEventArgs.cs
[Serializable]
public class RecievedMessageEventArgs : EventArgs {
    private string message;
    private string userName;

    /// <summary>
    /// メッセージを取得します。
    /// </summary>
    public string Message {
        get { return message; }
    }
    /// <summary>
    /// ユーザ名を取得します。
    /// </summary>
    public string UserName {
        get { return userName; }
    }

    public RecievedMessageEventArgs(string message, string userName) {
        this.message = message;
        this.userName = userName;
    }
}

クライアント⇔サーバー間でシリアル化できるように「Serializable」属性でマークしておく。

このイベント引数クラスを引数に取るイベント

public event EventHandler<RecievedMessageEventArgs> RecievedMessage;

で、クライアントがこのイベントにイベントハンドラを追加して、メッセージが受信された事を知ろうとしたが、実際にやってみるとセキュリティに引っかかった。自動シリアル化周りかと思っていろいろやってみたが、解決しなかった。適当にググッてみたら、同様の問題で引っかかっている人が結構いたが、結局のところ解決していなかった。
よく考えてみればクライアントがサーバーからコールバックを受けるには、サーバーがクライアントのオブジェクトのイベントハンドラを呼び出す必要があるのに、クライアントのオブジェクトはリモートオブジェクトでもなんでもない。そんなものをサーバーが呼び出せるわけがない。
じゃあクライアントのコールバックオブジェクトをリモートオブジェクトで公開して、それをサーバーに渡せばいいんじゃね?

ということで

まずはクライアントでコールバックを受けるクラスを作ってみた。

public class ChatEventListener : MarshalByRefObject, IChatEventListener {
}

「MarshalByRefObject」から継承して、そのインターフェース情報をサーバーに伝えるために「IChatEventListener」を定義する。

public interface IChatEventListener {
    event EventHandler<RecievedMessageEventArgs> RecievedMessage;

    [OneWay]
    void OnRecievedMessage(string message, string userName);
}

イベントとそれを呼び出す「OnRecievedMessage」メソッドを定義する。このメソッドはクライアントがいつ切断されてもいいように「OneWay」属性でマークしておく。これでクライアントが死んでいても例外が投げられない。

次にクライアントのリモートオブジェクトをサーバーに渡すためのメソッドを「IChatManager」に定義する。

bool Login(string userName, string objectUrl);

リモートオブジェクトの参照をそのまま渡そうと思ったが、またしてもセキュリティに引っかかったので、オブジェクトのURLだけを渡す事にした。接続に成功すればtrueを返す。

これと逆のメソッドと現在ログインしているユーザのリストを返すプロパティを追加した完全な「IChatManager」の定義が以下。

IChatManager.cs
public interface IChatManager {
    string[] LoginUsers { get; }
    
    void SendMessage(string message, string userName);
    bool Login(string userName, string objectUrl);
    bool Logout(string userName);
}

そして、ここで発生したイベントをクライアントに伝えるためのインターフェース「IChatEventListener」の完全な定義

IChatEventListener.cs
public interface IChatEventListener {
    /// <summary>
    /// メッセージを受信した時に呼び出されます。
    /// </summary>
    event EventHandler<RecievedMessageEventArgs> RecievedMessage;
    /// <summary>
    /// 接続しているユーザが変更された時に呼び出されます。
    /// </summary>
    event EventHandler<LoginUserChangedEventArgs> LoginUserChanged;

    [OneWay]
    void OnRecievedMessage(string message, string userName);
    [OneWay]
    void OnLoginUserChanged(string userName, bool loggedIn);
}

メッセージが受信されたイベントとログインされたユーザが変更された事を通知するイベントを定義する。

「LoginUserChangedEventArgs」クラスの実装は以下

LoginUserChangedEventArgs.cs
[Serializable]
public class LoginUserChangedEventArgs : EventArgs {
    private bool loggedIn;
    private string userName;

    /// <summary>
    /// ログインしているかどうかを取得します。
    /// </summary>
    public bool LoggedIn {
        get { return loggedIn; }
    }
    /// <summary>
    /// ユーザ名を取得します。
    /// </summary>
    public string UserName {
        get { return userName; }
    }
    
    public LoginUserChangedEventArgs(string userName) {
        this.userName = userName;
    }
    public LoginUserChangedEventArgs(string userName, bool loggedIn) : this(userName) {
        this.loggedIn = loggedIn;
    }
}

これでクライアント⇔サーバー間のインターフェースは確定したので、実際の各クラスを実装する。

サーバーアプリケーション

まずは、メッセージの送信と各クライアントへのイベントの伝達を行う「ChatManager」クラス

ChatManager.cs

public class ChatManager : MarshalByRefObject, IChatManager {
    /// <summary>
    /// ユーザ名とイベントリスナーのマップ
    /// </summary>
    private Dictionary<string, IChatEventListener> loginUsers = new Dictionary<string, IChatEventListener>();

    public ChatManager() { }

    public string[] LoginUsers {
        get {
            return new List<string>(loginUsers.Keys).ToArray();
        }
    }

    public void SendMessage(string message, string userName) {
        foreach(string sendUser in loginUsers.Keys) {
            loginUsers[sendUser].OnRecievedMessage(message, userName);
        }
    }

    public bool Login(string userName, string objectUrl) {
        if(loginUsers.ContainsKey(userName)) return false;

        try {
            IChatEventListener eventListener =
                (IChatEventListener)Activator.GetObject(typeof(IChatEventListener), objectUrl);

            loginUsers.Add(userName, eventListener);

            foreach(string sendUser in loginUsers.Keys) {
                loginUsers[sendUser].OnLoginUserChanged(userName, true);
            }
            return true;

        } catch {
            return false;
        }
    }
    
    public bool Logout(string userName) {
        if(!loginUsers.ContainsKey(userName)) return false;

        loginUsers.Remove(userName);
        try {
            foreach(string sendUser in loginUsers.Keys) {
                loginUsers[sendUser].OnLoginUserChanged(userName, false);
            }
            return true;

        } catch {
            return false;
        }
    }
}

そして、このオブジェクトをリモートオブジェクトとして公開するためのアプリケーション構成ファイル。

App.config
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <system.runtime.remoting>
        <application>
            <channels>
                <channel ref="tcp" port="6767" />
            </channels>
            <service>
                <wellknown
                    type="NChat.Service.ChatManager, NChat.Service"
                    mode="Singleton"
                    objectUri="chatManager.rem"
                 />
            </service>
        </application>
    </system.runtime.remoting>    
</configuration>

サーバーアプリケーションはWindowsServiceで実装するので、サービスクラスの「OnStart」メソッドでリモーティングオブジェクトを登録する。

protected override void OnStart(string[] args) {
    RemotingConfiguration.Configure(this.GetType().Assembly.Location + ".config", false);
}

あとはこのプロジェクトをビルドして、「InstallUtil.exe」を使ってWindowsServiceとして登録する。

> IntallUtil NChat.Service.exe

登録した後、サービスを開始しておく。

クライアントアプリケーション

サーバーからのイベントを受信する「ChatEventListener」クラス

ChatEventListener.cs
public class ChatEventListener : MarshalByRefObject, IChatEventListener {
    public ChatEventListener() { }

    public event EventHandler<RecievedMessageEventArgs> RecievedMessage;
    public event EventHandler<LoginUserChangedEventArgs> LoginUserChanged;

    public void OnRecievedMessage(string message, string userName) {
        if(RecievedMessage != null) {
            RecievedMessage(this, new RecievedMessageEventArgs(message, userName));
        }
    }
    public void OnLoginUserChanged(string userName, bool loggedIn) {
        if(LoginUserChanged != null) {
            LoginUserChanged(this, new LoginUserChangedEventArgs(userName, loggedIn));
        }
    }
}

このクラスをサーバーに公開するわけだけど、サーバーの要求がきてからインスタンス化(SAO)していてはクライアントがこのオブジェクトのイベントにイベントハンドラを追加することができないので、クライアントでインスタンス化して、イベントハンドラを追加してから、リモートオブジェクトとして公開してやる。

そのコードはメイン画面の「MainForm」クラスに実装する。

まずは「OnLoad」でサーバーのリモートオブジェクトを取得し、フィールドに格納しておく。サーバーオブジェクトのURLはアプリケーション構成ファイルに記述しておく。

protected override void OnLoad(EventArgs e) {
    base.OnLoad(e);

    chatManager = (IChatManager)Activator.GetObject(
        typeof(IChatManager), ConfigurationManager.AppSettings["serverUrl"]
    );
}

メイン画面の「参加」ボタンをクリックされた時

private void btnJoin_Click(object sender, EventArgs e) {
    ChatEventListener eventListener = new ChatEventListener();
    eventListener.RecievedMessage += eventListener_RecievedMessage;
    eventListener.LoginUserChanged += eventListener_LoginUserChanged;
    
    // オブジェクトを公開
    RemotingServices.Marshal(
        eventListener, "eventListener.rem", typeof(IChatEventListener)
    );
    string objectURL = string.Format("{0}/eventListener.rem", GetLocalServiceURL());
    // ログインする。
    if(chatManager.Login(txtUserName.Text, objectURL)) {
        lstLoginUsers.Items.AddRange(chatManager.LoginUsers);

        paneMain.Enabled = true;
        txtUserName.Enabled = btnJoin.Enabled = false;
    } else {
        RemotingServices.Disconnect(eventListener);

        MessageBox.Show(this, "ログインに失敗しました。",
            "エラー", MessageBoxButtons.OK, MessageBoxIcon.Error
        );
        txtUserName.Clear();
    }
}
/// <summary>
/// ローカルマシンで宣言しているチャンネルのURLを取得します。
/// </summary>
/// <returns></returns>
private static string GetLocalServiceURL() {
    TcpChannel tcpChannel = (TcpChannel)ChannelServices.RegisteredChannels[0];

    return ((ChannelDataStore)tcpChannel.ChannelData).ChannelUris[0];
}

  1. 「ChatEventListener」クラスをインスタンス化して、イベントハンドラを追加する。
  2. 「RemotingServices」の「Marshal」メソッドでオブジェクトをリモートオブジェクトとして公開する。

あとは見たまんま。

各種イベントハンドラ(他にもあるけど割愛)

/// <summary>
/// [送信]ボタンをクリックした時
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void btnSend_Click(object sender, EventArgs e) {
    chatManager.SendMessage(txtMessage.Text, txtUserName.Text);

    txtMessage.Clear(); txtMessage.Select();
}
/// <summary>
/// メッセージを受信した時
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void eventListener_RecievedMessage(object sender, RecievedMessageEventArgs e) {
    BeginInvoke((MethodInvoker)delegate() {
        txtLog.AppendText(
            string.Format("{0} => 「{1}」\r\n", e.UserName, e.Message)
        );
    });
}
/// <summary>
/// ユーザのログイン状態が変わった時
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void eventListener_LoginUserChanged(object sender, LoginUserChangedEventArgs e) {
    BeginInvoke((MethodInvoker)delegate() {
        txtLog.AppendText(
            string.Format("{0}さんが{1}しました。\r\n", e.UserName, e.LoggedIn ? "入室" : "退出")
        );
        if(e.LoggedIn) {
            if(!lstLoginUsers.Items.Contains(e.UserName)) lstLoginUsers.Items.Add(e.UserName);
        } else {
            lstLoginUsers.Items.Remove(e.UserName);
        }
    });
}

クライアントのアプリケーション構成ファイル

App.config
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <system.runtime.remoting>
        <application>
            <channels>
                <channel ref="tcp" port="6768" />
            </channels>
        </application>
    </system.runtime.remoting>
    
    <appSettings>
        <add key="serverUrl" value="tcp://localhost:6767/chatManager.rem" />
    </appSettings>
</configuration>

リモーティングの設定はプロトコルとポートの設定のみ。

Program.cs
static class Program {
    [STAThread]
    static void Main() {
        RemotingConfiguration.Configure(typeof(Program).Assembly.Location + ".config", false);

        Application.Run(new MainForm());
    }
}

リモーティング設定を初期化して、アプリケーションを開始する。

これでとりあえず動いた。といってもこのままではクライアントのリモートオブジェクトが5分以上放置されればリース期間が切れて、GCに回収されてアポーンしてしまうので、リース期間を延長する必要がある。
ということでリース期間を延々と延ばし続けるクラスを定義する。

InifiniteSponsor.cs
/// <summary>
/// リモーティングオブジェクトのリース期間を無期限にするスポンサークラス
/// </summary>
public class InfiniteSponsor : MarshalByRefObject, ISponsor {
    public InfiniteSponsor() {
    }
    
    public TimeSpan Renewal(ILease lease) {
        return lease.InitialLeaseTime;
    }
}

「ISponsor」インターフェースを実装し、Renewalメソッドで延長する期間を返すだけ。

このスポンサーをリモートオブジェクトに設定するクラス

InfiniteSponsorFactory.cs
/// <summary>
/// 無期限のスポンサーをリモートオブジェクトに設定するファクトリオブジェクトクラス
/// </summary>
public static class InfiniteSponsorFactory {
    public static ISponsor CreateSponsor(MarshalByRefObject refObj) {
        InfiniteSponsor sponsor = new InfiniteSponsor();

        ILease leaseObj = (ILease)refObj.InitializeLifetimeService();
        leaseObj.Register(sponsor);

        return sponsor;
    }
}

引数で渡されたリモートオブジェクトにスポンサーを設定するだけ。

これを「OnLoad」の以下の場所で使う。

// オブジェクトを公開
RemotingServices.Marshal(
    eventListener, "eventListener.rem", typeof(IChatEventListener)
);
InfiniteSponsorFactory.CreateSponsor(eventListener);

まぁこれでだいたい大丈夫だと思う。
今回チャットアプリケーションを作ってみて結構おもしろかったので、もうちょっと機能を追加して遊んでみようと思う。