ASP.NET CORE使用WebUploader对大文件分片上传,并通过ASP.NET CORE SignalR实时反馈后台处理进度给前端展示

时间:2022-04-12 14:51:20

本次,我们来实现一个单个大文件上传,并且把后台对上传文件的处理进度通过ASP.NET CORE SignalR反馈给前端展示,比如上传一个大的zip压缩包文件,后台进行解压缩,并且对压缩包中的文件进行md5校验,同时要求前台可以实时(实际情况看网络情况)展示后台对压缩包的处理进度(解压、校验文件)。

在前端上传文件的组件选择上,采用了WebUploader(http://fex.baidu.com/webuploader/)这个优秀的前端组件,下面是来自它的官网介绍:

WebUploader是由Baidu WebFE(FEX)团队开发的一个简单的以HTML5为主,FLASH为辅的现代文件上传组件。在现代的浏览器里面能充分发挥HTML5的优势,同时又不摒弃主流IE浏览器,沿用原来的FLASH运行时,兼容IE6+,iOS 6+, android 4+。两套运行时,同样的调用方式,可供用户任意选用。

采用大文件分片并发上传,极大的提高了文件上传效率。

WebUploader的功能很多,本次只使用它的上传前文件MD5校验并发分片上传分片MD5校验三个主要功能,分别来实现类似网盘中的文件【秒传】,浏览器多线程上传文件和文件的断点续传

阅读参考此文章前,请先看一下https://www.cnblogs.com/wdw984/p/14645614.html

此文章是上一篇的功能扩展,一些基本的程序模块逻辑都已经在上一篇文章中做了介绍,这里就不再重复。

在正式使用WebUploader进行上传文件之前,先对它的执行流程和触发的事件做个大致的介绍(如有不对的地方请指正),我们可以通过它触发的事件来做相应的流程或业务上的预处理,比如文件秒传,重复文件检测等。

当WebUploader正确加载完成后,会触发它的ready事件;

当点击文件选择框的时候(其它方式传入文件所触发的事件请参考官方文档),会触发它的dialogOpen事件;

当选择文件完成后,触发事件的流程为:beforeFileQueued ==> fileQueued ==> filesQueued;

当点击(开始)上传的时候,触发事件的流程为:

1、正常文件上传流程

startUpload(如秒传(后台通过文件的md5判断返回)秒传则触发UploadSkip) ==> uploadStart ==> uploadBeforeSend ==> uploadProgress ==> uploadAccept(接收服务器处理分块传输后的返回信息) ==> uploadSuccess ==> uploadComplete ==> uploadFinished

2、文件秒传或续传流程

startUpload ==> uploadStart(触发秒传或文件续传) ==> uploadSkip ==> uploadSuccess ==> uploadComplete ==> uploadFinished

现在,我们在上一次项目的基础上做一些改造升级,最终实现我们本次的功能。

先看效果(GIF录制时间略长,请耐心等待一下)

ASP.NET CORE使用WebUploader对大文件分片上传,并通过ASP.NET CORE SignalR实时反馈后台处理进度给前端展示

首先,我们引用大名鼎鼎的WebUploader组件库。在项目上右键==>添加==>客户端库 的界面中选择unpkg然后输入webuploader

ASP.NET CORE使用WebUploader对大文件分片上传,并通过ASP.NET CORE SignalR实时反馈后台处理进度给前端展示

为了实现压缩文件的解压缩操作,我们在Nuget中引用SharpZipLib组件

ASP.NET CORE使用WebUploader对大文件分片上传,并通过ASP.NET CORE SignalR实时反馈后台处理进度给前端展示

然后我们在appsettings.json中增加一个配置用来保存上传文件。

 1 {
2 "Logging": {
3 "LogLevel": {
4 "Default": "Information",
5 "Microsoft": "Warning",
6 "Microsoft.Hosting.Lifetime": "Information"
7 }
8 },
9 "FileUpload": {
10 "TempPath": "temp",//临时文件保存目录
11 "FileDir": "upload",//上传完成后的保存目录
12 "FileExt": "zip,rar"//允许上传的文件类型
13 },
14 "AllowedHosts": "*"
15 }

在项目中新建一个Model目录,用来实现上传文件的相关配置,建立相应的多个类文件

FileUploadConfig.cs 服务器用来接受和保存文件的配置

 1 using System;
2
3 namespace signalr.Model
4 {
5 /// <summary>
6 /// 上传文件配置类
7 /// </summary>
8 [Serializable]
9 public class FileUploadConfig
10 {
11 /// <summary>
12 /// 临时文件夹目录名
13 /// </summary>
14 public string TempPath { get; set; }
15 /// <summary>
16 /// 上传文件保存目录名
17 /// </summary>
18 public string FileDir { get; set; }
19 /// <summary>
20 /// 允许上传的文件扩展名
21 /// </summary>
22 public string FileExt { get; set; }
23 }
24 }

UploadFileWholeModel.cs 前台开始传输前会对文件进行一次MD5算法,这里可以通过文件MD5值传递给后台来通过比对已上传的文件MD5值列表来实现秒传功能

 1 namespace signalr.Model
2 {
3 /// <summary>
4 /// 文件秒传检测前台传递参数
5 /// </summary>
6 public class UploadFileWholeModel
7 {
8 /// <summary>
9 /// 请求类型,这里固定为:whole
10 /// </summary>
11 public string CheckType { get; set; }
12 /// <summary>
13 /// 文件的MD5
14 /// </summary>
15 public string FileMd5 { get; set; }
16 /// <summary>
17 /// 前台文件的唯一标识
18 /// </summary>
19 public string FileGuid { get; set; }
20 /// <summary>
21 /// 前台上传文件名
22 /// </summary>
23 public string FileName { get; set; }
24 /// <summary>
25 /// 文件大小
26 /// </summary>
27 public int? FileSize { get; set; }
28 }
29 }

UploadFileChunkModel.cs 前台文件分块传输的时候会对分块传输内容进行MD5计算,并且分块传输的时候会传递当前分块的一些信息,这里对应的后台接收实体类。

我们可以通过分块传输的MD5值来实现文件续传功能(如文件的某块MD5已存在则返回给前台跳过当前块)

 1 namespace signalr.Model
2 {
3 /// <summary>
4 /// 文件分块(续传)传递参数
5 /// </summary>
6 public class UploadFileChunkModel
7 {
8 /// <summary>
9 /// 文件分块传输检测类型,这里固定为chunk
10 /// </summary>
11 public string CheckType { get; set; }
12 /// <summary>
13 /// 文件的总大小
14 /// </summary>
15 public long? FileSize { get; set; }
16 /// <summary>
17 /// 当前块所属文件编号
18 /// </summary>
19 public string FileId { get; set; }
20 /// <summary>
21 /// 当前块基于文件的开始偏移量
22 /// </summary>
23 public long? ChunkStart { get; set; }
24 /// <summary>
25 /// 当前块基于文件的结束偏移量
26 /// </summary>
27 public long? ChunkEnd { get; set; }
28 /// <summary>
29 /// 当前块的大小
30 /// </summary>
31 public long? ChunkSize { get; set; }
32 /// <summary>
33 /// 当前块编号
34 /// </summary>
35 public string ChunkIndex { get; set; }
36 /// <summary>
37 /// 当前文件分块总数
38 /// </summary>
39 public string ChunkCount { get; set; }
40 /// <summary>
41 /// 当前块的编号
42 /// </summary>
43 public string ChunkId { get; set; }
44 /// <summary>
45 /// 当前块的md5
46 /// </summary>
47 public string Md5 { get; set; }
48 }
49 }

FormData.cs 这是分块传输时传递的当前块的信息配置

 1 using System;
2
3 namespace signalr.Model
4 {
5 /// <summary>
6 /// 上传文件时的附加信息
7 /// </summary>
8 [Serializable]
9 public class FormData
10 {
11 /// <summary>
12 /// 当前请求类型 分片传输是:chunk
13 /// </summary>
14 public string Checktype { get; set; }
15 /// <summary>
16 /// 文件总字节数
17 /// </summary>
18 public int? Filesize { get; set; }
19 /// <summary>
20 /// 文件唯一编号
21 /// </summary>
22 public string Fileid { get; set; }
23 /// <summary>
24 /// 分片数据大小
25 /// </summary>
26 public int? Chunksize { get; set; }
27 /// <summary>
28 /// 当前分片编号
29 /// </summary>
30 public int? Chunkindex { get; set; }
31 /// <summary>
32 /// 分片起始编译量
33 /// </summary>
34 public int? Chunkstart { get; set; }
35 /// <summary>
36 /// 分片结束编译量
37 /// </summary>
38 public int? Chunkend { get; set; }
39 /// <summary>
40 /// 分片总数量
41 /// </summary>
42 public int? Chunkcount { get; set; }
43 /// <summary>
44 /// 当前分片唯一编号
45 /// </summary>
46 public string Chunkid { get; set; }
47 /// <summary>
48 /// 当前块MD5值
49 /// </summary>
50 public string Md5 { get; set; }
51 }
52 }

UploadFileModel.cs 每次上传文件的时候,前台都会传递这些参数给服务器,服务器可以根据参数做相应的处理

 1 using System;
2 using Microsoft.AspNetCore.Mvc;
3
4 namespace signalr.Model
5 {
6 /// <summary>
7 /// WebUploader上传文件实体类
8 /// </summary>
9 [Serializable]
10 public class UploadFileModel
11 {
12 /// <summary>
13 /// 前台WebUploader的ID
14 /// </summary>
15 public string Id { get; set; }
16 /// <summary>
17 /// 当前文件(块)的前端计算的md5
18 /// </summary>
19 public string FileMd5 { get; set; }
20 /// <summary>
21 /// 当前文件块号
22 /// </summary>
23 public string Chunk { get; set; }
24 /// <summary>
25 /// 原始文件名
26 /// </summary>
27 public string Name { get; set; }
28 /// <summary>
29 /// 文件类型(如:image/png)
30 /// </summary>
31 [FromForm(Name = "type")]
32 public string FileType { get; set; }
33 /// <summary>
34 /// 当前文件(块)的大小
35 /// </summary>
36 public long? Size { get; set; }
37 /// <summary>
38 /// 前台给此文件分配的唯一编号
39 /// </summary>
40 public string Guid { get; set; }
41 /// <summary>
42 /// 附件信息
43 /// </summary>
44 public FormData FromData { get; set; }
45 /// <summary>
46 /// Post过来的数据容器
47 /// </summary>
48 public byte[] FileData { get; set; }
49 }
50 }

UploadFileMergeModel.cs 当所有块传输完成后,传递给后台一个合并文件的请求,后台通过参数中的信息把分块保存的文件合并成一个完整的文件

 1 namespace signalr.Model
2 {
3 /// <summary>
4 /// 文件合并请求参数类
5 /// </summary>
6 public class UploadFileMergeModel
7 {
8 /// <summary>
9 /// 请求类型
10 /// </summary>
11 public string CheckType { get; set; }
12 /// <summary>
13 /// 前台检测到的文件大小
14 /// </summary>
15 public long? FileSize { get; set; }
16 /// <summary>
17 /// 前台返回文件总块数
18 /// </summary>
19 public int? ChunkNumber { get; set; }
20 /// <summary>
21 /// 前台返回文件的md5值
22 /// </summary>
23 public string FileMd5 { get; set; }
24 /// <summary>
25 /// 前台返回上传文件唯一标识
26 /// </summary>
27 public string FileName { get; set; }
28 /// <summary>
29 /// 文件扩展名,不包含.
30 /// </summary>
31 public string FileExt { get; set; }
32 }
33 }

为了实现【秒传】和分块传输时的【断点续传】功能,我们在Class目录中定义一个UploadFileList.cs类,用来模拟持久化保存服务器所接收到的文件MD5校验列表和已接收的分块MD5值信息,这里我们使用了并发线程安全的ConcurrentDictionary和ConcurrentBag

 1 using System;
2 using System.Collections.Concurrent;
3
4 namespace signalr.Class
5 {
6 public class UploadFileList
7 {
8 private static readonly Lazy<ConcurrentDictionary<string, string>> _serverUploadFileList = new Lazy<ConcurrentDictionary<string, string>>();
9 private static readonly Lazy<ConcurrentDictionary<string, ConcurrentBag<string>>> _uploadChunkFileList =
10 new Lazy<ConcurrentDictionary<string, ConcurrentBag<string>>>();
11 public UploadFileList()
12 {
13 ServerUploadFileList = _serverUploadFileList;
14 UploadChunkFileList = _uploadChunkFileList;
15 }
16
17 /// <summary>
18 /// 服务器上已经存在的文件,key为文件的Md5,value为文件路径
19 /// </summary>
20 public readonly Lazy<ConcurrentDictionary<string, string>> ServerUploadFileList;
21 /// <summary>
22 /// 客户端分配上传文件时的记录信息,key为上传文件的唯一id,value为文件分片后的当前段的md5
23 /// </summary>
24 public readonly Lazy<ConcurrentDictionary<string, ConcurrentBag<string>>> UploadChunkFileList;
25 }
26 }

扩展一下HubInterface/IChatClient.cs 用来推送给前台展示后台处理的信息

public interface IChatClient
{
/// <summary>
/// 客户端接收数据触发函数名
/// </summary>
/// <param name="clientMessageModel">消息实体类</param>
/// <returns></returns>
Task ReceiveMessage(ClientMessageModel clientMessageModel);
/// <summary>
/// Echart接收数据触发函数名
/// </summary>
/// <param name="data">JSON格式的可以被Echarts识别的data数据</param>
/// <returns></returns>
Task EchartsMessage(Array data);
/// <summary>
/// 客户端获取自己登录后的UID
/// </summary>
/// <param name="clientMessageModel">消息实体类</param>
/// <returns></returns>
Task GetMyId(ClientMessageModel clientMessageModel);
/// <summary>
/// 上传成功后服务器处理数据时通知前台的信息内容
/// </summary>
/// <param name="clientMessageModel">消息实体类</param>
/// <returns></returns>
Task UploadInfoMessage(ClientMessageModel clientMessageModel);
}

扩展一下Class/ClientMessageModel.cs

    /// <summary>
/// 服务端发送给客户端的信息
/// </summary>
[Serializable]
public class ClientMessageModel
{
/// <summary>
/// 接收用户编号
/// </summary>
public string UserId { get; set; }
/// <summary>
/// 组编号
/// </summary>
public string GroupName { get; set; }
/// <summary>
/// 发送的内容
/// </summary>
public string Context { get; set; }
/// <summary>
/// 自定义的响应编码
/// </summary>
public string Code { get; set; }
}

我们在Startup.cs中注入上传文件的配置,同时把前文的XSRF防护去掉,我们在前台请求的时候带上防护认证信息。

public void ConfigureServices(IServiceCollection services)
{
services.AddSignalR();
services.AddRazorPages()
services.AddSingleton<UploadFileList>();//服务器上传的文件信息保存在内存中
services.AddOptions()
.Configure<FileUploadConfig>(Configuration.GetSection("FileUpload"));//服务器上传文件配置
}

在项目的wwwroot/js下新建一个uploader.js

ASP.NET CORE使用WebUploader对大文件分片上传,并通过ASP.NET CORE SignalR实时反馈后台处理进度给前端展示

"use strict";
var connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub")
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Debug)
.build();
var user = ""; connection.on("GetMyId", function (data) {
user = data.userId;
});
connection.on("ReceiveMessage", function (data) {
console.log(data.userId + data.context);
}); connection.on("UploadInfoMessage", function (data) {
switch (data.code) {
case "200":
$('.modal-body').append($("<p>" + data.context + "</p>"));//当后台返回处理完成或出错时,前台显示内容,同时显示关闭按钮
$(".modal-content").append($("<div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-dismiss=\"modal\">Close</button></div>"));
break;
case "300":
case "500":
$('.modal-body').append($("<p>" + data.context + "</p>"));//展示后台返回信息
break;
case "400":
if ($("#process").length == 0) {//展示后台推送的文件处理进度
$('.modal-body').append($("<p id='process'>" + data.context + "</p>"));
}
$('#process').text(data.context);
break;
}
}); connection.start().then(function () {
console.log("服务器已连接");
}).catch(function (err) {
return console.error(err.toString());
});

在项目的Pages/Shared中新建一个Razor布局页_LayoutUpload.cshtml

ASP.NET CORE使用WebUploader对大文件分片上传,并通过ASP.NET CORE SignalR实时反馈后台处理进度给前端展示

<!DOCTYPE html>

<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/lib/webuploader/dist/webuploader.css" />
<script type="text/javascript" src="~/lib/jquery/dist/jquery.min.js"></script>
<script type="text/javascript" src="~/lib/webuploader/dist/webuploader.js"></script>
<script type="text/javascript" src="~/lib/bootstrap/dist/js/bootstrap.min.js"></script>
<title>@ViewBag.Title</title>
@await RenderSectionAsync("Scripts", required: false)
</head>
<body>
@RenderBody()
</body>
</html>

在Pages目录下新建一个upload目录,然后在它下面新建一个index.cshtml,这个文件中实现了Webuploader中我们所要使用的事件监测、文件上传功能。

  1 @page "{handler?}"
2 @model MediatRStudy.Pages.upload.IndexModel
3 @{
4 ViewBag.Title = "WebUploader";
5 Layout = "_LayoutUpload";
6 }
7 @section Scripts
8 {
9 <script src="~/js/signalr/dist/browser/signalr.js"></script>
10 <script src="~/js/uploader.js"></script>
11
12 <script>
13 // 每次分片文件大小限制为5M
14 var chunkSize = 5 * 1024 * 1024;
15 // 全部文件限制10G大小
16 var fileTotalSize = 10 * 1024 * 1024 * 1024;
17 // 单文件限制5G大小
18 var fileSingleSize = 5 * 1024 * 1024 * 1024;
19 jQuery(function() {
20 var $ = jQuery,
21 $list = $('#thelist'),
22 $btn = $('#ctlBtn'),
23 state = 'pending',
24 md5s = {},//分块传输时的各个块的md5值
25 dataState,//当前状态
26 Token,//可以做用户验证
27 uploader;//webUploader的实例
28 var fileExt = ["zip", "rar"];//允许上传的类型
29 Token = '@ViewData["Token"]';
30 if (Token == '' || Token == 'undefined') {
31 $("#uploader").hide();
32 alert("登录超时,请重新登录。");
33 }
34 35 36 37 38 //注册Webuploader要监听的上传文件时的三个事件
39 //before-send-file 在执行文件上传前先执行这个;before-send在开始往服务器发送文件前执行;after-send-file所有文件上传完毕后执行
40
41 window.WebUploader.Uploader.register({
42 "before-send-file": "beforeSendFile",
43 "before-send": "beforeSend",
44 "after-send-file": "afterSendFile"
45 },
46 {
47 //第一步,开始上传前校验文件,并传递给服务器当前文件的MD5,服务器可根据MD5来实现类似秒传效果
48 beforeSendFile: function(file) {
49 var owner = this.owner;
50 md5s.length = 0;
51 var deferred = window.WebUploader.Deferred();
52 owner.md5File(file, 0, file.size)
53 .progress(function(percentage) {
54 console.log("文件MD5计算进度:", percentage);
55 })
56 .fail(function() {
57 deferred.reject();
58 console.log("文件MD5获取失败");
59 })
60 .then(function(md5) {
61 console.log("文件MD5:", md5);
62 file.md5 = md5;
63 var params = {
64 "checktype": "whole",
65 "filesize": file.size,
66 "filemd5": md5
67 ,"filename":file.name
68 ,"fileguid":file.guid
69 };
70 $.ajax({
71 url: '/upload/FileWhole', //通过md5校验实现文件秒传
72 type: 'POST',
73 headers: {//请求的时候传递进去防CSRF攻击的认证信息
74 RequestVerificationToken:
75 $('input:hidden[name="__RequestVerificationToken"]').val()
76 },
77 data: params,
78 contentType: 'application/x-www-form-urlencoded',
79 async: true, // 开启异步请求
80 dataType: 'JSON',
81 success: function(data) {
82 data = (typeof data) == 'string' ? JSON.parse(data) : data;
83 if (data.code != '200') {
84 dataState = data;
85 //服务器返回错误信息
86 alert('错误:' + data.msg);
87 deferred.reject();//取消后续上传
88 }
89 if (data.isExist) {
90 // 跳过当前文件并标记文件状态为上传完成
91 dataState = data;
92 owner.skipFile(file, window.WebUploader.File.Status.COMPLETE);
93 deferred.resolve();
94 $('#' + file.id).find('p.state').text('上传成功【秒传】');
95
96 } else {
97 deferred.resolve();
98 }
99 },
100 error: function(xhr, status) {
101 $('#' + file.id).find('p.state').text('上传失败:'+status);
102 console.log("上传失败:", status);
103 }
104 });
105 });
106
107 return deferred.promise();
108 },
109 //上传事件第二步:分块上传时,每个分块触发上传前执行
110 beforeSend: function(block) {
111 var deferred = window.WebUploader.Deferred();
112 var owner = this.owner;
113 owner.md5File(block.file, block.start, block.end)
114 .progress(function(percentage) {
115 console.log("当前分块内容的MD5计算进度:", percentage);
116 })
117 .fail(function() {
118 deferred.reject();
119 })
120 .then(function(md5) {
121 //计算当前块的MD5值并写入数组
122 md5s[block.blob.uid] = md5;
123 deferred.resolve();
124 });
125 return deferred.promise();
126 },
127 //时间点3:所有分块上传成功后调用此函数
128 afterSendFile: function(file) {
129 var deferred = $.Deferred();
130 $('#' + file.id).find('p.state').text('执行最后一步');
131 console.log(file);
132 if (file.skipped) {
133 deferred.resolve();
134 console.log("执行服务器合并分块文件操作");
135 return deferred.promise();
136 }
137 var chunkNumber = Math.ceil(file.size / chunkSize);//总块数
138 var params = {
139 "checktype": "merge",
140 "filesize": file.size,
141 "chunknumber": chunkNumber,
142 "filemd5": file.md5,
143 "filename": file.guid,
144 "fileext": file.ext//扩展名
145 };
146 $.ajax({
147 type: "POST",
148 url: "/upload/FileMerge",
149 headers: {
150 RequestVerificationToken:
151 $('input:hidden[name="__RequestVerificationToken"]').val(),
152 userid:user //传递SignalR分配的编号
153 },
154 data: params,
155 async: true,
156 success: function(response) {
157 if (response.code == 200) {
158 //服务器合并完成分块传输的文件后执行
159 dataState = response;
160 $("#myModal").modal('show');
161 } else {
162 alert(response.msg);
163 }
164 deferred.resolve();
165 },
166 error: function() {
167 dataState = undefined;
168 deferred.reject();
169 }
170 });
171 return deferred.promise();
172 }
173 });
174 uploader = window.WebUploader.create({
175 resize: false,
176 fileNumLimit: 1,
177 swf: '/lib/webuploader/dist/Uploader.swf',
178 server: '/upload/FileSave',
179 pick: { id: '#picker', multiple: false },
180 chunked: true,
181 chunkSize: chunkSize,
182 chunkRetry: 3,
183 fileSizeLimit: fileTotalSize,
184 fileSingleSizeLimit: fileSingleSize,
185 formData: {
186 }
187 });
188 uploader.on('beforeFileQueued',
189 function(file) {
190 var isAdd = false;
191 for (var i = 0; i < fileExt.length; i++) {
192 if (file.ext == fileExt[i]) {
193 file.guid = window.WebUploader.Base.guid();
194 isAdd = true;
195 break;
196 }
197 }
198 return isAdd;
199 });
200 //每次上传前,如果分块传输,则带上分块信息参数
201 uploader.on('uploadBeforeSend',
202 function(block, data, headers) {
203 var params = {
204 "checktype": "chunk",
205 "filesize": block.file.size,
206 "fileid": block.blob.ruid,
207 "chunksize": block.blob.size,
208 "chunkindex": block.chunk,
209 "chunkstart": block.start,
210 "chunkend": block.end,
211 "chunkcount": block.chunks,
212 "chunkid": block.blob.uid,
213 "md5": md5s[block.blob.uid]
214 };
215 data.formData = JSON.stringify(params);
216
217 headers.Authorization = Token;
218 headers.RequestVerificationToken = $('input:hidden[name="__RequestVerificationToken"]').val();
219 data.guid = block.file.guid;
220 });
221 // 当有文件添加进来的时候
222 uploader.on('fileQueued',
223 function(file) {
224 $list.append('<div id="' +
225 file.id +
226 '" class="item">' +
227 '<h4 class="info">' +
228 file.name +
229 '</h4>' +
230 '<input type="hidden" id="h_' +
231 file.id +
232 '" value="' +
233 file.guid +
234 '" />' +
235 '<p class="state">等待上传...</p>' +
236 '</div>');
237 });
238
239 // 文件上传过程中创建进度条实时显示。
240 uploader.on('uploadProgress',
241 function(file, percentage) {
242 var $li = $('#' + file.id),
243 $percent = $li.find('.progress .progress-bar');
244 // 避免重复创建
245 if (!$percent.length) {
246 $percent = $('<div class="progress progress-striped active">' +
247 '<div class="progress-bar" role="progressbar" style="width: 0%">' +
248 '</div>' +
249 '</div>').appendTo($li).find('.progress-bar');
250 }
251 $li.find('p.state').text('上传中');
252
253 $percent.css('width', percentage * 100 + '%');
254 });
255
256 uploader.on('uploadSuccess',
257 function(file) {
258 if (dataState == undefined) {
259 $('#' + file.id).find('p.state').text('上传失败');
260 $('#' + file.id).find('button').remove();
261 $('#' + file.id).find('p.state').before('<button id="retry" type="button" class="btn btn-primary fright retry pbtn">重新上传</button>');
262 file.setStatus('error');
263 return;
264 }
265 if (dataState.success == true) {
266 if (dataState.miaochuan == true) {
267 $('#' + file.id).find('p.state').text('上传成功[秒传]');
268 } else {
269 $('#' + file.id).find('p.state').text('上传成功');
270 }
271 $('#' + file.id).find('button').remove();
272 return;
273
274 } else {
275 $('#' + file.id).find('p.state').text('服务器未能成功接收,状态:' + dataState.success);
276 return;
277 }
278 });
279
280 uploader.on('uploadError',
281 function(file) {
282 $('#' + file.id).find('p.state').text('上传出错');
283 });
284 //分块传输后,可以在这个事件中获取到服务器返回的信息,同时这里可以实现文件续传(块文件的MD5存在时,后台可以跳过保存步骤)
285 uploader.on('uploadAccept',
286 function(file, response, reject) {
287 if (response.code !== 200) {
288 alert("上传出错:" + response.msg);
289 return false;
290 }
291 return true;
292 });
293 uploader.on('uploadComplete',
294 function(file) {
295 $('#' + file.id).find('.progress').fadeOut();
296 });
297
298 uploader.on('all',
299 function(type) {
300 if (type === 'startUpload') {
301 state = 'uploading';
302 } else if (type === 'stopUpload') {
303 state = 'paused';
304 } else if (type === 'uploadFinished') {
305 state = 'done';
306 }
307 if (state === 'done') {
308 $btn.text('继续上传');
309 } else if (state === 'uploading') {
310 $btn.text('暂停上传');
311 } else {
312 $btn.text('开始上传');
313 }
314 });
315 $btn.on('click',
316 function() {
317 if (state === 'uploading') {
318 uploader.stop();
319 } else if (state == 'done') {
320 window.location.reload();
321 } else {
322 uploader.upload();
323 }
324 });
325 });
326 </script>
327 }
328 <div class="container">
329 <div class="row">
330 <div id="uploader" class="wu-example">
331 <span style="color: red">请上传压缩包</span>
332 <div class="form-group" id="thelist">
333 </div>
334 <div class="form-group">
335 <form method="post">
336 <div id="picker" class="webuploader-container">
337 <div class="webuploader-pick">选择文件</div>
338 <div style="position: absolute; top: 0; left: 0; width: 88px; height: 34px; overflow: hidden; bottom: auto; right: auto;">
339 <input type="file" name="file" class="webuploader-element-invisible" />
340 <label style="-ms-opacity: 0; opacity: 0; width: 100%; height: 100%; display: block; cursor: pointer; background: rgb(255, 255, 255);"></label>
341 </div>
342 </div>
343 <button id="ctlBtn" class="btn btn-success" type="button">开始上传</button>
344 </form>
345 </div>
346 </div>
347 </div>
348 </div>
349
350 <div class="modal fade" id="myModal" tabindex="-1" aria-labelledby="exampleModalScrollableTitle" style="display: none;" data-backdrop="static" aria-hidden="true">
351 <div class="modal-dialog modal-dialog-scrollable">
352 <div class="modal-content">
353 <div class="modal-header">
354 <h5 class="modal-title" id="exampleModalScrollableTitle">正在处理。。。</h5>
355 <button type="button" class="close" data-dismiss="modal" aria-label="Close">
356
357 </button>
358 </div>
359 <div class="modal-body">
360 <p>服务器正在处理数据,请不要关闭和刷新此页面。</p>
361 </div>
362 </div>
363 </div>
364 </div>

index.cshtml的代码文件如下

本示例只能解压缩zip文件,并且密码是123456,友情提示,不要用QQ浏览器调试,否则会遇到选择文件后DEBUG停止运行。

本示例只能解压缩zip文件,并且密码是123456,友情提示,不要用QQ浏览器调试,否则会遇到选择文件后DEBUG停止运行。

本示例只能解压缩zip文件,并且密码是123456,友情提示,不要用QQ浏览器调试,否则会遇到选择文件后DEBUG停止运行。

ASP.NET CORE使用WebUploader对大文件分片上传,并通过ASP.NET CORE SignalR实时反馈后台处理进度给前端展示ASP.NET CORE使用WebUploader对大文件分片上传,并通过ASP.NET CORE SignalR实时反馈后台处理进度给前端展示
  1 using ICSharpCode.SharpZipLib.Zip;
2 using Microsoft.AspNetCore.Http;
3 using Microsoft.AspNetCore.Mvc;
4 using Microsoft.AspNetCore.Mvc.RazorPages;
5 using Microsoft.AspNetCore.SignalR;
6 using Microsoft.Extensions.Options;
7 using signalr.Class;
8 using signalr.HubInterface;
9 using signalr.Hubs;
10 using signalr.Model;
11 using System;
12 using System.Collections.Concurrent;
13 using System.Diagnostics;
14 using System.IO;
15 using System.Linq;
16 using System.Text.Json;
17 using System.Threading.Tasks;
18
19 namespace signalr.Pages.upload
20 {
21 public class IndexModel : PageModel
22 {
23 private readonly IOptionsSnapshot<FileUploadConfig> _fileUploadConfig;
24 private readonly IOptionsSnapshot<UploadFileList> _fileList;
25 private readonly string[] _fileExt;
26 private readonly IHubContext<ChatHub, IChatClient> _hubContext;
27 public IndexModel(IOptionsSnapshot<FileUploadConfig> fileUploadConfig, IOptionsSnapshot<UploadFileList> fileList, IHubContext<ChatHub, IChatClient> hubContext)
28 {
29 _fileUploadConfig = fileUploadConfig;
30 _fileList = fileList;
31 _fileExt = _fileUploadConfig.Value.FileExt.Split(',').ToArray();
32 _hubContext = hubContext;
33 }
34 public IActionResult OnGet()
35 {
36 ViewData["Token"] = "666";
37 return Page();
38 }
39
40 #region 上传文件
41
42 /// <summary>
43 /// 上传文件
44 /// </summary>
45 /// <returns></returns>
46 public async Task<JsonResult> OnPostFileSaveAsync(IFormFile file, UploadFileModel model)
47 {
48 if (_fileUploadConfig.Value == null)
49 {
50 return new JsonResult(new { code = 400, msg = "服务器配置不正确" });
51 }
52
53 if (file == null || file.Length < 1)
54 {
55 return new JsonResult(new { code = 404, msg = "没有接收到要保存的文件" });
56 }
57 Request.EnableBuffering();
58 var formData = Request.Form["formData"];
59 if (model == null || string.IsNullOrWhiteSpace(formData))
60 {
61 return new JsonResult(new { code = 401, msg = "没有接收到必要的参数" });
62 }
63
64 var request = model;
65 long.TryParse(Request.Form["size"], out var fileSize);
66 request.Size = fileSize;
67 try
68 {
69 request.FromData = JsonSerializer.Deserialize<FormData>(formData, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
70 }
71 catch (Exception e)
72 {
73 Debug.WriteLine(e);
74 }
75
76 if (request.FromData == null)
77 {
78 return new JsonResult(new { code = 402, msg = "参数错误" });
79 }
80
81 #if DEBUG
82 Debug.WriteLine($"原文件名:{request.Name},文件编号:{request.Guid},文件块编号:{request.Chunk},文件Md5:{request.FileMd5},当前块UID:{request.FromData?.Chunkid},当前块MD5:{request.FromData?.Md5}");
83 #endif
84 var fileExt = request.Name.Substring(request.Name.LastIndexOf('.') + 1).ToLowerInvariant();
85 if (!_fileExt.Contains(fileExt))
86 {
87 return new JsonResult(new { code = 403, msg = "文件类型不在允许范围内" });
88 }
89 if (_fileList.Value.UploadChunkFileList.Value.ContainsKey(request.Guid))
90 {
91 if (!_fileList.Value.UploadChunkFileList.Value[request.Guid].Any(x => string.Equals(x, request.FromData.Md5, StringComparison.OrdinalIgnoreCase)))
92 {
93 _fileList.Value.UploadChunkFileList.Value[request.Guid].Add(request.FromData.Md5);
94 }
95 #if DEBUG
96 else
97 {
98 Debug.WriteLine($"ContainsKey{request.FromData.Chunkindex}存在校验值{request.FromData.Md5}");
99 return new JsonResult(new { code = 200, msg = "成功接收", miaochuan = true });
100 }
101 #endif
102 }
103 else
104 {
105 return new JsonResult(new { code = 405, msg = "接收失败,因为服务器没有找到此文件的容器,请重新上传" });
106 }
107
108 var dirPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, _fileUploadConfig.Value.TempPath, request.Guid);
109 if (!Directory.Exists(dirPath))
110 {
111 Directory.CreateDirectory(dirPath);
112 }
113
114 var tempFile = string.Concat(dirPath, "\\", request.FromData.Chunkindex.ToString().PadLeft(4, '0'), ".", fileExt);
115 try
116 {
117
118 await using var fs = System.IO.File.OpenWrite(tempFile);
119 request.FileData = new byte[Convert.ToInt32(request.FromData.Chunksize ?? 0)];
120
121 await using var memStream = new MemoryStream();
122 await file.CopyToAsync(memStream);
123
124 request.FileData = memStream.ToArray();
125
126 await fs.WriteAsync(request.FileData, 0, request.FileData.Length);
127 await fs.FlushAsync();
128 }
129 catch (Exception e)
130 {
131 #if DEBUG
132 Debug.WriteLine($"White Error:{e}");
133 #endif
134 _fileList.Value.UploadChunkFileList.Value.TryRemove(request.Guid, out _);
135 }
136 return new JsonResult(new { code = 200, msg = "成功接收", miaochuan = false });
137 }
138
139 #endregion
140
141 #region 合并上传文件
142
143 /// <summary>
144 /// 合并分片上传的文件
145 /// </summary>
146 /// <param name="mergeModel">前台传递的请求合并的参数</param>
147 /// <returns></returns>
148 public async Task<JsonResult> OnPostFileMergeAsync(UploadFileMergeModel mergeModel)
149 {
150 return await Task.Run(async () =>
151 {
152 if (mergeModel == null || string.IsNullOrWhiteSpace(mergeModel.FileName) ||
153 string.IsNullOrWhiteSpace(mergeModel.FileMd5))
154 {
155 return new JsonResult(new { code = 300, success = false, count = 0, size = 0, msg = "合并失败,参数不正确。" });
156 }
157 if (!_fileExt.Contains(mergeModel.FileExt.ToLowerInvariant()))
158 {
159 return new JsonResult(new { code = 403, success = false, msg = "文件类型不在允许范围内" });
160 }
161
162 var fileSavePath = "";
163 if (!_fileList.Value.ServerUploadFileList.Value.ContainsKey(mergeModel.FileMd5))
164 {
165 //合并块文件、删除临时文件
166 var chunks = Directory.GetFiles(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, _fileUploadConfig.Value.TempPath, mergeModel.FileName), "*.*");
167 if (!chunks.Any())
168 {
169 return new JsonResult(new { code = 302, success = false, count = 0, size = 0, msg = "未找到文件块信息,请重试。" });
170 }
171 var dirPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, _fileUploadConfig.Value.FileDir);
172 if (!Directory.Exists(dirPath))
173 {
174 Directory.CreateDirectory(dirPath);
175 }
176 fileSavePath = Path.Combine(_fileUploadConfig.Value.FileDir,
177 string.Concat(mergeModel.FileName, ".", mergeModel.FileExt));
178 await using var fs =
179 new FileStream(Path.Combine(dirPath, string.Concat(mergeModel.FileName, ".", mergeModel.FileExt)), FileMode.Create);
180 foreach (var file in chunks.OrderBy(x => x))
181 {
182 //Debug.WriteLine($"File==>{file}");
183 var bytes = await System.IO.File.ReadAllBytesAsync(file);
184 await fs.WriteAsync(bytes.AsMemory(0, bytes.Length));
185 }
186 //Directory.Delete(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, _fileUploadConfig.Value.TempPath, mergeModel.FileName), true);
187
188
189 if (!_fileList.Value.ServerUploadFileList.Value.TryAdd(mergeModel.FileMd5, fileSavePath))
190 {
191 return new JsonResult(new { code = 301, success = false, count = 0, size = 0, msg = "服务器保存文件失败,请重试。" });
192 }
193 }
194 var user = Request.Headers["userid"];
195 //调用解压文件
196 if (string.Equals(mergeModel.FileExt.ToLowerInvariant(), "zip"))
197 {
198 DoUnZip(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileSavePath), user.ToString());
199 }
200 else
201 {
202 await SentMessage(user.ToString(), "服务器只能解压缩zip格式文件。", "200");
203 }
204 return new JsonResult(new { code = 200, success = true, count = 0, size = 0, msg = "上传成功", url = fileSavePath });
205 });
206
207 }
208
209 #endregion
210
211 #region 文件秒传检测、文件类型允许范围检测
212 public JsonResult OnPostFileWholeAsync(UploadFileWholeModel model)
213 {
214 if (model == null || string.IsNullOrWhiteSpace(model.FileMd5))
215 {
216 return new JsonResult(new { Code = 300, IsExist = false, success = false, FileUrl = "", Msg = "参数不正确" });
217 }
218 var fileExt = model.FileName.Substring(model.FileName.LastIndexOf('.') + 1).ToLowerInvariant();
219 if (!_fileExt.Contains(fileExt))
220 {
221 return new JsonResult(new { code = 403, success = false, msg = "文件类型不在允许范围内" });
222 }
223 if (_fileList.Value.ServerUploadFileList.Value.ContainsKey(model.FileMd5))
224 {
225 return new JsonResult(new { Code = 200, IsExist = true, success = true, FileUrl = _fileList.Value.ServerUploadFileList.Value[model.FileMd5], miaochuan = true });
226 }
227 //检测的时候创建待上传文件的分块MD5容器
228 _fileList.Value.UploadChunkFileList.Value.TryAdd(model.FileGuid, new ConcurrentBag<string>());
229
230 return new JsonResult(new { Code = 200, IsExist = false, FileUrl = "" });
231 }
232 #endregion
233
234 #region 文件块秒传检测
235 public JsonResult OnPostFileChunkAsync(UploadFileChunkModel model)
236 {
237 if (model == null || string.IsNullOrWhiteSpace(model.Md5) || string.IsNullOrWhiteSpace(model.FileId))
238 {
239 return new JsonResult(new { Code = 300, IsExist = false, success = false, FileUrl = "", Msg = "参数不正确" });
240 }
241
242 if (!_fileList.Value.UploadChunkFileList.Value.ContainsKey(model.FileId))
243 {
244 return new JsonResult(new { Code = 200, IsExist = false, FileUrl = "" });
245 }
246
247 if (!_fileList.Value.UploadChunkFileList.Value[model.FileId].Contains(model.Md5))
248 {
249 return new JsonResult(new { Code = 200, IsExist = false, FileUrl = "" });
250 }
251 return new JsonResult(new { Code = 200, IsExist = true, success = true, miaochuan = true });
252 }
253 #endregion
254
255 #region 解压、校验文件
256
257 private void DoUnZip(string zipFile, string user)
258 {
259 Task.Factory.StartNew(async () =>
260 {
261 if (!System.IO.File.Exists(zipFile))
262 {
263 //发送一条文件不存在的消息
264 await SentMessage(user, "访问上传的压缩包失败");
265 return;
266 }
267 var fastZip = new FastZip
268 {
269 Password = "123456",
270 CreateEmptyDirectories = true
271 };
272 try
273 {
274 var zipExtDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ZipEx", "601018");
275 //删除现有文件夹
276 if (Directory.Exists(zipExtDir))
277 Directory.Delete(zipExtDir, true);
278 //发送开始解压缩信息
279 await SentMessage(user, "开始解压缩文件。。。");
280 #if DEBUG
281 Debug.WriteLine("开始解压缩文件。。。");
282 #endif
283 fastZip.ExtractZip(zipFile, zipExtDir, "");
284 #if DEBUG
285 Debug.WriteLine("解压缩文件成功。。。");
286 #endif
287 await SentMessage(user, "解压缩文件成功,开始校验。。。");
288 //发送解压成功并开始校验文件信息
289 var zipFiles = Directory.GetFiles(zipExtDir, "*.jpg", SearchOption.AllDirectories);
290 for (var i = 0; i < zipFiles.Length; i++)
291 {
292 var file = zipFiles[i];
293 var i1 = i + 1;
294 await Task.Delay(100);//模拟文件处理需要100毫秒
295 //发送进度 i/length
296 await SentMessage(user, $"校验进度==>{i1}/{zipFiles.Length}", "400");
297 #if DEBUG
298 Debug.WriteLine($"当前进度:{i1},总数:{zipFiles.Length}");
299 #endif
300 }
301 await SentMessage(user, "校验完成", "200");
302 }
303 catch (Exception exception)
304 {
305 //发送解压缩失败信息
306 await SentMessage(user, $"解压缩文件失败:{exception}", "500");
307 #if DEBUG
308 Debug.WriteLine($"解压缩文件失败:{exception}");
309 #endif
310 }
311 }, TaskCreationOptions.LongRunning);
312 }
313
314 #endregion
315
316 #region 消息推送前台
317
318 private async Task SentMessage(string user, string content, string code = "300")
319 {
320
321 await _hubContext.Clients.Client(user).UploadInfoMessage(new ClientMessageModel
322 {
323 UserId = user,
324 GroupName = "upload",
325 Context = content,
326 Code = code
327 });
328 }
329
330 #endregion
331 }
332 }

未能完善的地方:

1、上传几百兆或更大的文件,webuploader计算md5时间太长;

2、后台处理错误的时候,前台接收消息后没能出现关闭按钮;

3、分块传输时文件断点续传没有具体实现(理论上是没问题的)

参考文章:

https://www.cnblogs.com/wdw984/p/11725118.html

http://fex.baidu.com/webuploader/

如此文章对你有帮助,请点个推荐吧。谢谢!