PHPでのレジューム可能なファイル転送の実装

概要

リソースファイルを暗号化する必要があるビジネス要件に対応するため、PHPインターフェースを介してヘッダー情報を変更し、ストリーム形式でデータを転送する方法を実装しました。

テスト中に判明した問題として、iOSデバイスにおいてファイルサイズが大きい場合(おおよそ10MB以上)、複数回のAPI呼び出しが自動的に発生します。このような状況でレジューム可能な転送方式を採用しないと、リソースの読み込みに失敗します。

  • iOSデバイスはHTTP Rangeリクエストの処理においてより厳格で敏感です。安定したダウンロードおよび再生体験を確保するために、大容量ファイルを取得する際に複数回のリクエストを試みることがあります。
  • Androidデバイスは同じHTTP Rangeリクエストを処理する際、より柔軟であり、大容量ファイルの取得を単回または少数回のリクエストで効率的に処理できます。

パケットキャプチャによる詳細調査では、Windows-ChromeやAndroidスマートフォンが動画取得時にRange: bytes=0-を使用していることが確認されました。これは「必要なだけすべて取得する」方式であり、サーバーがファイル全体を一度に転送できる場合は問題ありません。

一方、iOSデバイスは最初にRange: bytes=0-1を呼び出してファイル総容量を取得し、その後固定サイズのチャンクごとにリクエストを送信します。このため、サーバーとネットワークが一括転送を可能にしていても、レジューム機能が実装されていないとクライアントが正常に取得できなくなることがあります。

Rangeの仕組みについては後述します。

本記事ではサーバーサイドの実装(ファイル分割と送信)のみを説明します。クライアント側でのレジューム受信は同じ原理に基づき、HTTPヘッダーを構築し、最初のリクエストで返されたファイル総サイズに従って複数回のリクエストでチャンクを取得し、fopenfwriteを使用してファイルを結合することでダウンロードを実現できます。

実装コード

サーバーサイド送信処理

<?php
// 最長実行時間設定(秒)
ini_set('max_execution_time', '300');
// メモリ制限設定
ini_set('memory_limit', '512M');

// ファイル名取得
$target_filename = $_REQUEST['target_filename'];
// ファイルパス構築
$storage_path = $BASE_DIR . $target_filename;

$file_total_size = filesize($storage_path);
$content_mime = mime_content_type($storage_path);

// キャッシュ制御ヘッダーを適切に設定し、特にコンテンツが頻繁に更新される場合にファイルがキャッシュされないようにします。
header('Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0');
header('Pragma: no-cache');
header('Expires: Thu, 19 Nov 1981 08:52:00 GMT');

header('Content-Type: ' . $content_mime);
header('Content-Disposition: inline; filename="' . $target_filename . '"');

// 出力バッファをクリア
while (ob_get_level() > 0) {
    ob_end_flush();
}

// レジューム転送処理
if (isset($_SERVER['HTTP_RANGE'])) {
    $file_pointer = fopen($storage_path, 'rb');
    $request_range = $_SERVER['HTTP_RANGE'];
    list(, $range_value) = explode('=', $request_range, 2);
    list($offset_start, $offset_end) = explode('-', $range_value);
    $offset_start = intval($offset_start);
    $offset_end = ($offset_end === '') ? ($file_total_size - 1) : intval($offset_end);
    $data_length = $offset_end - $offset_start + 1;
    header('HTTP/1.1 206 Partial Content');
    header("Content-Range: bytes {$offset_start}-{$offset_end}/{$file_total_size}");
    header('Content-Length: ' . $data_length);
    fseek($file_pointer, $offset_start);
    echo fread($file_pointer, $data_length);
    fclose($file_pointer);
} else {
    header('Content-Length: ' . $file_total_size);
    readfile($storage_path);
}

クライアント側受信例

function resumeDownload($source_url, $destination_file)
{
    $segment_size = 2048 * 1024; // 2MB per segment
    $file_handle = fopen($destination_file, 'wb');

    if (!$file_handle) {
        die('出力ファイルの書き込みオープンに失敗しました');
    }

    $curl_session = curl_init();
    curl_setopt($curl_session, CURLOPT_URL, $source_url);
    curl_setopt($curl_session, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($curl_session, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($curl_session, CURLOPT_NOPROGRESS, false);

    // 受信データをファイルに書き込むコールバック関数を設定
    curl_setopt($curl_session, CURLOPT_WRITEFUNCTION, function($curl, $received_data) use ($file_handle) {
        fwrite($file_handle, $received_data);
        return strlen($received_data);
    });

    $total_file_size = 0;
    $response_headers = get_headers($source_url, 1);
    if (isset($response_headers['Content-Length'])) {
        $total_file_size = (int)$response_headers['Content-Length'];
    } else {
        die('ファイルサイズの判定ができません');
    }

    for ($position = 0; $position < $total_file_size; $position += $segment_size) {
        $finish_position = min($position + $segment_size - 1, $total_file_size - 1);
        $range_spec = "$position-$finish_position";

        // パーツダウンロード用にRangeヘッダーを設定
        curl_setopt($curl_session, CURLOPT_RANGE, $range_spec);

        // リクエスト実行
        curl_exec($curl_session);

        if (curl_errno($curl_session)) {
            die('Curlエラー: ' . curl_error($curl_session));
        }

        echo "取得範囲: $range_spec\n";
    }

    curl_close($curl_session);
    fclose($file_handle);
}

$source_url = "https://example.com/path/to/large/file";
$destination_file = "downloaded_file";
resumeDownload($source_url, $destination_file);

echo "ファイルのダウンロードが完了しました!";

仕組みの詳細

PHPによるファイル出力

PHPでは、HTTPヘッダー情報(例:Content-TypeContent-Dispositionなど)を設定することで、レスポンスコンテンツの処理方法と表示方法を制御できます。これらのヘッダー情報は、ブラウザやクライアントに受信データの解釈方法を伝え、データの表示と処理方法に影響を与えます。

1. HTTPヘッダー設定の役割:

PHPスクリプトが出力内容を生成する際、出力内容の前に適切なHTTPヘッダーを設定すると、これらのヘッダー情報はクライアント(ブラウザ)に一緒に送信されます。

ヘッダー情報中のContent-Typeは、ブラウザに返されるコンテンツの種類(テキスト、画像、動画など)を通知し、ブラウザはこれに基づいてデータの処理方法を決定します。

Content-Dispositionヘッダーは、ブラウザに出力内容の処理方法を通知し、添付ファイルとしてダウンロードするか、直接表示するかを指定できます。

2. ヘッダー情報未設定時の状況:

PHPがコンテンツ出力時にヘッダー情報を設定しない場合、PHPはデフォルトでtext/htmlタイプを使用してコンテンツを送信します。

PHPスクリプトが純粋なテキストコンテンツを出力する場合、ブラウザはそれをHTMLとして解釈し、ページ上に直接表示しようとします。

出力がバイナリデータ(ファイルコンテンツなど)の場合、ブラウザはファイルコンテンツの特徴に基づいて解釈を試みますが、この動作は確定的ではなく、予期せぬ結果や文字化けを引き起こす可能性があります。

3. ヘッダー設定後のブラウザへの直接出力理由:

正しいContent-TypeContent-Dispositionヘッダーを設定することで、ブラウザにレスポンスコンテンツの処理方法と表示方法を通知します。

例えば、Content-Disposition: attachment; filename="example.txt"を設定した場合、ブラウザはユーザーにexample.txtという名前のファイルをダウンロードするよう促しますが、ブラウザウィンドウ内での表示は試みません。

ダウンロード/表示/ファイル名

コードにおける実装

mime_content_type()を使用してファイルタイプを取得し、Content-Typeとして転送し、ブラウザにストリーム形式を通知します。

判別できない場合、application/octet-streamを返します。これはバイナリ形式を示します。

一般的なMIMEにはimage/jpegvideo/mp4などが含まれます。

レジューム転送

レジューム転送(Range Requests)はHTTP/1.1プロトコルがサポートする機能であり、クライアントがファイルダウンロード時、ファイルの一部のみを要求できるようにします。これは大容量ファイルやネットワーク切断時の再開ダウンロードに非常に有用です。

1. 影響:

レジューム転送により、特に大容量ファイルのダウンロード時に帯域幅とサーバーリソースへの負荷を軽減できます。データの管理と転送をより効率的に行えます。

モバイルデバイスでは、特にネットワーク不安定時やファイル容量が大きい場合に、レジューム転送によりユーザーエクスペリエンスをよりスムーズに保ち、中断によるダウンロード失敗や再試行を回避できます。

2. 動作原理 - HTTPヘッダーパラメータ

  • リクエストヘッダー:Range

クライアントはRangeヘッダーで要求するファイル部分を指定します。

例:Range: bytes=0-999はファイルの最初の1000バイトを要求することを示します。

  • レスポンスヘッダー:Content-Range

サーバーはContent-Rangeヘッダーで応答するファイル部分を示します。

例:Content-Range: bytes 0-999/12345は最初の1000バイトを示し、ファイル総サイズは12345バイトであることを示します。

  • レスポンスヘッダー:Accept-Ranges(サーバー対応状況)

サーバーはAccept-Ranges:<サポート形式>ヘッダーでクライアントに範囲リクエスト対応を通知します。

例:Accept-Ranges: bytesはサーバーがバイト単位の範囲リクエストをサポートしていることを示します。

3. PHPにおける実装

PHPでレジューム転送を実装するには以下の手順が必要です:

  • Rangeリクエストの識別

PHPスクリプト内で$_SERVER['HTTP_RANGE']をチェックすることで、クライアントのRange情報取得が可能です。

  • 部分的コンテンツリクエスト処理

クライアントのRange要求に基づき、ファイルを開いてfseek()で指定位置に移動し、対応するファイル部分を読み取り送信します。

Range: <データ形式>=<開始インデックス>-<終了インデックス>

カンマ区切りで複数セグメントを要求できますが、ここでは処理対象外とします。

  • 部分的コンテンツとステータスコードの返却

Content-RangeContent-Lengthなどの適切なHTTPヘッダーを設定し、ステータスコード206 Partial Content(206-部分的コンテンツ)を返却します。

サーバーが要求範囲を満たせない場合、416 Range Not Satisfiable(416-要求範囲が満たせません)を返却します。

  • 複数回リクエスト対応

クライアントが次のファイル部分を継続して要求する場合(通常はユーザー操作または自動化戦略による)、PHPスクリプトはこれらの連続Rangeリクエストを処理できる必要があります。

コードスニペットにおける実装

$_SERVER['HTTP_RANGE']に対する文字列解析。

3つのヘッダーを構築:HTTP/1.1 ...Content-RangeContent-Length

fseek()fread()を使用してファイルストリーム取得。

バッファリング

コードスニペットにおける実装

header('Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0');
header('Pragma: no-cache');
header('Expires: Thu, 19 Nov 1981 08:52:00 GMT');

これらのヘッダー情報は、クライアント(通常はブラウザ)のキャッシュ動作を制御します。ブラウザがレスポンスコンテンツをキャッシュしないことを確実にし、毎回リクエスト時にサーバーから最新データを取得するよう強制します。

Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0:

  • no-store:ブラウザがレスポンスコンテンツをメモリやディスクのいずれにも保存しないことを指示します。
  • no-cache:すべてのキャッシュコピーが使用前に再検証を強制します。ブラウザは毎回リクエスト時にサーバーにコンテンツ更新を確認します。
  • must-revalidate:キャッシュが期限切れになると、キャッシュコンテンツの再検証を必須とします。古くなったキャッシュコンテンツの使用を許可しません。
  • post-check=0, pre-check=0:これらパラメータはHTTP/1.1では使用頻度が低く、キャッシュ検証と期限切れ動作を制御します。これらのパラメータは多くの現代ブラウザで無視されていますが、古いキャッシュ機構の上書きを確実にするために使用されます。

Pragma: no-cache:

PragmaはHTTP/1.0標準のヘッダーであり、no-cacheはブラウザがレスポンスコンテンツをキャッシュしないことを指示します。旧バージョンHTTP/1.0との互換性確保に有用です。

Expires: Thu, 19 Nov 1981 08:52:00 GMT:

Expiresヘッダーは過去の時間を設定し、キャッシュコンテンツが既に期限切れであることを示します。この日付を選択することで、ブラウザがレスポンスコンテンツが期限切れであると考えるようにし、キャッシュされないようにします。

while (ob_get_level() > 0) {
    ob_end_flush();
}

このコードは出力バッファ処理用です:

ob_get_level():

現在の出力バッファレベルを返します。出力バッファがネストされている場合、各ob_start()呼び出しでレベルが増加します。

ob_end_flush():

バッファのコンテンツを送信(出力)し、現在の出力バッファを閉じます。これはob_flush()ob_end_clean()の組み合わせ操作に相当します。

ob_end_flush()をループで呼び出し、ob_get_level()が0(さらに出力バッファがない)になるまで実行することで、すべてのネストされた出力バッファがクリアされ、そのコンテンツがクライアントに送信されることを保証します。

HTTPヘッダー:ダウンロード/表示/ファイル名

コードにおける実装

header('Content-Type: ' . $mime_type);
header('Content-Disposition: inline; filename="' . $file_name . '"');

Content-Typeはブラウザにファイルタイプを通知します
Content-Dispositionはブラウザにファイルの処理方法を通知します。inline(直接表示)とattachment(ダウンロード)の2つのオプションがあります。

実際のシナリオでは、Chromeがvideo/quicktime(AppleのMOV形式)を処理する際、inlineが設定されていても直接ダウンロードすることがあります。これはChromeが対応MIMEの直接表示をサポートしていないためです。同様の状況が発生した場合、この点を参考にしてください。

拡張情報:
Content-Dispositionのフォームリクエストボディにおける使用
Content-Dispositionの日本語文字化け問題

タグ: PHP http-range resumable-download file-transfer streaming

7月11日 19:52 投稿