文件下载
本章是 Spring MVC 教程中"文件上传与异常处理"章节的成对内容。上一章讲解了 MultipartFile 处理文件上传,本章讲解文件下载——两者共同构成 Web 应用中文件传输的完整闭环。理解下载的实现原理,是处理报表导出、附件下载、静态资源分发等需求的必备能力。
定义与作用
文件下载是指服务器将存储在本地文件系统、数据库或远程存储中的文件,通过 HTTP 响应传输给客户端的过程。与文件上传(客户端→服务器)方向相反,但同样涉及 HTTP 协议中的 Content-Type、请求体/响应体等核心概念。
Spring MVC 中实现文件下载有两种基本思路:
超链接直接下载:在页面中放置
<a href="/files/report.pdf">链接,浏览器直接访问文件的真实 URL。这种方式简单,但会暴露服务器文件路径结构,且无法做权限控制。程序编码下载:由 Controller 方法读取文件流,手动设置响应头(Content-Type、Content-Disposition),将文件内容写入响应体。这种方式安全可控,可以在下载前检查用户权限、记录下载日志、动态生成文件内容。
Spring MVC 提供了 ResponseEntity 类,让开发者能够以声明式的方式构建包含文件流的 HTTP 响应,无需直接操作 HttpServletResponse 的 OutputStream。
生活类比:图书馆借书 vs 档案室调阅
- 超链接直接下载:像图书馆的开架借阅。书架上的书(文件)直接对读者可见,读者自己走过去拿(浏览器直接访问 URL)。好处是方便,坏处是任何人都能拿,珍贵书籍(敏感文件)无法保护。
- 程序编码下载:像档案室的调阅流程。你要先填申请表(Controller 方法),管理员检查你的身份(权限验证),然后从保险柜取出文件(读取文件流),装进密封袋(设置响应头),交到你手里(写入响应体)。流程复杂,但每一步都可控。
核心原理
文件下载流程
流程解读:
- 权限校验:在读取文件之前,先检查当前用户是否有下载权限。这是程序编码下载相比超链接下载的核心优势。
- 读取文件流:通过
Files.newInputStream()或new FileInputStream()获取文件的字节流。如果文件存储在数据库 BLOB 字段中,则通过 JDBC 读取。 - 设置 Content-Type:告诉浏览器响应体的 MIME 类型。
application/octet-stream表示"通用二进制流",浏览器不会尝试直接打开,而是触发下载行为。如果知道具体文件类型(如 PDF),可以设为application/pdf。 - 设置 Content-Disposition:这是触发浏览器下载对话框的关键响应头。
attachment表示"作为附件下载"(浏览器会弹保存对话框),filename="xxx.pdf"指定默认保存文件名。 - 写入响应体:将文件字节流复制到 HTTP 响应的输出流中。Spring MVC 的
ResponseEntity会自动处理流关闭和缓冲区管理。
两种下载方式对比
| 对比维度 | 超链接直接下载 | 程序编码下载(ResponseEntity) |
|---|---|---|
| 实现复杂度 | 极低,一个 <a> 标签即可 | 需要编写 Controller 方法 |
| 路径暴露 | 暴露服务器真实文件路径 | 路径完全隐藏,URL 可任意设计 |
| 权限控制 | ❌ 无法做下载前权限检查 | ✅ 可在读取文件前校验权限 |
| 下载统计 | ❌ 无法记录谁下载了什么 | ✅ 可记录用户、时间、IP |
| 动态内容 | ❌ 只能下载静态文件 | ✅ 可实时生成 Excel/PDF 再下载 |
| 中文文件名 | 依赖服务器编码配置 | ✅ 可程序控制编码,避免乱码 |
| 大文件支持 | 由服务器直接处理 | 需要设置缓冲区和流式传输 |
完整示例
场景
飞翔科技的员工管理系统需要支持"月度考勤报表下载"功能。CTO 大翔要求:
- 只有登录用户且属于 HR 部门才能下载
- 下载 URL 不能暴露服务器上报表的真实存储路径
- 中文文件名不能乱码
- 下载行为要记录审计日志
架构师白歌决定采用程序编码下载方案。小崔负责 Controller 实现,黄俪负责前端下载按钮,李眉负责文件存储目录的权限配置。
项目结构
employee-web/
├── src/main/java/
│ └── com/feixiang/web/
│ ├── DownloadController.java
│ └── config/
│ └── WebConfig.java
├── src/main/resources/
│ └── reports/ # 报表文件存储目录
│ └── 2024年3月考勤报表.xlsx
└── pom.xml
小崔的 Controller 实现
// DownloadController.java
package com.feixiang.web;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpSession;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@Controller
@RequestMapping("/downloads")
public class DownloadController {
@Autowired
private AuditLogService auditLogService;
@Autowired
private UserService userService;
private static final String REPORT_DIR = "src/main/resources/reports/";
/**
* 下载考勤报表
* URL: GET /downloads/reports/{filename}
*/
@GetMapping("/reports/{filename:.+}")
public ResponseEntity<InputStreamResource> downloadReport(
@PathVariable String filename,
HttpSession session) throws IOException {
// 1. 权限校验:检查用户是否登录且属于 HR 部门
User currentUser = (User) session.getAttribute("currentUser");
if (currentUser == null) {
return ResponseEntity.status(401).build(); // 未登录
}
if (!"HR".equals(currentUser.getDept())) {
return ResponseEntity.status(403).build(); // 无权限
}
// 2. 构造文件路径(注意:实际项目中应使用配置的路径,避免目录遍历攻击)
File file = new File(REPORT_DIR + filename);
if (!file.exists()) {
return ResponseEntity.notFound().build(); // 文件不存在
}
// 3. 记录审计日志
auditLogService.record(currentUser.getId(), "DOWNLOAD_REPORT", filename);
// 4. 读取文件流
InputStreamResource resource = new InputStreamResource(new FileInputStream(file));
// 5. 处理中文文件名乱码
String encodedFilename = encodeFilename(filename);
// 6. 构建响应头
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + encodedFilename + "\"");
headers.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate");
headers.add(HttpHeaders.PRAGMA, "no-cache");
headers.add(HttpHeaders.EXPIRES, "0");
// 7. 返回 ResponseEntity
return ResponseEntity.ok()
.headers(headers)
.contentLength(file.length())
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
/**
* 中文文件名编码处理
* 兼容 IE、Chrome、Firefox、Safari
*/
private String encodeFilename(String filename) throws UnsupportedEncodingException {
// 现代浏览器(Chrome、Firefox、Edge)使用 UTF-8 编码
return URLEncoder.encode(filename, StandardCharsets.UTF_8.toString())
.replace("+", "%20"); // 空格编码修正
}
}
代码分析:
@PathVariable("filename:.+")中的:.+是正则表达式,允许 filename 包含点号(如report.2024.xlsx)InputStreamResource是 Spring 对InputStream的包装,让 Spring 负责流的关闭和缓冲MediaType.APPLICATION_OCTET_STREAM即application/octet-stream,通用二进制流类型Content-Disposition: attachment强制浏览器弹出保存对话框,而非直接打开文件contentLength(file.length())告诉浏览器文件总大小,让下载进度条能正确显示
黄俪的前端下载按钮
<!-- reportList.html -->
<!DOCTYPE html>
<html>
<head>
<title>考勤报表</title>
</head>
<body>
<h1>月度考勤报表</h1>
<table>
<tr>
<th>报表名称</th>
<th>操作</th>
</tr>
<tr>
<td>2024年3月考勤报表.xlsx</td>
<td>
<!-- 直接指向 Controller URL,而非真实文件路径 -->
<a href="/downloads/reports/2024年3月考勤报表.xlsx"
class="download-btn">下载</a>
</td>
</tr>
</table>
</body>
</html>
黄俪的设计说明:前端只需要知道 /downloads/reports/{filename} 这个 URL,完全不知道服务器上文件存在 src/main/resources/reports/ 目录。即使以后文件改存到阿里云 OSS 或数据库 BLOB 中,前端代码无需任何修改。
李眉的部署配置
# application.yml
feixiang:
download:
report-dir: /var/feixiang/reports/ # 生产环境文件目录
allowed-depts: HR,ADMIN # 允许下载的部门
max-file-size: 10485760 # 最大 10MB
李眉的安全检查清单:
- ✅ 生产环境的报表目录放在应用部署目录之外,避免通过目录遍历下载源码
- ✅ Nginx 层面禁止直接访问
/reports/真实路径 - ✅ 所有下载请求必须经过 Controller 的权限校验
- ✅ 审计日志记录每次下载的用户、时间、文件名,保留 180 天
易错场景与面试考点
误区一:直接返回文件路径让浏览器访问
错误代码:
@GetMapping("/download")
public String download(String filename) {
// ❌ 错误:直接返回真实文件路径,暴露服务器目录结构
return "redirect:/uploads/" + filename;
}
风险:
- 攻击者可通过
filename=../../../etc/passwd进行目录遍历攻击 - 无法做权限控制,知道 URL 的人都能下载
- 无法记录下载日志
纠正:始终通过程序读取文件流,校验文件名合法性:
// 校验文件名,禁止路径穿越字符
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
throw new IllegalArgumentException("非法文件名");
}
误区二:中文文件名乱码
错误代码:
headers.add("Content-Disposition", "attachment; filename=\"2024年3月报表.xlsx\"");
// ❌ 未编码的中文文件名在 IE 中会乱码
纠正:不同浏览器对 Content-Disposition 的编码支持不同,需要兼容处理:
private String encodeFilename(String filename, String userAgent) throws UnsupportedEncodingException {
if (userAgent.contains("MSIE") || userAgent.contains("Trident")) {
// IE 浏览器使用 UTF-8 URL 编码
return URLEncoder.encode(filename, "UTF-8").replace("+", "%20");
} else {
// 现代浏览器使用 RFC 5987 编码
return new String(filename.getBytes("UTF-8"), "ISO-8859-1");
}
}
Spring 5 简化方案:使用 ContentDisposition 构建器(Spring 5.0+):
import org.springframework.http.ContentDisposition;
ContentDisposition disposition = ContentDisposition.attachment()
.filename("2024年3月报表.xlsx", StandardCharsets.UTF_8)
.build();
headers.setContentDisposition(disposition);
误区三:不设置 Content-Length
错误表现:下载大文件时,浏览器进度条显示"未知大小",用户体验差。
纠正:始终通过 contentLength(file.length()) 设置响应体大小。如果文件大小未知(如动态生成的流),至少设置 Transfer-Encoding: chunked。
面试高频:文件下载时 Content-Disposition 的 attachment 和 inline 有什么区别?
标准回答:
attachment:浏览器收到响应后,会弹出"保存文件"对话框,将文件保存到本地。适用于报表、附件等需要用户明确保存的场景。inline:浏览器会尝试直接在窗口内打开文件(如 PDF 用内置阅读器显示、图片直接展示)。适用于预览场景。
// 强制下载
ContentDisposition.attachment().filename("report.pdf").build();
// 浏览器内预览
ContentDisposition.inline().filename("report.pdf").build();
小结
文件下载有两种实现方式:超链接直接下载简单但不可控,程序编码下载安全且灵活。Spring MVC 的 ResponseEntity 配合 InputStreamResource,让开发者能够以声明式的方式构建下载响应,无需直接操作 Servlet 输出流。
实现程序编码下载时,必须注意三个关键点:权限校验在前、文件读取在后;中文文件名必须编码;Content-Length 让进度条可用。同时要做好目录遍历防护,禁止用户通过构造文件名访问服务器上的任意文件。
本章与全局的关系:本章与"MultipartFile 文件上传"共同构成文件传输的完整闭环。下一章"异常处理"将讲解如何在文件不存在、权限不足、磁盘满等异常场景下,给客户端返回友好的错误响应。