KazuProg's notes

技術メモや備忘録などを自由気ままに書き連ねています

PHPでファイルのアップロード処理を自作する ~進捗取得編~

作成日: ・ 更新日:

前回は、大容量ファイルのアップロード時に できるだけメモリに負荷をかけないように、 アップロード処理を自作しました。

PHPでファイルのアップロード処理を自作する

今回は、時間がかかるアップロード処理に欠かせない 進捗状況をリアルタイムで表示できるようにするため、 前回のプログラムを修正していきます。

まず私は、$_SESSIONを使って実装できるのではないかと思いました。

具体的には

  1. index.phpから、upload.phpにPOST
  2. upload.phpで、ファイルを保存しつつその進捗を$_SESSIONに保存
  3. index.phpから非同期でprogress.phpにアクセス
  4. progress.phpでは、$_SESSIONの中身をJSONエンコードなどして返す

という考えでした。

まあ、この考えは見事に失敗してしまいました。 $_SESSION変数は、DBでいう占有ロックのようなもので、 upload.phpが動いている間は、ほかのphp(今回はprogress.php)から読み取れませんでした…

そこで、どこかのファイルに保存すればいいのではないかとも思いましたが、 いちいち進捗をファイルに書き出していては、 アップロード速度に支障をきたしてしまうと考えたため却下。

最終的には、アップロードの一時ファイル名に細工をし、 ファイルサイズなどの情報を埋め込むことで実装しました。

具体的に説明すると

  1. index.phpから、upload.phpにPOST
  2. upload.phpで、一時ファイル名に細工をして保存
  3. index.phpから非同期でprogress.phpにアクセス
  4. progress.phpでは、アップロード中のファイルのファイル名やファイルサイズを調べて進捗送信

という方法に至りました。

以下に、それぞれのファイルのソースを載せておきます。 前回と同じファイルも一応載せています。

.htaccess

php_flag enable_post_data_reading Off

index.php

<?php
    session_start();
    $id = hash('md5',microtime(true));
    $_SESSION['upload']['id'] = $id;
?>
<!DOCTYPE html>
<html><head>
    <meta charset="UTF-8">
    <title>Upload Test</title>
    <style>
        div#progress{
            border:solid 1px;
        }
        div#progress > div{
            position:relative;
        }
        div#progress > div > div{
            position:absolute;
            bottom:0;
            height:0.5rem;
            background:#0f08;
        }
    </style>
</head><body>
    <input name="files" id="files" type="file" multiple>
    <button onclick="upload()">送信</button>
    <br>
    <br>
    <div id="progress">アップロードの進捗状況がここに表示されます。</div>
    <script>
        var uploading = false;
        function upload(){
            if(uploading){
                alert("アップロード中です。");
                return;
            }
            var fileList = document.querySelector("input#files").files;
            if(fileList.length == 0){
                alert("アップロードするファイルを指定してください。");
                return;
            }
            uploading = true;
            var uploaded = false;
            var fd = new FormData();
            for(var i = 0; i < fileList.length; i++) {
                fd.append('fileinfo_'+i,fileList[i].size + '\t' + fileList[i].name);
            }
            for(var i = 0; i < fileList.length; i++) {
                fd.append('files[]',fileList[i]);
            }
            fetch("./upload.php",{
                method:"POST",
                body:fd,
            }).then(function(response){
                return response.text();
            }).then(function(text){
                uploaded = true;
            })
            //以下、進捗を表示させるためのプログラム
            var progress = document.querySelector("#progress");
            progress.innerHTML = "";
            getProgress()
            function getProgress(){
                fetch("./progress.php?id=<?php print($id);?>")
                .then(function(res){
                    return res.json();
                })
                .then(function(json){
                    for(var i = 0; i < json.length; i++){
                        if(!document.querySelector("#progress_"+i)){
                            div = document.createElement("div");
                            div.innerHTML = json[i]['file_name']+'<div id="progress_'+i+'"></div>'
                            progress.appendChild(div);
                        }
                        document.querySelector('#progress_'+i).style.width =
                            (json[i]['file_upsize']/json[i]['file_size']*100) + "%";
                    }
                    //0.1秒後に進捗を再取得
                    setTimeout(function(){
                        if(uploaded){
                            //アップロード完了後の処理をここに書く。
                            alert("アップロードが完了しました。\nリロードします。")
                            location.reload();
                        }else{
                            getProgress();
                        }
                    },100);
                })
            }
        }
    </script>
</html>

upload.php

<?php
session_start();
$id = $_SESSION['upload']['id'];

ini_set("max_execution_time", 0);
$base_dir = "./file/";
$READ_BUF_SIZE = 8192;
$sleep_time = 1000;//ナノ秒

$handle_in = fopen("php://input", "rb");
if (FALSE === $handle_in) {
    exit("Failed to open stream to URL");
}

$header_sec = 0;

$contents = "";
$multi_part ="";
$multi_part_now = "";

while (!feof($handle_in)) {
    if($header_sec == 0){
        $multi_part = str_replace("\r\n", '', fgets($handle_in));
        $header_sec = 1;
        if(strlen($multi_part) == 0){
            print "multi_part_len 0 error \r\n";
            exit;
        }

        continue;
    }

    if($header_sec == 2){
        $multi_part_now = str_replace("\r\n", '', fgets($handle_in));

        if($multi_part_now === $multi_part){
            $header_sec = 1;
            continue;
        }else{
            if($multi_part_now == $multi_part . "--"){
                print "sucess\r\n";
            }else{
                print "sequence error step 2\r\n";
            }
            break;
        }
    }

    if($header_sec == 1){
        $line = str_replace("\r\n", '', fgets($handle_in));
        $sbuf = explode('filename="',$line);
        if(count($sbuf) == 1){
            $pbuf = explode('name="',$sbuf[0]);
            $pname = mb_substr($pbuf[1], 0, -1);

            fgets($handle_in);
            $pdata = str_replace("\r\n", '', fgets($handle_in));

            //なんとなくSESSIONを使用(別の変数を作っても可)
            $num = intval(explode('_',$pname)[1]);
            $upbuf = explode("\t",$pdata);
            $_SESSION['upload']['files'][$num]['size'] = $upbuf[0];
            $_SESSION['upload']['files'][$num]['name'] = $upbuf[1];
            $_SESSION['upload']['files'][$num]['tmp_path'] =
                $base_dir . "/" .
                $id . '_' .
                sprintf('%03d', $num) . '_' .
                $upbuf[0] . '_' .
                $upbuf[1];
            touch($_SESSION['upload']['files'][$num]['tmp_path']);

            $header_sec = 2;
            continue;

        }else{
            $filename = mb_substr($sbuf[1], 0, -1);

            fgets($handle_in);
            fgets($handle_in);

            if($filename == ""){
                fgets($handle_in);
                $header_sec = 2;
                continue;
            }else{
                for($i = 0; $i < count($_SESSION['upload']['files']); $i++){
                    $fileinfo = $_SESSION['upload']['files'][$i];
                    if($filename === $fileinfo['name']){
                        $filesize = $fileinfo['size'];
                        $handle_out = fopen($fileinfo['tmp_path'], "w+b");
                        $header_sec = 3;
                        break;
                    }
                }
            }
        }
    }

    if($header_sec == 3){
        if($filesize < $READ_BUF_SIZE){
            //ファイルサイズが0の時の例外を除外
            if($filesize != 0){
                $contents = fread($handle_in, $filesize);
                fwrite($handle_out, $contents);
            }
        }else{
            $readsize = 0;
            while (!feof($handle_in)) {
                $contents = fread($handle_in, $READ_BUF_SIZE);

                $readsize += $READ_BUF_SIZE;
                fwrite($handle_out,$contents);

                if(($readsize) + $READ_BUF_SIZE >= $filesize){
                    $contents = fread($handle_in, $filesize - $readsize);
                    fwrite($handle_out,$contents);
                    break;
                }
                usleep($sleep_time);
            }

        }
        fgets($handle_in);
        $header_sec = 2;
        fclose($handle_out);
    }
}
fclose($handle_in);
//アップロードされたファイルの処理
//データはすべてSESSIONに保存されている。
var_dump($_SESSION['upload']['files']);
?>

progress.php

<?php
$base_dir = "./file/";
$data = [];
$files = scandir($base_dir);
for($i = 0; $i < count($files); $i++){
    preg_match_all("/([^_]*)_([^_]*)_([^_]*)_(.*)/",$files[$i],$result);
    if(count($result[0]) != 0 && $result[1][0] == $_GET['id']){
        $index = intval($result[2][0]);
        $data[$index]['file_name'] = $result[4][0];
        $data[$index]['file_size'] = intval($result[3][0]);
        $data[$index]['file_upsize'] = filesize($base_dir . $files[$i]);
    }
}
echo json_encode($data);
?>

では、今回のポイントをまとめます。

index.php

session_start();
$id = hash('md5',microtime(true));
$_SESSION['upload']['id'] = $id;

複数の端末からのアップロードでも、 対象の端末のアップロード状況のみ取得したいので、 現在時刻をミリ秒まで取得したシリアル値をハッシュし、 それをIDとしています。 アクセス数が多いページで運用する場合などは、 このIDが被る可能性があるので、各自で工夫してください。

index.php

var fd = new FormData();
for(var i = 0; i < fileList.length; i++) {
    fd.append('fileinfo_'+i,fileList[i].size + '\t' + fileList[i].name);
}
for(var i = 0; i < fileList.length; i++) {
    fd.append('files[]',fileList[i]);
}
fetch("./upload.php",{
    method:"POST",
    body:fd,
}).then(function(response){
    return response.text();
}).then(function(text){
    uploaded = true;
})

今までformタグで行っていたPOST処理をJavaScriptに置き換えただけ。 アップロード終了を検知するためにuploaded変数を使用しています。

index.php

getProgress()
function getProgress(){
    fetch("./progress.php?id=<?php print($id);?>")
    .then(function(res){
        return res.json();
    })
    .then(function(json){
        for(var i = 0; i < json.length; i++){
            if(!document.querySelector("#progress_"+i)){
                div = document.createElement("div");
                div.innerHTML = json[i]['file_name']+'<div id="progress_'+i+'"></div>'
                progress.appendChild(div);
            }
            document.querySelector('#progress_'+i).style.width =
                (json[i]['file_upsize']/json[i]['file_size']*100) + "%";
        }
        //0.1秒後に進捗を再取得
        setTimeout(function(){
            if(uploaded){
                //アップロード完了後の処理をここに書く。
                alert("アップロードが完了しました。\nリロードします。")
                location.reload();
            }else{
                getProgress();
            }
        },100);
    })
}

ここは、getProgressという再帰関数を作成し、アップロード終了まで進捗を取得し続けています。 取得したデータをもとに、div要素の幅を変化させて、プログレスバーのように進捗を表示させています。

upload.php

$_SESSION['upload']['files'][$num]['tmp_path'] =
    $base_dir . "/" .
    $id . '_' .
    sprintf('%03d', $num) . '_' .
    $upbuf[0] . '_' .
    $upbuf[1];
touch($_SESSION['upload']['files'][$num]['tmp_path']);

ここで、一時ファイル名に細工しています。具体的には、 [ID]_[Index]_[FileSize]_[FileName] という名前で保存しています。

さらに、touchで空のファイルを作成することで、 progress.phpが、初めからアップロードされるすべてのファイルを認識することができます。 (これをしないと、ファイルの中身のPOSTデータが届くまで、そのファイルの存在を確認できない)

upload.php

preg_match_all("/([^_]*)_([^_]*)_([^_]*)_(.*)/",$files[$i],$result);
if(count($result[0]) != 0 && $result[1][0] == $_GET['id']){
    $index = intval($result[2][0]);
    $data[$index]['file_name'] = $result[4][0];
    $data[$index]['file_size'] = intval($result[3][0]);
    $data[$index]['file_upsize'] = filesize($base_dir . $files[$i]);
}

ここで、一時ファイル名を各要素のデータに分解しています。 進捗は、直接ファイルのサイズを調べて今どこまで保存されているかを取得しています。

ということで、アップロードの進捗状況を取得できるようになりました。

細かな設定やUIなどは各自でお好みなように変更してください。

最後になりますが、ここまで読んでくれてありがとうございました。 質問等あれば、自己紹介ページにあるTwitterのDMなどにて受け付けております。

それでは!