Simplify Complex File Downloads with a One‑Annotation Spring Library

This article walks through the design and implementation of a Spring‑based download library that lets developers download files, HTTP resources, or arbitrary objects with a single @Download annotation, covering the problem setup, reactive architecture, source abstraction, concurrency handling, compression, response writing, and the lessons learned.

Java Web Project
Java Web Project
Java Web Project
Simplify Complex File Downloads with a One‑Annotation Spring Library

Downloading files is a common requirement, but real‑world scenarios—such as exporting a zip of device QR‑code images—can become cumbersome, involving steps like fetching device lists, downloading images, caching, concurrent loading, compression, and streaming the result back to the client.

To address this, the author created a library that reduces the entire workflow to a single annotation. By annotating a controller method with @Download (optionally specifying source or filename), the framework automatically resolves the data source, performs any necessary loading, compresses multiple files, and writes the final output to the HTTP response.

@Download(source = "classpath:/download/README.txt")
@GetMapping("/classpath")
public void classpath() { }

@Download
@GetMapping("/file")
public File file() {
    return new File("/Users/Shared/README.txt");
}

@Download
@GetMapping("/http")
public String http() {
    return "http://127.0.0.1:8080/concept-download/image.jpg";
}

For a concrete use‑case, the author needed to export all device QR‑code images as a zip file. The steps required were:

Query the device list.

Download each QR‑code image from its HTTP URL.

Check the cache before downloading.

Perform concurrent downloads to improve performance.

After all downloads finish, create a zip archive.

Write the zip to the HTTP response.

Implementing this manually resulted in nearly 200 lines of code. The library abstracts these steps so that the controller only returns the list of Device objects:

@Download(filename = "二维码.zip")
@GetMapping("/download")
public List<Device> download() {
    return deviceService.all();
}

public class Device {
    private String name;
    @SourceObject
    private String qrCodeUrl;
    @SourceName
    public String getQrCodeName() {
        return name + ".png";
    }
    // other fields omitted
}

The library uses annotations @SourceModel, @SourceObject, and @SourceName to mark which fields provide the download URL and the desired file name. Reflection extracts these values at runtime.

Design Overview

The core design is reactive but not a pure reactive stream; it works with both Spring MVC and Spring WebFlux by wrapping an InputStream into a Mono<InputStream>. This hybrid approach was necessary because WebFlux does not expose the request/response objects via RequestContextHolder. Instead, a custom WebFilter stores the ServerHttpRequest and ServerHttpResponse in the Reactor context:

public class ReactiveDownloadFilter implements WebFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        return chain.filter(exchange)
            .contextWrite(ctx -> ctx.put(ServerHttpRequest.class, request))
            .contextWrite(ctx -> ctx.put(ServerHttpResponse.class, response));
    }
}

public class ReactiveDownloadHolder {
    public static Mono<ServerHttpRequest> getRequest() {
        return Mono.deferContextual(c -> Mono.just(c.get(ServerHttpRequest.class)));
    }
    public static Mono<ServerHttpResponse> getResponse() {
        return Mono.deferContextual(c -> Mono.just(c.get(ServerHttpResponse.class)));
    }
}

Because WebFlux runs on Netty, blocking calls like Mono.block() are prohibited, so the library fully embraces reactive composition.

Download Pipeline Architecture

The processing pipeline mirrors Spring Cloud Gateway's filter chain. Each step implements DownloadHandler and can be ordered via OrderProvider. The chain is driven by a DownloadContext that carries intermediate state:

public interface DownloadHandler extends OrderProvider {
    Mono<Void> handle(DownloadContext context, DownloadHandlerChain chain);
}

public interface DownloadHandlerChain {
    Mono<Void> next(DownloadContext context);
}

Typical steps include:

Resolve the source (file path, HTTP URL, custom object) into a Source via SourceFactory.

Load the source concurrently using a SourceLoader (e.g., DefaultSourceLoader or SchedulerSourceLoader).

Compress the loaded resources with a SourceCompressor (default implementation is ZipSourceCompressor).

Write the compressed bytes to the response through DownloadWriter, which abstracts both HttpServletResponse and ServerHttpResponse.

The architecture diagram (kept from the original article) illustrates these stages:

Download architecture diagram
Download architecture diagram

Source Abstraction

All download inputs are represented by the Source interface. Implementations include FileSource for local files and HttpSource for remote URLs. Factories such as FileSourceFactory and HttpSourceFactory decide which Source to create based on the raw object:

public interface SourceFactory extends OrderProvider {
    boolean support(Object source, DownloadContext context);
    Source create(Object source, DownloadContext context);
}

When a custom class is annotated with @SourceObject, the library uses reflection to read the field value (e.g., the QR‑code URL) and creates the appropriate Source.

Concurrent Loading

Network resources are loaded concurrently to improve throughput. The library does not enforce a fixed thread pool; instead, developers can plug in any SourceLoader implementation, mixing thread pools, coroutines, or even skipping loading for certain sources.

public interface SourceLoader {
    Mono<Source> load(Source source, DownloadContext context);
}

Compression

After loading, the data is passed to a SourceCompressor. The default ZipSourceCompressor produces a zip archive, but the interface allows custom compression formats:

public interface SourceCompressor extends OrderProvider {
    String getFormat();
    default boolean support(String format, DownloadContext ctx) {
        return format.equalsIgnoreCase(getFormat());
    }
    Compression compress(Source source, DownloadWriter writer, DownloadContext ctx);
}

Response Writing

The library abstracts the HTTP response via DownloadResponse, supporting both HttpServletResponse (Servlet) and ServerHttpResponse (WebFlux). For WebFlux, a custom ReactiveDownloadResponse converts a FluxSink<DataBuffer> into an OutputStream so that existing byte‑array based code can be reused:

public class ReactiveDownloadResponse implements DownloadResponse {
    private final ServerHttpResponse response;
    private OutputStream os;
    private Mono<Void> mono;

    @Override
    public Mono<Void> write(Consumer<OutputStream> consumer) {
        if (os == null) {
            mono = response.writeWith(Flux.create(sink -> {
                try {
                    os = new FluxSinkOutputStream(sink, response);
                    consumer.accept(os);
                } catch (Throwable e) {
                    sink.error(e);
                }
            }));
        } else {
            consumer.accept(os);
        }
        return mono;
    }

    @Override
    public void flush() {
        if (os != null) os.flush();
    }

    @AllArgsConstructor
    public static class FluxSinkOutputStream extends OutputStream {
        private final FluxSink<DataBuffer> sink;
        private final ServerHttpResponse response;
        @Override
        public void write(byte[] b) throws IOException { writeSink(b); }
        @Override
        public void write(byte[] b, int off, int len) throws IOException {
            byte[] bytes = new byte[len];
            System.arraycopy(b, off, bytes, 0, len);
            writeSink(bytes);
        }
        @Override
        public void write(int b) throws IOException { writeSink((byte) b); }
        @Override
        public void flush() { sink.complete(); }
        private void writeSink(byte... bytes) {
            DataBuffer buffer = response.bufferFactory().wrap(bytes);
            sink.next(buffer);
            DataBufferUtils.release(buffer);
        }
    }
}

The DownloadWriter interface further abstracts the actual byte‑copying, allowing custom handling of ranges, character sets, and progress callbacks.

Event‑Driven Logging

To avoid hard‑coded listeners, the author introduced an event system: DownloadEventPublisher publishes lifecycle events, and DownloadEventListener (compatible with Spring's @EventListener) consumes them. This enables detailed logs for each pipeline stage, progress updates, and total time spent, helping to surface hidden bugs.

Pitfalls and Lessons Learned

When using WebFlux, the response write operation returns Mono.empty(), so downstream handlers are never invoked. The author moved context cleanup to doAfterTerminate to guarantee execution.

Mixing reactive and servlet APIs required careful bridging; the custom ReactiveDownloadResponse solved the mismatch.

Properly handling back‑pressure and resource release (e.g., DataBufferUtils.release) was essential to avoid memory leaks.

Overall, the library demonstrates how a well‑structured, extensible pipeline can turn a verbose, error‑prone download implementation into a concise, annotation‑driven solution while remaining flexible for future extensions.

JavaSpringreactiveAnnotationWebFluxFile CompressionDownload
Java Web Project
Written by

Java Web Project

Focused on Java backend technologies, trending internet tech, and the latest industry developments. The platform serves over 200,000 Java developers, inviting you to learn and exchange ideas together. Check the menu for Java learning resources.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.