iT邦幫忙

4

[C#][ASP.NET] 將 Zip 寫入 OutputStream 的幾種方法比較

為什麼要使用 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 檔案。

https://ithelp.ithome.com.tw/upload/images/20200820/20106865rU4b1BBBUu.jpg

不過如果中間發生例外狀況,雖然檔案可以成功下載,但裡面的 PDF 是缺少的,且檔案會被加入錯誤訊息。

https://ithelp.ithome.com.tw/upload/images/20200820/20106865N3UqUW3EwR.jpg

https://ithelp.ithome.com.tw/upload/images/20200820/201068650ylb3phiuL.jpg


# 版本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 就不會缺檔了,會直接出現網路錯誤。 /images/emoticon/emoticon42.gif

https://ithelp.ithome.com.tw/upload/images/20200820/20106865g8PVJM6TlH.jpg


# 版本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(),但這個方法不會真正中斷連線,檔案還是會下載成功,所以目前無解。
/images/emoticon/emoticon70.gif

除了 .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


1 則留言

0
暐翰
iT邦大師 1 級 ‧ 2020-08-21 09:25:23

整理好文,讚!
另外想詢問 , 大大為何有需要大檔案放 RAM 的需求呢 ?

我這邊做 ZIP 放 RAM 通常是不想用到 IO , 並且會另外限制大小不能超過 100MB
假如是大檔案,我這邊作法會建立檔案,讓 nginx 代理下載文件 , 可以避免這問題

感謝!! /images/emoticon/emoticon41.gif

大大為何有需要大檔案放 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

暐翰 iT邦大師 1 級 ‧ 2020-08-24 10:39:34 檢舉

小碼農米爾 Mir 大大,不好意思通知被吃掉了,這時候才看到

不過我想問這個方法會需要寫程式來清理垃圾檔案嗎?

對,會需要一個排程器定時清理

了解 /images/emoticon/emoticon41.gif

我要留言

立即登入留言