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.
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:
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.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
