はじめに
前編ではサーバーアプリケーションの作成までを解説しました。
後編では、クライアントアプリケーションの作成~完成までを解説します。
クライアントアプリケーションはサーバーアプリケーションの処理と似ているため、前編の内容が理解できた方ならすぐに理解できると思います。
解説で使用しているソースコード
記事内にソースコードを全て掲載していますが、ソリューション毎ダウンロードしたいという方は以下Githubからダウンロード出来ます。
アプリケーションの作成
クライアントアプリケーションプロジェクトの作成
前半で作成したChatAppソリューションにClientAppプロジェクトを追加します。
ソリューションエクスプローラーを右クリック>追加>新しいプロジェクトを選択します。
Windowsフォームアプリケーション(.NET Framework)を選択し、プロジェクト名に「ClientApp」を設定します。
クライアントアプリケーションの作成
作成したClientAppプロジェクトのForm1を以下の様に変更します。
コントロール名 | プロパティ名 | 値 | 説明 |
---|---|---|---|
Form | Name | ClientForm | アプリケーションメインフォーム |
Text | Client | フォーム左上に表示される文字列を設定 | |
gbSettings | Text | 設定 | 設定グループボックス |
txtAddress | Text | 127.0.0.1 | デフォルトではループバックアドレスを設定。 (自分自身のPCのローカルIPアドレス) |
txtDestPort | 接続先ポート番号テキストボックス | ||
txtSourcePort | Text | 0 | 接続元ポート番号 (0を指定すると、OSが使用可能なポート番号を割り振ってくれる) |
btnStart | Text | 接続 | 開始ボタン |
Click | btnStart_Click | クリックイベント | |
btnEnd | Text | 切断 | 終了ボタン |
Click | btnEnd_Click | クリックイベント | |
Enabled | false | ||
gpOperation | Text | 操作 | |
txtName | Text | 名無しさん | チャットの送信者名デフォルト値 |
txtMessage | チャット内容テキストボックス | ||
gpConnectionLog | Text | ログ | ロググループボックス |
lbLog | ログリストボックス |
ソースコード
ClientApp.csの全体のコードを記載します。
すべてコピペで動作すると思います。
ChatAppCommonへの参照が必要です。
ClientApp.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
using ChatAppCommon; using System; using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Text; using System.Text.RegularExpressions; using System.Windows.Forms; namespace ClientApp { public partial class ClientForm : Form { private TcpClientEx Client { get; set; } public ClientForm() { InitializeComponent(); } private void ClientForm_FormClosing(object sender, FormClosingEventArgs e) { // クライアントを閉じる if(Client != null) { Client.Client.Close(); Client.Client.Dispose(); Client = null; } } private void btnStart_Click(object sender, EventArgs e) { var destPort = txtDestPort.Text.Trim(); var sourcePort = txtSourcePort.Text.Trim(); var ipAddress = txtAddress.Text.Trim(); try { // 接続情報有効チェック if (!CheckConnectionSettings(destPort, sourcePort, ipAddress)) return; // 接続先IPEndPoint作成 var remoteEndPoint = new IPEndPoint(IPAddress.Parse(ipAddress), int.Parse(destPort)); // 接続元IPEndPoint作成 var localEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), int.Parse(sourcePort)); // クライアント作成 Client = new TcpClientEx(localEndPoint); // サーバーに接続 Client.Client.Connect(remoteEndPoint); // 接続ログ出力 SetLog("サーバーに接続しました。"); // データ受信待機開始 var data = new CommunicationData(Client); Client.Socket.BeginReceive(data.Data, 0, data.Data.Length, SocketFlags.None, ReceiveCallback, data); // ボタンの有効状態を設定 btnStart.Enabled = false; btnEnd.Enabled = true; btnSend.Enabled = true; } catch (Exception ex) { Debug.WriteLine(ex.Message); // 接続ログ出力 SetLog("サーバーに接続できませんでした。"); } } private void ReceiveCallback(IAsyncResult result) { try { // サーバーからのデータを受信 var data = result.AsyncState as CommunicationData; var length = data.Client.Socket.EndReceive(result); // 受信データが0byteの場合切断と判定 if (length == 0) { // 切断ログ出力 SetLog("サーバーから切断されました。"); // データ受信を終了 return; } // 受信データを出力 SetLog($"サーバーからデータ受信<<{data}"); // 再度サーバーからのデータ受信を待機 data.Client.Socket.BeginReceive(data.Data, 0, data.Data.Length, SocketFlags.None, ReceiveCallback, data); } catch (Exception) { // ボタンの有効状態を設定 Invoke(new Action(() => { btnStart.Enabled = true; btnEnd.Enabled = false; btnSend.Enabled = false; })); } } private void btnEnd_Click(object sender, EventArgs e) { // クライアントを閉じる Client.Client.Close(); Client.Client.Dispose(); Client = null; // 切断ログ出力 SetLog("サーバーから切断しました。"); // ボタンの有効状態を設定 btnStart.Enabled = true; btnEnd.Enabled = false; btnSend.Enabled = false; } private void btnSend_Click(object sender, EventArgs e) { try { // 送信データを作成 var data = Encoding.UTF8.GetBytes(txtName.Text + ":" + txtMessage.Text); // サーバーにデータを送信 Client?.Socket.Send(data); // 送信ログ出力 SetLog($"サーバーにデータ送信>>{Encoding.UTF8.GetString(data)}"); } catch (Exception) { SetLog("データ送信に失敗しました。"); } } private void SetLog(string text) { Invoke(new Action(() => { // ログを追加 lbLog.Items.Add($"{DateTime.Now.ToString("HH:mm:ss.ff")}:{text}"); // リスト末尾を選択中とする lbLog.SelectedIndex = lbLog.Items.Count != -1 ? lbLog.Items.Count - 1 : -1; })); } private bool CheckConnectionSettings(string destPort, string sourcePort, string ipAddress) { // ポート番号空チェック if (string.IsNullOrEmpty(destPort) || string.IsNullOrEmpty(sourcePort)) { MessageBox.Show("ポート番号が空です。"); return false; } // ポート番号数値チェック if (!Regex.IsMatch(destPort, "^[0-9]+$") || !Regex.IsMatch(sourcePort, "^[0-9]+$")) { MessageBox.Show("ポート番号は数値を指定してください。"); return false; } var destPortNum = int.Parse(destPort); var sourcePortNum = int.Parse(sourcePort); // ポート番号有効値チェック if (destPortNum < IPEndPoint.MinPort || IPEndPoint.MaxPort < destPortNum || sourcePortNum < IPEndPoint.MinPort || IPEndPoint.MaxPort < sourcePortNum) { MessageBox.Show("無効なポート番号が指定されています。"); return false; } // IPアドレス有効チェック if (!IPAddress.TryParse(ipAddress, out IPAddress _)) { MessageBox.Show("無効なIPアドレスが指定されています。"); return false; } return true; } } } |
コードの解説
処理の要所を解説します。
ポート番号、IPアドレスのチェック
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
private bool CheckConnectionSettings(string destPort, string sourcePort, string ipAddress) { // ポート番号空チェック if (string.IsNullOrEmpty(destPort) || string.IsNullOrEmpty(sourcePort)) { MessageBox.Show("ポート番号が空です。"); return false; } // ポート番号数値チェック if (!Regex.IsMatch(destPort, "^[0-9]+$") || !Regex.IsMatch(sourcePort, "^[0-9]+$")) { MessageBox.Show("ポート番号は数値を指定してください。"); return false; } var destPortNum = int.Parse(destPort); var sourcePortNum = int.Parse(sourcePort); // ポート番号有効値チェック if (destPortNum < IPEndPoint.MinPort || IPEndPoint.MaxPort < destPortNum || sourcePortNum < IPEndPoint.MinPort || IPEndPoint.MaxPort < sourcePortNum) { MessageBox.Show("無効なポート番号が指定されています。"); return false; } // IPアドレス有効チェック if (!IPAddress.TryParse(ipAddress, out IPAddress _)) { MessageBox.Show("無効なIPアドレスが指定されています。"); return false; } return true; } |
サーバーアプリケーションで実装したチェック処理に、IPアドレスのチェックを追加したものです。
System.Net.IPAddressクラスのTryParseでIPアドレスが変換できなかった場合は無効なIPアドレスと判定しています。
クライアントの作成~サーバーへの接続~データ受信待機
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
private void btnStart_Click(object sender, EventArgs e) { var destPort = txtDestPort.Text.Trim(); var sourcePort = txtSourcePort.Text.Trim(); var ipAddress = txtAddress.Text.Trim(); try { // 接続情報有効チェック if (!CheckConnectionSettings(destPort, sourcePort, ipAddress)) return; // 接続先IPEndPoint作成 var remoteEndPoint = new IPEndPoint(IPAddress.Parse(ipAddress), int.Parse(destPort)); // 接続元IPEndPoint作成 var localEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), int.Parse(sourcePort)); // クライアント作成 Client = new TcpClientEx(localEndPoint); // サーバーに接続 Client.Client.Connect(remoteEndPoint); // 接続ログ出力 SetLog("サーバーに接続しました。"); // データ受信待機開始 var data = new CommunicationData(Client); Client.Socket.BeginReceive(data.Data, 0, data.Data.Length, SocketFlags.None, ReceiveCallback, data); // ボタンの有効状態を設定 btnStart.Enabled = false; btnEnd.Enabled = true; btnSend.Enabled = true; } catch (Exception ex) { Debug.WriteLine(ex.Message); // 接続ログ出力 SetLog("サーバーに接続できませんでした。"); } } |
- 接続情報(ポート番号、IPアドレス)の有効チェック。
ポート番号、IPアドレスの有効チャックを行い、有効な場合のみ後続の処理を実行します。 - クライアントを作成してサーバーへ接続。
サーバーとやりとりをするためのクライアントを作成します。
TcpClientのコンストラクタに接続元情報を渡すことでクライアントを作成できます。
サーバーへの接続は、TcpClient.Connectメソッドを使用します。
Connectメソッドの引数に接続先情報を渡して接続要求を投げます。
接続に失敗した場合、ConnectメソッドでSystem.Net.Sockets.SocketException例外が発生するため、例外の有無で接続の成否を判定できます。
Connectの戻り値で接続の成否を返してくれたほうが使い勝手がいいのになあ・・・とか思ったり思わなかったり。 - 非同期でサーバーからのデータ受信を待機。
サーバーからの非同期データ受信待機は、サーバーアプリケーションで実装した非同期受信待機と同様の方法です。
TcpClient.Client.BeginReceiveメソッドを使用します。
繰り返しになってしまいますが、BeginReceiveメソッドの引数には
・受信するデータのバッファ(byte[])
・受信するデータのバッファ内の読込み開始位置(int)
・バッファサイズ(int)
・送受信時の動作(SocketFlags)
・受信時コールバックデリゲート(受信時に実行される処理)
・コールバックに渡されるユーザー定義オブジェクト(自由に設定出来るオブジェクト)
を指定します。
ユーザー定義オブジェクトには、ChatAppCommonに定義した通信データクラスを設定しています。
非同期データ受信処理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
private void ReceiveCallback(IAsyncResult result) { try { // ユーザー定義型のオブジェクト取得 var data = result.AsyncState as CommunicationData; // 切断をクリックしている場合 if (data.Client.Socket == null) return; // サーバーからのデータを受信 data.Client.Socket?.EndReceive(result); // 受信データを出力 SetLog($"サーバーからデータ受信<<{data}"); // 再度サーバーからのデータ受信を待機 data.Client.Socket.BeginReceive(data.Data, 0, data.Data.Length, SocketFlags.None, ReceiveCallback, data); } catch (Exception) { // 切断ログ出力 SetLog("サーバーから切断されました。"); // ボタンの有効状態を設定 Invoke(new Action(() => { btnStart.Enabled = true; btnEnd.Enabled = false; btnSend.Enabled = false; })); } } |
サーバーからの非同期データ受信待機で設定したコールバック処理です。
サーバーアプリケーションからデータを受信したときに実行されます。
- BeginReceiveの引数に指定したユーザー定義型からデータを取得する。
IAsyncResult.AsyncStateにはBeginReceiveメソッドの引数に渡したユーザー定義型のオブジェクトが設定されています。
object型なので、渡したユーザー定義型にキャストして取得します。 - データ受信に失敗した場合、サーバーから切断されたと判定する。
TcpClient.Client.EndReceiveメソッドでサーバーからのデータを取得しますが、サーバーから切断されている場合例外となります。(切断されたサーバーからデータを取得しようとするため)
ここでは、切断されたことを画面に表示し、非同期データ受信を終了しています。
再度サーバーアプリケーションとやりとりをするには、接続からやり直す必要があります。 - 受信データを画面に表示する。
サーバーアプリケーションから受信したデータを画面に表示します。
これは特に解説する事もないですね。
しいて言うなら、非同期処理からUIスレッドへのアクセスになるため、Invokeを使用してUIスレッドで画面コントロールにデータを設定する必要がある という事でしょうか。 - 受信処理後、再度受信待機する。
再度TcpClient.Client.BeginReceiveを実行し、データの受信を待機します。
これで切断されるまでデータ受信待機→データ受信→クライアントに転送→再度データ受信待機→…というループになります。
アプリケーションの動作確認
サーバーアプリケーションを起動して、ポートの監視を開始します。
クライアンアプリケーションを複数起動し、接続先ポート番号にサーバーアプリケーションで監視しているポート番号を指定して接続します。
接続したらクライアン同士でメッセージを送信します。
サーバーの切断、クライアンの切断、メッセージの送信が正常に行われている事がわかります。
まとめ
以上でチャットアプリケーションの作成は完了になります。
非同期での通信は色々と考慮すべき点が多いです。
サーバーでデータ受信処理中にクライアントから切断されたり等、例外をすべてハンドリングするのも大変です。
今回は例外を握りつぶしちゃっていますが、もっと真面目に作るのであれば、切断された時に再接続を試みたり、一定周期でポーリング(クライアントに接続しているか確認するPingを送信)したりする必要があると思います。
需要があるようであれば、データ送信先クライアンの指定、ファイルの送信等の機能拡張の記事を書こうかと思いますので、気軽にコメントやDMで連絡をお願いします。
また、わかりにくいところがありましたら、気軽にコメントやDMで連絡をお願いします。
可能な限り対応させていただきます。
コメント