Swimburger

Create ZIP files on HTTP request without intermediate files using ASP.NET Core MVC, Razor Pages, and endpoints

Niels Swimberghe

Niels Swimberghe - - .NET

Follow me on Twitter, buy me a coffee

.NET Bot holding a package with "ZIP" on it. Title: Create ZIP files on HTTP request without intermediate files using ASP.NET MVC, Razor Pages, and endpoints

This article has been updated for .NET 6 on 1/21/2022.

If you're trying to generate ZIPs and send them to the browser using ASP.NET MVC Framework, check out "Create ZIP files on HTTP request without intermediate files using ASP.NET MVC Framework".

.NET has multiple built-in APIs to create ZIP files. The ZipFile class has static methods to create and extract ZIP files without dealing with streams and byte-arrays. ZipFile is a great API for simple use-cases where the source and target are both on disk. 
On the other hand, the ZipArchive class uses streams to read and write ZIP files. The latter is more complicated to use but provides more flexibility because you can accept any stream to read from or write to whether the data comes from disk, from an HTTP request, or from a complicated data pipeline.

Using ZipArchive you can create a ZIP file on the fly and send it to the client from ASP.NET without having to save the ZIP file to disk.

In this blog post, you'll learn how to do just that from an ASP.NET (Core) MVC Action, a Razor Page, and a simple endpoint. But first, let's learn how to create the ZIP archive.

You can find all the source code on this GitHub Repository.

Create a ZIP using streams and the ZipArchive class #

For this demo, I have downloaded all the .NET bots from the .NET Brand repository and put them in a folder:

Screenshot of folder full of .NET bots

The following code will put all these files into a ZIP archive stored in a MemoryStream:

var botFilePaths = Directory.GetFiles("/path/to/bots");
using (var zipFileMemoryStream = new MemoryStream())
{
    using (ZipArchive archive = new ZipArchive(zipFileMemoryStream, ZipArchiveMode.Update, leaveOpen: true))
    {
        foreach (var botFilePath in botFilePaths)
        {
            var botFileName = Path.GetFileName(botFilePath);
            var entry = archive.CreateEntry(botFileName);
            using (var entryStream = entry.Open())
            using (var fileStream = System.IO.File.OpenRead(botFilePath))
            {
                await fileStream.CopyToAsync(entryStream);
            }
        }
    }

    zipFileMemoryStream.Seek(0, SeekOrigin.Begin);
    // use stream as needed
}

The code does the following:

  • Get all paths to the bot files in the folder using Directory.GetFiles and store it in botFilePaths.
  • You can use any stream which supports synchronous read and write operations, but for this snippet a MemoryStream is used which will store the entire ZIP file in memory. This can consume a lot of memory which is why a different stream will be used in later samples.
    But for now, create a new MemoryStream and store it in zipFileMemoryStream. The ZIP archive will be written to zipfileMemoryStream.
    You can also use a FileStream instead of a MemoryStream if you want to write the ZIP archive to a file on disk.
  • Create a new ZipArchive and store it in archive. Instantiate the ZipArchive with the following parameters
    • stream: pass in zipFileMemoryStream. The ZipArchive wraps the stream and uses it to read/write.
    • mode: pass in ZipArchiveMode.Update to allow both reading and writing. Other options are Read and Create
    • leaveOpen: pass in true to leave open the underlying stream after disposing the ZipArchive. Otherwise zipfileMemoryStream will be disposed when ZipArchive is disposed which prevents you from interacting with the stream.
  • For every path in botFilePaths:
    • Extract the file name from the bot file path and store it in botFileName.
    • Create a ZipArchiveEntry by calling archive.CreateEntry passing in the file name. This entry is where the file data will be stored.
    • Get a writable Stream to write to the ZipArchiveEntry by calling entry.Open().
    • Open a readable FileStream to read the bot file by calling System.IO.File.OpenRead passing in the bot file path.
      At this point, you don't need to use the full namespace, but it will be required later to avoid collision between System.IO.File and the File method inside of MVC controllers and Razor Pages.
    • Copy the data from the file stream to the zip entry stream.
  • If you now want to read from the memory stream, you have to set the position of the stream back to the beginning. You can do this using .Seek(0, SeekOrigin.Begin).
  • Lastly, do whatever you need to do with your in-memory ZIP file.

Warning: Your mileage may vary depending on the size and amount of files you're trying to archive and the amount of resources available on your machine. Saving the ZIP file to disk may be favorable in some scenarios so you can save some rework when the same files are requested again.

If you eventually end up saving or downloading this ZIP archive to disk, it will look like this:

Screenshot of opening a ZIP file using Windows file explorer

Now that you know how to generate a ZIP file into a stream using ZipArchive and FileStreams, let's take a look at how you can send the stream to the browser in ASP.NET (Core) beginning with MVC.

Create a ZIP file and send it to the browser from an ASP.NET MVC Controller #

Version 1 #

The solution below (first version) stores the entire ZIP file in memory (MemoryStream) before sending the entire file to the client. Feel free to skip to the next section to see the improved version.

The code below shows how you can send the zipFileMemoryStream from the previous code sample to the browser using the File method inherited from the Controller class:

using System.IO;
using System.IO.Compression;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;

namespace WebZipIt.Controllers
{
    public class HomeController : Controller
    {
        private readonly IWebHostEnvironment hostEnvironment;

        public HomeController(IWebHostEnvironment hostEnvironment)
        {
            this.hostEnvironment = hostEnvironment;
        }

        public IActionResult Index()
        {
            return View();
        }

        public async Task<IActionResult> DownloadBots()
        {
            var botsFolderPath = Path.Combine(hostEnvironment.ContentRootPath, "bots");
            var botFilePaths = Directory.GetFiles(botsFolderPath);
            var zipFileMemoryStream = new MemoryStream();
            using (ZipArchive archive = new ZipArchive(zipFileMemoryStream, ZipArchiveMode.Update, leaveOpen: true))
            {
                foreach (var botFilePath in botFilePaths)
                {
                    var botFileName = Path.GetFileName(botFilePath);
                    var entry = archive.CreateEntry(botFileName);
                    using (var entryStream = entry.Open())
                    using (var fileStream = System.IO.File.OpenRead(botFilePath))
                    {
                        await fileStream.CopyToAsync(entryStream);
                    }
                }
            }

            zipFileMemoryStream.Seek(0, SeekOrigin.Begin);
            return File(zipFileMemoryStream, "application/octet-stream", "Bots.zip");
        }
    }
}

The majority of the code is the same as the prior sample, but there are a couple of changes to take note off:

  • The bot files have been stored in a folder in the ASP.NET project called "bots". To get the path to the folder, you first need to get the path to where the project is running.
    This path is grabbed from the ContentRootPath property on the IWebHostEnvironment class which is injected through the constructor of the HomeController.
    In older versions of .NET Core, you may need to use IHostingEnvironment instead of IWebHostEnvironment. The former has been deprecated since .NET Core 3.0.
  • The zipFileMemoryStream is not in a using block anymore. That's because the stream will be returned to the invoker and disposed of later.
  • The File method inherited from the Controller is invoked which returns a FileStreamResult. You could create the FileStreamResult yourself, but the File method is provided as a more convenient way to do it.
    The File method uses the following signature with these parameters:
    • fileStream: Pass in the stream holding the data you want to send to the client which in this case is zipFileMemoryStream.
    • contentType: Provide the content-type of the file. Pass in "application/octet-stream" for binary files or any files you want to force the browser to download instead of display. 
    • fileDownloadName: Tells the browser which file name to use for the downloaded file. 
  • At the end of the DownloadBots action, the FileStreamResult is returned to MVC and MVC will send the data to the client and dispose of the stream. 

The associated view generates the link to the DownloadBots action as follows:

<p class="text-center">
    <a class="btn btn-primary" asp-controller="Home" asp-action="DownloadBots" download>Download .NET Bots ZIP</a>
</p>

To give this a try, download the sample repository and run it locally. Then, open the browser and browse to 'https://localhost:5001/mvc/home' (change the protocol and port if necessary).
On this page, click on the "Download .NET Bots ZIP with MemoryStream" which will execute the code above, and download the .NET Bots ZIP file. 

Version 2: A more memory efficient way

After getting some hints from David Fowler, I found a way to send the file to the client without a MemoryStream. He pointed me towards pipelines which is an interesting API for writing to and reading from pipes at the same time. 
You could create a Pipe, get the .Writer, and use the .AsStream() to get a stream to replace the MemoryStream. You could then use the .Reader to read the data as it becomes available and write it to the body.

Or even simpler, you could use HttpContext.Response.BodyWriter. This property is a PipeWriter and when you write to it, it will stream the data to the client.
You can get a Stream from the PipeWriter which you can pass into the ZipArchive.

using System.IO;
using System.IO.Compression;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;

namespace WebZipIt.Controllers
{
    public class HomeController : Controller
    {
        private readonly IWebHostEnvironment hostEnvironment;

        public HomeController(IWebHostEnvironment hostEnvironment)
        {
            this.hostEnvironment = hostEnvironment;
        }

        public IActionResult Index()
        {
            return View();
        }

        public async Task DownloadBots()
        {
            Response.ContentType = "application/octet-stream";
            Response.Headers.Add("Content-Disposition", "attachment; filename=\"Bots.zip\"");

            var botsFolderPath = Path.Combine(hostEnvironment.ContentRootPath, "bots");
            var botFilePaths = Directory.GetFiles(botsFolderPath);
            using (ZipArchive archive = new ZipArchive(Response.BodyWriter.AsStream(), ZipArchiveMode.Create))
            {
                foreach (var botFilePath in botFilePaths)
                {
                    var botFileName = Path.GetFileName(botFilePath);
                    var entry = archive.CreateEntry(botFileName);
                    using (var entryStream = entry.Open())
                    using (var fileStream = System.IO.File.OpenRead(botFilePath))
                    {
                        await fileStream.CopyToAsync(entryStream);
                    }
                }
            }
        }

        public async Task<IActionResult> DownloadBotsWithMemoryStream() { ... }
    }
}

There are some important differences compared to the previous solution to take note off:

  • The return type is an empty Task and not an IActionResult because the File method and FileStreamResult class is not being used anymore. Instead of using File and FileStreamResult, the data will be written to the body inside the action.
  • Instead of passing the filename and content-type to the File method, the headers are set directly on the Response object.
  • Instead of using a MemoryStream, a Stream requested using Response.BodyWriter.AsStream().
  • Instead of using ZipArchiveMode.Update, you need to use ZipArchiveMode.Create because you can only write to a PipeWriterStream.
    You also don't need to read from the stream anymore since ASP.NET Core will take care of that.
  • Instead of using leaveOpen: true, the parameter is omitted and the stream will be disposed of when the ZipArchive is disposed.
  • The .Seek and File method are no longer necessary.
  • The old code has been moved to DownloadBotsWithMemoryStream for side by side comparison.

The associated view generates the link to the DownloadBots action as follows:

<p class="text-center">
    <a class="btn btn-primary" asp-controller="Home" asp-action="DownloadBots" download>Download .NET Bots ZIP</a>
    <a class="btn btn-primary" asp-controller="Home" asp-action="DownloadBotsWithMemoryStream" download>Download .NET Bots ZIP with MemoryStream</a>
</p>

To give this a try, download the sample repository and run it locally. Then, open the browser and browse to 'https://localhost:5001/mvc/home' (change the protocol and port if necessary).
On this page, click on the "Download .NET Bots ZIP" which will execute the code above, and download the .NET Bots ZIP file. 

Comparing version 1 and 2

The biggest downside of the first version is that the entire ZIP file is being stored in memory as it is generated and then send to the client. As more bot files are stored in the archive, more memory is accumulated.
On the other hand, the second version writes file by file to the BodyWriter and ASP.NET Core takes care of streaming the data to the client. This prevents the data from accumulating in memory.
The difference is huge, especially with lots of large files. The memory graph has been recorded when using lots of large files to make the difference more apparent:

Screenshot of a graph plotting memory usage of the application over time. There&#x27;s no visible increase in memory for version 1 compared to version 2.

In the graph above, you can see the memory usage plotted over time while executing both version 1 (w MemoryStream) and version 2 (w/o MemoryStream). You may be deceived that there's no increase in memory for version 2, but there actually is.
But the memory fluctuations for version 2 (w/o MemoryStream) are so small in comparison to version 1 that you can't even see it. On the other hand, you can clearly see the accumulation of memory for version 1 (w MemoryStream).

Another interesting difference can be found in the download experience:

Recording of download .NET Bots ZIP application where version 2 start downloading immediately and slowly streams while you have to wait for a long time for version 2 to generate the ZIP and then send the entire file to you.

Version 2 (w/o MemoryStream) immediately starts downloading and streams the data as files are added to the ZIP file. On the other hand, you have to wait for a long time for anything to happen with version 1 and then the entire ZIP file is sent all at once.

In the following GIF you can clearly see how more and more data is being sent to the browser every time the data from the file is copied to the ZipArchive at await fileStream.CopyToAsync(entryStream):

Recording of version 2 code executed step by step. As files are copied to the archive, more data is sent to the browser.

Create a ZIP file and send it to the browser from an ASP.NET Razor Page #

The code for sending the ZIP file to the browser using Razor Pages is essentially the same.
The only difference is that you're inheriting from a PageModel instead of a Controller, and you have to follow the naming convention of Razor Pages handler methods which results in the method name OnGetDownloadBots.

using System.IO;
using System.IO.Compression;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace WebZipIt.Pages
{
    public class IndexModel : PageModel
    {
        private readonly IWebHostEnvironment hostEnvironment;

        public IndexModel(IWebHostEnvironment hostEnvironment)
        {
            this.hostEnvironment = hostEnvironment;
        }

        public void OnGet()
        {
        }

        public async Task<IActionResult> OnGetDownloadBots()
        {
            Response.ContentType = "application/octet-stream";
            Response.Headers.Add("Content-Disposition", "attachment; filename=\"Bots.zip\"");

            var botsFolderPath = Path.Combine(hostEnvironment.ContentRootPath, "bots");
            var botFilePaths = Directory.GetFiles(botsFolderPath);
            using (ZipArchive archive = new ZipArchive(Response.BodyWriter.AsStream(), ZipArchiveMode.Create))
            {
                foreach (var botFilePath in botFilePaths)
                {
                    var botFileName = Path.GetFileName(botFilePath);
                    var entry = archive.CreateEntry(botFileName);
                    using (var entryStream = entry.Open())
                    using (var fileStream = System.IO.File.OpenRead(botFilePath))
                    {
                        await fileStream.CopyToAsync(entryStream);
                    }
                }
            }
            
            return new EmptyResult();
        }
    }
}

Unlike MVC, you need to return an `EmptyResult` even though the response won't be empty. It will still work if you don't, but you will get an error in your logs. The error comes from Razor Pages trying to add some headers after the headers have already been sent to the client in your handler.

In the cshtml-file for this Razor Page, you can see how the asp-page-handler attribute is used to point to OnGetDownloadBots:

@page
@model IndexModel

<p class="text-center">
    <a class="btn btn-primary" asp-page-handler="DownloadBots" download>Download .NET Bots ZIP</a>
</p>

To give this a try, download the sample repository and run it locally. Then, open the browser and browse to 'https://localhost:5001/pages' (change the protocol and port if necessary).
On this page, click on the "Download .NET Bots ZIP" which will execute the code above, and download the .NET Bots ZIP file. 

Create a ZIP file and send it to the browser from an ASP.NET Endpoint #

If you're not using MVC or Razor Pages, you can use a raw endpoint like this:

using System.IO;
using System.IO.Compression;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
// unrelated code omitted for brevity

public class Startup
{
    // unrelated code omitted for brevity

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        // unrelated code omitted for brevity

        app.UseEndpoints(endpoints =>
        {
            // unrelated code omitted for brevity

            endpoints.MapGet("/download-bots", async context =>
            {
                context.Response.ContentType = "application/octet-stream";
                context.Response.Headers.Add("Content-Disposition", "attachment; filename=\"Bots.zip\"");

                var botsFolderPath = Path.Combine(env.ContentRootPath, "bots");
                var botFilePaths = Directory.GetFiles(botsFolderPath);
                using (ZipArchive archive = new ZipArchive(context.Response.BodyWriter.AsStream(), ZipArchiveMode.Create))
                {
                    foreach (var botFilePath in botFilePaths)
                    {
                        var botFileName = Path.GetFileName(botFilePath);
                        var entry = archive.CreateEntry(botFileName);
                        using (var entryStream = entry.Open())
                        using (var fileStream = System.IO.File.OpenRead(botFilePath))
                        {
                            await fileStream.CopyToAsync(entryStream);
                        }
                    }
                }
            });

            // unrelated code omitted for brevity
        });
    }
}

To give this a try, download the sample repository and run it locally. Then, open the browser and browse to 'https://localhost:5001/download-bots' (change the protocol and port if necessary).
The above code will be executed and the ZIP file will be downloaded to your machine. 

These endpoints work exactly the same when using ASP.NET Core 6's minimal APIs. Here's what that code would look like inside of Program.cs:

// more code, see https://github.com/Swimburger/WebZipIt/blob/dotNET6/Program.cs

app.MapGet("/download-bots", async context =>
{
    context.Response.ContentType = "application/octet-stream";
    context.Response.Headers.Add("Content-Disposition", "attachment; filename=\"Bots.zip\"");

    var botsFolderPath = Path.Combine(app.Environment.ContentRootPath, "bots");
    var botFilePaths = Directory.GetFiles(botsFolderPath);
    using (ZipArchive archive = new ZipArchive(context.Response.BodyWriter.AsStream(), ZipArchiveMode.Create))
    {
        foreach (var botFilePath in botFilePaths)
        {
            var botFileName = Path.GetFileName(botFilePath);
            var entry = archive.CreateEntry(botFileName);
            using (var entryStream = entry.Open())
            using (var fileStream = System.IO.File.OpenRead(botFilePath))
            {
                await fileStream.CopyToAsync(entryStream);
            }
        }
    }
});

// more code, see https://github.com/Swimburger/WebZipIt/blob/dotNET6/Program.cs

You can find the full copy of the .NET 6 version in the "dotNET6" branch of the GitHub repository.

Summary #

The ZipArchive wraps any stream to read, create, and update ZIP archives whether the data is coming from disk, from an HTTP request, from a long data-pipeline, or from anywhere else. This makes ZipArchive very flexible and you can avoid saving a ZIP file to disk as an intermediary step. You can send the resulting stream to the browser using ASP.NET MVC, Razor Pages, and endpoints as demonstrated above.

Related Posts

Related Posts