อัพโหลดไฟล์ขนาดใหญ่ด้วยการแบ่งไฟล์

การอัพโหลดไฟล์บางครั้งจะมีข้อจำกัดจากทางฝั่ง server อย่างเช่น ขนาดไฟล์ที่อนุญาต ซึ่งใน PHP ส่วนใหญ่จะตั้งค่าเดิมมาที่ 2 MB เท่านั้น ถือเป็นขนาดไฟล์ปกติสำหรับไฟล์ทั่วๆไป. แต่ถ้าหากจะต้องการให้มีการอัพโหลดไฟล์ที่ขนาดใหญ่กว่านี้มาก เช่น ไฟล์งาน zip ของลูกค้า, ไฟล์ video เป็นต้น ก็จะทำได้โดยการแก้ไข php.ini, .user.ini, กำหนดใน .htaccess ให้รับการโพสต์และอัพโหลดไฟล์ขนาดใหญ่ต่อครั้งได้ แต่สุดท้ายมันก็จะมีขีดจำกัดที่ server จะรับได้และไม่สามารถจะเพิ่มได้อีก เช่น ไฟล์ขนาด 2GB บ้าง แล้วแต่ผู้ดูแลจะกำหนดไว้.

ปัญหาทั้งหมดนี้จะปลดล็อคแก้ไขได้สมบูรณ์หรือค่อนข้างสมบูรณ์ด้วย 2 วิธี คือ 1. ใช้ FTP upload หรือ 2. ใช้การแบ่งไฟล์ให้เหลือจำนวนขนาดน้อยๆแล้วทยอยอัพโหลดจนกว่าจะครบ.

PHP สำหรับอัพโหลดแบบแบ่งไฟล์

<?php
/**
 * This file will be included under the condition that `chunkNumber` is `-1`.
 */

$file_chunkcount = (isset($_POST['file_chunkcount']) ? (int) $_POST['file_chunkcount'] : false);
$file_name = 'upload/' . ($_POST['file_name'] ?? date('Y-m-dHis') . '_' . uniqid() . '.unknown');

if ($file_chunkcount !== false && $file_chunkcount > 0) {
    // if chunk count is more than one file.
    // limit max merge tasks per loop to prevent maximum execution timeout error. this way don't have to modify php ini value.
    // do not limit max merge tasks per loop too much because it maybe out of memory or timeout error.
    $maxTaskPerLoop = 10;
    $totalMergeLoop = ceil($file_chunkcount / $maxTaskPerLoop);
    $startMergeOffset = (int) ($_GET['startMergeOffset'] ?? 0);
    $endMergeOffset = ($startMergeOffset + ($maxTaskPerLoop - 1));
    $output['totalMergeLoop'] = $totalMergeLoop;

    if ($endMergeOffset > $file_chunkcount) {
        $endMergeOffset = $file_chunkcount;
    }

    for ($i = $startMergeOffset; $i <= $endMergeOffset; $i++) {
        $eachChunkFile = 'upload/' . session_id() . '_' . $i . '.tmp';
        $handleRead = fopen($eachChunkFile, 'rb');
        $eachChunkContents = fread($handleRead, filesize($eachChunkFile));
        fclose($handleRead);
        unset($handleRead);

        if ($i === 0) {
            $mode = 'wb';
        } else {
            $mode = 'ab';
        }

        $handleWrite = fopen($file_name, $mode);
        $writeStatus = fwrite($handleWrite, $eachChunkContents);
        fclose($handleWrite);

        if ($writeStatus === false) {
            $output['error']['message'] = 'Write file error! chunk file name: ' . $eachChunkFile
                . 'target file name: ' . $file_name;
            $hasError = true;
            http_response_code(500);
            break;
        } else {
            unlink($eachChunkFile);
        }

        unset($eachChunkContents, $eachChunkFile, $handleWrite, $mode, $writeStatus);
    }// endfor;

    $output['mergedChunkNumberStart'] = $startMergeOffset;
    $output['mergedChunkNumberEnd'] = $i;// the last $i is already +1. if $i is loop 0 to 9, the last one is $i++ = 10.
    if (($i - 1) === $file_chunkcount) {
        // if the last $i - 1 equal to total chunks.
        // mark as merge completed.
        $output['mergeSuccess'] = true;
    }
} elseif ($file_chunkcount === 0) {
    // if only one file, just rename it.
    rename(
        'upload/' . session_id() . '_0.tmp', 
        $file_name
    );
    $output['mergeSuccess'] = true;
}

if (isset($output['mergeSuccess']) && $output['mergeSuccess'] === true) {
    $fileSize = (int) ($_POST['file_size'] ?? 0);
    
    if ($fileSize < (20 * 1024 * 1024) && is_file($file_name)) {
        // if file size less than xx MB
        // allow to calculate md5 and sha1. otherwise skip it or it will be execution timeout error.
        $output['md5file'] = md5_file($file_name);
        $output['sha1file'] = sha1_file($file_name);
    }

    // do the task after uploaded complete here.
    // ...
}

includes/upload-single-merge-chunks.php

<?php

if (
    isset($testFailConnection) && 
    $testFailConnection === true && 
    ($chunkNumber === 3 || $chunkNumber === 6)
) {
    if (!isset($_SESSION['retryforchunk3'])) {
        $_SESSION['retryforchunk3'] = 0;
    }

    if ($_SESSION['retryforchunk3'] < 1) {
        $_SESSION['retryforchunk3'] = ($_SESSION['retryforchunk3'] + 1);
        http_response_code(500);
        header('Content-Type: application/json');
        echo json_encode($output);
        exit();
    } else {
        $_SESSION['retryforchunk3'] = 0;
    }
}

includes/upload-single-test-connection-error.php

<?php

session_start();

$output = [];

$chunkNumber = (int) ($_GET['chunkNumber'] ?? 0);
$output['chunkNumber'] = $chunkNumber;

// test upload failure due to server or connection errors.
$testFailConnection = false;
include 'includes/upload-single-test-connection-error.php';

if (
    isset($_SERVER['REQUEST_METHOD']) &&
    strtolower($_SERVER['REQUEST_METHOD']) === 'post'
) {
    // if method post.
    $output['post_data'] = $_POST;// for debug

    if (
        isset($_FILES['file']['name'])
    ) {
        // if there is file upload.
        if (!is_dir('upload')) {
            mkdir('upload');
        }

        if ($chunkNumber !== -1 && $chunkNumber >= 0) {
            // temp file name for create uploaded chunk and merge files later.
            $tempFilename = 'upload/' . session_id() . '_' . $chunkNumber . '.tmp';

            // move uploaded file. you can use any php class to handle this but target file must be temporary for merge/rename later.
            move_uploaded_file($_FILES['file']['tmp_name'], $tempFilename);
            $output['uploadedFile'] = $tempFilename;
            $output['result'] = 'success';

            // don't do anything here because it maybe not yet completed upload the whole file (just chunk or part of a file).
            // to do anything after all chunks were uploaded and merged complete, please look at **includes/upload-single-merge-chunk.php** file.
        }
    }// endif; file upload

    if ($chunkNumber === -1) {
        // if chunk number is -1 means merge all files.
        $_SESSION['retryforchunk3'] = 0;// for test upload failure due to server or connection errors.

        require 'includes/upload-single-merge-chunks.php';
    }// endifl chunk number -1 (merge)
}// endif method post.

header('Content-Type: application/json');

echo json_encode($output);

upload-single.php

ตัวอย่างฟอร์ม

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>XHR upload large file with slice</title>
        <style>
            #debug {
                border: 3px dashed #ccc;
                margin: 10px 0;
                padding: 10px;
            }
        </style>
    </head>
    <body>
        <p>XHR upload large file with slice. Upload multiple chunks per time.</p>
        <form id="upload-form" method="post" enctype="multipart/form-data">
            <input type="hidden" name="hidden-name" value="hidden-value">
            <p>text: <input type="text" name="text"></p>
            <p>file: <input id="file" type="file" name="file"></p>

            <button type="submit">Submit</button>
            <button type="reset">Reset</button>

            <div id="debug"></div>
        </form>
    </body>
</html>

โค้ด JavaScript (JS)

กำหนดค่าต่างๆให้กับตัวแปรก่อนเขียน JavaScript ให้ทำงาน

let debugElement = document.getElementById('debug');
const thisForm = document.getElementById('upload-form');
const inputFile = thisForm.querySelector('#file');
const chunkFileSize = 1000000;// 1,000,000 bytes = 1MB. ถ้ากำหนดสูงเกินไปจะเป็นเหตุให้หน่วยความจำหมดได้ในขั้นตอนการ merge.
const maxConcurrentConnection = 3;// ถ้ากำหนดมากเกินไปในจำนวนการเชื่อมต่อทั้งหมดต่อครั้ง, มันจะทำให้เกิด request ท่วม server และถูกเตะออกโดย firewall ได้.
const maxFailConnection = 3;

จากนั้นดัก event ที่จะใช้ ตัวอย่างเช่น เมื่อมีการกด submit form เท่านั้น

thisForm.addEventListener('submit', (event) => {
    event.preventDefault();

    let formData = new FormData(thisForm);
    formData.delete('file');// delete original input file.

    if (!inputFile || !inputFile.files || inputFile.files.length <= 0) {
      alert('Please select a file to upload.');
      return ;
    }

    // ...
});// form event listener submit.

ในตัวอย่างนี้จะเห็นว่าใช้ FormData() โดยเอาค่า input ต่างๆทั้งหมดไปด้วย แล้วจึงลบ input file เพื่อไม่ให้ส่งค่าไฟล์ไปทั้งหมดทีเดียว แต่จะเอามาแบ่งในขั้นต่อไป.

ขั้นต่อไปคือคำณวนหาจำนวนไฟล์ที่จะถูกแบ่งทั้งหมด ในกรณีนี้เนื่องจากมันเป็นกลุ่มก้อนไฟล์ จึงขอเรียกว่า chunk. โดยขั้นตอนนี้จะยังอยู่ภายใน event form submit ต่อจากด้านบน.

const file = inputFile.files[0];
const fileName = inputFile.files[0].name;
const numberOfChunks = Math.ceil(parseInt(file.size) / chunkFileSize);

// for debug
debugElement.innerHTML = '';
let debugMessage = '<p>File size: ' + file.size + ' bytes.<br>'
    + ' Chunk file size: ' + chunkFileSize + ' bytes.<br>'
    + ' Number of chunks: ' + numberOfChunks + ' (loop 0 - ' + (parseInt(numberOfChunks) - 1) + ').'
    + '</p><hr style="border: none; border-top: 1px dashed #ccc;">';
debugElement.insertAdjacentHTML('beforeend', debugMessage);

จากนั้นขยับขึ้นมาด้านบน ด้านนอกของ event form submit ให้สร้าง function uploadChunks() ขึ้นมาเพื่อใช้งานเสียก่อน.

function uploadChunks(formData, file, numberOfChunks) {
    function doUpload(i, allResolve, allReject) {
        formData.delete('file');// delete previous for append new.
        formData.append('file', chunkContentParts[i]);

        return XHR('upload-single.php?chunkNumber=' + i, formData)
            .then((responseObject) => {
                const response = responseObject.response;

                if (typeof(response.chunkNumber) === 'number' && totalFailure > 0) {
                    totalFailure = (totalFailure - 1);// decrease total failure.
                } else if (typeof(response.chunkNumber) === 'undefined' || response.chunkNumber === null || response.chunkNumber === '') {
                    // if did not response chunk number that was uploaded
                    // mark as failure permanently and can't continue. you must have return `chunkNumber` from server before continue.
                    totalFailure = (totalFailure + 100000);

                    // for debug
                    debugElement.insertAdjacentHTML('beforeend', '<p style="color: red;"> &nbsp; &gt; The property chunkNumber must be returned from the server.</p>');

                    throw new Error('The property chunkNumber must be returned from the server.');
                }

                console.log('upload for chunk number ' + i + ' of ' + (numberOfChunks - 1) + ' success.', response);
                // for debug
                debugElement.insertAdjacentHTML('beforeend', '<p> &nbsp; &gt; Chunk number ' + response.chunkNumber + ' uploaded success!</p>');

                if (response.chunkNumber) {
                    // if there is response chunk number.
                    // add success number to array.
                    ajaxSuccessChunks.push(response.chunkNumber);
                    delete chunkContentParts[i];
                }

                if (parseInt(ajaxSuccessChunks.length) === (parseInt(numberOfChunks) - 1)) {
                    // if finish upload all chunks.
                    console.log('all chunks uploaded completed.');
                    allResolve(responseObject);
                }

                return Promise.resolve(responseObject);
            })
            .catch((responseObject) => {
                const response = responseObject.response;

                totalFailure++;// increase total failure.

                console.warn('connection error! Loop ' + i, responseObject);
                if (totalFailure <= maxFailConnection) {
                    // if total failure does not reach limit.
                    // retry.
                    console.warn('retrying from total failure: ', totalFailure);
                    // for debug
                    debugElement.insertAdjacentHTML('beforeend', '<p style="color: red;"> &nbsp; &gt; Error in chunk number ' + response.chunkNumber + '!, retrying. (see console.)</p>');

                    doUpload(i, allResolve, allReject);
                } else {
                    // for debug
                    debugElement.insertAdjacentHTML('beforeend', '<p style="color: red;"> &nbsp; &gt; Error in chunk number ' + response.chunkNumber + '! aborting.(see console.)</p>');
                }

                return Promise.reject(responseObject);
            })
    }// doUpload


    let chunkStart = 0;
    let chunkEnd = parseInt(chunkFileSize);
    let totalFailure = 0;
    let ajaxCons = [];
    let ajaxSuccessChunks = [];
    let chunkContentParts = {};

    let promiseObject = new Promise((allResolve, allReject) => {
        let loopPromise = Promise.resolve();

        for (let i = 0; i < numberOfChunks; i++) {
            loopPromise = loopPromise.then(() => {
                if (ajaxCons.length < maxConcurrentConnection && totalFailure <= maxFailConnection) {
                    // for debug
                    debugElement.insertAdjacentHTML('beforeend', '<p>Uploading file to server for chunk number ' + i + ' of ' + (numberOfChunks - 1) + '</p>');

                    chunkContentParts[i] = file.slice(chunkStart, chunkEnd, file.type);
                    chunkStart = chunkEnd;
                    chunkEnd = (chunkStart + parseInt(chunkFileSize));

                    ajaxCons.push(
                        doUpload(i, allResolve, allReject)
                    );

                    if ((parseInt(ajaxCons.length)) === parseInt(maxConcurrentConnection)) {
                        // if number of concurrent connection reach maximum allowed.
                        // hold using Promise.
                        return new Promise((resolve, reject) => {
                            Promise.any(ajaxCons)
                            .then((responseObject) => {
                                // for debug
                                debugElement.insertAdjacentHTML('beforeend', '<p> &nbsp; <em>Removing finished connection count to allow make new connection.</em></p>');
                                ajaxCons.splice(0, 1);
                                resolve();
                            });
                        });
                    }
                }// endif; concurrent connection not reach maximum.
            });// loopPromise.then()
        }// endfor;
    });

    return promiseObject;
}// uploadChunks

function ดังกล่าวนี้อธิบายอย่างย่อก็คือ เมื่อได้จำนวน chunk แล้วก็เริ่มวนตั้งแต่ 0 ไปจนถึงจำนวน chunk สูงสุด -1 (ที่ลบหนึ่งเพราะเริ่มจาก 0). โดยจำกัดจำนวนการเชื่อมต่อพร้อมกันสูงสุดไม่เกินค่าที่กำหนดไว้ในขั้นตอนกำหนดค่าต่างๆ. รอจนกระทั่งรายการใดๆอัพโหลดเสร็จแล้วจึงอนุญาตให้เริ่มการเชื่อมต่อใหม่เพื่ออัพโหลดได้ แต่ยังคงไม่ให้เกินจำนวนสูงสุดอยู่อย่างนี้จนกว่าจะครบจำนวน chunk.

function doUpload() ที่อยู่ภายในเป็นตัวสั่งอัพโหลดจริงๆ แล้วติดตามว่าการอัพโหลดสำเร็จหรือล้มเหลว. ถ้าหากล้มเหลวก็ให้ทดลองใหม่ โดยจำนวนล้มเหลวทั้งหมดจะไม่ให้เกิน 3 หากเกินแสดงว่า server มีปัญหาแล้วก็ไม่จำเป็นต้องทำงานต่อ. หากอัพโหลดสำเร็จจะทำการ resolve() เพื่อให้ chunk ต่อไปได้อัพโหลดต่อได้.

เมื่อฟังก์ชั่น uploadChunks() เรียบร้อยแล้ว ก็ขยับกลับเข้ามาใน event form submit ต่อจากที่ค้างไว้ด้านล่าง

// upload chunks.
uploadChunks(formData, file, numberOfChunks)
// all chunks were uploaded successfully.
.then((responseObject) => {
    // prepare for merge them.
    formData.delete('file');
    formData.append('file_name', file.name);
    formData.append('file_size', file.size);
    formData.append('file_mimetype', file.type);
    formData.append('file_chunkcount', (parseInt(numberOfChunks) - 1));

    let totalMergeLoop = 0;
    let allSuccess = false;
    let startMergeOffset = 0;

    console.log('starting to merge chunks.');

    // ...
}); // uploadChunks() promise finished.

ในส่วนนี้เป็นการเรียกใช้ฟังก์ชั่น uploadChunks() ที่เขียนเอาไว้นั่นเอง และ .then() ที่ต่อจากฟังก์ชั่นนี้คือกระบวนการอัพโหลดที่เสร็จหมดแล้ว. ขั้นตอนต่อไปเป็นการประสานรวม file ที่อัพโหลดไว้บน server ซึ่งคำสั่ง php ที่ทำการย้ายไฟล์ที่อัพโหลดนั้น ทำการย้ายจาก temp มาเป็น temp ที่รอการประสานรวมไฟล์เฉยๆ ยังไม่เป็นไฟล์จริงที่พร้อมใช้.

ในการประสานไฟล์นั้น จะสั่งให้ php ทำการวนรอบตามจำนวน chunk โดยทำงานครั้งละ 10 ไฟล์เพื่อป้องกันปัญหา memory limit exceeded หรือ execution timeout. โดยทาง JavaScript จะเป็นผู้สั่งเรียกประสานไฟล์เป็นรอบๆไปดังนี้. ให้เขียน function mergeChunks() ขึ้นมารองรับไว้ก่อนที่ด้านนอก event form submit.

function mergeChunks(formData, totalMergeLoop, startMergeOffset, numberOfChunks) {
    let loopPromise = Promise.resolve();
    // loop request for merge uploaded chunks.
    // start from 1 because 0 already has been done by first request where there is only chunkNumber=-1 in GET parameter.
    for (let i = 1; i < totalMergeLoop; i++) {
        loopPromise = loopPromise.then(() => {
            return new Promise((resolve, reject) => {
                XHR('upload-single.php?chunkNumber=-1&startMergeOffset=' + startMergeOffset, formData)
                .then((responseObject) => {
                    const response = responseObject.response;

                    console.log('merged chunk ' + startMergeOffset + ' to ' + (parseInt(response.mergedChunkNumberEnd) - 1) + ' of ' + (numberOfChunks - 1));
                    // for debug
                    debugElement.insertAdjacentHTML('beforeend', '<p> &nbsp; &gt; Merged chunk ' + startMergeOffset + ' to ' + (parseInt(response.mergedChunkNumberEnd) - 1) + ' of ' + (numberOfChunks - 1) + '</p>');
                    
                    startMergeOffset = response.mergedChunkNumberEnd

                    resolve(responseObject);
                })
                .catch((responseObject) => {
                    const response = responseObject.response;

                    reject(responseObject);
                });
            });// end return new Promise()
        });// end loopPromise.then()
    }// endfor;
    
    return loopPromise;
}// mergeChunks

จากนั้นจึงกลับมาต่อในส่วนที่กำลังจะเริ่ม merge chunks.

XHR('upload-single.php?chunkNumber=-1', formData)
.then((responseObject) => {
    const response = responseObject.response;

    if (response.mergeSuccess === true) {
        // if merged success.
        allSuccess = true;

        console.log('all chunks merged completed (in 1 request).');

        // for debug
        debugElement.insertAdjacentHTML('beforeend', '<p style="color: green;">All chunks were uploaded and merged successfully.</p><hr>');
    } else {
        if (response.totalMergeLoop && totalMergeLoop === 0) {
            totalMergeLoop = parseInt(response.totalMergeLoop);
        }
        startMergeOffset = parseInt(response.mergedChunkNumberEnd);

        // for debug
        debugElement.insertAdjacentHTML('beforeend', '<hr style="border: none; border-top: 1px dashed #ccc;">');
        debugElement.insertAdjacentHTML('beforeend', '<p>Starting to merge uploaded temp files. Total loop: ' + totalMergeLoop + ', start next merge offset: ' + startMergeOffset + '</p>');
    }

    return Promise.resolve(responseObject);
}, (responseObject) => {
    return Promise.reject(responseObject);
})
.then((responseObject) => {
    if (totalMergeLoop > 0 && allSuccess === false) {
        mergeChunks(formData, totalMergeLoop, startMergeOffset, numberOfChunks)
        .then(() => {
            // for debug
            debugElement.insertAdjacentHTML('beforeend', '<p style="color: green;">All chunks were uploaded and merged successfully.</p>');
            console.log('all chunks were merged complete successfully.');
        });
    }

    return Promise.resolve(responseObject);
}, (responseObject) => {
    return Promise.reject(responseObject);
})
.catch((responseObject) => {
    console.warn(responseObject);
    const response = responseObject.response;

    if (response.error && response.error.message) {
        alert(response.error.message);
    }

    return Promise.reject(responseObject);
}); // merge chunks first round finished.

ในขั้นตอนนี้จะสังเหตุว่าค่า GET parameter ที่ส่งไปคือ chunkNumber จะเป็น -1 แล้วเพราะทาง php รองรับไว้ว่าค่านี้คือเริ่มทำการ merge. และเมื่อทำการวน merge จบครบจบแล้วก็จะได้ไฟล์ที่พร้อมใช้งาน และไฟลฺ์ temp ต่างๆก็จะถูกลบจนหมด. ทั้งนี้หากไฟล์ไม่ใหญ่เกินไป จะสามารถหาค่า md5, sha1 ของไฟล์เพื่อไปตรวจสอบกับไฟล์ต้นฉบับได้ด้วย.

สำหรับโค้ดทั้งหมด รวมทั้งตัวอย่างอื่นๆ เช่น การใส่ progress bar, การใช้ progress bar กับการตรวจการอัพโหลดจริงๆ ซึ่งจะอัพโหลดได้ครั้งละไฟล์ย่อยเท่านั้น สามารถดูได้บน Gist GitHub.

ตัวอย่าง slice upload พร้อม progress bar.

ใส่ความเห็น

อีเมลของคุณจะไม่แสดงให้คนอื่นเห็น ช่องข้อมูลจำเป็นถูกทำเครื่องหมาย *

คุณอาจใช้แท็กHTMLและแอททริบิวต์เหล่านี้: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>