
本文介绍如何在 Quarkus 中基于 CompletionStage<Response> 和 StreamingOutput 实现无临时文件、低内存占用的大文件流式预览(如 MP4 视频),直接对接 MinIO,支持 HTTP Range 请求,避免阻塞线程与全量加载。
本文介绍如何在 quarkus 中基于 `completionstage
在构建现代云原生媒体服务时,为大型二进制文件(如高清视频)提供流畅的浏览器内预览(<video> 标签直播)是一项常见但易出错的需求。关键挑战在于:不能将整个文件加载到 JVM 堆内存中,也不能落盘缓存(违背“直接读取 MinIO”的设计约束),同时必须正确响应 Range 请求以支持拖拽播放、启停缓冲等前端行为。
Quarkus 官方推荐的响应式实践(如 Uni + AsyncFile)虽强大,但在 JAX-RS 层处理流式响应时,更轻量、更可控且完全兼容的标准方案是 CompletionStage<Response> 配合 StreamingOutput ——它天然适配 Quarkus 的 I/O 优化线程模型(如 Vert.x Event Loop),无需引入额外响应式抽象层,也避免了 AsyncFile 在非 Vert.x 文件系统场景下的局限性。
以下是一个生产就绪的实现示例,聚焦核心逻辑,已移除冗余异常包装与重复查询:
@GET
@Path("/download/{id}/ctx/{ctx}")
@Produces(MediaType.APPLICATION_OCTET_STREAM)
public CompletionStage<Response> downloadFile(
@PathParam("id") UUID fileId,
@PathParam("ctx") String ctx,
@QueryParam("preview") boolean preview,
@HeaderParam("Range") String rangeHeader) {
// 1. 同步获取元数据(快速、轻量)
ResFile resFile;
try {
resFile = resFileService.getResFile(fileId, ctx);
} catch (NotFoundException e) {
return CompletableFuture.completedFuture(
Response.status(NOT_FOUND).entity(String.format("File %s not found", fileId)).build());
}
final String contentType = resFile.getMimeType();
final long fileSize = resFile.getSize();
final String filename = resFile.getFileName();
// 2. 非预览模式:完整下载(attachment)
if (!preview) {
return streamFile(resFileService::loadFileAsResource, fileId, ctx, contentType,
"attachment; filename=\"" + filename + "\"");
}
// 3. 预览模式:支持 Range 的流式响应
if (rangeHeader == null) {
// 全量流式响应(首次加载)
return streamFile(resFileService::loadFileAsResource, fileId, ctx, contentType,
"inline; filename=\"" + filename + "\"")
.thenApply(resp -> resp
.header("Accept-Ranges", "bytes")
.header("Content-Length", String.valueOf(fileSize))
.build());
} else {
// 解析 Range: bytes=0-1023
long rangeStart = 0;
long rangeEnd = fileSize - 1;
try {
String[] parts = rangeHeader.trim().substring(6).split("-");
rangeStart = Long.parseLong(parts[0].trim());
if (parts.length > 1 && !parts[1].trim().isEmpty()) {
rangeEnd = Long.parseLong(parts[1].trim());
}
rangeEnd = Math.min(rangeEnd, fileSize - 1);
} catch (Exception e) {
return CompletableFuture.completedFuture(
Response.status(BAD_REQUEST).entity("Invalid Range header").build());
}
final long finalRangeStart = rangeStart;
final long finalRangeEnd = rangeEnd;
final String contentLength = String.valueOf(finalRangeEnd - finalRangeStart + 1);
return streamFile(resFileService::loadFileAsResource, fileId, ctx, contentType,
"inline; filename=\"" + filename + "\"")
.thenApply(resp -> resp
.status(PARTIAL_CONTENT)
.header("Accept-Ranges", "bytes")
.header("Content-Range", "bytes " + finalRangeStart + "-" + finalRangeEnd + "/" + fileSize)
.header("Content-Length", contentLength)
.build());
}
}
// 提取复用逻辑:异步流式封装
private CompletionStage<Response> streamFile(
BiFunction<UUID, String, InputStream> fileLoader,
UUID fileId, String ctx, String contentType, String contentDisposition) {
return CompletableFuture.supplyAsync(() -> {
try {
return fileLoader.apply(fileId, ctx);
} catch (MinIOException e) {
throw new CompletionException(
new WebApplicationException("MinIO read failed", INTERNAL_SERVER_ERROR));
}
}).thenApply(inputStream -> Response.ok((StreamingOutput) output -> {
try (InputStream is = inputStream) {
// 使用 Apache Commons IO 的分段拷贝,避免 OOM
if (contentDisposition.contains("inline")) {
// 若有 Range,则只拷贝指定区间(需底层 InputStream 支持 mark/reset 或重定位)
// ⚠️ 注意:MinIO getObject() 返回的 InputStream 不支持 seek,因此此处需结合自定义 RangeReader
// 实际生产中建议改用 minio-java 的 getPresignedObjectUrl + redirect,或使用下面的替代方案
IOUtils.copyLarge(is, output);
} else {
IOUtils.copyLarge(is, output);
}
}
}).header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.header(HttpHeaders.CONTENT_TYPE, contentType));
}⚠️ 关键注意事项与增强建议:
-
MinIO InputStream 不支持随机读取:minioClient.getObject(...) 返回的 InputStream 是顺序流,无法直接 skip() 到 RangeStart。原答案中 IOUtils.copyLarge(is, output, rangeStart, rangeEnd) 会失败(跳过无效)。正确做法有两种:
- 推荐:使用 minioClient.getPresignedObjectUrl() 生成带签名的直链,由 Quarkus 服务 Response.seeOther(url).build() 重定向,交由浏览器/CDN 处理 Range;
-
自研 RangeReader:包装 MinIO InputStream,通过 mark()/reset()(若底层支持)或缓冲前 N 字节实现模拟 seek(仅适用于小偏移);或采用 GetObjectArgs 的 offset/length 参数(需 minio-java ≥ 8.5.0):
GetObjectArgs.builder() .bucket(bucket) .object(filename) .offset(rangeStart) .length(rangeEnd - rangeStart + 1) .build()
-
线程安全与资源释放:CompletableFuture.supplyAsync() 默认使用 ForkJoinPool.commonPool(),不适用于阻塞 I/O。应显式配置专用线程池:
private static final Executor IO_EXECUTOR = Executors.newFixedThreadPool( Runtime.getRuntime().availableProcessors(), r -> new Thread(r, "minio-io-thread")); // 然后在 supplyAsync 中传入:supplyAsync(() -> ..., IO_EXECUTOR) 性能与可观测性:添加 Micrometer 指标监控流速、延迟;对大文件启用 quarkus.vertx.http.io-threads=2*cores 并调优连接超时。
安全性:始终校验 filename 防止路径遍历(如 Paths.get(filename).getFileName().toString());对 ctx 做租户隔离鉴权。
总结而言,Quarkus 下服务大文件预览,优先选择标准 JAX-RS 异步流(CompletionStage<Response> + StreamingOutput)而非强行套用 Uni ——它更简洁、更稳定、更易调试。只要规避 InputStream 的 seek 陷阱,并合理调度 I/O 线程,即可在零临时文件、低内存占用前提下,完美支撑视频流式播放。










