PHP大文件分片上传、断点续传、上传进度条
程序员文章站
2024-02-19 10:57:16
...
目录
【前言】
因公司有大文件上传后处理的需求,但是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学习—全局对象