はじめに
初心者向けにC#でのTCP/IPを使用したチャットアプリの作成方法を解説します。
例外処理や細かい部分で考慮が足りない部分があるかもしれませんが、TCP/IPの大まかなやり取りの流れを掴んでいただければと思います。
この記事はC#の基本的な文法を理解されている方を対象としています。
完成形は以下の様になります。

少し長くなってしまうので前編後編に記事を分けています。
前編ではサーバーアプリケーションの作成までを解説します。
解説で使用しているソースコード
記事内にソースコードを全て掲載していますが、ソリューション毎ダウンロードしたいという方は以下Githubからダウンロード出来ます。
アプリケーションの構成
コードを記載する前に、ざっくりとした構成をまとめます。
サーバーアプリ1つに対して、クライアントアプリはN個とします。
また、クライアントはデータのやり取りを必ずサーバーアプリを介して行います。

やり取りするデータのフォーマットも定義します。
データ長は5120byteで、全ての領域をメッセージ部とします。
今回のチャットアプリでは文字列のやり取りしかしないため、メッセージ部しかありませんが、仮に機能を拡張して、ファイルの受け渡し、あて先を指定等出来るようにする場合は上記通信データの領域にデータを割り当てることになります。
ちょっとした独自のプロトコルですね。

サーバー側処理の大まかな流れをシーケンス図で記載しました。
あまりシーケンス図を描くのは得意ではないので細かい点は目をつぶってください。

アプリケーションの作成
サーバーアプリケーションプロジェクトの作成
VisualStudio2022で解説します。
VisualStudio2019でもほぼ同様に進められると思います。
まず、Windowsフォームアプリケーション(.NET Framework)プロジェクトを作成します。
プロジェクト名は「ServerApp」、ソリューション名は「ChatApp」とします。



チャットアプリケーションコモンプロジェクトの作成
サーバーアプリケーションとクライアントアプリケーション共通で使用するデータをDLLにまとめるため、チャットアプリケーションコモンプロジェクトを作成します。
ソリューションエクスプローラーを右クリック>追加>新しいプロジェクトを選択します。
クラスライブラリ(.NET Framework)を選択し、プロジェクト名に「ChatAppCommon」を設定します。


サーバーアプリケーションの作成
作成したServerAppプロジェクトのForm1を以下の様に変更します。

コントロール名 | プロパティ名 | 値 | 説明 |
---|---|---|---|
Form | Name | ServerForm | アプリケーションメインフォーム |
Text | Server | フォーム左上に表示される文字列を設定 | |
gbSettings | Text | 設定 | 設定グループボックス |
lblPort | Text | 監視ポート番号(0-65535) | 監視ポート番号のラベル |
txtPort | |||
btnStart | Text | 開始 | 開始ボタン |
Click | btnStart_Click | クリックイベント | |
btnEnd | Text | 終了 | 終了ボタン |
Click | btnEnd_Click | クリックイベント | |
Enabled | false | ||
gpConnectingClient | Text | 接続中クライアント | 接続中クライアントグループボックス |
lbConnectingClient | 接続中クライアントリストボックス | ||
gpConnectionLog | Text | ログ | ロググループボックス |
lbLog | ログリストボックス | ||
ssStatus | ステータスストリップ | ||
sslblStatus | Text | 状態: | ステータスストリップラベル |
ソースコード
まずはServerForm.csとChatAppCommon.csの全体のコードを記載します。
すべてコピペで動作すると思います。
ServerForm.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 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 |
using ChatAppCommon; using System; using System.Collections.Generic; using System.Data; using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Sockets; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows.Forms; namespace ServerApp { public partial class ServerForm : Form { /// <summary> /// TCPサーバー。 /// </summary> private TcpListenerEx Server { get; set; } /// <summary> /// 接続中クライアントリスト。 /// </summary> private SynchronizedCollection<TcpClientEx> ClientList { get; set; } public ServerForm() { InitializeComponent(); ClientList = new SynchronizedCollection<TcpClientEx>(); } private void MainForm_Load(object sender, EventArgs e) { // 接続状態設定 SetConnectionStatus(false); } private void btnStart_Click(object sender, EventArgs e) { var port = txtPort.Text.Trim(); try { // 接続情報有効チェック if (!CheckConnectionSettings(port)) return; // サーバーを作成して監視開始 var localEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), int.Parse(port)); Server = new TcpListenerEx(localEndPoint); Server.Start(); // 接続受付ループ開始 _ = AsyncAcceptWaitLoop(); // 接続状態設定 SetConnectionStatus(true); // ボタンの有効状態を設定 btnStart.Enabled = false; btnEnd.Enabled = true; } catch(SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) { MessageBox.Show("指定したポートは他のシステムに使用されています。"); } catch (Exception ex) { Debug.WriteLine(ex.Message); } } private void btnEnd_Click(object sender, EventArgs e) { // サーバー停止 Server?.Stop(); Server = null; // 接続状態設定 SetConnectionStatus(false); // 接続中クライアントリストをクリア lbConnectingClient.Items.Clear(); // 接続中クライアントを全て閉じる lock (ClientList.SyncRoot) { foreach (var client in ClientList) { client.Socket.Close(); } ClientList.Clear(); } // ボタンの有効状態を設定 btnStart.Enabled = true; btnEnd.Enabled = false; } private bool CheckConnectionSettings(string port) { // ポート番号空チェック if (string.IsNullOrEmpty(port)) { MessageBox.Show("ポート番号が空です。"); return false; } // ポート番号数値チェック if (!Regex.IsMatch(port, "^[0-9]+$")) { MessageBox.Show("ポート番号は数値を指定してください。"); return false; } var portNum = int.Parse(port); // ポート番号有効値チェック if (portNum < IPEndPoint.MinPort || IPEndPoint.MaxPort < portNum) { MessageBox.Show("無効なポート番号が指定されています。"); return false; } return true; } private void SetConnectionStatus(bool connection) { // 状態設定 Invoke(new Action(() => sslblStatus.Text = $"状態:{(connection ? "監視中" : "停止")}")); } private void SetConnectingClient(TcpClient client, bool add) { Invoke(new Action(() => { if (add) { // 接続中クライアント追加 lbConnectingClient.Items.Add(client.Client.RemoteEndPoint); } else { // 接続中クライアント削除 lbConnectingClient.Items.Remove(client.Client.RemoteEndPoint); } // リスト末尾を選択中とする lbConnectingClient.SelectedIndex = lbConnectingClient.Items.Count != -1 ? lbConnectingClient.Items.Count - 1 : -1; })); } private void AddLog(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 async Task AsyncAcceptWaitLoop() { AddLog("接続受け入れ開始。"); await Task.Run(() => { // サーバーが監視中の間は接続を受け入れ続ける while (Server != null && Server.Active) { try { // 非同期で接続を待ち受ける Server.BeginAcceptTcpClient(AcceptCallback, null).AsyncWaitHandle.WaitOne(-1); } catch (Exception) { AddLog("接続受け入れでエラーが発生しました。"); break; } } }); AddLog("接続受け入れ終了。"); } private void AcceptCallback(IAsyncResult result) { try { // 接続を受け入れる var client = Server.EndAcceptTcpClient(result); // 接続ログを出力 AddLog($"{client.Client.RemoteEndPoint}からの接続"); // 接続中クライアントを追加 var clientInfo = new TcpClientEx(client); SetConnectingClient(client, true); ClientList.Add(clientInfo); // クライアントからのデータ受信を待機 var data = new CommunicationData(clientInfo); client.Client.BeginReceive(data.Data, 0, data.Data.Length, SocketFlags.None, ReceiveCallback, data); // 接続中クライアント(接続したクライアント以外)に対してクライアントが接続した情報を送信する SendDataToAllClient(data, $"{data.Client.RemoteEndPoint}がサーバーに接続しました。"); } catch (Exception) { } } private void SendDataToAllClient(CommunicationData data, string text) { lock (ClientList.SyncRoot) { foreach (var client in ClientList.Where(e => !e.Equals(data.Client))) { // データ送信 client.Socket.Send(Encoding.UTF8.GetBytes(text)); // 送信ログを出力 AddLog($"{client.RemoteEndPoint}にデータ送信>>{text}"); } } } private void ReceiveCallback(IAsyncResult result) { try { // クライアントからのデータを受信 var data = result.AsyncState as CommunicationData; var length = data.Client.Socket.EndReceive(result); // 受信データが0byteの場合切断と判定 if (length == 0) { // 切断ログを出力 AddLog($"{data.Client.RemoteEndPoint}からの切断"); // 接続中クライアントを削除 SetConnectingClient(data.Client.Client, false); ClientList.Remove(data.Client); // 接続中クライアント(切断したクライアント以外)に対してクライアントが切断した情報を送信する SendDataToAllClient(data, $"{data.Client.RemoteEndPoint}がサーバーから切断しました。"); // データ受信を終了 return; } // 受信データを出力 AddLog($"{data.Client.RemoteEndPoint}からデータ受信<<{data}"); // 接続中クライアント(送信したクライアント以外)に対して受信したデータを送信する SendDataToAllClient(data, data.ToString()); // サーバーが監視中の場合 if (Server != null && Server.Active) { // 再度クライアントからのデータ受信を待機 data.Client.Socket.BeginReceive(data.Data, 0, data.Data.Length, SocketFlags.None, ReceiveCallback, data); } } catch (Exception) { } } } } |
ChatAppCommon.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 |
using System.Net; using System.Net.Sockets; using System.Text; namespace ChatAppCommon { /// <summary> /// 通信データクラス。 /// </summary> public class CommunicationData { /// <summary> /// 通信データサイズ最大値 /// </summary> const int MAX_COMMUNICATION_DATA_SIZE = 5120; /// <summary> /// 通信データバッファ。 /// </summary> public byte[] Data = new byte[MAX_COMMUNICATION_DATA_SIZE]; /// <summary> /// 通信クライアント。 /// </summary> public TcpClientEx Client { get; private set; } public CommunicationData(TcpClientEx info) { Client = info; } /// <summary> /// 通信データをUTF8でエンコーディングした文字列を取得する。 /// </summary> /// <returns></returns> public override string ToString() { return Encoding.UTF8.GetString(Data); } } /// <summary> /// Activeプロパティを外部から参照できる様にしたTcpListener拡張クラス。 /// </summary> public class TcpListenerEx : TcpListener { /// <summary> // System.Net.Sockets.TcpListener がクライアント接続をアクティブに待機しているかどうかを示す値を取得します。 // System.Net.Sockets.TcpListener がアクティブに待機している場合は true。それ以外の場合は false。 /// </summary> public new bool Active => base.Active; public TcpListenerEx(IPEndPoint ep) : base(ep) { } } /// <summary> /// 接続中クライアントクラス。 /// TcpClientのSocketプロパティへのアクセスが面倒なのでアクセスを省略するための拡張クラス。 /// </summary> public class TcpClientEx { public EndPoint RemoteEndPoint => Socket.RemoteEndPoint; public TcpClient Client { get; private set; } public Socket Socket => Client.Client; public TcpClientEx(TcpClient client) { Client = client; } public TcpClientEx(IPEndPoint ep) { Client = new TcpClient(ep); } } } |
コードの解説
処理の要所を解説します。
ポート番号のチェック
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 |
private bool CheckConnectionSettings(string port) { // ポート番号空チェック if (string.IsNullOrEmpty(port)) { MessageBox.Show("ポート番号が空です。"); return false; } // ポート番号数値チェック if (!Regex.IsMatch(port, "^[0-9]+$")) { MessageBox.Show("ポート番号は数値を指定してください。"); return false; } var portNum = int.Parse(port); // ポート番号有効値チェック if (portNum < IPEndPoint.MinPort || IPEndPoint.MaxPort < portNum) { MessageBox.Show("無効なポート番号が指定されています。"); return false; } return true; } |
ポート番号が空または数値ではないまたは0-65535以外の場合はエラーとしています。
サーバーの作成~ポート監視待機
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 btnStart_Click(object sender, EventArgs e) { var port = txtPort.Text.Trim(); try { // 接続情報有効チェック if (!CheckConnectionSettings(port)) return; // サーバーを作成して監視開始 var localEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), int.Parse(port)); Server = new TcpListenerEx(localEndPoint); Server.Start(); // 接続受付ループ開始 _ = AsyncAcceptWaitLoop(); // 接続状態設定 SetConnectionStatus(true); // ボタンの有効状態を設定 btnStart.Enabled = false; btnEnd.Enabled = true; } catch(SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) { MessageBox.Show("指定したポートは他のシステムに使用されています。"); } catch (Exception ex) { Debug.WriteLine(ex.Message); } } |
- 接続情報(ポート番号)の有効チェック。
- サーバーを作成して指定のポートの監視を開始。
- 非同期で接続待機のループを開始。
- 接続情報(ポート番号)の有効チェック。
ポート番号のチェックを行い、有効なポート番号の場合のみ後続の処理を実行します。 - サーバーを作成して指定のポートの監視を開始。
ポートを監視するためにTcpListnerを作成します。
作成時に監視するIPアドレスとポート番号をコンストラクタに渡します。
監視するポートが既に使用されているポートの場合、TcpListner.Start()で例外となり、SocketException.SocketErrorCodeにAddressAlreadyInUseが設定されるため、「指定したポートは他のシステムに使用されています。」エラーメッセージを表示します。 - 非同期で接続待機のループを開始。
ポートの監視が正常に開始できたら、接続受け入れを非同期で待機するループ処理を開始します。
接続受け入れを非同期で待機するのは、同期で待機すると接続を受け入れるまでアプリケーションが固まってしまうためです。
また、開始ボタンを無効、終了ボタンを有効に設定します。
(開始しているときに開始ボタンを押せないようにするためです。)
非同期接続待機ループ処理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
private async Task AsyncAcceptWaitLoop() { AddLog("接続受け入れ開始。"); await Task.Run(() => { // サーバーが監視中の間は接続を受け入れ続ける while (Server != null && Server.Active) { try { // 非同期で接続を待ち受ける Server.BeginAcceptTcpClient(AcceptCallback, null).AsyncWaitHandle.WaitOne(-1); } catch (Exception) { AddLog("接続受け入れでエラーが発生しました。"); break; } } }); AddLog("接続受け入れ終了。"); } |
- 非同期で接続を待機する。
- サーバーの監視が停止されるまで、ずっと接続要求を受け入れ続ける。
- 非同期で接続を待機する。
非同期で接続を待機するには、TcpListner.BeginAcceptTcpClientメソッドを使用します。サーバーが監視中の間は非同期で接続を待機し続けます。
非同期メソッドなので、メソッドのシンタックスをasyncとし、戻り値としてvoidではなくTaskを返します。ここでの戻り値は使用しないため、呼び出し元のbtnStart_Clickでは「_」(破棄)として受け取っていますいます。
TcpListner.BeginAcceptTcpClientメソッドの引数には、
・接続要求時コールバックデリゲート(接続要求があった時に実行される処理)
・コールバックに渡されるユーザー定義オブジェクト(自由に設定出来るオブジェクト)
を指定します。コールバックデリゲートには「public delegate void AsyncCallback(IAsyncResult ar);」型のデリゲートを指定します。
つまり、戻り値がvoidで引数にIAsyncResultを受け取るメソッドを指定する必要があります。 - サーバーの監視が停止されるまで、ずっと接続要求を受け入れ続ける。
アプリケーションで終了がクリックされるまで、whileで接続待機処理をループさせます。
こうすることで、何度でもクライアントからの接続要求を受け入れる事が可能です。
非同期接続受け入れ処理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
private void AcceptCallback(IAsyncResult result) { try { // 接続を受け入れる var client = Server.EndAcceptTcpClient(result); // 接続ログを出力 AddLog($"{client.Client.RemoteEndPoint}からの接続"); // 接続中クライアントを追加 var clientInfo = new TcpClientEx(client); SetConnectingClient(client, true); ClientList.Add(clientInfo); // クライアントからのデータ受信を待機 var data = new CommunicationData(clientInfo); client.Client.BeginReceive(data.Data, 0, data.Data.Length, SocketFlags.None, ReceiveCallback, data); // 接続中クライアント(接続したクライアント以外)に対してクライアントが接続した情報を送信する SendDataToAllClient(data, $"{data.Client.RemoteEndPoint}がサーバーに接続しました。"); } catch (Exception) { } } |
接続要求を受けたときに非同期で実行されます。
クライアントアプリケーションでTcpClient.Connectメソッドが実行された時という事ですね。
- TcpListner.EndAcceptTcpClientメソッドにIAsyncResultを渡して接続してきたクライアントを取得する。
- 取得したクライアントからのデータ受信を非同期で待機する。
- 接続中クライアントに、新規クライアントからの接続があった事を通知する。
- TcpListner.EndAcceptTcpClientメソッドにIAsyncResultを渡すことで、接続元との通信をするためのTcpClientを取得します。
ここで取得したTcpClientからデータを受信又は送信することで、接続元とのデータのやり取りを行います。
- 取得したクライアントからのデータ受信を非同期で待機します。
非同期での受信待機にはTcpClient.Client.BeginReceiveメソッドを使用します。BeginReceiveメソッドの引数には
・受信するデータのバッファ(byte[])
・受信するデータのバッファ内の読込み開始位置(int)
・バッファサイズ(int)
・送受信時の動作(SocketFlags)
・受信時コールバックデリゲート(受信時に実行される処理)
・コールバックに渡されるユーザー定義オブジェクト(自由に設定出来るオブジェクト)
を指定します。 - 接続中クライアントに、新規クライアントからの接続があった事を通知する。
1234567891011121314private void SendDataToAllClient(CommunicationData data, string text){lock (ClientList.SyncRoot){foreach (var client in ClientList.Where(e => !e.Equals(data.Client))){// データ送信client.Socket.Send(Encoding.UTF8.GetBytes(text));// 送信ログを出力AddLog($"{client.RemoteEndPoint}にデータ送信>>{text}");}}}
これはメンバ変数の接続中のクライアントリストに対して接続元の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 |
private void ReceiveCallback(IAsyncResult result) { try { // クライアントからのデータを受信 var data = result.AsyncState as CommunicationData; var length = data.Client.Socket.EndReceive(result); // 受信データが0byteの場合切断と判定 if (length == 0) { // 切断ログを出力 AddLog($"{data.Client.RemoteEndPoint}からの切断"); // 接続中クライアントを削除 SetConnectingClient(data.Client.Client, false); ClientList.Remove(data.Client); // 接続中クライアント(切断したクライアント以外)に対してクライアントが切断した情報を送信する SendDataToAllClient(data, $"{data.Client.RemoteEndPoint}がサーバーから切断しました。"); // データ受信を終了 return; } // 受信データを出力 AddLog($"{data.Client.RemoteEndPoint}からデータ受信<<{data}"); // 接続中クライアント(送信したクライアント以外)に対して受信したデータを送信する SendDataToAllClient(data, data.ToString()); // サーバーが監視中の場合 if (Server != null && Server.Active) { // 再度クライアントからのデータ受信を待機 data.Client.Socket.BeginReceive(data.Data, 0, data.Data.Length, SocketFlags.None, ReceiveCallback, data); } } catch (Exception) { } } |
AcceptCallbackで実行したBeginReceiveのコールバック処理です。
クライアントアプリケーションからデータを受信したときに実行されます。
- BeginReceiveの引数に指定したユーザー定義型からデータを取得する。
- 受信データサイズが0byteの場合、切断判定とする。
- 受信データを接続中クライアントに転送する。
- 受信処理後、再度受信待機する。
- BeginReceiveの引数に指定したユーザー定義型からデータを取得する。
IAsyncResult.AsyncStateにはBeginReceiveメソッドの引数に渡したユーザー定義型のオブジェクトが設定されています。object型なので、渡したユーザー定義型にキャストして取得します。
- 受信データサイズが0byteの場合、切断判定とする。
TcpClient.Client.EndReceiveメソッドは、IAsyncResultを渡すと受信したデータサイズを返します。
受信したデータが0byteの場合、クライアントから切断されたと判定できます。ここでは、切断されたことを接続中クライアントに対して通知しています。
- 受信データを接続中クライアントに転送する。
BeginReceiveメソッドのバッファ引数に渡した通信データクラスのバッファに受信したデータが設定されています。
受信したデータを接続中クライアント全てに対して転送します。 - 受信処理後、再度受信待機する。
再度TcpClient.Client.BeginReceiveを実行し、データの受信を待機します。
これで切断されるまでデータ受信待機→データ受信→クライアントに転送→再度データ受信待機→…というループになります。
まとめ
サーバーアプリケーションの解説は以上となります。
思った以上にだらだらと長くなってしまいました。
非同期の処理と、TPC/IPの受信・送信について、なんとなくイメージを掴んでいただけたらと思います。
後半ではクライアントアプリケーションの解説を行います。
コメント