.NET Core Web API 实现大文件分片上传
Index.html
1 @{ 2 ViewData["Title"] = "Home Page"; 3 } 4 5 @{ 6 ViewBag.Title = "Home Page"; 7 } 8 <div class="form-horizontal" style="margin-top:80px;"> 9 <div class="form-group"> 10 <div class="col-md-10"> 11 <input name="file" id="file" type="file" /> 12 </div> 13 </div> 14 <div class="form-group"> 15 <div class="col-md-offset-2 col-md-10"> 16 <input type="submit" id="submit" value="上传" class="btn btn-success" /> 17 </div> 18 </div> 19 </div> 20 21 <script type="text/javascript" src="~/js/jquery-3.4.1.min.js"></script> 22 <script type="text/javascript"> 23 24 $(function () { 25 $('#submit').click(function () { 26 UploadFile($('#file')[0].files); 27 }); 28 }); 29 30 function UploadFile(targetFile) { 31 // 创建上传文件分片缓冲区 32 var fileChunks = []; 33 // 目标文件 34 var file = targetFile[0]; 35 // 设置分片缓冲区大小 36 var maxFileSizeMB = 8; 37 var bufferChunkSize = maxFileSizeMB * (1024 * 1024); 38 // 读取文件流其实位置 39 var fileStreamPos = 0; 40 // 设置下一次读取缓冲区初始大小 41 var endPos = bufferChunkSize; 42 // 文件大小 43 var size = file.size; 44 // 将文件进行循环分片处理塞入分片数组 45 while (fileStreamPos < size) { 46 var fileChunkInfo = { 47 file: file.slice(fileStreamPos, endPos), 48 start: fileStreamPos, 49 end: endPos 50 } 51 fileChunks.push(fileChunkInfo); 52 fileStreamPos = endPos; 53 endPos = fileStreamPos + bufferChunkSize; 54 } 55 // 获取上传文件分片总数量 56 var totalParts = fileChunks.length; 57 var partCount = 0; 58 // 循环调用上传每一片 59 while (chunk = fileChunks.shift()) { 60 partCount++; 61 // 上传文件命名约定 62 var filePartName = file.name + ".partNumber-" + partCount; 63 chunk.filePartName = filePartName; 64 // url参数 65 var url = 'partNumber=' + partCount + '&chunks=' + totalParts + '&size=' + bufferChunkSize + '&start=' + chunk.start + '&end=' + chunk.end + '&total=' + size; 66 chunk.urlParameter = url; 67 // 上传文件 68 UploadFileChunk(chunk); 69 } 70 } 71 72 function UploadFileChunk(chunk) { 73 var data = new FormData(); 74 data.append("file", chunk.file, chunk.filePartName); 75 $.ajax({ 76 url: '/api/upload/upload?' + chunk.urlParameter, 77 type: "post", 78 cache: false, 79 contentType: false, 80 processData: false, 81 data: data, 82 }); 83 } 84 </script>
UploadController.cs
1 [Route("api/[controller]/[action]")] 2 [ApiController] 3 public class UploadController : ControllerBase 4 { 5 private const string DEFAULT_FOLDER = "Upload"; 6 private readonly IWebHostEnvironment _environment; 7 8 public UploadController(IWebHostEnvironment environment) 9 { 10 this._environment = environment; 11 } 12 13 /// <summary> 14 /// 文件分片上传 15 /// </summary> 16 /// <param name="chunk"></param> 17 /// <returns></returns> 18 [HttpPost] 19 [DisableFormValueModelBinding] 20 public async Task<IActionResult> Upload([FromQuery] FileChunk chunk) 21 { 22 if (!this.IsMultipartContentType(this.Request.ContentType)) 23 { 24 return this.BadRequest(); 25 } 26 27 var boundary = this.GetBoundary(); 28 if (string.IsNullOrEmpty(boundary)) 29 { 30 return this.BadRequest(); 31 } 32 33 var reader = new MultipartReader(boundary, this.Request.Body); 34 35 var section = await reader.ReadNextSectionAsync(); 36 37 while (section != null) 38 { 39 var buffer = new byte[chunk.Size]; 40 var fileName = this.GetUploadFileSerialName(section.ContentDisposition); 41 chunk.FileName = fileName; 42 var path = Path.Combine(this._environment.WebRootPath, DEFAULT_FOLDER, fileName); 43 using (var stream = new FileStream(path, FileMode.Append)) 44 { 45 int bytesRead; 46 do 47 { 48 bytesRead = await section.Body.ReadAsync(buffer, 0, buffer.Length); 49 stream.Write(buffer, 0, bytesRead); 50 51 } while (bytesRead > 0); 52 } 53 54 section = await reader.ReadNextSectionAsync(); 55 } 56 57 //TODO: 计算上传文件大小实时反馈进度 58 59 //合并文件(可能涉及转码等) 60 if (chunk.PartNumber == chunk.Chunks) 61 { 62 await this.MergeChunkFile(chunk); 63 } 64 65 return this.Ok(); 66 } 67 68 /// <summary> 69 /// 判断是否含有上传文件 70 /// </summary> 71 /// <param name="contentType"></param> 72 /// <returns></returns> 73 private bool IsMultipartContentType(string contentType) 74 { 75 return !string.IsNullOrEmpty(contentType) 76 && contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0; 77 } 78 79 /// <summary> 80 /// 得到上传文件的边界 81 /// </summary> 82 /// <returns></returns> 83 private string GetBoundary() 84 { 85 var mediaTypeHeaderContentType = MediaTypeHeaderValue.Parse(this.Request.ContentType); 86 return HeaderUtilities.RemoveQuotes(mediaTypeHeaderContentType.Boundary).Value; 87 } 88 89 /// <summary> 90 /// 得到带有序列号的上传文件名 91 /// </summary> 92 /// <param name="contentDisposition"></param> 93 /// <returns></returns> 94 private string GetUploadFileSerialName(string contentDisposition) 95 { 96 return contentDisposition 97 .Split(';') 98 .SingleOrDefault(part => part.Contains("filename")) 99 .Split('=') 100 .Last() 101 .Trim('"'); 102 } 103 104 /// <summary> 105 /// 合并文件 106 /// </summary> 107 /// <param name="chunk"></param> 108 /// <returns></returns> 109 public async Task MergeChunkFile(FileChunk chunk) 110 { 111 var uploadDirectoryName = Path.Combine(this._environment.WebRootPath, DEFAULT_FOLDER); 112 113 var baseFileName = chunk.FileName.Substring(0, chunk.FileName.IndexOf(FileSort.PART_NUMBER)); 114 115 var searchpattern = $"{Path.GetFileName(baseFileName)}{FileSort.PART_NUMBER}*"; 116 117 var fileNameList = Directory.GetFiles(uploadDirectoryName, searchpattern).ToArray(); 118 if (fileNameList.Length == 0) 119 { 120 return; 121 } 122 123 List<FileSort> mergeFileSortList = new List<FileSort>(fileNameList.Length); 124 125 string fileNameNumber; 126 foreach (string fileName in fileNameList) 127 { 128 fileNameNumber = fileName.Substring(fileName.IndexOf(FileSort.PART_NUMBER) + FileSort.PART_NUMBER.Length); 129 130 int.TryParse(fileNameNumber, out var number); 131 if (number <= 0) 132 { 133 continue; 134 } 135 136 mergeFileSortList.Add(new FileSort 137 { 138 FileName = fileName, 139 PartNumber = number 140 }); 141 } 142 143 // 按照分片排序 144 FileSort[] mergeFileSorts = mergeFileSortList.OrderBy(s => s.PartNumber).ToArray(); 145 146 mergeFileSortList.Clear(); 147 mergeFileSortList = null; 148 149 // 合并文件 150 string fileFullPath = Path.Combine(this._environment.WebRootPath, DEFAULT_FOLDER, baseFileName); 151 if (System.IO.File.Exists(fileFullPath)) 152 { 153 System.IO.File.Delete(fileFullPath); 154 } 155 bool error = false; 156 using var fileStream = new FileStream(fileFullPath, FileMode.Create); 157 foreach (FileSort fileSort in mergeFileSorts) 158 { 159 error = false; 160 do 161 { 162 try 163 { 164 using FileStream fileChunk = new FileStream(fileSort.FileName, FileMode.Open, FileAccess.Read, FileShare.Read); 165 await fileChunk.CopyToAsync(fileStream); 166 error = false; 167 } 168 catch (Exception) 169 { 170 error = true; 171 Thread.Sleep(0); 172 } 173 } 174 while (error); 175 } 176 177 //删除分片文件 178 foreach (FileSort fileSort in mergeFileSorts) 179 { 180 System.IO.File.Delete(fileSort.FileName); 181 } 182 Array.Clear(mergeFileSorts, 0, mergeFileSorts.Length); 183 mergeFileSorts = null; 184 } 185 }
await Policy.Handle<IOException>() .RetryForeverAsync() .ExecuteAsync(async () => { foreach (FileSort fileSort in mergeFileSorts) { using FileStream fileChunk = new FileStream(fileSort.FileName, FileMode.Open, FileAccess.Read, FileShare.Read); await fileChunk.CopyToAsync(fileStream); } }); //删除分片文件 Parallel.ForEach(mergeFiles, f => { System.IO.File.Delete(f.FileName); });
FileChunk.cs
1 /// <summary> 2 /// 文件批量上传的URL参数模型 3 /// </summary> 4 public class FileChunk 5 { 6 //文件名 7 public string FileName { get; set; } 8 /// <summary> 9 /// 当前分片 10 /// </summary> 11 public int PartNumber { get; set; } 12 /// <summary> 13 /// 缓冲区大小 14 /// </summary> 15 public int Size { get; set; } 16 /// <summary> 17 /// 分片总数 18 /// </summary> 19 public int Chunks { get; set; } 20 /// <summary> 21 /// 文件读取起始位置 22 /// </summary> 23 public int Start { get; set; } 24 /// <summary> 25 /// 文件读取结束位置 26 /// </summary> 27 public int End { get; set; } 28 /// <summary> 29 /// 文件大小 30 /// </summary> 31 public int Total { get; set; } 32 }
FileSort.cs
1 public class FileSort 2 { 3 public const string PART_NUMBER = ".partNumber-"; 4 /// <summary> 5 /// 带有序列号的文件名 6 /// </summary> 7 public string FileName { get; set; } 8 /// <summary> 9 /// 文件分片号 10 /// </summary> 11 public int PartNumber { get; set; } 12 }