(转)在ASP.NET MVC中实现大文件异步上传
在ASP.NET MVC中实现大文件异步上传(原文)
在ASP.NET MVC中,我们使用StaticWorkerRequest建立虚假声明的方式实现大文件上传,现在你可以在ASP.NET MVC中通过直接访问数据流上传大文件,同时还可保证内存资源的消耗相对平稳。
AD:
【51CTO独家特稿】在ASP.NET中通过HTTP上传大文件是一个由来已久的挑战,它是许多活跃的ASP.NET论坛最常讨论 的问题之一,除了处理大文件外,用户还经常被要求要显示出文件上传的进度,当你需要直接控制从浏览器上传数据流时,你会四处碰壁。51CTO.com之前 就曾针对性的报道过《解除ASP.NET上传文件的大小限制》和《ASP.NET大文件上传开发总结》等文章。
绝大多数人认为在ASP.NET中上传大文件有以下这些解决方案:
◆不要这样做。你最好是在页面中嵌入一个Silverlight或Flash进程上传文件。
◆不要这样做。因为HTTP本身设计就不是为了上传大文件,重新思考你要的功能。
◆不要这样做。ASP.NET本身设计最大也就能处理2GB大小的文件。
◆购买商业产品,如SlickUpload,它使用了一个HttpModule实现了文件流分块。
◆使用开源产品,如NeatUpload,它使用了一个HttpModule实现了文件流分块。
最近我接到一个任务,需构建一个上传工具实现以下功能:
◆必须工作在HTTP协议
◆必须允许非常大的文件上传(会大于2GB)
◆必须允许断点续传
◆必须允许并行上传
因此前三个解决方案都不适应我的需求,其它解决方案对于我而言又太笨重了,因此我开始着手解决在ASP.NET MVC中的这个问题,如果有这方面的开发背景,你一定了解大部分问题最终都归结于对ASP.NET输入流和连锁请求过程的控制,网上的资料一般都是这样描 述的,只要你的代码访问了HttpRequest的InputStream属性,在你访问流之前,ASP.NET就会缓存整个上传的文件,这就意味着当我 向云服务上传文件时,我必须等待整个大文件抵达服务器,然后才能将其传输到预定目的地,这意味着需要两倍的时间。
首先,我们推荐你阅读一下Scott Hanselman的有关ASP.NET MVC文件上传文章,地址http://www.hanselman.com/blog/CommentView.aspx?guid=bc137b6b-d8d0-47d1-9795-f8814f7d1903, 先对文件上传有一个大致的了解,但Scott Hanselman的方法是不能上传大文件的,根据Scott Hanselman的方法,你只需要修改一下web.config文件,确保ASP.NET允许最大支持2GB大小的文件上传,不要担心,这样设置并不会 吃掉你的内存,因为凡是大于256KB的数据都被缓存到磁盘上去了。
- ﹤system.web﹥
- ﹤httpruntime requestlengthdiskthreshold="256" maxrequestlength="2097151"﹥
- ﹤/httpruntime﹥﹤/system.web﹥
这是一个简单的适合大多数应用的解决办法,但我的任务中不能借用这种方法,即使会将数据缓存到磁盘中,但这种类似于另存为的方法也会使用大量的内存。
图 1 :通过缓存整个文件,然后另存为的方式会使内存消耗突然上升
那么在ASP.NET MVC中通过直接访问流,不触发任何缓存机制,上传大文件该如何实现呢?解决办法就是尽量远离ASP.NET,我们先来看一看 UploadController,它有三个行为方法,一个是索引我们上传的文件,一个是前面讨论的缓存逻辑,另一个是基于实时流的方法。
1 public class UploadController : Controller 2 { 3 [AcceptVerbs(HttpVerbs.Get)] 4 [Authorize] 5 public ActionResult Index() 6 { 7 return View(); 8 } 9 10 [AcceptVerbs(HttpVerbs.Post)] 11 public ActionResult BufferToDisk() 12 { 13 var path = Server.MapPath("~/Uploads"); 14 15 foreach (string file in Request.Files) 16 { 17 var fileBase = Request.Files[file]; 18 19 try 20 { 21 if (fileBase.ContentLength > 0) 22 { 23 fileBase.SaveAs(Path.Combine(path, fileBase.FileName)); 24 } 25 } 26 catch (IOException) 27 { 28 29 } 30 } 31 32 return RedirectToAction("Index", "Upload"); 33 } 34 35 //[AcceptVerbs(HttpVerbs.Post)] 36 //[Authorize] 37 public void LiveStream() 38 { 39 var path = Server.MapPath("~/Uploads"); 40 41 var context = ControllerContext.HttpContext; 42 43 var provider = (IServiceProvider)context; 44 45 var workerRequest = (HttpWorkerRequest)provider.GetService(typeof(HttpWorkerRequest)); 46 47 //[AcceptVerbs(HttpVerbs.Post)] 48 var verb = workerRequest.GetHttpVerbName(); 49 if(!verb.Equals("POST")) 50 { 51 Response.StatusCode = (int)HttpStatusCode.NotFound; 52 Response.SuppressContent = true; 53 return; 54 } 55 56 //[Authorize] 57 if(!context.User.Identity.IsAuthenticated) 58 { 59 Response.StatusCode = (int)HttpStatusCode.Unauthorized; 60 Response.SuppressContent = true; 61 return; 62 } 63 64 var encoding = context.Request.ContentEncoding; 65 66 var processor = new UploadProcessor(workerRequest); 67 68 processor.StreamToDisk(context, encoding, path); 69 70 //return RedirectToAction("Index", "Upload"); 71 Response.Redirect(Url.Action("Index", "Upload")); 72 } 73 }
虽然这里明显缺少一两个类,但基本的方法还是讲清楚了,看起来和缓存逻辑并没有太大的不同之处,我们仍然将流缓存到了磁盘,但具体处理方式却有些不 同了,首先,没有与方法关联的属性,谓词和授权限制都被移除了,使用手动等值取代了,使用手工响应操作而不用ActionFilterAttribute 声明的原因是这些属性涉及到了一些重要的ASP.NET管道代码,实际上在我的代码中,我还特意拦截了原生态的HttpWorkerRequest,因为 它不能同时做两件事情。
HttpWorkerRequest有VIP访问传入的请求,通常它是由ASP.NET本身支持工作的,但我们绑架了请求,然后欺骗剩下的请求,让 它们误以为前面的请求已经全部得到处理,为了做到这一点,我们需要上面例子中未出现的UploadProcessor类,这个类的职责是物理读取来自浏览 器的每个数据块,然后将其保存到磁盘上,因为上传的内容被分解成多个部分,UploadProcessor类需要找出内容头,然后拼接成带状数据输出,这 一可以在一个上传中同时上传多个文件。
1 internal class UploadProcessor 2 { 3 private byte[] _buffer; 4 private byte[] _boundaryBytes; 5 private byte[] _endHeaderBytes; 6 private byte[] _endFileBytes; 7 private byte[] _lineBreakBytes; 8 9 private const string _lineBreak = "\r\n"; 10 11 private readonly Regex _filename = 12 new Regex(@"Content-Disposition:\s*form-data\s*;\s*name\s*=\s*""file""\s*;\s*filename\s*=\s*""(.*)""", 13 RegexOptions.IgnoreCase | RegexOptions.Compiled); 14 15 private readonly HttpWorkerRequest _workerRequest; 16 17 public UploadProcessor(HttpWorkerRequest workerRequest) 18 { 19 _workerRequest = workerRequest; 20 } 21 22 public void StreamToDisk(IServiceProvider provider, Encoding encoding, string rootPath) 23 { 24 var buffer = new byte[8192]; 25 26 if (!_workerRequest.HasEntityBody()) 27 { 28 return; 29 } 30 31 var total = _workerRequest.GetTotalEntityBodyLength(); 32 var preloaded = _workerRequest.GetPreloadedEntityBodyLength(); 33 var loaded = preloaded; 34 35 SetByteMarkers(_workerRequest, encoding); 36 37 var body = _workerRequest.GetPreloadedEntityBody(); 38 if (body == null) // IE normally does not preload 39 { 40 body = new byte[8192]; 41 preloaded = _workerRequest.ReadEntityBody(body, body.Length); 42 loaded = preloaded; 43 } 44 45 var text = encoding.GetString(body); 46 var fileName = _filename.Matches(text)[0].Groups[1].Value; 47 fileName = Path.GetFileName(fileName); // IE captures full user path; chop it 48 49 var path = Path.Combine(rootPath, fileName); 50 var files = new List {fileName}; 51 var stream = new FileStream(path, FileMode.Create); 52 53 if (preloaded > 0) 54 { 55 stream = ProcessHeaders(body, stream, encoding, preloaded, files, rootPath); 56 } 57 58 // Used to force further processing (i.e. redirects) to avoid buffering the files again 59 var workerRequest = new StaticWorkerRequest(_workerRequest, body); 60 var field = HttpContext.Current.Request.GetType().GetField("_wr", BindingFlags.NonPublic | BindingFlags.Instance); 61 field.SetValue(HttpContext.Current.Request, workerRequest); 62 63 if (!_workerRequest.IsEntireEntityBodyIsPreloaded()) 64 { 65 var received = preloaded; 66 while (total - received >= loaded && _workerRequest.IsClientConnected()) 67 { 68 loaded = _workerRequest.ReadEntityBody(buffer, buffer.Length); 69 stream = ProcessHeaders(buffer, stream, encoding, loaded, files, rootPath); 70 71 received += loaded; 72 } 73 74 var remaining = total - received; 75 buffer = new byte[remaining]; 76 77 loaded = _workerRequest.ReadEntityBody(buffer, remaining); 78 stream = ProcessHeaders(buffer, stream, encoding, loaded, files, rootPath); 79 } 80 81 stream.Flush(); 82 stream.Close(); 83 stream.Dispose(); 84 } 85 86 private void SetByteMarkers(HttpWorkerRequest workerRequest, Encoding encoding) 87 { 88 var contentType = workerRequest.GetKnownRequestHeader(HttpWorkerRequest.HeaderContentType); 89 var bufferIndex = contentType.IndexOf("boundary=") + "boundary=".Length; 90 var boundary = String.Concat("--", contentType.Substring(bufferIndex)); 91 92 _boundaryBytes = encoding.GetBytes(string.Concat(boundary, _lineBreak)); 93 _endHeaderBytes = encoding.GetBytes(string.Concat(_lineBreak, _lineBreak)); 94 _endFileBytes = encoding.GetBytes(string.Concat(_lineBreak, boundary, "--", _lineBreak)); 95 _lineBreakBytes = encoding.GetBytes(string.Concat(_lineBreak + boundary + _lineBreak)); 96 } 97 98 private FileStream ProcessHeaders(byte[] buffer, FileStream stream, Encoding encoding, int count, ICollection files, string rootPath) 99 { 100 buffer = AppendBuffer(buffer, count); 101 102 var startIndex = IndexOf(buffer, _boundaryBytes, 0); 103 if (startIndex != -1) 104 { 105 var endFileIndex = IndexOf(buffer, _endFileBytes, 0); 106 if (endFileIndex != -1) 107 { 108 var precedingBreakIndex = IndexOf(buffer, _lineBreakBytes, 0); 109 if (precedingBreakIndex > -1) 110 { 111 startIndex = precedingBreakIndex; 112 } 113 114 endFileIndex += _endFileBytes.Length; 115 116 var modified = SkipInput(buffer, startIndex, endFileIndex, ref count); 117 stream.Write(modified, 0, count); 118 } 119 else 120 { 121 var endHeaderIndex = IndexOf(buffer, _endHeaderBytes, 0); 122 if (endHeaderIndex != -1) 123 { 124 endHeaderIndex += _endHeaderBytes.Length; 125 126 var text = encoding.GetString(buffer); 127 var match = _filename.Match(text); 128 129 var fileName = match != null ? match.Groups[1].Value : null; 130 fileName = Path.GetFileName(fileName); // IE captures full user path; chop it 131 132 if (!string.IsNullOrEmpty(fileName) && !files.Contains(fileName)) 133 { 134 files.Add(fileName); 135 136 var filePath = Path.Combine(rootPath, fileName); 137 138 stream = ProcessNextFile(stream, buffer, count, startIndex, endHeaderIndex, filePath); 139 } 140 else 141 { 142 var modified = SkipInput(buffer, startIndex, endHeaderIndex, ref count); 143 stream.Write(modified, 0, count); 144 } 145 } 146 else 147 { 148 _buffer = buffer; 149 } 150 } 151 } 152 else 153 { 154 stream.Write(buffer, 0, count); 155 } 156 157 return stream; 158 } 159 160 private static FileStream ProcessNextFile(FileStream stream, byte[] buffer, int count, int startIndex, int endIndex, string filePath) 161 { 162 var fullCount = count; 163 var endOfFile = SkipInput(buffer, startIndex, count, ref count); 164 stream.Write(endOfFile, 0, count); 165 166 stream.Flush(); 167 stream.Close(); 168 stream.Dispose(); 169 170 stream = new FileStream(filePath, FileMode.Create); 171 172 var startOfFile = SkipInput(buffer, 0, endIndex, ref fullCount); 173 stream.Write(startOfFile, 0, fullCount); 174 175 return stream; 176 } 177 178 private static int IndexOf(byte[] array, IList value, int startIndex) 179 { 180 var index = 0; 181 var start = Array.IndexOf(array, value[0], startIndex); 182 183 if (start == -1) 184 { 185 return -1; 186 } 187 188 while ((start + index) < array.Length) 189 { 190 if (array[start + index] == value[index]) 191 { 192 index++; 193 if (index == value.Count) 194 { 195 return start; 196 } 197 } 198 else 199 { 200 start = Array.IndexOf(array, value[0], start + index); 201 202 if (start != -1) 203 { 204 index = 0; 205 } 206 else 207 { 208 return -1; 209 } 210 } 211 } 212 213 return -1; 214 } 215 216 private static byte[] SkipInput(byte[] input, int startIndex, int endIndex, ref int count) 217 { 218 var range = endIndex - startIndex; 219 var size = count - range; 220 221 var modified = new byte[size]; 222 var modifiedCount = 0; 223 224 for (var i = 0; i < input.Length; i++) 225 { 226 if (i >= startIndex && i < endIndex) 227 { 228 continue; 229 } 230 231 if (modifiedCount >= size) 232 { 233 break; 234 } 235 236 modified[modifiedCount] = input[i]; 237 modifiedCount++; 238 } 239 240 input = modified; 241 count = modified.Length; 242 return input; 243 } 244 245 private byte[] AppendBuffer(byte[] buffer, int count) 246 { 247 var input = new byte[_buffer == null ? buffer.Length : _buffer.Length + count]; 248 if (_buffer != null) 249 { 250 Buffer.BlockCopy(_buffer, 0, input, 0, _buffer.Length); 251 } 252 Buffer.BlockCopy(buffer, 0, input, _buffer == null ? 0 : _buffer.Length, count); 253 _buffer = null; 254 255 return input; 256 } 257 }
在处理代码的中间位置,你应该注意到了另一个类StaticWorkerRequest,这个类负责欺骗ASP.NET,在点击提交按钮时,它欺骗 ASP.NET,让他认为没有文件上传,这是必需的,因为当上传完毕时,如果我们要重定向到所需的页面时,ASP.NET将会检查到在HTTP实体主体中 仍然有数据,然后会尝试缓存整个上传,于是我们兜了一圈又回到了原点,为了避免这种情况,我们必须欺骗HttpWorkerRequest,将它注入到 HttpContext中,获得请求开始部分的StaticWorkerRequest,它是唯一有用的数据。
1 internal class StaticWorkerRequest : HttpWorkerRequest 2 { 3 readonly HttpWorkerRequest _request; 4 private readonly byte[] _buffer; 5 6 public StaticWorkerRequest(HttpWorkerRequest request, byte[] buffer) 7 { 8 _request = request; 9 _buffer = buffer; 10 } 11 12 public override int ReadEntityBody(byte[] buffer, int size) 13 { 14 return 0; 15 } 16 17 public override int ReadEntityBody(byte[] buffer, int offset, int size) 18 { 19 return 0; 20 } 21 22 public override byte[] GetPreloadedEntityBody() 23 { 24 return _buffer; 25 } 26 27 public override int GetPreloadedEntityBody(byte[] buffer, int offset) 28 { 29 Buffer.BlockCopy(_buffer, 0, buffer, offset, _buffer.Length); 30 return _buffer.Length; 31 } 32 33 public override int GetPreloadedEntityBodyLength() 34 { 35 return _buffer.Length; 36 } 37 38 public override int GetTotalEntityBodyLength() 39 { 40 return _buffer.Length; 41 } 42 43 public override string GetKnownRequestHeader(int index) 44 { 45 return index == HeaderContentLength 46 ? "0" 47 : _request.GetKnownRequestHeader(index); 48 } 49 50 // All other methods elided, they're just passthrough 51 }
使用StaticWorkerRequest建立虚假的声明,现在你可以在ASP.NET MVC中通过直接访问数据流上传大文件,使用这个代码作为开始,你可以很容易地保存过程数据,并使用Ajax调用另一个控制器行为展示其进度,将大文件缓 存到一个临时区域,可以实现断点续传,不用再等待ASP.NET进程将整个文件缓存到磁盘上,同样,保存文件时也不用消耗另存为方法那么多的内存了。
【更多关于ASP.NET上传文件的介绍】
- 专访微软MVP衣明志:走进ASP.NET MVC 2框架开发
- ASP.NET大文件上传方法浅析
- ASP.NET上传文件面面观
- ASP.NET上传文件控件实例详解
- ASP.NET多附件上传和附件编辑的实现