產生 Large Size 的 Zip 壓縮檔時,如果將檔案寫入 MemoryStream,容易超出記憶體限制,引發 System.OutOfMemoryException
例外狀況。
這時可以將寫入目標,由記憶體改成網路串流 OutputStream,就能避免此問題,由於 .NET 版本眾多,這邊做個統整,紀錄一下自己的實驗心得。
首先是 MVC5 的寫法,新增 PushStreamResult 包裝 FileResult。
public class PushStreamResult : FileResult
{
private readonly Action<Stream> stream;
public PushStreamResult(string fileName, string contentType, Action<Stream> stream)
: base(contentType)
{
this.stream = stream;
this.FileDownloadName = fileName;
}
protected override void WriteFile(HttpResponseBase response)
{
response.Buffer = false;
stream(response.OutputStream);
}
}
參考文章:
https://stackoverflow.com/questions/943122/writing-to-output-stream-from-action
public ActionResult Zip()
{
var funcZIP = (Action<Stream>)((stream) =>
{
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create))
{
for (var i = 0; i < 1000; i++)
{
var zipEntry = archive.CreateEntry($@"aaa{i}.pdf", CompressionLevel.Fastest);
using (var zipStream = zipEntry.Open())
{
var bytes = System.IO.File.ReadAllBytes(@"D:\專案\aaa.pdf");
zipStream.Write(bytes, 0, bytes.Length);
}
}
}
});
return new PushStreamResult("aaa.zip", "application/zip",
stream => funcZIP(new ZipWrapStream(stream)));
}
※ ZipWrapStream
由於 OutputStream 的Position
屬性是不可讀的,所以需要重新包裝才能讓 ZipArchive 使用。(程式放在文章下方)
成功壓縮 1000 個 PDF 檔案。
不過如果中間發生例外狀況,雖然檔案可以成功下載,但裡面的 PDF 是缺少的,且檔案會被加入錯誤訊息。
想到 Web API 有現成的 PushStreamContent 可以使用,不知道能不能解決 PDF 檔案缺失的問題。
public HttpResponseMessage Zip()
{
var funcZIP = (Action<Stream>)((stream) =>
{
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create))
{
for (var i = 0; i < 1000; i++)
{
if (i == 100)
throw new Exception();
var zipEntry = archive.CreateEntry($@"aaa{i}.pdf", CompressionLevel.Fastest);
using (var zipStream = zipEntry.Open())
{
var bytes = System.IO.File.ReadAllBytes(@"D:\專案\aaa.pdf");
zipStream.Write(bytes, 0, bytes.Length);
}
}
}
});
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Content = new PushStreamContent((stream, content, transport) =>
{
funcZIP(new ZipWrapStream(stream));
stream.Close();
});
response.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment");
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/zip");
response.Content.Headers.ContentDisposition.FileName = "aaa.zip";
return response;
}
使用 Web API 就不會缺檔了,會直接出現網路錯誤。
.NET Core 和 MVC5 一樣沒有內建功能可以用,需要自己包裝 FileResult。
public class FileCallbackResult : FileResult
{
private readonly Func<Stream, ActionContext, Task> callback;
public FileCallbackResult(string fileName, string contentType, Func<Stream, ActionContext, Task> callback)
: base(contentType)
{
this.callback = callback;
this.FileDownloadName = fileName;
}
public override Task ExecuteResultAsync(ActionContext context)
{
var executor = new FileCallbackResultExecutor(context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>());
return executor.ExecuteAsync(context, this);
}
private sealed class FileCallbackResultExecutor : FileResultExecutorBase
{
public FileCallbackResultExecutor(ILoggerFactory loggerFactory)
: base(CreateLogger<FileCallbackResultExecutor>(loggerFactory))
{
}
public Task ExecuteAsync(ActionContext context, FileCallbackResult result)
{
SetHeadersAndLog(context, result, null, false);
return result.callback(context.HttpContext.Response.Body, context);
}
}
}
參考文章:
https://stackoverflow.com/questions/42771409/how-to-stream-with-asp-net-core
public ActionResult Zip()
{
return new FileCallbackResult("aaa.zip", "application/zip", async (stream, _) =>
{
using (var archive = new ZipArchive(new ZipWrapStream(stream), ZipArchiveMode.Create))
{
for (var i = 0; i < 1000; i++)
{
if (i == 100)
throw new Exception();
var zipEntry = archive.CreateEntry($@"aaa{i}.pdf", CompressionLevel.Fastest);
using (var zipStream = zipEntry.Open())
{
var bytes = await System.IO.File.ReadAllBytesAsync(@"D:\專案\aaa.pdf");
await zipStream.WriteAsync(bytes, 0, bytes.Length);
}
}
}
stream.Close();
});
}
一樣會有缺檔案的問題。
Webform 也測看看。
public void ProcessRequest(HttpContext context)
{
var funcZIP = (Action<Stream>)((stream) =>
{
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true))
{
for (var i = 0; i < 1000; i++)
{
if (i == 100)
throw new Exception();
var zipEntry = archive.CreateEntry($@"aaa{i}.pdf", CompressionLevel.Fastest);
using (var zipStream = zipEntry.Open())
{
var bytes = System.IO.File.ReadAllBytes(@"D:\專案\aaa.pdf");
zipStream.Write(bytes, 0, bytes.Length);
}
}
}
});
HttpContext.Current.Response.BufferOutput = false;
HttpContext.Current.Response.Clear();
HttpContext.Current.Response.ContentType = System.Net.Mime.MediaTypeNames.Application.Zip;
HttpContext.Current.Response.AddHeader("content-disposition", "attachment; filename=" + HttpContext.Current.Server.UrlEncode("aaa.zip"));
funcZIP(new ZipWrapStream(HttpContext.Current.Response.OutputStream));
HttpContext.Current.Response.Flush();
HttpContext.Current.Response.Close();
HttpContext.Current.Response.End();
}
一樣會缺檔,看來只要是寫入 OutputStream 都會。
除了 Web API 之外,其他三種方法出現 Exception 都會導致 Zip 缺檔和損毀,目前還沒有解決辦法,所以如果可以盡量使用 Web API 處理會比較好。
發現使用 Request.Abort()
可以中斷連線,讓瀏覽器下載出錯,這樣就不會有缺檔和損毀的問題。
不過 .NET Core 已經沒有 Request.Abort()
,而是改成 HttpContext.Abort()
,但這個方法不會真正中斷連線,檔案還是會下載成功,所以目前無解。
除了 .NET Core 不建議使用外,其他版本都可以安心使用,修改後的程式如下。
public class PushStreamResult : FileResult
{
private readonly Action<Stream> stream;
public PushStreamResult(string fileName, string contentType, Action<Stream> stream)
: base(contentType)
{
this.stream = stream;
this.FileDownloadName = fileName;
}
public override void ExecuteResult(ControllerContext context)
{
try
{
base.ExecuteResult(context);
}
catch(Exception)
{
context.HttpContext.Request.Abort();
throw;
}
}
protected override void WriteFile(HttpResponseBase response)
{
response.Buffer = false;
stream(response.OutputStream);
}
}
public void ProcessRequest(HttpContext context)
{
var funcZIP = (Action<Stream>)((stream) =>
{
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true))
{
for (var i = 0; i < 1000; i++)
{
if (i == 100)
throw new Exception();
var zipEntry = archive.CreateEntry($@"aaa{i}.pdf", CompressionLevel.Fastest);
using (var zipStream = zipEntry.Open())
{
var bytes = System.IO.File.ReadAllBytes(@"D:\專案\aaa.pdf");
zipStream.Write(bytes, 0, bytes.Length);
}
}
}
});
HttpContext.Current.Response.BufferOutput = false;
HttpContext.Current.Response.Clear();
HttpContext.Current.Response.ContentType = System.Net.Mime.MediaTypeNames.Application.Zip;
HttpContext.Current.Response.AddHeader("content-disposition", "attachment; filename=" + HttpContext.Current.Server.UrlEncode("aaa.zip"));
try
{
funcZIP(new ZipWrapStream(HttpContext.Current.Response.OutputStream));
}
catch(Exception)
{
HttpContext.Current.Request.Abort();
throw;
}
HttpContext.Current.Response.Flush();
HttpContext.Current.Response.Close();
HttpContext.Current.Response.End();
}
由於 OutputStream 的 Position
是不可讀取的,但 ZipArchive 需要這個屬性,所以需要另外包裝 Stream 類別。
public class ZipWrapStream : Stream
{
private readonly Stream stream;
private long position = 0;
public ZipWrapStream(Stream stream)
{
this.stream = stream;
}
public override bool CanSeek { get { return false; } }
public override bool CanWrite { get { return true; } }
public override long Position
{
get { return position; }
set { throw new NotImplementedException(); }
}
public override bool CanRead { get { throw new NotImplementedException(); } }
public override long Length { get { throw new NotImplementedException(); } }
public override void Flush()
{
stream.Flush();
}
public override int Read(byte[] buffer, int offset, int count)
{
throw new NotImplementedException();
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotImplementedException();
}
public override void SetLength(long value)
{
throw new NotImplementedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
position += count;
stream.Write(buffer, offset, count);
}
}
整理好文,讚!
另外想詢問 , 大大為何有需要大檔案放 RAM 的需求呢 ?
我這邊做 ZIP 放 RAM 通常是不想用到 IO , 並且會另外限制大小不能超過 100MB
假如是大檔案,我這邊作法會建立檔案,讓 nginx 代理下載文件 , 可以避免這問題
感謝!!
大大為何有需要大檔案放 RAM 的需求呢 ?
我這邊做 ZIP 放 RAM 通常是不想用到 IO
主要是產生 Zip 需要提供一個串流讓他寫入,一般常用的會是 MemoryStream
var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create))
然後 Action 就可以直接回傳
return File(stream.ToArray(), "application/zip");
恩...其實就是不想用到 IO,哈哈哈
用到 IO 就需要去清理過期的檔案,有點麻煩
假如是大檔案,我這邊作法會建立檔案,讓 nginx 代理下載文件
這個不錯,不過我想問這個方法會需要寫程式來清理垃圾檔案嗎?
研究這個其實是我比較偏好檔案不落地,盡量不要用到檔案串流
使用上 MemoryStream > OutputStream > FileStream
了解