FTPでのファイル受信時に注意すべきポイント

# FTPでのファイル受信時に注意すべきポイントと実装方法（PHP & C#コードサンプル付き）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84965/92d102c4-93ee-42cf-6971-114e487fff7e.png)


いまだにデータのやり取りをAPIとしてOAuthとか使わずに、FTP（File Transfer Protocol）を使って閉まってるケースはまだまだあると思います。FTPは、システム間でファイルを転送するための標準的なプロトコルです。しかし、受信側で適切な対策を取らないと、転送が完了していないファイルを取得してしまい、データの不整合やファイルの破損などの問題が発生する可能性があります。


## なぜFTPは大変なのか
FTPというプロトコルの性質上、ファイルを送る際（PUT）に、まずはファイルのエントリーを0KBで作成します。この瞬間に、受け取り側からもファイル名を取得できるため、ファイルを取得（GET）できてしまいます。（ファイルのロック機能はありません）
PUTは、ファイルエントリーを作成後、ファイルのデータを順次FTPサーバーに送信していきます。受取側では、ファイルの中身がまだ不完全な状態でも取得できてしまいます。いつ、ファイルが最後までFTPサーバーに転送されたのか受け取り側ではわからないのです。

## 受信側で取るべき具体的な対策

対策のメインは、１のファイルサイズ監視か、２の一時ファイルの利用かなとおもいますが、受け取り側しか制御できないのであれば、１の**ファイルサイズ監視**のみしか手段はないかとおもいます。が、一応網羅的に可能性のある対策を記載していますのでご参考までに。

### 1. ファイルサイズの監視

FTPを使用する際、送信中のファイルがまだ完全に転送されていない段階で受信を開始すると、不完全なファイルが生成される可能性があります。この問題を避けるため、ファイルサイズを一定間隔で監視し、サイズが増加しなくなったことを確認してからファイルを取得することで安全にファイルを取得できます。

#### PHPでの実装例

```php:sample.php
<?php
$ftp_server = "ftp.example.com";
$ftp_user = "username";
$ftp_pass = "password";
$file = "example.txt";
$local_file = "local_example.txt";

$conn_id = ftp_connect($ftp_server);
$login_result = ftp_login($conn_id, $ftp_user, $ftp_pass);

if (!$conn_id || !$login_result) {
    die("FTP接続に失敗しました。");
}

$prev_size = 0;
do {
    sleep(5); // 5秒間隔でチェック
    $curr_size = ftp_size($conn_id, $file);
    if ($curr_size == $prev_size && $curr_size != -1) {
        break;
    }
    $prev_size = $curr_size;
} while (true);

if (ftp_get($conn_id, $local_file, $file, FTP_BINARY)) {
    echo "ファイルを正常にダウンロードしました。";
} else {
    echo "ファイルのダウンロードに失敗しました。";
}

ftp_close($conn_id);
?>
```

#### C#での実装例（非同期処理実装してます）

```csharp:sample.cs
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;

class FtpDownloadExample
{
    static async Task Main()
    {
        string ftpServer = "ftp://ftp.example.com";
        string username = "username";
        string password = "password";
        string remoteFile = "/example.txt";
        string localFile = "local_example.txt";

        long prevSize = 0;
        long currSize = 0;
        bool isComplete = false;

        while (!isComplete)
        {
            currSize = await GetFileSizeAsync(ftpServer, remoteFile, username, password);
            if (currSize == prevSize)
            {
                isComplete = true;
            }
            prevSize = currSize;
            await Task.Delay(5000); // 5秒間隔でチェック
        }

        await DownloadFileAsync(ftpServer, remoteFile, localFile, username, password);
        Console.WriteLine("ファイルのダウンロードが完了しました。");
    }

    static async Task<long> GetFileSizeAsync(string ftpServer, string remoteFile, string username, string password)
    {
        FtpWebRequest request = (FtpWebRequest)WebRequest.Create(ftpServer + remoteFile);
        request.Method = WebRequestMethods.Ftp.GetFileSize;
        request.Credentials = new NetworkCredential(username, password);

        using (FtpWebResponse response = (FtpWebResponse)await request.GetResponseAsync())
        {
            return response.ContentLength;
        }
    }

    static async Task DownloadFileAsync(string ftpServer, string remoteFile, string localFile, string username, string password)
    {
        FtpWebRequest request = (FtpWebRequest)WebRequest.Create(ftpServer + remoteFile);
        request.Method = WebRequestMethods.Ftp.DownloadFile;
        request.Credentials = new NetworkCredential(username, password);

        using (FtpWebResponse response = (FtpWebResponse)await request.GetResponseAsync())
        using (Stream responseStream = response.GetResponseStream())
        using (FileStream fileStream = new FileStream(localFile, FileMode.Create))
        {
            await responseStream.CopyToAsync(fileStream);
        }
    }
}
```

### 2. 一時ファイルの利用

転送中のファイルが一時的な名前（例: `filename.tmp`）で保存され、転送完了後に正式な名前にリネームされるような運用が可能な場合、受信側で一時ファイルを無視し、正式な名前になった後にファイルを取得します。
※転送が完了するまでファイルの受信を遅らせることができます。

### 3. ファイル転送完了の推定

ファイルの最終アクセス時間（`atime`）や最終修正時間（`mtime`）を監視し、これらが一定期間更新されていない場合に、転送が完了したと推定する方法もあります。

この方法は特に、ファイルが非常に大きく、転送時間が長い場合に有効です。転送が完了していないファイルを誤って取得するリスクを減らせます。

### 4. 一定の遅延を設定

ファイル転送が完了したかどうかを直接確認できない場合、`PUT`操作の完了から一定の遅延を挟んでから`GET`を行うことが一つの方法です。

遅延の長さは、通常の転送時間を考慮して設定します。この方法はシンプルですが、特に転送時間が予測しやすい環境で効果的です。

### 5. 転送確認プロセスの導入

トリガーファイル（例: `filename.complete`）を使用して、転送が完了したことを受信側に通知する方法も有効です。トリガーファイルが存在することを確認してからメインのファイルを`GET`するプロセスを導入することで、確実に転送が完了したファイルを取得できます。

### 6. ファイルの一貫性チェック

受信したファイルの一貫性を確認するため、`MD5`や`SHA-256`などのハッシュ値を生成し、送信側のハッシュ値と比較することも重要です。ファイルが正確に転送されたかどうかを確認できます。



## 非同期処理の検討

特に大きなファイルや多数のファイルを扱う場合は、非同期処理を導入することが推奨されます。非同期でファイルサイズの監視やダウンロードを行うことで、メインプロセスをブロックせずに効率的な処理を実現できます。

上記の`C#`のコードサンプルでは、`async`と`await`を使用して非同期処理を実装しています。`PHP`の場合、非同期処理のサポートが限られているため、`pcntl_fork`などを使用して簡易的な非同期処理を行うことが可能です。または、コマンドラインで動作するような`PHP`にして、`cron`のようなもので実行させるなど、ですかね。

## まとめ

FTPでのファイル受信時には、転送が完了していないファイルを取得しないように注意が必要です。各種対策を実装することで、不完全なファイルの受信を防ぎ、システムの安定性とデータの整合性を確保することができます。