How to Zip Multiple Files for Download in Spring Boot/Java

In this tutorial, we will explore how to zip multiple files for download in a Spring Boot. Zipping files allows you to combine multiple files into a single compressed archive, making it convenient for users to download and access multiple files as a single package. 

Spring provides StreamingResponseBody interface which is designed for handling asynchronous processing and streaming large or real-time data, providing better performance, scalability, and memory efficiency in relevant use cases. This interface allows the application to write directly to the response OutputStream without blocking the Servlet container thread.

When using StreamingResponseBody for handling asynchronous requests, it is also highly recommended to explicitly configure the TaskExecutor.

The given code snippet represents a Spring Boot controller class named DownloadController that handles a RESTful API endpoint for downloading multiple files as a zip archive:

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import jakarta.servlet.http.HttpServletResponse;

@RestController
@RequestMapping(path = "/api")
public class DownloadController {
  private static final Logger logger = LoggerFactory.getLogger(DownloadController.class);

  @GetMapping(path = "/downloads/{exampleId}")
  public ResponseEntity downloadZip(HttpServletResponse response, @PathVariable(name = "exampleId") String exampleId) {
     logger.info("download request for exampleId = {}", exampleId);

     // list of file paths for download
     List<String> paths = Arrays.asList("/home/Videos/part1.mp4", "/home/Videos/part2.mp4", "/home/Videos/part3.mp4", "/home/Videos/part4.pp4");

     int BUFFER_SIZE = 1024;

     StreamingResponseBody streamResponseBody = out -> {
       final ZipOutputStream zipOutputStream = new ZipOutputStream(response.getOutputStream());
       ZipEntry zipEntry = null;
       InputStream inputStream = null;

       try {
            for (String path : paths) {
		 File file = new File(path);
	         zipEntry = new ZipEntry(file.getName());

		 inputStream = new FileInputStream(file);

		 zipOutputStream.putNextEntry(zipEntry);
		 byte[] bytes = new byte[BUFFER_SIZE];
		 int length;
	         while ((length = inputStream.read(bytes)) >= 0) {
		     zipOutputStream.write(bytes, 0, length);
                 }
       }

       // set zip size in response
       response.setContentLength((int) (zipEntry != null ? zipEntry.getSize() : 0));
       } catch (IOException e) {
          logger.error("Exception while reading and streaming data {} ", e);
       } finally {
                   if (inputStream != null) {
			inputStream.close();
		   }
		   if (zipOutputStream != null) {
		        zipOutputStream.close();
		   }
       }

     };

     response.setContentType("application/zip");
     response.setHeader("Content-Disposition", "attachment; filename=example.zip");
     response.addHeader("Pragma", "no-cache");
     response.addHeader("Expires", "0");

     return ResponseEntity.ok(streamResponseBody);
  }

}

In this example, the class DownloadZipController is annotated with @RestController, indicating that it's a REST controller that handles RESTful requests. The class-level annotation @RequestMapping(path = "/api") specifies the base path for the API endpoints defined in this controller. Inside the class, there is a GET mapping method defined with @GetMapping(path = "/downloads/large-files/{sampleId}"). This method handles requests to download a zip archive containing multiple large files. The sampleId is a path variable indicating the specific sample to download. The method starts by logging the download request information using the logger object. A list of file paths (paths) is defined, representing the files that will be included in the zip archive. In this example, the file paths point to four video files. The BUFFER_SIZE variable is set to 1024, representing the buffer size used for reading and writing data during the zip creation process. The StreamingResponseBody object is created using a lambda expression. This object handles the streaming of the zip archive to the response. Inside the lambda expression, the code creates a ZipOutputStream using the response's output stream. Iterates over each file path in the paths list. For each file, creates a File object and a corresponding ZipEntry with the file's name. Opens an InputStream by reading the file using a FileInputStream. Writes the file's contents to the ZipOutputStream in chunks using the defined buffer size. Sets the content length of the response based on the size of the last processed ZipEntry. After the lambda expression, the method sets the necessary headers and content type for the HTTP response, indicating that it is a zip file download. Finally, the ResponseEntity.ok() method is used to wrap the StreamingResponseBody object and return it as the response entity.

This controller allows clients to download a zip archive containing multiple large files by sending a GET request to the /api/downloads/large-files/{sampleId} endpoint, where {sampleId} is the specific sample to download. The files are read from the specified file paths, compressed into a zip archive, and streamed as the response to the client.

Here's an example code for configuring the TaskExecutor, which is required when using StreamingResponseBody. This class is responsible for configuring asynchronous operations in a Spring application.:

package com.example.config;

import java.util.concurrent.Callable;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.async.CallableProcessingInterceptor;
import org.springframework.web.context.request.async.TimeoutCallableProcessingInterceptor;
import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@EnableAsync
@EnableScheduling
public class AsyncConfig implements AsyncConfigurer {

  @Override
  @Bean(name = "taskExecutor")
  public AsyncTaskExecutor getAsyncExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(15);
    executor.setQueueCapacity(50);
    return executor;
  }

  @Override
  public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
    return new SimpleAsyncUncaughtExceptionHandler();
  }

  @Bean
  public WebMvcConfigurer webMvcConfigurerConfigurer(AsyncTaskExecutor taskExecutor,
      CallableProcessingInterceptor callableProcessingInterceptor) {
    return new WebMvcConfigurer() {
      @Override
      public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
        configurer.setDefaultTimeout(3600000).setTaskExecutor(taskExecutor);
        configurer.registerCallableInterceptors(callableProcessingInterceptor);
        WebMvcConfigurer.super.configureAsyncSupport(configurer);
      }
    };
  }

  @Bean
  public CallableProcessingInterceptor callableProcessingInterceptor() {
    return new TimeoutCallableProcessingInterceptor() {
      @Override
      public <T> Object handleTimeout(NativeWebRequest request, Callable<T> task) throws Exception {
        return super.handleTimeout(request, task);
      }
    };
  }

}

Here, the class is annotated with @Configuration, indicating that it is a configuration class for the Spring application. @EnableAsync and @EnableScheduling annotations are used to enable asynchronous processing and scheduling support in the application. The class implements the AsyncConfigurer interface, which allows custom configuration of asynchronous processing. The getAsyncExecutor() method is overridden to define a bean named "taskExecutor" that provides an implementation of AsyncTaskExecutor. In this example, a ThreadPoolTaskExecutor is used. It sets the core pool size to 10, maximum pool size to 15, and queue capacity to 50. The getAsyncUncaughtExceptionHandler() method is overridden to specify a custom implementation of AsyncUncaughtExceptionHandler. In this case, a SimpleAsyncUncaughtExceptionHandler is used, which handles uncaught exceptions in asynchronous tasks. The webMvcConfigurerConfigurer() method defines a bean of type WebMvcConfigurer to configure async support for Spring MVC. It takes an AsyncTaskExecutor and a CallableProcessingInterceptor as arguments. Inside the method, AsyncSupportConfigurer is used to set the default timeout for async requests to 3600000 milliseconds (60 minutes) and register the callableProcessingInterceptor. The callableProcessingInterceptor() method defines a bean of type CallableProcessingInterceptor. In this example, a TimeoutCallableProcessingInterceptor is used, which handles timeouts for async callable tasks.

By utilizing this AsyncConfiguration class in a Spring application, the ThreadPoolTaskExecutor is configured as the task executor for asynchronous operations. The class also provides options for handling uncaught exceptions and setting timeouts for async requests.