杰瑞科技汇

java servlet 下载

核心原理

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

java servlet 下载-图1
(图片来源网络,侵删)

关键的两个 HTTP 响应头是:

  1. Content-Type (内容类型)

    • 作用:告诉浏览器响应体中数据的类型。
    • 下载场景:我们需要将其设置为 application/octet-stream (二进制流),这是一个通用的类型,浏览器遇到它就会触发下载行为,而不是尝试在窗口中打开。
  2. Content-Disposition (内容处置)

    • 作用:告诉浏览器如何处理响应体。
    • 下载场景:这是最关键的头信息,我们需要设置为 attachment,表示这是一个附件,可以通过 filename 参数指定默认的下载文件名。
    • 格式Content-Disposition: attachment; filename="要显示的文件名.ext"

流程总结:

java servlet 下载-图2
(图片来源网络,侵删)
  1. 服务器端:读取要下载的文件。
  2. 设置响应头:Content-Typeapplication/octet-streamContent-Disposition 包含 attachmentfilename
  3. 将文件的二进制数据写入到 HttpServletResponse 的输出流 (getOutputStream()) 中。
  4. 浏览器:接收到这些响应头后,识别出这是一个附件,于是弹出“另存为”对话框,让用户保存文件。

基础实现代码

下面是一个最简单、最直接的 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

java servlet 下载-图3
(图片来源网络,侵删)

浏览器就会弹出下载对话框,让你保存 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:大文件下载与内存问题

上面的代码使用 FileInputStreamOutputStream 逐块读写,这种方式对于大文件是可行的,因为它不会一次性将整个文件加载到内存中,如果并发下载用户非常多,每个请求都保持一个文件流打开,可能会消耗大量文件句柄资源。

更现代、更高效的方式是使用 NIO (New I/O)。

NIO 的 FileChanneltransferTo 方法可以利用操作系统的零拷贝技术,将文件直接从磁盘传输到网络套接字,效率更高,代码也更简洁。

使用 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 方法封装了复杂的读写逻辑。

最佳实践与安全考虑

  1. 安全性:防止路径遍历攻击

    • 绝对不要直接将用户提供的文件名拼接到文件路径中,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);
  2. 用户体验:显示下载进度

    • 通过设置 Content-Length 响应头,浏览器可以显示下载进度条和预计剩余时间。
    • 代码中已经展示:resp.setContentLengthLong(downloadFile.length());
  3. 代码结构:

    • 将文件下载逻辑封装成一个工具类(FileDownloadUtils),Servlet 只负责接收请求和调用工具类,这样代码更清晰、更易于维护和测试。
功能点 实现方式 关键代码/头信息
触发下载 设置 Content-Type 为通用二进制流 resp.setContentType("application/octet-stream");
指定文件名 设置 Content-Dispositionattachment 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 应用中实现一个健壮、安全、高效的文件下载功能。

分享:
扫描分享到社交APP
上一篇
下一篇