乐途乐途
主页
  • 计算机基础

    • TCP/IP协议
    • Linux命令
    • HTTP协议
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C语言
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • MyBatis
    • Spring
    • Spring MVC
    • Maven 入门
    • Maven 进阶
    • Java 设计模式
  • 缓存

    • Redis
联系
阿里云
主页
  • 计算机基础

    • TCP/IP协议
    • Linux命令
    • HTTP协议
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C语言
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • MyBatis
    • Spring
    • Spring MVC
    • Maven 入门
    • Maven 进阶
    • Java 设计模式
  • 缓存

    • Redis
联系
阿里云
  • 学习路径
  • 第1章 SpringMVC概述与DispatcherServlet

    • 本章导读:Spring MVC概述与DispatcherServlet
    • Spring MVC 是什么
    • MVC 设计模式
    • 前端控制器模式
    • DispatcherServlet
    • 核心组件协作
  • 第2章 控制器与请求映射

    • 本章导读:控制器与请求映射
    • Controller
    • RestController
    • RequestMapping
    • GetMapping
    • PostMapping
    • PutMapping
    • DeleteMapping
    • PathVariable
    • RESTful
    • 请求映射原理
  • 第3章 请求参数获取与转换

    • 本章导读:请求参数获取与转换
    • RequestParam
    • RequestBody
    • RequestHeader
    • CookieValue
    • Model
    • ModelAttribute
    • 数据绑定原理
    • 数据校验
  • 第4章 响应数据与视图解析

    • 本章导读:响应数据与视图解析
    • ResponseBody
    • ResponseEntity
    • ModelAndView
    • ViewResolver
    • HttpMessageConverter
    • forward与redirect
  • 第5章 拦截器过滤器与跨域

    • 本章导读:拦截器、过滤器与跨域
    • HandlerInterceptor
    • WebMvcConfigurer
    • CrossOrigin
    • 登录验证实战
  • 第6章 文件上传与异常处理

    • 本章导读:文件上传与异常处理
    • MultipartFile
    • 文件下载
    • ExceptionHandler
    • ControllerAdvice
    • RestControllerAdvice
    • ResponseStatus
  • 第7章 高级特性与最佳实践

    • 本章导读:高级特性与最佳实践
    • SessionAttributes
    • SessionAttribute
    • RedirectAttributes
    • MockMvc测试
    • 国际化
    • 最佳实践
  • 第8章 扩展与异步机制

    • 本章导读:扩展与异步机制
    • 异步请求处理
    • 自定义参数解析器
    • 内容协商

文件下载

本章是 Spring MVC 教程中"文件上传与异常处理"章节的成对内容。上一章讲解了 MultipartFile 处理文件上传,本章讲解文件下载——两者共同构成 Web 应用中文件传输的完整闭环。理解下载的实现原理,是处理报表导出、附件下载、静态资源分发等需求的必备能力。


定义与作用

文件下载是指服务器将存储在本地文件系统、数据库或远程存储中的文件,通过 HTTP 响应传输给客户端的过程。与文件上传(客户端→服务器)方向相反,但同样涉及 HTTP 协议中的 Content-Type、请求体/响应体等核心概念。

Spring MVC 中实现文件下载有两种基本思路:

  1. 超链接直接下载:在页面中放置 <a href="/files/report.pdf"> 链接,浏览器直接访问文件的真实 URL。这种方式简单,但会暴露服务器文件路径结构,且无法做权限控制。

  2. 程序编码下载:由 Controller 方法读取文件流,手动设置响应头(Content-Type、Content-Disposition),将文件内容写入响应体。这种方式安全可控,可以在下载前检查用户权限、记录下载日志、动态生成文件内容。

Spring MVC 提供了 ResponseEntity 类,让开发者能够以声明式的方式构建包含文件流的 HTTP 响应,无需直接操作 HttpServletResponse 的 OutputStream。

生活类比:图书馆借书 vs 档案室调阅

  • 超链接直接下载:像图书馆的开架借阅。书架上的书(文件)直接对读者可见,读者自己走过去拿(浏览器直接访问 URL)。好处是方便,坏处是任何人都能拿,珍贵书籍(敏感文件)无法保护。
  • 程序编码下载:像档案室的调阅流程。你要先填申请表(Controller 方法),管理员检查你的身份(权限验证),然后从保险柜取出文件(读取文件流),装进密封袋(设置响应头),交到你手里(写入响应体)。流程复杂,但每一步都可控。

核心原理

文件下载流程

流程解读:

  1. 权限校验:在读取文件之前,先检查当前用户是否有下载权限。这是程序编码下载相比超链接下载的核心优势。
  2. 读取文件流:通过 Files.newInputStream() 或 new FileInputStream() 获取文件的字节流。如果文件存储在数据库 BLOB 字段中,则通过 JDBC 读取。
  3. 设置 Content-Type:告诉浏览器响应体的 MIME 类型。application/octet-stream 表示"通用二进制流",浏览器不会尝试直接打开,而是触发下载行为。如果知道具体文件类型(如 PDF),可以设为 application/pdf。
  4. 设置 Content-Disposition:这是触发浏览器下载对话框的关键响应头。attachment 表示"作为附件下载"(浏览器会弹保存对话框),filename="xxx.pdf" 指定默认保存文件名。
  5. 写入响应体:将文件字节流复制到 HTTP 响应的输出流中。Spring MVC 的 ResponseEntity 会自动处理流关闭和缓冲区管理。

两种下载方式对比

对比维度超链接直接下载程序编码下载(ResponseEntity)
实现复杂度极低,一个 <a> 标签即可需要编写 Controller 方法
路径暴露暴露服务器真实文件路径路径完全隐藏,URL 可任意设计
权限控制❌ 无法做下载前权限检查✅ 可在读取文件前校验权限
下载统计❌ 无法记录谁下载了什么✅ 可记录用户、时间、IP
动态内容❌ 只能下载静态文件✅ 可实时生成 Excel/PDF 再下载
中文文件名依赖服务器编码配置✅ 可程序控制编码,避免乱码
大文件支持由服务器直接处理需要设置缓冲区和流式传输

完整示例

场景

飞翔科技的员工管理系统需要支持"月度考勤报表下载"功能。CTO 大翔要求:

  1. 只有登录用户且属于 HR 部门才能下载
  2. 下载 URL 不能暴露服务器上报表的真实存储路径
  3. 中文文件名不能乱码
  4. 下载行为要记录审计日志

架构师白歌决定采用程序编码下载方案。小崔负责 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;
}

风险:

  1. 攻击者可通过 filename=../../../etc/passwd 进行目录遍历攻击
  2. 无法做权限控制,知道 URL 的人都能下载
  3. 无法记录下载日志

纠正:始终通过程序读取文件流,校验文件名合法性:

// 校验文件名,禁止路径穿越字符
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 文件上传"共同构成文件传输的完整闭环。下一章"异常处理"将讲解如何在文件不存在、权限不足、磁盘满等异常场景下,给客户端返回友好的错误响应。

上一页
MultipartFile
下一页
ExceptionHandler