在ASP.NET MVC中實現(xiàn)大文件異步上傳
原創(chuàng)【51CTO獨家特稿】在ASP.NET中通過HTTP上傳大文件是一個由來已久的挑戰(zhàn),它是許多活躍的ASP.NET論壇最常討論的問題之一,除了處理大文件外,用戶還經常被要求要顯示出文件上傳的進度,當你需要直接控制從瀏覽器上傳數(shù)據流時,你會四處碰壁。51CTO.com之前就曾針對性的報道過《解除ASP.NET上傳文件的大小限制》和《ASP.NET大文件上傳開發(fā)總結》等文章。
絕大多數(shù)人認為在ASP.NET中上傳大文件有以下這些解決方案:
◆不要這樣做。你***是在頁面中嵌入一個Silverlight或Flash進程上傳文件。
◆不要這樣做。因為HTTP本身設計就不是為了上傳大文件,重新思考你要的功能。
◆不要這樣做。ASP.NET本身設計***也就能處理2GB大小的文件。
◆購買商業(yè)產品,如SlickUpload,它使用了一個HttpModule實現(xiàn)了文件流分塊。
◆使用開源產品,如NeatUpload,它使用了一個HttpModule實現(xiàn)了文件流分塊。
最近我接到一個任務,需構建一個上傳工具實現(xiàn)以下功能:
◆必須工作在HTTP協(xié)議
◆必須允許非常大的文件上傳(會大于2GB)
◆必須允許斷點續(xù)傳
◆必須允許并行上傳
因此前三個解決方案都不適應我的需求,其它解決方案對于我而言又太笨重了,因此我開始著手解決在ASP.NET MVC中的這個問題,如果有這方面的開發(fā)背景,你一定了解大部分問題最終都歸結于對ASP.NET輸入流和連鎖請求過程的控制,網上的資料一般都是這樣描述的,只要你的代碼訪問了HttpRequest的InputStream屬性,在你訪問流之前,ASP.NET就會緩存整個上傳的文件,這就意味著當我向云服務上傳文件時,我必須等待整個大文件抵達服務器,然后才能將其傳輸?shù)筋A定目的地,這意味著需要兩倍的時間。
首先,我們推薦你閱讀一下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的數(shù)據都被緩存到磁盤上去了。
- ﹤system.web﹥
- ﹤httpruntime requestlengthdiskthreshold="256" maxrequestlength="2097151"﹥
- ﹤/httpruntime﹥﹤/system.web﹥
這是一個簡單的適合大多數(shù)應用的解決辦法,但我的任務中不能借用這種方法,即使會將數(shù)據緩存到磁盤中,但這種類似于另存為的方法也會使用大量的內存。
圖 1 :通過緩存整個文件,然后另存為的方式會使內存消耗突然上升
那么在ASP.NET MVC中通過直接訪問流,不觸發(fā)任何緩存機制,上傳大文件該如何實現(xiàn)呢?解決辦法就是盡量遠離ASP.NET,我們先來看一看UploadController,它有三個行為方法,一個是索引我們上傳的文件,一個是前面討論的緩存邏輯,另一個是基于實時流的方法。
- public class UploadController : Controller
- {
- [AcceptVerbs(HttpVerbs.Get)]
- [Authorize]
- public ActionResult Index()
- {
- return View();
- }
- [AcceptVerbs(HttpVerbs.Post)]
- public ActionResult BufferToDisk()
- {
- var path = Server.MapPath("~/Uploads");
- foreach (string file in Request.Files)
- {
- var fileBase = Request.Files[file];
- try
- {
- if (fileBase.ContentLength > 0)
- {
- fileBase.SaveAs(Path.Combine(path, fileBase.FileName));
- }
- }
- catch (IOException)
- {
- }
- }
- return RedirectToAction("Index", "Upload");
- }
- //[AcceptVerbs(HttpVerbs.Post)]
- //[Authorize]
- public void LiveStream()
- {
- var path = Server.MapPath("~/Uploads");
- var context = ControllerContext.HttpContext;
- var provider = (IServiceProvider)context;
- var workerRequest = (HttpWorkerRequest)provider.GetService(typeof(HttpWorkerRequest));
- //[AcceptVerbs(HttpVerbs.Post)]
- var verb = workerRequest.GetHttpVerbName();
- if(!verb.Equals("POST"))
- {
- Response.StatusCode = (int)HttpStatusCode.NotFound;
- Response.SuppressContent = true;
- return;
- }
- //[Authorize]
- if(!context.User.Identity.IsAuthenticated)
- {
- Response.StatusCode = (int)HttpStatusCode.Unauthorized;
- Response.SuppressContent = true;
- return;
- }
- var encoding = context.Request.ContentEncoding;
- var processor = new UploadProcessor(workerRequest);
- processor.StreamToDisk(context, encoding, path);
- //return RedirectToAction("Index", "Upload");
- Response.Redirect(Url.Action("Index", "Upload"));
- }
- }
雖然這里明顯缺少一兩個類,但基本的方法還是講清楚了,看起來和緩存邏輯并沒有太大的不同之處,我們仍然將流緩存到了磁盤,但具體處理方式卻有些不同了,首先,沒有與方法關聯(lián)的屬性,謂詞和授權限制都被移除了,使用手動等值取代了,使用手工響應操作而不用ActionFilterAttribute聲明的原因是這些屬性涉及到了一些重要的ASP.NET管道代碼,實際上在我的代碼中,我還特意攔截了原生態(tài)的HttpWorkerRequest,因為它不能同時做兩件事情。
#p#
HttpWorkerRequest有VIP訪問傳入的請求,通常它是由ASP.NET本身支持工作的,但我們綁架了請求,然后欺騙剩下的請求,讓它們誤以為前面的請求已經全部得到處理,為了做到這一點,我們需要上面例子中未出現(xiàn)的UploadProcessor類,這個類的職責是物理讀取來自瀏覽器的每個數(shù)據塊,然后將其保存到磁盤上,因為上傳的內容被分解成多個部分,UploadProcessor類需要找出內容頭,然后拼接成帶狀數(shù)據輸出,這一可以在一個上傳中同時上傳多個文件。
- internal class UploadProcessor
- {
- private byte[] _buffer;
- private byte[] _boundaryBytes;
- private byte[] _endHeaderBytes;
- private byte[] _endFileBytes;
- private byte[] _lineBreakBytes;
- private const string _lineBreak = "\r\n";
- private readonly Regex _filename =
- new Regex(@"Content-Disposition:\s*form-data\s*;\s*name\s*=\s*""file""\s*;\s*filename\s*=\s*""(.*)""",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
- private readonly HttpWorkerRequest _workerRequest;
- public UploadProcessor(HttpWorkerRequest workerRequest)
- {
- _workerRequest = workerRequest;
- }
- public void StreamToDisk(IServiceProvider provider, Encoding encoding, string rootPath)
- {
- var buffer = new byte[8192];
- if (!_workerRequest.HasEntityBody())
- {
- return;
- }
- var total = _workerRequest.GetTotalEntityBodyLength();
- var preloaded = _workerRequest.GetPreloadedEntityBodyLength();
- var loaded = preloaded;
- SetByteMarkers(_workerRequest, encoding);
- var body = _workerRequest.GetPreloadedEntityBody();
- if (body == null) // IE normally does not preload
- {
- body = new byte[8192];
- preloaded = _workerRequest.ReadEntityBody(body, body.Length);
- loaded = preloaded;
- }
- var text = encoding.GetString(body);
- var fileName = _filename.Matches(text)[0].Groups[1].Value;
- fileName = Path.GetFileName(fileName); // IE captures full user path; chop it
- var path = Path.Combine(rootPath, fileName);
- var files = new List {fileName};
- var stream = new FileStream(path, FileMode.Create);
- if (preloaded > 0)
- {
- stream = ProcessHeaders(body, stream, encoding, preloaded, files, rootPath);
- }
- // Used to force further processing (i.e. redirects) to avoid buffering the files again
- var workerRequest = new StaticWorkerRequest(_workerRequest, body);
- var field = HttpContext.Current.Request.GetType().GetField("_wr", BindingFlags.NonPublic | BindingFlags.Instance);
- field.SetValue(HttpContext.Current.Request, workerRequest);
- if (!_workerRequest.IsEntireEntityBodyIsPreloaded())
- {
- var received = preloaded;
- while (total - received >= loaded && _workerRequest.IsClientConnected())
- {
- loaded = _workerRequest.ReadEntityBody(buffer, buffer.Length);
- stream = ProcessHeaders(buffer, stream, encoding, loaded, files, rootPath);
- received += loaded;
- }
- var remaining = total - received;
- buffer = new byte[remaining];
- loaded = _workerRequest.ReadEntityBody(buffer, remaining);
- stream = ProcessHeaders(buffer, stream, encoding, loaded, files, rootPath);
- }
- stream.Flush();
- stream.Close();
- stream.Dispose();
- }
- private void SetByteMarkers(HttpWorkerRequest workerRequest, Encoding encoding)
- {
- var contentType = workerRequest.GetKnownRequestHeader(HttpWorkerRequest.HeaderContentType);
- var bufferIndex = contentType.IndexOf("boundary=") + "boundary=".Length;
- var boundary = String.Concat("--", contentType.Substring(bufferIndex));
- _boundaryBytes = encoding.GetBytes(string.Concat(boundary, _lineBreak));
- _endHeaderBytes = encoding.GetBytes(string.Concat(_lineBreak, _lineBreak));
- _endFileBytes = encoding.GetBytes(string.Concat(_lineBreak, boundary, "--", _lineBreak));
- _lineBreakBytes = encoding.GetBytes(string.Concat(_lineBreak + boundary + _lineBreak));
- }
- private FileStream ProcessHeaders(byte[] buffer, FileStream stream, Encoding encoding, int count, ICollection files, string rootPath)
- {
- buffer = AppendBuffer(buffer, count);
- var startIndex = IndexOf(buffer, _boundaryBytes, 0);
- if (startIndex != -1)
- {
- var endFileIndex = IndexOf(buffer, _endFileBytes, 0);
- if (endFileIndex != -1)
- {
- var precedingBreakIndex = IndexOf(buffer, _lineBreakBytes, 0);
- if (precedingBreakIndex > -1)
- {
- startIndex = precedingBreakIndex;
- }
- endFileIndex += _endFileBytes.Length;
- var modified = SkipInput(buffer, startIndex, endFileIndex, ref count);
- stream.Write(modified, 0, count);
- }
- else
- {
- var endHeaderIndex = IndexOf(buffer, _endHeaderBytes, 0);
- if (endHeaderIndex != -1)
- {
- endHeaderIndex += _endHeaderBytes.Length;
- var text = encoding.GetString(buffer);
- var match = _filename.Match(text);
- var fileName = match != null ? match.Groups[1].Value : null;
- fileName = Path.GetFileName(fileName); // IE captures full user path; chop it
- if (!string.IsNullOrEmpty(fileName) && !files.Contains(fileName))
- {
- files.Add(fileName);
- var filePath = Path.Combine(rootPath, fileName);
- stream = ProcessNextFile(stream, buffer, count, startIndex, endHeaderIndex, filePath);
- }
- else
- {
- var modified = SkipInput(buffer, startIndex, endHeaderIndex, ref count);
- stream.Write(modified, 0, count);
- }
- }
- else
- {
- _buffer = buffer;
- }
- }
- }
- else
- {
- stream.Write(buffer, 0, count);
- }
- return stream;
- }
- private static FileStream ProcessNextFile(FileStream stream, byte[] buffer, int count, int startIndex, int endIndex, string filePath)
- {
- var fullCount = count;
- var endOfFile = SkipInput(buffer, startIndex, count, ref count);
- stream.Write(endOfFile, 0, count);
- stream.Flush();
- stream.Close();
- stream.Dispose();
- stream = new FileStream(filePath, FileMode.Create);
- var startOfFile = SkipInput(buffer, 0, endIndex, ref fullCount);
- stream.Write(startOfFile, 0, fullCount);
- return stream;
- }
- private static int IndexOf(byte[] array, IList value, int startIndex)
- {
- var index = 0;
- var start = Array.IndexOf(array, value[0], startIndex);
- if (start == -1)
- {
- return -1;
- }
- while ((start + index) < array.Length)
- {
- if (array[start + index] == value[index])
- {
- index++;
- if (index == value.Count)
- {
- return start;
- }
- }
- else
- {
- start = Array.IndexOf(array, value[0], start + index);
- if (start != -1)
- {
- index = 0;
- }
- else
- {
- return -1;
- }
- }
- }
- return -1;
- }
- private static byte[] SkipInput(byte[] input, int startIndex, int endIndex, ref int count)
- {
- var range = endIndex - startIndex;
- var size = count - range;
- var modified = new byte[size];
- var modifiedCount = 0;
- for (var i = 0; i < input.Length; i++)
- {
- if (i >= startIndex && i < endIndex)
- {
- continue;
- }
- if (modifiedCount >= size)
- {
- break;
- }
- modified[modifiedCount] = input[i];
- modifiedCount++;
- }
- input = modified;
- count = modified.Length;
- return input;
- }
- private byte[] AppendBuffer(byte[] buffer, int count)
- {
- var input = new byte[_buffer == null ? buffer.Length : _buffer.Length + count];
- if (_buffer != null)
- {
- Buffer.BlockCopy(_buffer, 0, input, 0, _buffer.Length);
- }
- Buffer.BlockCopy(buffer, 0, input, _buffer == null ? 0 : _buffer.Length, count);
- _buffer = null;
- return input;
- }
- }
在處理代碼的中間位置,你應該注意到了另一個類StaticWorkerRequest,這個類負責欺騙ASP.NET,在點擊提交按鈕時,它欺騙ASP.NET,讓他認為沒有文件上傳,這是必需的,因為當上傳完畢時,如果我們要重定向到所需的頁面時,ASP.NET將會檢查到在HTTP實體主體中仍然有數(shù)據,然后會嘗試緩存整個上傳,于是我們兜了一圈又回到了原點,為了避免這種情況,我們必須欺騙HttpWorkerRequest,將它注入到HttpContext中,獲得請求開始部分的StaticWorkerRequest,它是唯一有用的數(shù)據。
- internal class StaticWorkerRequest : HttpWorkerRequest
- {
- readonly HttpWorkerRequest _request;
- private readonly byte[] _buffer;
- public StaticWorkerRequest(HttpWorkerRequest request, byte[] buffer)
- {
- _request = request;
- _buffer = buffer;
- }
- public override int ReadEntityBody(byte[] buffer, int size)
- {
- return 0;
- }
- public override int ReadEntityBody(byte[] buffer, int offset, int size)
- {
- return 0;
- }
- public override byte[] GetPreloadedEntityBody()
- {
- return _buffer;
- }
- public override int GetPreloadedEntityBody(byte[] buffer, int offset)
- {
- Buffer.BlockCopy(_buffer, 0, buffer, offset, _buffer.Length);
- return _buffer.Length;
- }
- public override int GetPreloadedEntityBodyLength()
- {
- return _buffer.Length;
- }
- public override int GetTotalEntityBodyLength()
- {
- return _buffer.Length;
- }
- public override string GetKnownRequestHeader(int index)
- {
- return index == HeaderContentLength
- ? "0"
- : _request.GetKnownRequestHeader(index);
- }
- // All other methods elided, they're just passthrough
- }
使用StaticWorkerRequest建立虛假的聲明,現(xiàn)在你可以在ASP.NET MVC中通過直接訪問數(shù)據流上傳大文件,使用這個代碼作為開始,你可以很容易地保存過程數(shù)據,并使用Ajax調用另一個控制器行為展示其進度,將大文件緩存到一個臨時區(qū)域,可以實現(xiàn)斷點續(xù)傳,不用再等待ASP.NET進程將整個文件緩存到磁盤上,同樣,保存文件時也不用消耗另存為方法那么多的內存了。
【更多關于ASP.NET上傳文件的介紹】