> 作業効率UP!! オススメのモバイルモニターを紹介

【C#】Socket通信でファイル転送をやってみた

当ページのリンクには広告が含まれています。
  • URLをコピーしました!
これまでC#のSocekt通信を使ってデータの送受信をする方法について、Socket通信の理解を深める為に以下のように記事にまとめてきました。
あわせて読みたい
【C#】ソケット通信(非同期)でクライアントの処理を記述する方法 C#でソケット(socket)を使って非同期でプログラムを実装することがあったのですが、ソケットについて理解が不十分で苦戦したことがありました。 ソケットについて記事...

単発のデータの送受信をする事はできるようになりましたが、テキストデータや画像データ等のファイルを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()でバイト型の配列データで取得します。

受け取ったパケットの中にある<EOL>を検索し、その文字がある配列のインデックス番号を取得します。インデックス番号は見つかった最初の位置をリストに追加していきます。
//パターンに一致するバイト型配列のインデックスを検索
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;
}
この取得した配列のインデックス番号を元に、ファイル名とファイルデータの2つのバイト型の配列に分割を行います。ファイルデータ取得の際は<EOF>が含まれないようにCopyする配列の開始位置や取得する要素数を調整します。
//バイト型配列をファイル名とデータに分割
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アドレスとポート番号を入力するテキストボックス、ログを出力するテキストボックス、ファイルのパスを参照するボタンとサーバーに接続するボタンを配置します。

MainWindow.xml.csに記述したコードは以下です。
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;
    }
}

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

サーバーはポート番号を入力するテキストボックス、ログを出力するテキストボックス、クライアントの受信を開始するボタンを配置します。

MainWindow.xml.csに記述したコードは以下です。
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()を使うと受信データの終端を判断することができるようです。終端の判断ができるならファイルデータの末尾に付けている特定文字もいらないかもしれません。

Qiita
NetworkStream.DataAvailable についての勘違い - Qiita #はじめにまとめを言ってしまいます。DataAvailableは受信データの終端を判断するために使っても大丈夫だと思っていた。しかし本来DataAvailableは「読み取り対象のデータ...

以上、最後まで読んでいただきありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!