Spring Boot实现文件下载:普通下载、压缩包下载、断点续传
摘要: 文件下载看起来就是把文件写到响应流里,但项目里真要做好并不简单。中文文件名乱码、大文件内存溢出、批量打包下载、浏览器预览、断点续传,这些都是经常踩的坑。这篇用 Spring Boot 把普通下载、压缩包下载和断点续传完整讲清楚。
有个同事之前做过一个导出功能。
需求听起来很简单:
用户点击按钮,把服务器上的文件下载下来。
他第一反应也很简单:
读文件。 写 response。 完事。
结果一上线,问题一个接一个。
中文文件名在浏览器里乱码。 大文件下载时内存飙升。 有些浏览器直接打开文件,不触发下载。 多个文件打包下载时,压缩包打不开。 用户下载大文件,中途断了只能重新下。
后来才发现,文件下载这个功能,看起来简单,细节是真不少。
很多项目里都绕不开文件下载。
比如:
下载用户上传的附件。 下载系统生成的报表。 下载合同、发票、图片、压缩包。 批量下载多份资料。 下载大文件、安装包、视频文件。
所以这篇就把 Spring Boot 里常见的几种文件下载方式讲清楚:
普通文件下载。 多文件压缩包下载。 大文件断点续传下载。
文件下载的核心是什么?
文件下载本质上就是:
后端读取文件内容,然后通过 HTTP 响应写给浏览器。
但这里有几个关键点:
响应头要设置对。 文件名要处理好。 内容类型要明确。 大文件不能一次性读进内存。 异常时不能把半截脏数据返回给用户。
尤其是响应头。
文件能不能下载、下载后的名字对不对、浏览器是预览还是保存,很多时候都由响应头决定。
常用响应头有这几个:
Content-Type: application/octet-streamContent-Disposition: attachment; filename*=UTF-8''xxx.pdfContent-Length: 102400Accept-Ranges: bytesContent-Range: bytes 0-1023/102400
其中最关键的是 Content-Disposition。
如果是:
Content-Disposition: attachment
浏览器通常会下载。
如果是:
Content-Disposition: inline
浏览器可能会直接预览,比如 PDF、图片。
项目里大多数下载场景,都用 attachment。
准备一个文件工具类
先准备一个文件名编码工具。
中文文件名乱码,大部分都是这里没处理好。
import jakarta.servlet.http.HttpServletRequest;import java.net.URLEncoder;import java.nio.charset.StandardCharsets;public class DownloadFileNameUtil {public static String encodeFileName(HttpServletRequest request, String fileName) {String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");String userAgent = request.getHeader("User-Agent");if (userAgent != null && userAgent.contains("MSIE")) {return "filename=\"" + encoded + "\"";}return "filename*=UTF-8''" + encoded;}}
现在主流浏览器基本都支持 filename*。
这个写法对中文、空格、特殊字符更友好。
普通文件下载
先看最常见的下载方式。
根据文件路径,把文件写到响应流。
import jakarta.servlet.ServletOutputStream;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import org.springframework.http.MediaType;import org.springframework.util.FileCopyUtils;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.RestController;import java.io.BufferedInputStream;import java.io.File;import java.io.FileInputStream;import java.io.IOException;@RestController@RequestMapping("/api/files")public class FileDownloadController {private static final String BASE_DIR = "/data/upload";@GetMapping("/download")public void download(@RequestParam String fileName,HttpServletRequest request,HttpServletResponse response) throws IOException {File file = new File(BASE_DIR, fileName);if (!file.exists() || !file.isFile()) {response.setStatus(HttpServletResponse.SC_NOT_FOUND);return;}response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);response.setContentLengthLong(file.length());response.setHeader("Content-Disposition","attachment; " + DownloadFileNameUtil.encodeFileName(request, file.getName()));try (BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(file));ServletOutputStream outputStream = response.getOutputStream()) {FileCopyUtils.copy(inputStream, outputStream);outputStream.flush();}}}
这段代码可以满足普通下载。
但这里有一个安全问题。
fileName 是用户传进来的。
如果用户传:
../../etc/passwd
就可能出现目录穿越风险。
所以真实项目不能直接拼文件路径。
防止目录穿越
更安全的写法是,先拿到规范路径,再判断是否还在基础目录下。
import java.io.File;import java.io.IOException;public class FilePathUtil {public static File safeFile(String baseDir, String fileName) throws IOException {File base = new File(baseDir).getCanonicalFile();File target = new File(base, fileName).getCanonicalFile();if (!target.getPath().startsWith(base.getPath())) {throw new SecurityException("非法文件路径");}return target;}}
然后下载接口改成:
File file = FilePathUtil.safeFile(BASE_DIR, fileName);if (!file.exists() || !file.isFile()) {response.setStatus(HttpServletResponse.SC_NOT_FOUND);return;}
这个细节很重要。
文件下载接口属于比较容易被攻击的接口。
不要相信前端传来的路径。
根据文件类型设置 Content-Type
有些文件下载,用户希望浏览器预览。
比如图片、PDF。
这时候可以根据文件类型设置 Content-Type。
import jakarta.servlet.ServletContext;import lombok.RequiredArgsConstructor;import org.springframework.stereotype.Component;@Component@RequiredArgsConstructorpublic class ContentTypeResolver {private final ServletContext servletContext;public String resolve(String fileName) {String contentType = servletContext.getMimeType(fileName);if (contentType == null) {return "application/octet-stream";}return contentType;}}
如果你想强制下载,Content-Disposition 用 attachment。
如果你想浏览器预览,改成 inline。
比如:
response.setHeader("Content-Disposition","inline; " + DownloadFileNameUtil.encodeFileName(request, file.getName()));
普通业务下载,建议默认强制下载。
预览功能单独做一个接口。
这样逻辑更清楚。
多文件压缩包下载
很多系统都有批量下载需求。
比如:
批量下载合同。 批量下载发票。 批量下载附件。 批量下载图片资料。
最常见做法就是后端边压缩边输出 zip。
注意,不要先把所有文件压缩到内存里。
应该用 ZipOutputStream 流式写出。
import jakarta.servlet.ServletOutputStream;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import org.springframework.http.MediaType;import org.springframework.util.FileCopyUtils;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestBody;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import java.io.BufferedInputStream;import java.io.File;import java.io.FileInputStream;import java.io.IOException;import java.util.List;import java.util.zip.ZipEntry;import java.util.zip.ZipOutputStream;@RestController@RequestMapping("/api/files")public class ZipDownloadController {private static final String BASE_DIR = "/data/upload";@PostMapping("/download-zip")public void downloadZip(@RequestBody List<String> fileNames,HttpServletRequest request,HttpServletResponse response) throws IOException {String zipName = "批量文件.zip";response.setContentType("application/zip");response.setHeader("Content-Disposition","attachment; " + DownloadFileNameUtil.encodeFileName(request, zipName));try (ServletOutputStream outputStream = response.getOutputStream();ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) {for (String fileName : fileNames) {File file = FilePathUtil.safeFile(BASE_DIR, fileName);if (!file.exists() || !file.isFile()) {continue;}addFileToZip(file, zipOutputStream);}zipOutputStream.finish();zipOutputStream.flush();}}private void addFileToZip(File file, ZipOutputStream zipOutputStream) throws IOException {ZipEntry zipEntry = new ZipEntry(file.getName());zipOutputStream.putNextEntry(zipEntry);try (BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(file))) {FileCopyUtils.copy(inputStream, zipOutputStream);}zipOutputStream.closeEntry();}}
这就是最基础的批量打包下载。
它不会把整个 zip 一次性放进内存,而是边读文件、边写 zip、边返回给浏览器。
压缩包里文件重名怎么办?
上面的代码有个问题。
如果多个文件名字一样,压缩包里可能冲突。
比如:
合同.pdf合同.pdf合同.pdf
更稳的做法是给文件名加序号。
private String buildZipEntryName(int index, File file) {return index + "_" + file.getName();}
然后:
ZipEntry zipEntry = new ZipEntry(buildZipEntryName(index, file));
完整一点:
for (int i = 0; i < fileNames.size(); i++) {File file = FilePathUtil.safeFile(BASE_DIR, fileNames.get(i));if (!file.exists() || !file.isFile()) {continue;}zipOutputStream.putNextEntry(new ZipEntry((i + 1) + "_" + file.getName()));try (BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(file))) {FileCopyUtils.copy(inputStream, zipOutputStream);}zipOutputStream.closeEntry();}
这样即使文件名重复,也不容易出问题。
大文件下载为什么要断点续传?
普通下载有一个问题:
如果文件很大,用户下载到一半网络断了,只能重新开始。
比如 2GB 的安装包,下载到 1.8GB 失败了。
重新下载很痛苦。
断点续传就是为了解决这个问题。
浏览器或下载器会在请求头里带上 Range:
Range: bytes=1024-
意思是:
我不要从头开始。 请从第 1024 个字节开始返回。
后端收到 Range 后,只返回指定范围的数据,并设置响应状态码 206 Partial Content。
同时返回:
Content-Range: bytes 1024-204799/204800Accept-Ranges: bytes
这就是断点续传的核心。
断点续传接口实现
下面给一个完整实现。
import jakarta.servlet.ServletOutputStream;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import org.springframework.http.HttpHeaders;import org.springframework.http.MediaType;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.RestController;import java.io.File;import java.io.IOException;import java.io.RandomAccessFile;@RestController@RequestMapping("/api/files")public class RangeDownloadController {private static final String BASE_DIR = "/data/upload";private static final int BUFFER_SIZE = 8192;@GetMapping("/download-range")public void downloadRange(@RequestParam String fileName,HttpServletRequest request,HttpServletResponse response) throws IOException {File file = FilePathUtil.safeFile(BASE_DIR, fileName);if (!file.exists() || !file.isFile()) {response.setStatus(HttpServletResponse.SC_NOT_FOUND);return;}long fileLength = file.length();String rangeHeader = request.getHeader(HttpHeaders.RANGE);long start = 0;long end = fileLength - 1;if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {String range = rangeHeader.substring("bytes=".length());String[] parts = range.split("-");start = Long.parseLong(parts[0]);if (parts.length > 1 && !parts[1].isBlank()) {end = Long.parseLong(parts[1]);}if (end >= fileLength) {end = fileLength - 1;}response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);}long contentLength = end - start + 1;response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);response.setHeader("Accept-Ranges", "bytes");response.setHeader("Content-Disposition","attachment; " + DownloadFileNameUtil.encodeFileName(request, file.getName()));response.setHeader("Content-Length", String.valueOf(contentLength));if (rangeHeader != null) {response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength);}writeRange(file, response, start, end);}private void writeRange(File file, HttpServletResponse response, long start, long end) throws IOException {try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r");ServletOutputStream outputStream = response.getOutputStream()) {randomAccessFile.seek(start);byte[] buffer = new byte[BUFFER_SIZE];long remaining = end - start + 1;while (remaining > 0) {int readLength = (int) Math.min(buffer.length, remaining);int len = randomAccessFile.read(buffer, 0, readLength);if (len == -1) {break;}outputStream.write(buffer, 0, len);remaining -= len;}outputStream.flush();}}}
关键点就几个:
读取请求头 Range。 解析开始位置和结束位置。 设置状态码 206。 设置 Content-Range。 用 RandomAccessFile.seek(start) 从指定位置读取。
如果没有 Range 头,就按普通下载从头返回。
Range 头还有哪些格式?
常见格式有三种:
Range: bytes=1000-Range: bytes=1000-2000Range: bytes=-500
分别表示:
从 1000 字节下载到文件末尾。 从 1000 字节下载到 2000 字节。 下载最后 500 字节。
上面的示例主要处理前两种。
如果要更完整,可以补上第三种。
private long[] parseRange(String rangeHeader, long fileLength) {long start = 0;long end = fileLength - 1;if (rangeHeader == null || !rangeHeader.startsWith("bytes=")) {return new long[]{start, end};}String range = rangeHeader.substring("bytes=".length());String[] parts = range.split("-", -1);if (parts[0].isBlank()) {long suffixLength = Long.parseLong(parts[1]);start = Math.max(fileLength - suffixLength, 0);} else {start = Long.parseLong(parts[0]);if (parts.length > 1 && !parts[1].isBlank()) {end = Long.parseLong(parts[1]);}}if (end >= fileLength) {end = fileLength - 1;}if (start > end || start < 0) {throw new IllegalArgumentException("Range 请求不合法");}return new long[]{start, end};}
如果 Range 不合法,建议返回 416 Requested Range Not Satisfiable。
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);response.setHeader("Content-Range", "bytes */" + fileLength);
这样更符合 HTTP 规范。
大文件下载要不要用 Nginx?
如果文件很大,或者下载量很高,不建议所有流量都走 Java 应用。
原因很简单:
Java 应用线程会被占用。 带宽压力会打到应用服务。 大量慢连接会影响正常业务接口。 文件越大,应用层压力越明显。
更推荐的方案是:
小文件、权限敏感文件:Spring Boot 直接下载。 大文件、公开文件:Nginx 或对象存储下载。 私有大文件:后端鉴权后生成临时 URL。 高并发下载:对象存储 + CDN。
比如用对象存储时,后端只负责鉴权,然后返回一个带过期时间的下载地址。
这样下载流量就不压在业务服务上。
下载接口里的权限校验
文件下载还有一个经常被忽略的问题:权限。
很多人只校验文件是否存在。
但没有校验当前用户能不能下载这个文件。
比如:
/api/files/download?fileName=合同A.pdf
如果用户把文件名改成:
合同B.pdf
后端如果只按文件名下载,就可能越权。
正确做法是:
前端不要直接传物理文件名。 传文件 ID。 后端根据文件 ID 查数据库。 校验当前用户是否有权限。 再拿真实存储路径下载。
示例:
@GetMapping("/download-by-id")public void downloadById(@RequestParam Long fileId,HttpServletRequest request,HttpServletResponse response) throws IOException {FileInfo fileInfo = fileInfoService.getById(fileId);if (fileInfo == null) {response.setStatus(HttpServletResponse.SC_NOT_FOUND);return;}boolean allowed = filePermissionService.canDownload(getCurrentUserId(), fileInfo);if (!allowed) {response.setStatus(HttpServletResponse.SC_FORBIDDEN);return;}File file = new File(fileInfo.getStoragePath());// 后面继续执行下载逻辑}
这里的重点不是代码,而是思路。
下载接口必须做权限校验。
尤其是合同、发票、用户资料、报表这类敏感文件。
常见坑点
坑一:中文文件名乱码
这个最常见。
不要直接写:
response.setHeader("Content-Disposition", "attachment; filename=" + fileName);
中文、空格、特殊字符都可能出问题。
建议使用:
filename*=UTF-8''xxx
也就是前面工具类里的写法。
坑二:一次性把文件读进内存
有些代码会这么写:
byte[] bytes = Files.readAllBytes(file.toPath());outputStream.write(bytes);
小文件没问题。
大文件就很危险。
比如几百 MB 的文件,一次性读进内存,很容易把 JVM 打爆。
下载文件应该用流式读取。
一边读,一边写。
坑三:压缩包下载后打不开
常见原因有几个:
没有调用 closeEntry()。 没有调用 finish()。 中途异常后仍然返回了 200。 文件名里有非法字符。 把错误 JSON 写进了 zip 响应里。
压缩包下载时,一旦开始写响应,就很难再返回普通 JSON 错误。
所以建议在写响应前,先完成参数校验和权限校验。
坑四:下载接口没有权限控制
这个比乱码更严重。
文件下载接口如果只靠文件名,很容易被猜路径、猜文件名。
更稳的方式是:
用文件 ID 下载。 后端查数据库。 校验用户权限。 隐藏真实存储路径。
不要把服务器物理路径暴露给前端。
坑五:断点续传状态码写错
断点续传时,如果请求带 Range,后端应该返回 206 Partial Content。
如果还是返回 200,有些下载器就无法续传。
同时要设置:
Accept-Ranges: bytesContent-Range: bytes start-end/total
少一个都可能导致兼容问题。
坑六:文件名存在响应头注入风险
如果文件名来自用户输入,要过滤换行符。
比如:
private String cleanFileName(String fileName) {return fileName.replaceAll("[\\r\\n]", "_");}
否则极端情况下可能造成响应头注入。
这个问题不常见,但做公共下载接口时最好顺手处理。
三种下载方式怎么选?
简单总结一下:
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
说白了:
不是所有文件都适合让 Spring Boot 直接扛。
小文件可以。 权限敏感文件可以。 大文件、高并发下载,最好交给更适合的组件。
推荐的项目落地方式
真实项目里,我更推荐这样设计文件下载:
数据库保存文件元数据:文件 ID、原始文件名、存储路径、大小、类型、所属用户。 前端下载时只传文件 ID。 后端根据文件 ID 查数据库。 校验当前用户是否有权限。 根据文件大小决定普通下载还是断点续传。 多个文件下载时,后端动态生成 zip。 大文件可以返回对象存储临时 URL。
这样可维护性会好很多。
不要让前端传真实文件路径。
也不要让下载接口变成一个毫无权限控制的静态文件读取器。
总结一下
文件下载不是简单写个 outputStream.write()。
项目里真正要考虑的是:
文件名怎么编码。 大文件怎么流式传输。 多文件怎么压缩。 断点续传怎么处理 Range。 权限怎么校验。 路径怎么防穿越。 异常怎么提前处理。
普通文件下载,重点是响应头和流式写出。
压缩包下载,重点是边压缩边输出,不要占用大量内存。
断点续传,重点是解析 Range,返回 206 和 Content-Range。
如果是小项目,Spring Boot 直接实现完全够用。
如果是大文件、高并发、公开下载,建议交给 Nginx、对象存储或 CDN。
技术方案没有绝对好坏,关键是看业务场景。
你们项目里的文件下载,是 Java 应用直接返回,还是走对象存储?
夜雨聆风