欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

PHP大文件分片上传、断点续传、上传进度条

程序员文章站 2024-02-19 10:57:16
...

目录

【前言】

分片上传原理

断点续传原理

进度条显示

用到的工具及引入的附件

html代码

js代码

PHP代码


【前言】

因公司有大文件上传后处理的需求,但是nginx和php都有做文件大小的限制,所以要用到分片上传;因为文件比较大,可能会遇到 网络断开、误关闭页面、服务端暂不可用等问题,所以要实现断点续传的功能。

分片上传原理

前端将大文件按照固定大小切分,每次上传单个分片二进制流,最后一片上传完毕后后端对文件进行分片合并。

断点续传原理

服务端记录某个文件的上传进度; 

好多人都是每次上传分片都检测下上传进度,没必要;

我的例子中是首次上传时先从后端获取分片位置【position】,然后从上次上传position开始继续上传,中间有任何异常,都会重置是否获取 position的变量,再重新获取上传进度,接着上传;

这样会减少很多没必要的请求,不会有任何问题。

进度条显示

很简单, 已上传的分片进度 / 总分片数 * 100 

用到的工具及引入的附件

redis 、jquery

jquery-1.8.0.min.js  和 md5.js  都是公用的,网上一查就有,我没找到上传附件的地方...

html代码

<html>
    <head>
        <script src="html/js/jquery-1.8.0.min.js"></script>
        <script src="html/js/md5.js"></script>
    </head>
    <body>
    <input type="file" id="zipfile-inputEl">
    <button id="upload">点击上传</button>
    <br>
    <p style="display: inline">
        上传进度:
        <p id="progressBar" style="display: inline">0</p>
        %
    </p>
    </body>
</html>

js代码

/**
     * 分片上传相关变量
     */
    var	nowIndex = 0;          // 计算分片上传文件切片设置,默认从0开始
    var getPosition = true;    // 获取分片上传位置,默认获取(断点续传)

    /**
     * 文件分片上传
     * file    文件对象
     * filemd5 整个文件的md5值
     */
    function shardToUpload(file, fileMd5)
    {
        var fileName   = file.name;        //文件名
        var fileSize   = file.size;        //总大小
        var shardSize  = 2 * 1024 * 1024;                 // 每个分片的大小(2M)
        var shardTotal = Math.ceil(fileSize / shardSize); // 总分片数
        var fileSuffix = fileName.substr(fileName.lastIndexOf('.')+1); //文件后缀(例:zip)

        // 进度条展示
        $('#progressBar').html(parseInt(nowIndex / shardTotal * 100));

        //计算每一片的起始与结束位置
        var start = nowIndex * shardSize,
            end = Math.min(fileSize, start + shardSize);

        //构造一个表单
        var form = new FormData();

        form.append('file_md5', fileMd5);
        form.append('file_name', fileName);
        form.append('file_suffix', fileSuffix);    // 文件后缀
        form.append('shard_total', shardTotal);    // 总分片数
        form.append('shard_index', nowIndex);      // 当前是第几片(0~shardCount-1)

        // 获取分片上传位置
        if (getPosition) {
            form.append('get_position', '1');
        } else {
            // 按大小切割文件段
            var tmp_blob = file.slice(start, end);
            form.append('tmp_file', tmp_blob);
        }


        $.ajax({
            url: '/logistics/basicfile/php/good.php?do=shardToUpload',
            type: 'POST',
            data: form,
            dataType: 'json',
            processData: false,  //很重要,告诉jquery不要对form进行处理
            contentType: false,  //很重要,指定为false才能形成正确的Content-Type
            success: function(data){
                console.log(data);
                if (data.success === true) {
                    if (getPosition === true) {
                        // 获取之前文件分片上传的位置,断点续传
                        nowIndex = data.position;
                        getPosition = false;
                    } else {
                        nowIndex++;
                    }

                    // 分片上传完毕
                    if (nowIndex >= shardTotal) {
                        alert('最后一片已经上传完毕');
                        // 重置分片上传变量
                        nowIndex = 0;
                        getPosition = true;
                        $('#progressBar').html('100');
                        return true;
                    }
                    shardToUpload(file, fileMd5);
                } else {
                        // 重置分片上传变量
                        nowIndex = 0;
                        getPosition = true;
                        alert('上传失败,可点击继续上传,支持断点续传~');
                        return false;
                }
            },error: function() {
                alert("服务器出错!");
            }
        });

        // 获取分片md5方式
        // var r = new FileReader();
        // r.readAsBinaryString(data);
        // $(r).load(function(e) {
            // form.append('shard_md5', hex_md5(e.target.result));
        // })
    }

    $('#upload').click(function() {
        // 文件对象
        var file = $("#zipfile-inputEl")[0].files[0];

        // 验证文件大小
        var limitFileSizeByM = 1000; // 限制上传大小为1G
        var nowFileSizeByM = Math.ceil(file.size / 1024 / 1024); // 当前上传文件大小
        if (file.size > (limitFileSizeByM * 1024 * 1024)) {
            alert('当前上传文件约' + nowFileSizeByM + 'M, 请上传小于'+ limitFileSizeByM + 'M的图片文件!');
            return false;
        }

        var r = new FileReader();
        r.readAsBinaryString(file);
        $(r).load(function(e) {
            // 上传文件操作
            var bolb = e.target.result;
            var fileMd5 = hex_md5(bolb);  // 整个文件的md5值

            // 首次调用,先检测分片上传位置
            getPosition = true;
            shardToUpload(file, fileMd5);
        });
    });

PHP代码

/**
 * 大文件分片上传,支持断点上传
 * 文件上传成功后,可根据 {$fileMd5}_path key 去redis中获取到文件路径
 * @param int $_POST['get_position'] 是否获取分片上传(true:获取上次上传分片位置;false:上传文件分片)
 * @param int $_POST['shard_total']  总分片数
 * @param int $_POST['shard_index']  分片偏移量(0 ~ $shardTotal-1)
 * @param int $_POST['file_md5']     整个文件内容的md5值
 * @param int $_POST['file_suffix']  文件后缀(因为客户端每次上传的是分片 所以服务端无法知道整个文件的后缀 最后合并分片时需要)
 * @param int $_FILES['tmp_file']    二进制分片文件
 */
function shardToUploadAction()
{
    $redis       = AWRedis::getInstance();
    $getPosition = isset($_POST['get_position']) ? $_POST['get_position'] : 0;// 是否获取分片上传位置
    $shardTotal  = $_POST['shard_total'];    // 总分片数
    $shardIndex  = $_POST['shard_index'];    // 分片偏移量(0 ~ $shardTotal-1)
    $fileMd5     = $_POST['file_md5'];       // 整个文件md5值
    $fileSuffix  = $_POST['file_suffix'];    // 文件后缀(例:zip)
    $filePositionKey = $fileMd5.'_position'; // 文件分片上传位置key

    $returnData = [
        'success'  => true,
        'msg'      => '',
        'position' => $shardIndex,
        'file_path'=> ''
    ];

    try {
        // 首先判断文件是否之前已经生成【{$fileMd5}_path => 文件路径</opt/lampp/logs/20211220/upload/d41d8cd98f00b204e9800998ecf8427e.zip>】
        $redisFilePath = $redis->get($fileMd5.'_path');
        if ($redisFilePath) {
            $returnData['file_path'] = $redisFilePath;
            echo aw_json_encode($returnData);die;
        }

        // 获取分片位置(断点续传功能)
        if ($getPosition) {
            $filePosition = $redis->get($filePositionKey);
            empty($filePosition) && $filePosition = 0;
            // 前端下次应该上传到上次上传分片位置的下个分片
            $returnData['position'] = $filePosition > 0 ? $filePosition +1 : 0;
            echo aw_json_encode($returnData);die;
        }

        if ($_FILES['tmp_file']) {
            // 获取文件第一次上传时间
            $indexFirstUploadDateKey = $fileMd5 . '_date';
            $firstUploadDate = $redis->get($indexFirstUploadDateKey);
            empty($firstUploadDate) && $firstUploadDate = date('Ymd');

            // 生成上传文件的路径信息,按天生成,方便后期清理历史过期文件
            $tempSavePath = '/opt/lampp/logs/'. $firstUploadDate . '/upload_tmp/' . $fileMd5 . '/';
            $lastSavePath = '/opt/lampp/logs/'. $firstUploadDate . '/upload/';
            // 验证路径是否存在,不存在则创建目录
            if (!is_dir($tempSavePath)) {
                $bool = mkdir($tempSavePath,0777,true);
                if ($bool === false) {
                    // 创建文件夹失败
                    throw new AWException('创建分片所在目录失败【'.$tempSavePath.'】');
                }
            }
            $indexFileName = $fileMd5.'_'.$shardIndex;
            $fullFilePath = $tempSavePath . $indexFileName;
            $bool = move_uploaded_file($_FILES['tmp_file']['tmp_name'], $fullFilePath);
            if ($bool === false) {
                throw new AWException('移动分片到临时目录失败'.$_FILES['tmp_file']['tmp_name'].'->【'.$fullFilePath.'】');
            }

            // 文件第一个分片上传后记录上传日期,避免后续分片上传跨天,保存位置错误(有效期:2h)
            if ($shardIndex == 0 && $shardIndex+1 != $shardTotal) {
                $redis->set($indexFirstUploadDateKey, date('Ymd'), 2*60*60);
            }

            // 分片上传成功后, 记录 position , 用来实现断点续传(有效期:10min)
            $redis->set($filePositionKey, $shardIndex, 10*60);

            // 分片上传完毕,进行合并操作
            if ($shardIndex+1 == $shardTotal) {

                //创建要合并的最终文件资源
                $finalFile = $lastSavePath.$fileMd5.'.'.$fileSuffix;
                if (!is_dir($lastSavePath)) {
                    $bool = mkdir($lastSavePath,0777,true);
                    if ($bool === false) { // 创建文件夹失败
                        throw new AWException('创建文件最终保存目录失败【'.$lastSavePath.'】');
                    }
                }

                $finalFileHandler = fopen($finalFile, 'wb');

                // 合并文件前,先删除分片 position;避免合并有问题后,后续重试时上传分片有误
                $redis->del($filePositionKey);

                /**
                 * 这里涉及高并发对文件进行写入,暂时使用usleep函数来防止写入丢失;
                 * 后期如果有写入丢失情况,请使用文件锁,使用文件锁时请考虑死锁问题
                 **/
                for ($i = 0;$i < $shardTotal; $i++) {
                    $tempIndexFile   = $tempSavePath.$fileMd5.'_'.$i;
                    $tempFileHandler = fopen($tempIndexFile, 'rb');
                    $tempFileContent = fread($tempFileHandler, filesize($tempIndexFile));
                    fwrite($finalFileHandler, $tempFileContent);
                    unset($tempFileContent);
                    fclose($tempFileHandler);        // 销毁分片文件资源
                    unlink($tempIndexFile);          // 删除已经合并的分片文件(todo:是否可以放到最后再进行删除)
                    usleep(10000);       // 延迟执行10毫秒
                }
                fclose($finalFileHandler);

                // redis保存文件最终路径,之前上传过就直接返回文件实际路径,保留24h
                $redis->set($fileMd5.'_path', $finalFile, 60*60*24);
                $returnData['file_path'] = $finalFile;
            }
        }
    } catch (AWException $e) {
        $returnData['success'] = false;
        $returnData['msg']     = $e->getMessage();
        // 记录失败日志,方便线上排查
        (new Apilog('shardToUpload'))->writeLog($e->getMessage());
    }

    echo aw_json_encode($returnData);
}


/**
 * 大于5.3版本的时候中文不转义
 * @param $data
 * @return string
 */
function aw_json_encode($data){
    if(version_compare(PHP_VERSION, "5.4", ">")){
        return json_encode($data,JSON_UNESCAPED_UNICODE);
    }
    return json_encode($data);
}

【end】

上一篇: node学习—全局对象

下一篇: