核心原理
Servlet 实现文件下载的本质,不是将文件内容写入到 Servlet 的输出流中,而是通过设置 HTTP 响应头,通知浏览器这是一个需要下载的文件,而不是一个需要解析显示的 HTML 或图片。

关键的两个 HTTP 响应头是:
-
Content-Type(内容类型)- 作用:告诉浏览器响应体中数据的类型。
- 下载场景:我们需要将其设置为
application/octet-stream(二进制流),这是一个通用的类型,浏览器遇到它就会触发下载行为,而不是尝试在窗口中打开。
-
Content-Disposition(内容处置)- 作用:告诉浏览器如何处理响应体。
- 下载场景:这是最关键的头信息,我们需要设置为
attachment,表示这是一个附件,可以通过filename参数指定默认的下载文件名。 - 格式:
Content-Disposition: attachment; filename="要显示的文件名.ext"
流程总结:

- 服务器端:读取要下载的文件。
- 设置响应头:
Content-Type为application/octet-stream,Content-Disposition包含attachment和filename。 - 将文件的二进制数据写入到
HttpServletResponse的输出流 (getOutputStream()) 中。 - 浏览器:接收到这些响应头后,识别出这是一个附件,于是弹出“另存为”对话框,让用户保存文件。
基础实现代码
下面是一个最简单、最直接的 Servlet 下载实现。
Servlet 代码 (DownloadServlet.java)
假设你的文件存放在 webapp 下的 download 文件夹中。
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
@WebServlet("/download")
public class DownloadServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 1. 获取要下载的文件名
String fileName = req.getParameter("fileName");
if (fileName == null || fileName.isEmpty()) {
resp.getWriter().write("Error: fileName parameter is missing.");
return;
}
// 2. 设置文件在服务器上的实际路径 (注意安全,不要直接暴露根目录)
// getServletContext().getRealPath("/") 获取 webapp 的绝对路径
String realPath = getServletContext().getRealPath("/download/");
File downloadFile = new File(realPath + fileName);
// 3. 检查文件是否存在
if (!downloadFile.exists()) {
resp.getWriter().write("Error: File not found.");
return;
}
// 4. 设置响应头
// 告诉浏览器这是一个需要下载的文件
resp.setContentType("application/octet-stream");
// 设置Content-Disposition,指定下载的文件名
// 注意:这里直接使用文件名,如果文件名是中文,可能会乱码,后面会解决
resp.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
// 5. 获取文件的输入流
try (FileInputStream in = new FileInputStream(downloadFile);
// 获取响应的输出流
OutputStream out = resp.getOutputStream()) {
// 6. 使用缓冲区进行文件拷贝
byte[] buffer = new byte[4096]; // 4KB的缓冲区
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
}
}
}
如何访问
假设你的项目名为 mywebapp,并且你在 webapp/download 目录下放了一个名为 test.txt 的文件。
你可以通过以下 URL 访问 Servlet:
http://localhost:8080/mywebapp/download?fileName=test.txt

浏览器就会弹出下载对话框,让你保存 test.txt。
关键问题与解决方案
问题1:中文文件名乱码
这是最常见的问题,浏览器对文件名的编码方式不同,导致 filename 参数的值无法被正确解析。
- 旧版IE (IE 9及以下):使用
GBK编码。 - 新版IE (IE 10及以上)、Chrome、Firefox:使用
UTF-8编码。
解决方案:我们需要根据不同的浏览器,使用不同的编码方式来处理文件名。
改进后的 DownloadServlet:
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@WebServlet("/download")
public class DownloadServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String fileName = req.getParameter("fileName");
if (fileName == null || fileName.isEmpty()) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "fileName parameter is missing.");
return;
}
String realPath = getServletContext().getRealPath("/download/");
File downloadFile = new File(realPath + fileName);
if (!downloadFile.exists()) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND, "File not found.");
return;
}
// 1. 设置 Content-Type
resp.setContentType("application/octet-stream");
// 2. 处理文件名编码
String agent = req.getHeader("User-Agent"); // 获取浏览器代理信息
String encodedFileName = null;
try {
if (agent.contains("MSIE") || agent.contains("Trident")) { // IE浏览器
encodedFileName = URLEncoder.encode(fileName, "UTF-8");
encodedFileName = encodedFileName.replace("+", " "); // IE中空格会被替换为+
} else if (agent.contains("Firefox")) { // Firefox
encodedFileName = "=?UTF-8?B?" + new String(java.util.Base64.getEncoder().encode(fileName.getBytes(StandardCharsets.UTF_8))) + "?=";
} else { // Chrome, Safari等其他浏览器
encodedFileName = URLEncoder.encode(fileName, "UTF-8);
}
} catch (UnsupportedEncodingException e) {
encodedFileName = fileName; // 如果编码失败,使用原始文件名
}
// 3. 设置 Content-Disposition
resp.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");
// 4. 设置文件大小 (可选,但推荐)
resp.setContentLengthLong(downloadFile.length());
// 5. 拷贝文件
try (FileInputStream in = new FileInputStream(downloadFile);
OutputStream out = resp.getOutputStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
}
}
}
问题2:大文件下载与内存问题
上面的代码使用 FileInputStream 和 OutputStream 逐块读写,这种方式对于大文件是可行的,因为它不会一次性将整个文件加载到内存中,如果并发下载用户非常多,每个请求都保持一个文件流打开,可能会消耗大量文件句柄资源。
更现代、更高效的方式是使用 NIO (New I/O)。
NIO 的 FileChannel 和 transferTo 方法可以利用操作系统的零拷贝技术,将文件直接从磁盘传输到网络套接字,效率更高,代码也更简洁。
使用 NIO 的 DownloadServlet:
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.nio.channels.Channels;
@WebServlet("/download-nio")
public class DownloadNIOServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String fileName = req.getParameter("fileName");
if (fileName == null || fileName.isEmpty()) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "fileName parameter is missing.");
return;
}
String realPath = getServletContext().getRealPath("/download/");
File downloadFile = new File(realPath + fileName);
if (!downloadFile.exists()) {
resp.sendError(HttpServletResponse.SC_NOT_FOUND, "File not found.");
return;
}
// 设置响应头 (文件名处理逻辑同上,这里省略)
resp.setContentType("application/octet-stream");
// ... (添加文件名编码处理代码)
resp.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\""); // 简化示例
resp.setContentLengthLong(downloadFile.length());
// 使用 NIO 进行文件传输
try (RandomAccessFile raf = new RandomAccessFile(downloadFile, "r");
FileChannel channel = raf.getChannel()) {
// 将 FileChannel 的数据传输到 HttpServletResponse 的输出流
// transferTo 会尽可能多地传输数据,返回实际传输的字节数
long transferred = channel.transferTo(0, channel.size(), Channels.newChannel(resp.getOutputStream()));
System.out.println("Transferred " + transferred + " bytes.");
}
}
}
NIO 的优势:
- 性能更高:零拷贝减少了数据在内核空间和用户空间之间的复制次数。
- 代码更简洁:
transferTo方法封装了复杂的读写逻辑。
最佳实践与安全考虑
-
安全性:防止路径遍历攻击
- 绝对不要直接将用户提供的文件名拼接到文件路径中,
new File("/download/" + req.getParameter("fileName"))。 - 恶意用户可以通过 来访问服务器上任何位置的文件(
fileName=../../../etc/passwd)。 - 解决方案:在拼接路径前,对文件名进行校验和过滤,只允许合法的文件名字符(如字母、数字、下划线、点等)。
// 安全的文件名处理示例 String requestedFileName = req.getParameter("fileName"); if (requestedFileName == null || !requestedFileName.matches("^[a-zA-Z0-9._-]+$")) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid file name."); return; } String safeFileName = new File(requestedFileName).getName(); // 获取文件名部分,防止路径 File downloadFile = new File(realPath, safeFileName); - 绝对不要直接将用户提供的文件名拼接到文件路径中,
-
用户体验:显示下载进度
- 通过设置
Content-Length响应头,浏览器可以显示下载进度条和预计剩余时间。 - 代码中已经展示:
resp.setContentLengthLong(downloadFile.length());
- 通过设置
-
代码结构:
- 将文件下载逻辑封装成一个工具类(
FileDownloadUtils),Servlet 只负责接收请求和调用工具类,这样代码更清晰、更易于维护和测试。
- 将文件下载逻辑封装成一个工具类(
| 功能点 | 实现方式 | 关键代码/头信息 |
|---|---|---|
| 触发下载 | 设置 Content-Type 为通用二进制流 |
resp.setContentType("application/octet-stream"); |
| 指定文件名 | 设置 Content-Disposition 为 attachment |
resp.setHeader("Content-Disposition", "attachment; filename=\"...\""); |
| 处理中文乱码 | 根据 User-Agent 使用不同编码 |
URLEncoder.encode(), Base64.encode() |
| 大文件处理 | 使用 NIO 的 FileChannel.transferTo() |
channel.transferTo(0, size, response.getOutputStream()); |
| 安全性 | 过滤用户输入的文件名,防止 | fileName.matches("^[a-zA-Z0-9._-]+$") |
| 进度显示 | 设置 Content-Length |
resp.setContentLengthLong(file.length()); |
掌握以上几点,你就可以在 Java Web 应用中实现一个健壮、安全、高效的文件下载功能。
