単発のデータの送受信をする事はできるようになりましたが、テキストデータや画像データ等のファイルをSocket通信で転送する事はできないのか疑問に思ったので、今回はファイル転送についてまとめたいと思います。
ファイル転送について調べるとPCとAndroid端末間で通信する記事がありましたが、この記事では送信側も受信側もWindows OSのパソコンを対象としています。
環境
OS | Windows10 |
IDE | Visual Studio 2019 |
フレームワーク | .NET Framework 4.8 |
UIフレームワーク | WPFアプリ |
ファイル転送について
Socket通信でファイル転送する方法をいくつか調べたところ、.NETに「Sokect.BeginSendFile」というメソッドが用意されていました。説明文には「接続されたSocketオブジェクトに、ファイルを非同期的に送信します。」と記載があり、これを使えばやりたいことが実現できそうです。
- fileName:送信するファイルのパス。指定したパスのデータが送信される。
- preBuffer:ファイルの送信前に送信されるデータ。
- postBuffer:ファイルの送信後に送信されるデータ。
- flags:列挙値のビット毎の組み合わせ。
- callback:このメソッドの完了時に呼び出されるデリゲート。
- state:要求先の状態を格納するオブジェクト。
Socket通信で送受信するパケットの構造
上記で記述したメソッドを使って、ファイルの名前とファイルのデータを送受信できるようにします。
ファイル名とファイルデータの間に特定の文字で区切り、ファイルのデータの末尾に特定の文字をつける構造です。特定の文字は何でもよかったので適当に<EOL>としました。ファイル名と区切り文字をpreBufferに格納し、ファイルのデータの末尾文字をpostBufferに格納して送信します。
このパケットを受信側で特定の文字で分解して、ファイルを作成することになります。
ソースコード作成
送信側(クライアント)と受信側(サーバー)のコードを作成してみましょう。
クライアントの処理
ソケット通信でファイルデータを送信するまでのフローチャートが以下の通りです。
// マニュアルリセットイベントのインスタンスを生成
private static ManualResetEvent sendDone = new ManualResetEvent(false); //送信シグナル用
public async Task<bool> StartClient(string ipaddress, int port, string filepath)
{
//シグナルをリセット
sendDone.Reset();
// サーバーへ接続
try
{
// IPアドレスとポート番号を取得
IPEndPoint endpoint = new IPEndPoint(IPAddress.Parse(ipaddress), port);
// TCP/IPのソケットを作成
Socket client = new Socket(IPAddress.Parse(ipaddress).AddressFamily, SocketType.Stream, ProtocolType.Tcp);
// エンドポイント(IPアドレスとポート)へ接続
var task = client.ConnectAsync(endpoint);
if (await Task.WhenAny(task, Task.Delay(5000)) != task)
{
//タイムアウトの例外
throw new SocketException(10060);
}
var filename = Path.GetFileName(filepath); // ファイルパスからファイル名を取得
var preBuffer = Encoding.UTF8.GetBytes(filename + "<EOL>");
var postBuffer = Encoding.UTF8.GetBytes("<EOL>");
// ファイルの送信
client.BeginSendFile(filepath, preBuffer, postBuffer, TransmitFileOptions.UseDefaultWorkerThread, new AsyncCallback(SendFileCallback), client);
sendDone.WaitOne(); //送信シグナルになるまで待機
// ソケット接続終了
client.Shutdown(SocketShutdown.Both);
client.Close();
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return false;
}
return true;
}
private void SendFileCallback(IAsyncResult ar)
{
try
{
// ソケットを取得
Socket client = (Socket)ar.AsyncState;
// 非同期送信を終了
client.EndSendFile(ar);
Debug.WriteLine("送信完了");
// シグナル状態にし、メインスレッドの処理を続行する
sendDone.Set();
}
catch (Exception e)
{
Debug.WriteLine(e.ToString());
}
}
BeginSendFileメソッドを使ってパケットをサーバーへ送信します。非同期で送信処理を行い、送信処理が完了するとコールバックメソッドであるSendFileCallbackが呼び出しされますので、EndSendFileで非同期処理を終了させます。
サーバーの処理
ソケット通信でファイルデータを受信して、ファイル作成するまでのフローチャートが以下の通りです。
//マニュアルリセットイベントのインスタンスを生成
public ManualResetEvent allDone = new ManualResetEvent(false);
//TCP/IPの接続開始処理
public async Task<bool> StartListening(int port)
{
Debug.WriteLine("TCP/IP接続開始");
// IPアドレスとポート番号を取得
IPEndPoint localEndPoint = new IPEndPoint(IPAddress.Any, port);
// TCP/IPのソケットを作成
Socket TcpServer = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try
{
TcpServer.Bind(localEndPoint); // ローカルエンドポイントにバインド
TcpServer.Listen(1); // 待ち受け開始
// 非同期で接続要求待ち受け
Debug.WriteLine("接続待機中...");
var socket = await Task.Run(() => TcpServer.Accept());
using (NetworkStream ns = new NetworkStream(socket))
using (MemoryStream ms = new MemoryStream())
{
// クライアントからのデータを受信
byte[] gdata = new byte[256];
do
{
int dataSize = await ns.ReadAsync(gdata, 0, gdata.Length);
if (dataSize == 0) return false;
await ms.WriteAsync(gdata, 0, dataSize);
}
while (ns.DataAvailable);
byte[] bytes = ms.GetBuffer();
Debug.WriteLine("データの受信完了");
// 受信データを分割
var pattern = Encoding.ASCII.GetBytes("<EOL>");
var index = PatternAt(bytes, pattern);
if (index.Count() != 2) return false;
byte[] fbytes, dbytes;
SplitByte(bytes, index[0], index[1], out fbytes, out dbytes);
Debug.WriteLine("受信データの分割完了");
// ファイルを作成
CreateFile(fbytes, dbytes);
Debug.WriteLine("ファイル作成完了");
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
}
return false;
}
クライアントから接続要求があり接続が確立したら、NetworkStreamで受信データの読み出しを行います。読み出した受信データはMemoryStreamに書き出します。
全ての受信データを読み出した後はmStream.GetBuffer()でバイト型の配列データで取得します。
//パターンに一致するバイト型配列のインデックスを検索
private List<int> PatternAt(byte[] source, byte[] pattern)
{
var index = new List<int>();
for (int i = 0; i < source.Length; i++)
{
if (source.Skip(i).Take(pattern.Length).SequenceEqual(pattern))
{
index.Add(i);
}
}
return index;
}
//バイト型配列をファイル名とデータに分割
private void SplitByte(byte[] source, int findex, int dindex, out byte[] fbytes, out byte[] dbytes)
{
var flenth = findex;
fbytes = new byte[flenth];
Array.Copy(source, 0, fbytes, 0, flenth);
var dlenth = dindex - findex - 5;
dbytes = new byte[dlenth];
Array.Copy(source, findex + 5, dbytes, 0, dlenth);
}
private void CreateFile(byte[] fbytes, byte[] dbytes)
{
try
{
var filename = Encoding.UTF8.GetString(fbytes);
var path = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); // 保存先パス
using (FileStream fs = new FileStream(path + "\\" + filename, FileMode.Create))
{
fs.Write(dbytes, 0, dbytes.Length);
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
}
}
サンプル使用例
上記で説明したサンプルソースコードを使用して、WPFでサーバーのアプリケーションを作成してみます。
クライアント側のアプリケーション
クライアントはファイルのパスやIPアドレスとポート番号を入力するテキストボックス、ログを出力するテキストボックス、ファイルのパスを参照するボタンとサーバーに接続するボタンを配置します。
public partial class MainWindow : Window
{
tcpclient client = new tcpclient();
public MainWindow()
{
InitializeComponent();
}
private async void btnConnection_Click(object sender, RoutedEventArgs e)
{
//ファイルを転送
await client.StartClient(txtIPaddress.Text, int.Parse(txtPort.Text), txtPath.Text);
}
private void btnPath_Click(object sender, RoutedEventArgs e)
{
//ファイルのパスを取得
txtPath.Text = "";
txtPath.Text = SelectPath();
}
private string SelectPath()
{
var path = "";
// ダイアログのインスタンスを生成
var dialog = new OpenFileDialog();
if (dialog.ShowDialog() == true)
{
path = dialog.FileName; // 選択されたファイル名を取得
}
return path;
}
}
サーバー側のアプリケーション
サーバーはポート番号を入力するテキストボックス、ログを出力するテキストボックス、クライアントの受信を開始するボタンを配置します。
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;
}
}
}
ファイル転送の動作確認
サーバーとクライアントのアプリケーションを起動してファイルを転送してみましょう。動作確認では同一PC上で2つのアプリケーションを起動しています。下のようにクライアント→サーバーにファイルを転送し、サーバーがファイルをデスクトップに出力することができました。
画像ファイルだけでなく、テキストファイルも転送できることを確認しました。
問題点
サーバーが受信したバイト型の配列から特定文字のインデックス番号を取得するPatternAt()の処理時間がかかりすぎる点です。ファイルの末尾にある特定文字まで全てのバイト型の配列まで検索するので、ファイルの容量が100kbyte程度ならさほど時間はかかりませんが、ファイルの容量が1000kbyte程度になると、バイト型の配列要素数が多くなるのでかなり時間がかかります。
ファイルデータの末尾の特定文字は、PatternAt()ではなくバイト型の配列に格納されている最後の要素を特定文字数だけ取得して比較するようにしたら解決しそうです。
ファイル名とファイルデータの受信は、ns.DataAvailableを使っているのでファイルデータの終端まで受け取りができているかどうか判断できませんが、ns.Read()を使うと受信データの終端を判断することができるようです。終端の判断ができるならファイルデータの末尾に付けている特定文字もいらないかもしれません。
以上、最後まで読んでいただきありがとうございました。