乐于分享
好东西不私藏

Spring Boot实现文件下载:普通下载、压缩包下载、断点续传

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[]{startend};    }    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[]{startend};}

如果 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&#039;&#039;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]""_");}

否则极端情况下可能造成响应头注入。

这个问题不常见,但做公共下载接口时最好顺手处理。

三种下载方式怎么选?

简单总结一下:

场景
推荐方案
单个小文件下载
普通流式下载
多个文件批量下载
ZipOutputStream 边压缩边输出
大文件下载
Range 断点续传
公开大文件
Nginx / 对象存储 / CDN
私有敏感文件
后端鉴权后下载或生成临时 URL
图片/PDF预览
inline + 正确 Content-Type

说白了:

不是所有文件都适合让 Spring Boot 直接扛。

小文件可以。 权限敏感文件可以。 大文件、高并发下载,最好交给更适合的组件。

推荐的项目落地方式

真实项目里,我更推荐这样设计文件下载:

数据库保存文件元数据:文件 ID、原始文件名、存储路径、大小、类型、所属用户。 前端下载时只传文件 ID。 后端根据文件 ID 查数据库。 校验当前用户是否有权限。 根据文件大小决定普通下载还是断点续传。 多个文件下载时,后端动态生成 zip。 大文件可以返回对象存储临时 URL。

这样可维护性会好很多。

不要让前端传真实文件路径。

也不要让下载接口变成一个毫无权限控制的静态文件读取器。

总结一下

文件下载不是简单写个 outputStream.write()

项目里真正要考虑的是:

文件名怎么编码。 大文件怎么流式传输。 多文件怎么压缩。 断点续传怎么处理 Range。 权限怎么校验。 路径怎么防穿越。 异常怎么提前处理。

普通文件下载,重点是响应头和流式写出。

压缩包下载,重点是边压缩边输出,不要占用大量内存。

断点续传,重点是解析 Range,返回 206 和 Content-Range。

如果是小项目,Spring Boot 直接实现完全够用。

如果是大文件、高并发、公开下载,建议交给 Nginx、对象存储或 CDN。

技术方案没有绝对好坏,关键是看业务场景。

你们项目里的文件下载,是 Java 应用直接返回,还是走对象存储?