为什么要使用 OutputStream
产生 Large Size 的 Zip 压缩档时,如果将档案写入 MemoryStream,容易超出记忆体限制,引发 System.OutOfMemoryException
例外状况。
这时可以将写入目标,由记忆体改成网路串流 OutputStream,就能避免此问题,由于 .NET 版本众多,这边做个统整,纪录一下自己的实验心得。
# 版本1 - MVC5
新增 PushStreamResult
首先是 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 是缺少的,且档案会被加入错误讯息。
# 版本2 - Web API
想到 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 就不会缺档了,会直接出现网路错误。
# 版本3 - .NET Core 3.1
新增 FileCallbackResult
.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(); });}
结果
一样会有缺档案的问题。
# 版本4 - Webform 的泛型处理常式
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 处理会比较好。
2020/08/21 更新
发现使用 Request.Abort()
可以中断连线,让浏览器下载出错,这样就不会有缺档和损毁的问题。
不过 .NET Core 已经没有 Request.Abort()
,而是改成 HttpContext.Abort()
,但这个方法不会真正中断连线,档案还是会下载成功,所以目前无解。
除了 .NET Core 不建议使用外,其他版本都可以安心使用,修改后的程式如下。
MVC5
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); }}
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")); 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();}
ZipWrapStream
由于 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); }}
参考文章:
https://stackoverflow.com/questions/16585488/writing-to-ziparchive-using-the-httpcontext-outputstream