laravel框架配合webupload实现大文件分片上传

1,156 阅读6分钟

引言

上传大文件时,我们一般都会采用分片上传的方式,这样如果上传过程中断了,下次继续上传的话就不用重新全部上传,只需继续上传未上传的部分即可,进而可以实现秒传的效果。

分片上传

其原理其实就是在客户端将文件分割成多个小的分片,然后再将这些分片一片一片的上传给服务端,服务端拿到所有分片后再将这些分片合并起来还原成原来的文件。那服务端怎么知道我合并出来的文件是否和服务端上传的文件完全一样呢?这就需要用到文件的MD5值了。文件的MD5值就相当于是这个文件的“数字指纹”,只有当两个文件内容完全一样时,他们的MD5值才会一样。所以在上传文件前,客户端需要先计算出文件的MD5值,并且把这MD5值传递给服务端。服务端在合并出文件后,在计算合并出的文件的MD5值,与客户端传递过来的进行比较,如果一致,则说明上传成功,若不一致,则说明上传过程中可能出现了丢包,上传失败。 image.png

断点续传

其原理其实就是在客户端将文件分割成多个小的分片,然后再将这些分片一片一片的上传给服务端,服务端拿到所有分片后再将这些分片合并起来还原成原来的文件。那服务端怎么知道我合并出来的文件是否和服务端上传的文件完全一样呢?这就需要用到文件的MD5值了。文件的MD5值就相当于是这个文件的“数字指纹”,只有当两个文件内容完全一样时,他们的MD5值才会一样。所以在上传文件前,客户端需要先计算出文件的MD5值,并且把这MD5值传递给服务端。服务端在合并出文件后,在计算合并出的文件的MD5值,与客户端传递过来的进行比较,如果一致,则说明上传成功,若不一致,则说明上传过程中可能出现了丢包,上传失败。 image.png

文件秒传

文件秒传其实是利用文件的MD5值作为文件的身份标识,服务端发现要上传的文件的MD5与附件库中的某个文件的MD5值完全一样,则要上传的文件已在附件库中,不用再重复上传。

image.png

前端处理

进入正题,前端进行分片,后端进行拼接。前端分片采用的是webuploader,我们先进入他提供的官网链接(Web Uploader)。

1.下载包的内容

github.com/fex-team/we… 下载webuploader并解压,

image.png

2.移动到public下面,可以重命名为webuploader

image.png

3.引入js资源和css资源

具体更换为你自己的项目路径

<link rel="stylesheet" type="text/css" href="/webuploader/css/webuploader.css"/>
<link rel="stylesheet" type="text/css" href="/webuploader/examples/image-upload/style.css"/>
<script type="text/javascript" src="/webuploader/examples/image-upload/jquery.js"></script>
<script type="text/javascript" src="/webuploader/dist/webuploader.js"></script>

4.编写html代码

<form action="{{route('重点:你接收表单数据的路由')}}" method="post" enctype="multipart/form-data">
    @csrf
    <input type="hidden" id="image_url" name="course_file"/>
    <table class="table">
        <tr>
            <td>上传封面</td>
            <td>
                <div id="wrapper">
                    <div id="container">
                        <!--头部,相册选择和格式选择-->
                        <div id="uploader">
                            <div class="queueList">
                                <div id="dndArea" class="placeholder">
                                    <div id="filePicker"></div>
                                    <p>或将照片拖到这里,单次最多可选300张</p>
                                </div>
                            </div>
                            <div class="statusBar" style="display:none;">
                                <div class="progress">
                                    <span class="text">0%</span>
                                    <span class="percentage"></span>
                                </div>
                                <div class="info"></div>
                                <div class="btns">
                                    <div id="filePicker2"></div>
                                    <div class="uploadBtn">开始上传</div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </td>
        </tr>
        <tr>
            <td></td>
            <td><input type="submit" value="提交" class="btn btn-primary"></td>
        </tr>
    </table>
</form>

5.编写script代码

<script type="text/javascript" src="/webuploader/examples/image-upload/upload.js"></script>

重点,其实下面的js就是upload.js粘过来的。可以把upload.js里的代码进行更改,更改成自己的配置,然后引入这个js,也是可以的。我做的时候,引入js,但路径虽然改成自己的,但还是显示路径不对,索性把代码粘贴过来,进行自己的配置更改。这样就没问题了。如果不知道修改哪里,可以crrl+F,搜索重点两个字,搜出来的东西都是要自己更改的


var imgurl = [];//重点,这个是自己添加的,用于把多张图片的路径放到这个jQuery数组中然后赋值到表单提交

// 实例化
uploader = WebUploader.create({
    pick: {
        id: '#filePicker',
        label: '点击选择图片'
    },
    formData: {
        uid: 123,
        _token: '{{ csrf_token() }}'//重点,别忘了加_token这个参数
    },
    dnd: '#dndArea',
    paste: '#uploader',
    swf: '/webuploader/dist/Uploader.swf',//重点,自己的路径
    chunked: true, //切片开启功能
    chunkSize: 1 * 1024 * 1024,//512 * 1024,
    server: '{{route('teacher.course.upload')}}',//重点,自己的路径。和表单提交路径可以区分开,也可以共用一个。我这边是区分开用了两个路由。
  
    // 禁掉全局的拖拽功能。这样不会出现图片拖进页面的时候,把图片打开。
    disableGlobalDnd: true,
    fileNumLimit: 300,
    fileSizeLimit: 200 * 1024 * 1024,    // 200 M
    fileSingleSizeLimit: 100 * 1024 * 1024    // 100 M 单个文件大小
});

//把返回来的的图片路径赋值到表单里

/**重点,底下的uploadSuccess也是自己添加的,用来接收控制器返回的图片路径**/
uploader.on('uploadSuccess', function (file, response) {
    // console.log(response.filePaht)
    imgurl.push(response.filePaht);//重点,控制器返回多张图片路径追加到刚开始声明的imgurl数组中
    $("#image_url").val(imgurl);//重点,然后赋给hidden表单
    // alert(imgurl);
});

//这个引用也行,不引入也可以。一个预览图的功能。

if (file.getStatus() === 'invalid') {
    showError(file.statusText);
} else {
    // @todo lazyload
    $wrap.text('预览中');
    uploader.makeThumb(file, function (error, src) {
        var img;

        if (error) {
            $wrap.text('不能预览');
            return;
        }

        if (isSupportBase64) {
            img = $('<img src="' + src + '">');
            $wrap.empty().append(img);
        } else {
            $.ajax('{{route('teacher.course.upload')}}', {//重点,自己的路径
                method: 'POST',
                data: src,
                dataType: 'json'
            }).done(function (response) {
                if (response.result) {
                    img = $('<img src="' + response.result + '">');
                    $wrap.empty().append(img);
                } else {
                    $wrap.text("预览出错");
                }
            });
        }
    }, thumbnailWidth, thumbnailHeight);

后端处理

1.拼接分片

首先要把前端处理好的分片,用下面的方法进行拼接。这段代码可以在下载的包里找到。路径是/webuploader/server/fileupload.php里找到。下面代码有点长。

在框架里,可以写一个service层存放这段代码,用控制器调用这个service层。来进行分片拼接。注意!!!这个方法只是用来拼接分片,不是用来提交表单的方法。前端写了两个路由,一个是用来处理分片图片路由,一个是用来提交表单数据路由。一定要区分开。

 @set_time_limit(5 * 60);
        $targetDir = 'uploads'.DIRECTORY_SEPARATOR.'file_material_tmp'; //用PHP 预定义常量DIRECTORY_SEPARATOR来代替'','/'这样的路径分隔符
        $uploadDir = 'uploads'.DIRECTORY_SEPARATOR.'file_material';
        $cleanupTargetDir = true; // Remove old files
        $maxFileAge = 5 * 3600; // Temp file age in seconds
        // Create target dir
        if (!file_exists($targetDir)) {
            @mkdir($targetDir);
        }
        // Create target dir
        if (!file_exists($uploadDir)) {
            @mkdir($uploadDir);
        }
        // Get a file name
        if (isset($_REQUEST["name"])) {
            $fileName = $_REQUEST["name"];
        } elseif (!empty($_FILES)) {
            $fileName = $_FILES["file"]["name"];
        } else {
            $fileName = uniqid("file_"); //以微秒计的当前时间,生成一个唯一的 ID
        }
        $oldName = $fileName;
        $filePath = $targetDir . DIRECTORY_SEPARATOR . $fileName;
        // $uploadPath = $uploadDir . DIRECTORY_SEPARATOR . $fileName;
        // Chunking might be enabled
        $chunk = isset($_REQUEST["chunk"]) ? intval($_REQUEST["chunk"]) : 0;
        $chunks = isset($_REQUEST["chunks"]) ? intval($_REQUEST["chunks"]) : 1;
        // Remove old temp files
        if ($cleanupTargetDir) {
            if (!is_dir($targetDir) || !$dir = opendir($targetDir)) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 100, "message": "Failed to open temp directory."}, "id" : "id"}');
            }
            while (($file = readdir($dir)) !== false) {
                $tmpfilePath = $targetDir . DIRECTORY_SEPARATOR . $file;
                // If temp file is current file proceed to the next
                if ($tmpfilePath == "{$filePath}_{$chunk}.part" || $tmpfilePath == "{$filePath}_{$chunk}.parttmp") {
                    continue;
                }
                // Remove temp file if it is older than the max age and is not the current file
                if (preg_match('/.(part|parttmp)$/', $file) && (@filemtime($tmpfilePath) < time() - $maxFileAge)) {
                    @unlink($tmpfilePath);
                }
            }
            closedir($dir);
        }

        // Open temp file
        if (!$out = @fopen("{$filePath}_{$chunk}.parttmp", "wb")) {
            die('{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}');
        }
        if (!empty($_FILES)) {
            if ($_FILES["file"]["error"] || !is_uploaded_file($_FILES["file"]["tmp_name"])) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 103, "message": "Failed to move uploaded file."}, "id" : "id"}');
            }
            // Read binary input stream and append it to temp file
            if (!$in = @fopen($_FILES["file"]["tmp_name"], "rb")) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}');
            }
        } else {
            if (!$in = @fopen("php://input", "rb")) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 101, "message": "Failed to open input stream."}, "id" : "id"}');
            }
        }
        while ($buff = fread($in, 4096)) {
            fwrite($out, $buff);
        }
        @fclose($out);
        @fclose($in);
        rename("{$filePath}_{$chunk}.parttmp", "{$filePath}_{$chunk}.part"); //用该函数可以实现文件移动功能
        $index = 0;
        $done = true;
        for( $index = 0; $index < $chunks; $index++ ) {
            if ( !file_exists("{$filePath}_{$index}.part") ) {
                $done = false;
                break;
            }
        }

        if ( $done ) {
            $pathInfo = pathinfo($fileName); //返回文件路径的信息
            $hashStr = substr(md5($pathInfo['basename']),8,16);
            $hashName = time() . $hashStr . '.' .$pathInfo['extension'];
            $uploadPath = $uploadDir . DIRECTORY_SEPARATOR .$hashName;

            if (!$out = @fopen($uploadPath, "wb")) {
                die('{"jsonrpc" : "2.0", "error" : {"code": 102, "message": "Failed to open output stream."}, "id" : "id"}');
            }
            if ( flock($out, LOCK_EX) ) {
                for( $index = 0; $index < $chunks; $index++ ) {
                    if (!$in = @fopen("{$filePath}_{$index}.part", "rb")) {
                        break;
                    }
                    while ($buff = fread($in, 4096)) {
                        fwrite($out, $buff); //向指定的文件中写入若干数据块
                    }
                    @fclose($in);
                    @unlink("{$filePath}_{$index}.part");
                }
                flock($out, LOCK_UN);
            }
            @fclose($out);
            $response = [
                'success'=>true,
                'oldName'=>$oldName,
                'filePaht'=>$uploadPath,
//                'fileSize'=>$data['size'],
                'fileSuffixes'=>$pathInfo['extension'],
//                'file_id'=>$data['id'],
            ];
            die(json_encode($response));
        }

        // Return Success JSON-RPC response
        die('{"jsonrpc" : "2.0", "result" : null, "id" : "id"}');

2.提交表单路由

下面的云存储根据自己的喜好

     if (!empty($data['course_file'])) {
            $data['course_file'] = './' . $data['course_file'];
        }
        // 文件路径
        $path = $data['course_file'];
        //文件后缀
        $extension = substr($path, strrpos($path, '.') + 1);
        //命名文件名称
        $output_file = date('Y-m-d', time()) . '/' . time() . '.' . $extension;
        $config = [
            'ak' => '',//SecretId /Access_Key
            'sk' => '',//SecretKe /Secret_Key
            'bucket' => '',//桶名
            'region' => ''//地区 七牛云为'',腾讯云在控制台对象存储界面获取,如上海(ap-shanghai)
        ];
        //云存储类型 腾讯云:Tencent  七牛云:Qiniu
        $obj = (new OssService())->getOssService('');
        //云存储
        $obj->uploadFile($config, $path, $output_file);
        $data['course_file'] = $output_file;
        return $this->courseTeacherDao->store($data);

总结

上面提到的方法,总结一句话就是前端分片->后端拼接->上传云服务器。但这样有个弊端,就是大一点的文件只能分片到本地。云服务商那边对大文件上传有限制。可以尝试把一个3M文件分成10片300KB的文件。然后进行上传云存储来测试这个功能。再或者你压根用不到云存储,单纯的上传到自己的文件服务器上,那么你就不用担心云存储第三方的限制了。直接拿来用就可以。理论上是可以上传无限大的文件的。具体自行测试。

如果你想直接跳过前端分片,直接后端分片,上传到云存储第三方。可以参考这个链接如何上传大文件到OSS (aliyun.com)。起初我也想的是采用阿里云存储分片,直接一步到位。方便快捷。但仔细想了一下,这样就失去了前端分片,后端拼接的过程。而且有的伙伴并不想云存储。而是存到自己的文件服务器上或本地。那这样的话,阿里云的分片上传就不适合了。

而采用我上面提到的方法,就可以根据自己的喜好,把分完片的大文件存储到本地或者存储到自己的文件服务器上再或者存储到云存储第三方上。三种方式选择。

阿里的oss它本质是一个对象存储服务器,而非文件服务器,因此如果有涉及到大量删除或者修改文件的需求,oss可能就不是一个好的选择。