C# で TCP/IP 通信を非同期で実装するのに苦戦したことがありました。原因は、TCP/IP 通信についての理解が不十分だったからです。
前提知識が記事にまとめてあれば、スムーズに理解が進むと思うので、いろいろと調べたことを整理して記事にまとめてみました。
これからTCP/IP通信を実装する方には、特にオススメの記事となりますので、参考にしてみてください。
オススメの参考書
C#の使い方が丁寧に解説しており、「基礎からしっかりと学びたい」という初心者の方にオススメの一冊です。サンプルコードも記載してあり、各章の最後に復習問題があるので理解度を確認しながら読み進めることができます。新しい C# のバージョンにも対応している書籍です。
TCP/IP通信の理解
まずは TCP/IP についておさらいしましょう。
TCP/IPとは
TCP/IP は 「インターネット・プロトコル・スイート」 とも呼ばれ、現在でも世界標準で利用されている通信規格です。TCP/IP は機器や OS が異なっていても、共通のプロトコルを用いて通信を成立させることができます。
例えば、パソコンとパソコン同士だけでなく、産業機器で使用されるPLC(プログラマブルコントローラー)と言った異なる機器同士で通信することができます。
特徴として、通信相手を確認して接続をしてから通信をするため、確実にデータを送受信することができます。確実にデータの送受信ができるので通信品質が高く、現在でも使用されている通信手段の1つです。
- これから通信を始めることを確認した後で通信を開始する方式である
- 通信を開始する時点から通信相手に届くことが保証されている
- 通信終了時も、通信相手との間で通信を終了することを確認してから終える
この通信プロトコルをプログラムで利用するには ソケット を使って通信をします。
ソケットとは
ソケットは、TCP/IP をプログラムから利用する際に使用され、プログラムとネットワークを結びつける為のエンドポイント(通信の出入口)のことです。
エンドポイントは、IPアドレス と ポート番号 を指定して識別します。この中でポート番号の指定が特に重要です。
通信相手のIPアドレスが分かれば、そのIPアドレスにデータを送信することまではできますが、そのデータをどこに受け渡したらいいのか相手は判断ができません。
ポート番号があることで、通信を行うアプリケーションを特定することができ、データの受け渡しができます。
また、ポート番号は16ビットの数値なので、0~65535(0xFFFF)の範囲内で指定されます。
ポート番号のタイプ | ポート番号の範囲 | 意味 |
---|---|---|
ウェルノウンポート | 0~1023 | サーバーアプリケーション用に予約されているポート番号 (例:HTTPなら80、FTPなら20/21) |
登録済みポート | 1024~49151 | よく利用されるアプリケーションのサーバー側ポート番号 |
ダイナミック 又は プライベートポート | 49152~65535 | クライアントアプリケーション用のポート番号 |
通信処理の流れ
上記の「TCP/IPとは」で説明した内容をフローチャートにすると以下になります。
この通信フローの順番でプログラムの処理を書くことになります。
今回のサンプルコードではサーバーとクライアント間で接続をして、クライアントから「Hello」を受信したら、「OK」を送信するプログラムを作成します。通信には非同期なソケット通信を使用します。
サーバー側の処理
サーバー側の処理を行う為に4つの関数を用意します。上図のフローチャート(通信処理の流れ)と比較しながら関数の役割を確認してみてください。
関数 | 役割 |
---|---|
StartListening | 指定されたIPアドレスとポートを、TCP/IP通信のソケットのエンドポイントとしてバインドする。非同期ソケットを開始して、接続を待機する。 |
AcceptCallback | クライアントとソケットで接続を確立し、データが送信されるを待ち受ける。 |
ReadCallback | クライアントからデータを受信し、受信したデータを蓄積する。蓄積データに終了タブ<EOF>があれば、受信を終了する。 |
SendCallback | クライアントへデータ送信を行い、ソケット通信を終了する。 |
サンプルソースコード
「tcpserver.cs」という新しい項目(ファイル)を作成して、プロジェクトに追加します。
ソケット関連で使用する名前空間System.Net.Sockets
とSystem.Net
を参照するために、usingディレクティブを用いてコードの先頭に記述します。
using System;
using System.Net.Sockets;
using System.Net;
StartListening関数
まず IP アドレスとポート番号を指定して、エンドポイントを設定します。IP アドレスは、IPAddress.Any
として、どんなIPアドレスも受け付けるようにします。ポート番号は引数で渡すことで変更できるようにしました。
作成したソケットにローカルエンドポイントをバインドして、TCP/IP の接続を待機します。
今回はwhile
でクライアントと接続をし続けるので、接続待機状態はUI画面が操作できなくなります。そこでTask.Run()
で非同期処理とし、別スレッドでwhile
を実行します。
TcpServerの接続時の処理を非同期にする際は、BeginAccept
を用いて実装します。第1引数には、接続が確立された時に実行されるメソッド、第2引数には、ソケットを指定します。
while
でループさせ続けるため、BeginAccept
が何度も行われないように、System.Threading
のManualResetEvent
クラスを用いて、接続が完了するまで待機します。
ManualResetEvent
にWaitOne()
を呼び出しすると、シグナルを受け取るまでスレッドをブロックして待機し続けます。
Set()
を呼び出しすると、シグナル状態となり、待機中のスレッドが進行されます。シグナル状態になった後は、再利用するためにReset()
を呼び出ししておきます。
//マニュアルリセットイベントのインスタンスを生成
public ManualResetEvent allDone = new ManualResetEvent(false);
//TCP/IPの接続開始処理
public async Task<bool> StartListening(int port)
{
// IPアドレスとポート番号を指定して、ローカルエンドポイントを設定
IPEndPoint localEndPoint = new IPEndPoint(IPAddress.Any, port);
// TCP/IPのソケットを作成
Socket TcpServer = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try
{
TcpServer.Bind(localEndPoint); // TCP/IPのソケットをローカルエンドポイントにバインド
TcpServer.Listen(10); // 待ち受け開始
await Task.Run(() = >
{
while (true)
{
// シグナルの状態をリセット
allDone.Reset();
// 非同期ソケットを開始して、接続をリッスンする
Debug.WriteLine("接続待機中...");
TcpServer.BeginAccept(new AsyncCallback(AcceptCallback), TcpServer);
// シグナル状態になるまで待機
allDone.WaitOne();
}
});
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
return false;
}
AcceptCallback関数
クライアントと通信が確立されると、このメソッドの処理が実行されます。接続先のソケット情報を取得して、クライアントからのデータ受信を待ち受けます。
ここでも非同期処理にする為に、データ受信ではBeginReceive
を用いて開始をします。
public void AcceptCallback(IAsyncResult ar)
{
// シグナル状態にし、メインスレッドの処理を続行する
allDone.Set();
// クライアント要求を処理するソケットを取得
Socket TcpServer = (Socket)ar.AsyncState;
Socket TcpClient = TcpServer.EndAccept(ar);
// 端末からデータ受信を待ち受ける
StateObject state = new StateObject();
state.workSocket = TcpClient;
TcpClient.BeginReceive(state.buffer, 0, StateObject.BufferSize, 0, new AsyncCallback(ReceiveCallback), state);
}
ReceiveCallback関数
クライアントから送信されたデータを受信すると、このメソッドの処理が実行されます。
この処理では、受信したデータが0になるまでデータを蓄積します。データの区切り文字である<EOF>を受け取れば、「OK」を送信する処理へ移行します。それ以外の場合は、もう一度、ReceiveCallback
を行い受信を再開します。
データを送信する際も同様に非同期で行うので、データ送信ではBeginSend
を用いて開始をします。
public static void ReceiveCallback(IAsyncResult ar)
{
var content = string.Empty;
try
{
// 非同期オブジェクトからソケット情報を取得
StateObject state = (StateObject)ar.AsyncState;
Socket TcpClient = state.workSocket;
// クライアントソケットからデータを読み取り
int bytesRead = TcpClient.EndReceive(ar);
if (bytesRead > 0)
{
// 受信したデータを蓄積
state.sb.Append(Encoding.ASCII.GetString(state.buffer, 0, bytesRead));
// 蓄積データの終端タグを確認
content = state.sb.ToString();
if (content.IndexOf("<EOF>") > -1)
{
// 終了タグ<EOF>があれば、読み取り完了
Debug.WriteLine(string.Format("クライアントから「{0}」を受信", content));
// ASCIIコードをバイトデータに変換
byte[] byteData = Encoding.ASCII.GetBytes("OK");
// クライアントへデータの送信を開始
TcpClient.BeginSend(byteData, 0, byteData.Length, 0, new AsyncCallback(SendCallback), TcpClient);
}
else
{
// 取得していないデータがあるので、受信再開
TcpClient.BeginReceive(state.buffer, 0, StateObject.BufferSize, 0, new AsyncCallback(ReceiveCallback), state);
}
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
}
SendCallback関数
クライアントに対して、データの送信が完了すると、このメソッドの処理が実行されます。
送信と受信側のソケット通信をシャットダウンさせて終了させます。
private static void SendCallback(IAsyncResult ar)
{
try
{
// 非同期オブジェクトからソケット情報を取得
Socket TcpClient = (Socket)ar.AsyncState;
// クライアントへデータ送信完了
int bytesSent = TcpClient.EndSend(ar);
Debug.WriteLine("「OK」をクライアントへ送信");
//ソケット通信を終了
Debug.WriteLine("接続終了");
TcpClient.Shutdown(SocketShutdown.Both);
TcpClient.Close();
}
catch (Exception e)
{
Debug.WriteLine(e.ToString());
}
}
StateObjectクラス
ここでは非同期処理の際、ソケット情報(受信バッファと蓄積した受信データ)を保持するために使用します。
クライアントと接続が確立された時にソケットが保存され、受信時のコールバック関数により受信したデータをバッファのクラス変数に保存します。
// 非同期処理でソケット情報を保持する為のオブジェクト
public class StateObject
{
// 受信バッファサイズ
public const int BufferSize = 1024;
// 受信バッファ
public byte[] buffer = new byte[BufferSize];
// 受信データ
public StringBuilder sb = new StringBuilder();
// ソケット
public Socket workSocket = null;
}
サンプル使用例
上記で説明したサンプルソースコードを使用して、WPFでサーバーのアプリケーションを作成してみます。
ポート番号を入力して、受信開始ボタンをクリックすればクライアントと接続されるまで待機します。画面はマテリアルデザインを適用させてオシャレな画面に仕上げました。
マテリアルデザインの適用方法については、以下の記事で詳しく記載しています。
XAMLのソースは以下の記述になります。
<Window
x:Class="TcpServer.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="TCP/IP (Server)"
Width="500"
Height="320"
WindowStyle="None"
mc:Ignorable="d">
<Grid>
<!-- ヘッダー -->
<materialDesign:ColorZone
Height="50"
Padding="12"
Mode="PrimaryMid">
<DockPanel>
<materialDesign:PopupBox DockPanel.Dock="Right" PlacementMode="BottomAndAlignRightEdges">
<ListBox>
<ListBoxItem Content="Close" />
</ListBox>
</materialDesign:PopupBox>
<StackPanel Orientation="Horizontal">
<materialDesign:PackIcon
Width="26"
Height="26"
Kind="AccessPoint" />
<TextBlock
Margin="16,0,0,0"
VerticalAlignment="Center"
Text="TCP/IP SAMPLE" />
</StackPanel>
</DockPanel>
</materialDesign:ColorZone>
<Label
Margin="15,61,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Content="TCP/IP通信のサーバー側のサンプル画面です。" />
<!-- ポート番号入力欄 -->
<Label
x:Name="lblPort"
Margin="15,98,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Content="ポート番号:" />
<TextBox
x:Name="txtPort"
Width="185"
Margin="90,85,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
materialDesign:HintAssist.Hint="ポート番号を入力してください。"
Style="{StaticResource MaterialDesignFloatingHintTextBox}" />
<!-- 受信開始ボタン -->
<Button
x:Name="btnStart"
Width="90"
Height="30"
Margin="0,92,10,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Click="BtnStart_Click"
Content="受信開始"
Foreground="Black"
Style="{StaticResource MaterialDesignOutlinedButton}" />
<!-- ログ出力欄 -->
<TextBox
x:Name="txtLog"
Margin="10,140,10,10"
AcceptsReturn="True"
IsEnabled="{Binding ElementName=MaterialDesignOutlinedTextBoxEnabledComboBox}"
Style="{StaticResource MaterialDesignOutlinedTextBox}"
TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto" />
</Grid>
</Window>
コードは以下の記述になります。
using System.Windows;
namespace TcpServer
{
public partial class MainWindow : Window
{
tcpserver tcp = new tcpserver();
public MainWindow()
{
InitializeComponent();
}
private async void BtnStart_Click(object sender, RoutedEventArgs e)
{
btnStart.IsEnabled = false;
if (!await tcp.StartListening(int.Parse(txtPort.Text)))
{
btnStart.IsEnabled = true;
}
}
}
}
動作確認
サーバー側とクライアント側のアプリケーションをそれぞれ起動します。
サーバーアプリにポート番号を入力してから受信開始状態にし、クライアントアプリからサーバーへ接続要求をしてみましょう。
TCP/IP通信でデータの送受信ができていることが確認できました。
まとめ
この記事では Soket クラスを使用してサーバー側の通信方法について記載しました。
.NETでは Soket クラスよりも簡単に TCP 通信ができるように、TcpListener クラスが用意されています。初心者の方には扱いやすいクラスですので、こちらの記事も参考にしてみてください。
以上、最後まで読んでいただきありがとうございました。