1

I'm trying to download a large file with with Spring web client without loading an entire file to the memory. I have such service method:

@Override
public StreamingResponseBody download() {
    Flux<DataBuffer> dataBufferFlux = webClient.get()
            .uri("/download")
            .retrieve()
            .bodyToFlux(DataBuffer.class);

    StreamingResponseBody streamingResponseBody = outputStream -> {
        DataBufferUtils.write(dataBufferFlux, outputStream)
                .doOnNext(DataBufferUtils::release)
                .blockLast();
    };
    return streamingResponseBody;
}

Also we use Spring MVC framework so I can't use Flux in my endpoints. The endpoint code is:

@GetMapping(value = "/download", produces = "application/octet-stream")
public ResponseEntity<StreamingResponseBody> down() {
    var download = service.download();
    return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).body(download);
}

But this code does not work. When I call my endpoint I get an empty body with 0 content type. I can't load all bytes[] to memory because I'll get OOM.

1
  • Also we use Spring MVC framework so I can't use Flux in my endpoints. Sure you can, you will end-up with only the async part of Webflux but there is nothing preventing you from returning a Flux. Commented Nov 17 at 13:28

1 Answer 1

1

Project Tree

demo-download-file-spring-mvc-webclient
├── pom.xml
├── Dockerfile
└── src
    └── main
        ├── java
        │   └── com
        │       └── example
        │           ├── controller
        │           │   └── DownloadController.java
        │           └── DemoDownloadFileSpringMVCApplication.java
        └── resources
            └── application.properties

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.7</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>demo-download-file-spring-mvc-webclient</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo-download-file-spring-mvc-webclient</name>
    <description>Demo project for Spring Boot</description>

    <properties>
            <maven.compiler.source>17</maven.compiler.source>
                <maven.compiler.target>17</maven.compiler.target>
                <maven.compiler.encoding>UTF-8</maven.compiler.encoding>
                <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
                <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>2.15.1</version>
        </dependency>

    </dependencies>

    <build>
    <finalName>app</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

application.properties

  • config remote download url
spring.application.name=demo-download-file
remote-download-base-url=http://demo.example.com/download

DemoDownloadFileSpringMVCApplication.java

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoDownloadFileSpringMVCApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoDownloadFileSpringMVCApplication.class, args);
    }
}

DownloadController.java

package com.example.controller;

import org.apache.commons.io.IOUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import reactor.core.publisher.Flux;

import jakarta.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.io.OutputStream;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Controller
public class DownloadController {
    static final Logger log= LoggerFactory.getLogger(DownloadController.class);
    
    private final WebClient webClient;

    @Value("${remote-download-base-url}")
    private String baseUrl; // This can be overridden via application.properties + environment.

    public DownloadController(WebClient.Builder builder) {
        this.webClient = builder.build();
    }

    @GetMapping("/download-remote")
    public ResponseEntity<StreamingResponseBody> downloadRemote(
            @RequestParam("param1") String param1,
            @RequestParam("param2") String param2) {

        log.info("param1 = {}, param2 = {}", param1, param2);
        
        String remoteUrl = baseUrl + "?param1=" + param1 +"&param2="+ param2;
        log.info("remoteUrl = {}", remoteUrl);

        StreamingResponseBody body = outputStream -> {

            // Obtain Flux<DataBuffer> from remote stream
            Flux<DataBuffer> dataBufferFlux = webClient.get()
                    .uri(remoteUrl)
                    .retrieve()
                    .bodyToFlux(DataBuffer.class);
           log.info("dataBufferFlux ok");
           
            // Writing Flux to OutputStream (blocking MVC environment)
            DataBufferUtils.write(dataBufferFlux, outputStream)
                    .doOnNext(DataBufferUtils::release)
                    .blockLast();  // MVC needs to block until the streaming is complete.
        };
           log.info("DataBufferUtils.write ok");
           
           
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        "attachment; filename=" + fileName)
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .body(body);
    }
}

Dockerfile

optional - only for verify.

FROM eclipse-temurin:17-jre-alpine

WORKDIR /app-data

COPY target/app.jar app.jar

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

Build

mvn clean package

Run

export REMOTE_DOWNLOAD_BASE_URL="http://demo.server.local:58080/download"

java -jar target/app.jar

Test

Open Browser

http://localhost:8080/download-remote?param1=TEMP&param2=TEST

NOTE:

Spring Boot Controller will download file from http://demo.server.local:58080/download?param1=TEMP&param2=TEST

String remoteUrl = baseUrl + "?param1=" + param1 +"&param2="+ param2;

To verify that the program does not download the entire file to memory, we create a Docker image and limit the Docker container's memory to 1GB during execution. We then test downloading a remote file up to 4GB (exceeding the 1GB allocated to the Docker container) to verify that the file is not downloaded entirely to memory.

Build Docker Image

docker build -t spring-download-remote .

Run Docker

docker run \
  -it \
  --rm \
  --name download-remote \
  -p 8080:8080 \
  --memory="1g" \
  --memory-swap="1g" \
  -e REMOTE_DOWNLOAD_BASE_URL="http://demo.server.local:58080/download" \
  spring-download-remote

Test

Open Browser:

http://localhost:8080/download-remote?param1=TEMP&param2=TEST

In fact, the program will connect to this URL (http://demo.server.local:58080/download?param1=TEMP&param2=TEST) to download the file.

Code:

MVC Controller Download remoteUrl file:


    @Value("${remote-download-base-url}")
    private String baseUrl; // This can be overridden via application.properties + environment.

    @GetMapping("/download-remote")
    public ResponseEntity<StreamingResponseBody> downloadRemote(
            @RequestParam("param1") String param1,
            @RequestParam("param2") String param2) {

    ...
           String remoteUrl = baseUrl + "?param1=" + param1 +"&param2="+ param2;
    ...          
+--------+                     +-------------------------+                           +--------+ 
| Broser +-- Download File --> + Spring Boot Application +                           +        +
|        |                     + (MVC Controller) +------------+                     + Remote +
+--------+                     +------------------+ Web Client +-- Download File --> + Server +
                                                  +------------+                     +--------+
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.