一个支持断点续传的大文件分片上传的小模块
- 2021 年 12 月 23 日
本文字数:5265 字
阅读完需:约 17 分钟

为什么要分片
当前几乎所有的 cms 系统都会有上传文件的需求,而要上传的文件除了文档,图片等小型文件外,偶尔也会遇到上传大的视频文件,压缩包等等,这时候就要用到分片上传的功能。如果是内网环境下,我们可以考虑使用 ftp 来上传大文件,但大多数情况我们都是在外网环境下进行上传的,当然也可以配置 ftp 来上传,但少不了一番折腾,而且还会造成很大的安全隐患。
分片上传是走的 http 协议的,优点就是安全,方便;缺点就是速度慢,而且 http 上传大文件会有网络限制等问题,所以就需要进行分片上传。
服务端
基本思路就是
1.接收分块文件基本信息,md5 值,大小,分片数量等
2.根据 md5 值判定是否完整上传过该文件,完整上传过的话,返回结果是否需要覆盖,非完整上传过则根据上传日志开始续传
3.开始接收分块文件,并记录上传日志
4.合并文件,记录日志,上传结束
这里有两种接收形式,一种是接收文件客户端发来的分片文件,还有一种是直接通过 web 网页上传
代码着实乱了一点,我就不贴太多了,最后放上 Gitee 的地址
/// <summary>/// 外部系统上传文件/// </summary>/// <returns></returns>[HttpPost]public IActionResult UploadApi([FromService] FIleLib fi){ var data = Request.Form.Files[0]; string md5 = Request.Form["md5"].ToString(); var total = Request.Form["total"]; var fileName = Request.Form["fileName"]; var index = Request.Form["index"]; var is_rewrite = Request.Form["rewrite"]; string temporary = Path.Combine(_baseUploadDir, md5);//临时保存分块的目录 if (redis.HashExists("upload_list", md5)) { resp.code = 3; resp.msg = "已上传"; if (string.IsNullOrEmpty(is_rewrite) || is_rewrite.ToString() == "n") { return Json(resp); } else if (!string.IsNullOrEmpty(is_rewrite) && is_rewrite.ToString() == "y") { if (Directory.Exists(temporary)) Directory.Delete(temporary, true); redis.HashDelete("upload_list", md5); } } try { bool mergeOk = false; if (!Directory.Exists(temporary)) Directory.CreateDirectory(temporary);
string filePath = Path.Combine(temporary, index.ToString()); //如果该文件上传过,且没有上传完成,执行如下续传逻辑 //如果只有一个文件,直接删掉,如果大于一个文件,为了保证文件完整性,把最后一个文件删掉,再继续上传; if (index.ToString() == "0" && System.IO.File.Exists(filePath) && is_rewrite.ToString() != "y") { resp.code = 2; resp.msg = $"{fileName}已上传,开始续传"; var files = Directory.GetFiles(temporary); if (files.Length > 1) { //把标号最大的文件删掉; string last_file = files.OrderByDescending(u => u.Length).ThenByDescending(u => u).First(); System.IO.File.Delete(last_file); resp.data = new { index = files.Length - 2, total, mergeOk, fileName }; } else System.IO.File.Delete(filePath); return Json(resp); } if (!Convert.IsDBNull(data)) { using (FileStream fs = new FileStream(filePath, FileMode.Create)) { data.CopyTo(fs); } } if (total == index) { mergeOk = fi.MergeFileForClient(md5, fileName,_baseUploadDir); if (mergeOk) redis.HashSet("upload_list", md5, "success"); } string url = $"/upfile/{DateTime.Now.ToString("yyyyMM")}/{md5}/{fileName}"; resp.code = 1; resp.msg = $"{index}/{total}上传完成"; resp.data = new { index, total, mergeOk, fileName, url }; return Json(resp);
} catch (Exception ex) { Directory.Delete(temporary);//删除文件夹 throw ex; }}
验证文件逻辑
/// <summary>/// 验证文件上传情况/// </summary>/// <returns></returns>[HttpPost]public IActionResult CheckFile(){ //前端传来的md5值 var file_name = Request.Form["file_name"]; var md5 = Request.Form["file_md5"]; var maxChunk = Convert.ToInt32(Request.Form["file_total"]); string logPath = _baseUploadDir + md5 + ".txt"; var logs = LogicLog.ReadLogicLog(logPath); if (logs != null) { int shards = logs.Count(); if (shards >= Convert.ToInt32(maxChunk)) { resp.code = 2; resp.msg = "该文件已上传"; resp.data = new { url = $"upfile/{DateTime.Now.ToString("yyyyMM")}/{md5}/{file_name}" }; } else { resp.code = 0; resp.msg = $"该文件已上传步长{shards}/{maxChunk},继续上传"; resp.data = new { file_index = shards - 1, percent = Convert.ToDouble(shards) / Convert.ToDouble(maxChunk) * 100 }; } //存在日志,文件却找不着了,说明被删了,注意排除掉临时文件夹的情况 string file_path = $"{_baseUploadDir}/{DateTime.Now.ToString("yyyyMM")}/{md5}/{file_name}"; string tmp_path = $"{_baseUploadDir}/{md5}"; if (!System.IO.Directory.Exists(tmp_path) && !System.IO.File.Exists(file_path) && System.IO.File.Exists(logPath)) { System.IO.File.Delete(logPath); resp.code = 0; resp.msg = "该文件已被删除"; return Json(resp); } } return Json(resp);}合并文件的逻辑,分为客户端 api 和 web 两种方式
/// <summary>/// 合并文件/// </summary>/// <param name="md5"></param>/// <param name="fileName"></param>/// <param name="basdir"></param>/// <returns></returns>public bool MergeFileForClient(string md5, string fileName, string basdir){ bool ok = false; try { //临时文件夹 var temporary = Path.Combine(basdir, md5); //获得下面的所有分片文件 var files = Directory.GetFiles(temporary); //指定最终存储路径(注意兼容Linux路径写法) //string finalPath = $"{basdir}\\{DateTime.Now.ToString("yyyyMM")}\\{md5}"; string finalPath = $"{basdir}/{DateTime.Now.ToString("yyyyMM")}/{md5}"; if (!Directory.Exists(finalPath)) Directory.CreateDirectory(finalPath); //finalPath += $"\\{fileName}"; finalPath += $"/{fileName}";
using (var fs = new FileStream(finalPath, FileMode.Create)) { foreach (var part in files.OrderBy(x => x.Length).ThenBy(x => x))//排一下序,保证从0-N Write { var bytes = File.ReadAllBytes(part); fs.Write(bytes, 0, bytes.Length); bytes = null; File.Delete(part);//删除分块 } fs.Flush(); fs.Close(); } //删除临时文件夹 Directory.Delete(temporary); ok = true; } catch (Exception ex) { Console.WriteLine($"文件合并出错,{ex.Message},{ex.StackTrace}"); return false; } return ok;}客户端
客户端的核心逻辑就是获取文件 md5 值,构造并提交接收模型,分片提交文件
/// <summary>/// 切片上传/// </summary>/// <param name="filePath"></param>/// <param name="displayProgress"></param>/// <returns></returns>public static async Task<bool> SliceUploadAsync(string filePath, Func<int, int> displayProgress = null){ try { string filename = Path.GetFileName(filePath); string md5 = GetMD5HashFromFile(filePath); var client = new RestClient(upload_url); client.Timeout = -1; var request = new RestRequest(Method.POST); Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine($"开始上传文件[{filename}]...{DateTime.Now.ToString("yy/MM/dd HH:mm:ss")}"); Console.WriteLine(); using (FileStream fsRead = new FileStream(filePath, FileMode.Open, FileAccess.Read)) { //文件总长度 long left = fsRead.Length; //存储读取结果 byte[] bytes = new byte[1024 * 1024]; //每次读取长度 int maxLength = bytes.Length; int total = Convert.ToInt32(Math.Ceiling(Convert.ToDouble(left / 1024 / 1024))); int index = 0; long start = 0; int num = 0; request.AddParameter("md5", md5); request.AddParameter("total", total); request.AddParameter("fileName", filename);
while (left > 0) { fsRead.Position = start; num = 0; if (left < maxLength) num = await fsRead.ReadAsync(bytes, 0, Convert.ToInt32(left)); else num = await fsRead.ReadAsync(bytes, 0, maxLength); if (num == 0) break; if (request.Files.Count > 0) request.Files.RemoveAt(0); if (request.Parameters.Count(u => u.Name == "index") > 0) request.Parameters.Remove(request.Parameters.Where(u => u.Name == "index").FirstOrDefault());
request.AddFile("data", bytes, filename); request.AddParameter("index", index); IRestResponse response = await client.ExecuteAsync(request); if (index == 0) { //Console.WriteLine(response.Content); Response resp = JsonHelper.JsonDeserialize<Response>(response.Content); if (resp.code == 2) { Console.ForegroundColor = ConsoleColor.Cyan; Console.WriteLine($"\n{resp.message}\n"); index = Convert.ToInt32(resp.data.index) - 1; if (index < 0) index = 0; start = index * maxLength; left = left - start; //num = await fsRead.ReadAsync(bytes, 0, maxLength); } else if (resp.code == 3) { Console.WriteLine($"\n {filename}已上传,确定要覆盖吗? y-覆盖,n-跳过, y/n"); is_rewrite: string is_rewrite = Console.ReadLine(); if (is_rewrite == "y") { //一切从头来 num = 0; start = 0; left = fsRead.Length; index = -1; if (request.Files.Count > 0) request.Files.RemoveAt(0); if (request.Parameters.Count(u => u.Name == "index") > 0) request.Parameters.Remove(request.Parameters.Where(u => u.Name == "index").FirstOrDefault()); request.AddParameter("rewrite", is_rewrite); } else if (is_rewrite == "n") { request.AddParameter("rewrite", is_rewrite); Console.WriteLine("已跳过"); return true; } else { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine("无效参数,请重新输入"); Console.ForegroundColor = ConsoleColor.Yellow; goto is_rewrite; //return false; } } } //增加大于0的条件是,当重写的时候,index会出现-1的情况; if (displayProgress != null && index > 0) displayProgress(Convert.ToInt32(index / (total * 1.0) * 100)); start += num; left -= num; index++; } }
} catch(Exception ex) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine($"上传失败:{ex.Message},{ex.StackTrace}"); return false; } return true;}最后上传的效果就是这样
然后,网页端的效果是这样
好了,基本就这些,最后放上 Gitee 地址:https://gitee.com/Tony_df/big-file-upload-demo.git
版权声明: 本文为 InfoQ 作者【为自己带盐】的原创文章。
原文链接:【http://xie.infoq.cn/article/0eef4205691cd0f503e1dc2c5】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
为自己带盐
学着码代码,学着码人生。 2019.04.11 加入
狂奔的小码农











评论