Skip to content

文件上传与下载

在Web应用中,文件上传和下载是常见的功能需求。Spring MVC提供了优雅且灵活的方式来处理这些功能。本文将详细介绍如何在Spring MVC中实现文件上传与下载。

文件上传

配置MultipartResolver

在Spring MVC中,文件上传功能需要配置MultipartResolver。Spring提供了两个实现类:

  1. CommonsMultipartResolver:基于Apache Commons FileUpload的实现
  2. StandardServletMultipartResolver:基于Servlet 3.0+的实现(推荐)

使用StandardServletMultipartResolver(Servlet 3.0+)

java
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
    
    @Bean
    public MultipartResolver multipartResolver() {
        return new StandardServletMultipartResolver();
    }
}

在Servlet配置中(如web.xml或Java配置):

xml
<!-- web.xml配置 -->
<servlet>
    <servlet-name>dispatcherServlet</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/spring-mvc.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
    <multipart-config>
        <location>/tmp</location>
        <max-file-size>5242880</max-file-size>
        <max-request-size>26214400</max-request-size>
        <file-size-threshold>0</file-size-threshold>
    </multipart-config>
</servlet>

Java配置:

java
public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
    // ...其他配置...
    
    @Override
    protected void customizeRegistration(ServletRegistration.Dynamic registration) {
        registration.setMultipartConfig(
            new MultipartConfigElement("/tmp",
                5 * 1024 * 1024,  // 最大文件大小 (5MB)
                25 * 1024 * 1024, // 最大请求大小 (25MB)
                0)                // 不缓存文件到内存
        );
    }
}

使用CommonsMultipartResolver

java
@Bean
public CommonsMultipartResolver multipartResolver() {
    CommonsMultipartResolver resolver = new CommonsMultipartResolver();
    resolver.setMaxUploadSizePerFile(5 * 1024 * 1024); // 5MB
    resolver.setMaxUploadSize(25 * 1024 * 1024);       // 25MB
    resolver.setDefaultEncoding("UTF-8");
    return resolver;
}

Spring Boot中的配置

在Spring Boot中,文件上传功能开箱即用,只需在application.propertiesapplication.yml中配置:

properties
# 应用属性配置
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=25MB
spring.servlet.multipart.enabled=true
spring.servlet.multipart.location=${java.io.tmpdir}

创建文件上传控制器

单文件上传

java
@Controller
public class FileUploadController {

    @PostMapping("/upload")
    public String handleFileUpload(@RequestParam("file") MultipartFile file,
                                   RedirectAttributes redirectAttributes) {
        
        if (file.isEmpty()) {
            redirectAttributes.addFlashAttribute("message", "请选择一个文件上传");
            return "redirect:/uploadForm";
        }

        try {
            // 获取文件名
            String originalFilename = file.getOriginalFilename();
            
            // 创建保存路径
            String uploadDir = "uploads/";
            File dir = new File(uploadDir);
            if (!dir.exists()) {
                dir.mkdirs();
            }
            
            // 创建目标文件
            File dest = new File(dir.getAbsolutePath() + File.separator + originalFilename);
            
            // 保存文件
            file.transferTo(dest);
            
            redirectAttributes.addFlashAttribute("message", 
                "文件上传成功: " + originalFilename);
                
        } catch (IOException e) {
            e.printStackTrace();
            redirectAttributes.addFlashAttribute("message", 
                "文件上传失败: " + e.getMessage());
        }
        
        return "redirect:/uploadForm";
    }
    
    @GetMapping("/uploadForm")
    public String showUploadForm() {
        return "uploadForm";
    }
}

多文件上传

java
@PostMapping("/uploadMultiple")
public String handleMultipleFileUpload(@RequestParam("files") MultipartFile[] files,
                                      RedirectAttributes redirectAttributes) {
    
    if (files.length == 0) {
        redirectAttributes.addFlashAttribute("message", "请选择至少一个文件上传");
        return "redirect:/uploadForm";
    }
    
    StringBuilder fileNames = new StringBuilder();
    
    for (MultipartFile file : files) {
        if (!file.isEmpty()) {
            try {
                String originalFilename = file.getOriginalFilename();
                String uploadDir = "uploads/";
                File dir = new File(uploadDir);
                if (!dir.exists()) {
                    dir.mkdirs();
                }
                
                File dest = new File(dir.getAbsolutePath() + File.separator + originalFilename);
                file.transferTo(dest);
                
                fileNames.append(originalFilename).append(", ");
                
            } catch (IOException e) {
                e.printStackTrace();
                redirectAttributes.addFlashAttribute("message", 
                    "部分文件上传失败: " + e.getMessage());
                return "redirect:/uploadForm";
            }
        }
    }
    
    redirectAttributes.addFlashAttribute("message", 
        "文件上传成功: " + fileNames.toString());
        
    return "redirect:/uploadForm";
}

上传表单页面

Thymeleaf模板:

html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>文件上传</title>
</head>
<body>
    <div th:if="${message}">
        <h2 th:text="${message}"></h2>
    </div>
    
    <div>
        <h3>单文件上传</h3>
        <form method="POST" enctype="multipart/form-data" action="/upload">
            <input type="file" name="file" />
            <button type="submit">上传</button>
        </form>
    </div>
    
    <div>
        <h3>多文件上传</h3>
        <form method="POST" enctype="multipart/form-data" action="/uploadMultiple">
            <input type="file" name="files" multiple />
            <button type="submit">上传多文件</button>
        </form>
    </div>
</body>
</html>

JSP页面:

jsp
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<html>
<head>
    <title>文件上传</title>
</head>
<body>
    <c:if test="${not empty message}">
        <h2>${message}</h2>
    </c:if>
    
    <div>
        <h3>单文件上传</h3>
        <form method="POST" enctype="multipart/form-data" action="<c:url value='/upload'/>">
            <input type="file" name="file" />
            <button type="submit">上传</button>
        </form>
    </div>
    
    <div>
        <h3>多文件上传</h3>
        <form method="POST" enctype="multipart/form-data" action="<c:url value='/uploadMultiple'/>">
            <input type="file" name="files" multiple />
            <button type="submit">上传多文件</button>
        </form>
    </div>
</body>
</html>

处理大文件上传

对于大文件上传,可以使用流式处理:

java
@PostMapping("/uploadLarge")
public ResponseEntity<String> handleLargeFileUpload(@RequestParam("file") MultipartFile file) {
    if (file.isEmpty()) {
        return ResponseEntity.badRequest().body("请选择一个文件上传");
    }
    
    try {
        String originalFilename = file.getOriginalFilename();
        String uploadDir = "uploads/large/";
        File dir = new File(uploadDir);
        if (!dir.exists()) {
            dir.mkdirs();
        }
        
        File dest = new File(dir.getAbsolutePath() + File.separator + originalFilename);
        
        // 使用流式处理大文件
        try (InputStream inputStream = file.getInputStream();
             FileOutputStream outputStream = new FileOutputStream(dest)) {
            
            byte[] buffer = new byte[1024];
            int bytesRead;
            
            while ((bytesRead = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, bytesRead);
            }
        }
        
        return ResponseEntity.ok("文件上传成功: " + originalFilename);
        
    } catch (IOException e) {
        e.printStackTrace();
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body("文件上传失败: " + e.getMessage());
    }
}

文件下载

基本文件下载

java
@GetMapping("/download/{fileName:.+}")
public ResponseEntity<Resource> downloadFile(@PathVariable String fileName, 
                                            HttpServletRequest request) {
    
    // 加载文件作为资源
    String uploadDir = "uploads/";
    Path path = Paths.get(uploadDir + fileName);
    Resource resource;
    
    try {
        resource = new UrlResource(path.toUri());
    } catch (MalformedURLException e) {
        return ResponseEntity.badRequest().build();
    }
    
    // 检查文件是否存在
    if (!resource.exists() || !resource.isReadable()) {
        return ResponseEntity.notFound().build();
    }
    
    // 确定文件的内容类型
    String contentType = null;
    try {
        contentType = request.getServletContext().getMimeType(resource.getFile().getAbsolutePath());
    } catch (IOException e) {
        // 无法确定内容类型
    }
    
    // 如果无法确定内容类型,则使用默认类型
    if (contentType == null) {
        contentType = "application/octet-stream";
    }
    
    return ResponseEntity.ok()
        .contentType(MediaType.parseMediaType(contentType))
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"")
        .body(resource);
}

流式下载大文件

对于大文件,可以使用流式下载:

java
@GetMapping("/downloadLarge/{fileName:.+}")
public void downloadLargeFile(@PathVariable String fileName, 
                            HttpServletResponse response) {
    
    String uploadDir = "uploads/large/";
    File file = new File(uploadDir + fileName);
    
    if (!file.exists()) {
        try {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件未找到");
            return;
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    
    // 设置内容类型和头信息
    response.setContentType("application/octet-stream");
    response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"");
    response.setContentLengthLong(file.length());
    
    // 使用流传输文件
    try (FileInputStream inputStream = new FileInputStream(file);
         OutputStream outputStream = response.getOutputStream()) {
        
        byte[] buffer = new byte[4096];
        int bytesRead;
        
        while ((bytesRead = inputStream.read(buffer)) != -1) {
            outputStream.write(buffer, 0, bytesRead);
        }
        
        outputStream.flush();
        
    } catch (IOException e) {
        e.printStackTrace();
    }
}

带进度反馈的下载

对于需要显示下载进度的情况,可以使用如下方式:

java
@GetMapping("/downloadWithProgress/{fileName:.+}")
public ResponseEntity<StreamingResponseBody> downloadWithProgress(@PathVariable String fileName) {
    
    String uploadDir = "uploads/";
    File file = new File(uploadDir + fileName);
    
    if (!file.exists()) {
        return ResponseEntity.notFound().build();
    }
    
    StreamingResponseBody responseBody = outputStream -> {
        try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) {
            byte[] buffer = new byte[1024];
            int bytesRead;
            while ((bytesRead = randomAccessFile.read(buffer)) != -1) {
                outputStream.write(buffer, 0, bytesRead);
                outputStream.flush();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    };
    
    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"")
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .contentLength(file.length())
        .body(responseBody);
}

安全注意事项

在实现文件上传和下载功能时,需要注意以下安全问题:

文件上传安全

  1. 验证文件类型:限制允许上传的文件类型
java
private boolean isValidFileType(MultipartFile file) {
    String fileName = file.getOriginalFilename();
    return fileName != null && (
        fileName.endsWith(".jpg") || 
        fileName.endsWith(".png") || 
        fileName.endsWith(".pdf"));
}
  1. 限制文件大小:防止DOS攻击
  2. 使用安全的文件名:防止路径遍历攻击
java
private String getSafeFileName(String fileName) {
    return fileName.replaceAll("[^a-zA-Z0-9.-]", "_")
                 .replace("..", "_");
}
  1. 将上传目录放在Web根目录之外:防止直接访问上传的文件
  2. 使用病毒扫描API:检测上传文件中的恶意代码

文件下载安全

  1. 验证用户权限:确保用户有权下载请求的文件
  2. 防止路径遍历:验证文件路径
java
private boolean isValidFilePath(String fileName) {
    Path requestedPath = Paths.get(fileName).normalize();
    Path baseDir = Paths.get("uploads").normalize().toAbsolutePath();
    Path resolvedPath = baseDir.resolve(requestedPath).normalize().toAbsolutePath();
    
    return resolvedPath.startsWith(baseDir);
}
  1. 设置适当的内容类型:防止浏览器将脚本作为HTML执行

实用技巧

断点续传

java
@GetMapping("/resumable/{fileName:.+}")
public ResponseEntity<Resource> resumableDownload(
        @PathVariable String fileName,
        @RequestHeader(value = "Range", required = false) String rangeHeader) {
    
    String uploadDir = "uploads/";
    Path path = Paths.get(uploadDir + fileName);
    Resource resource;
    
    try {
        resource = new UrlResource(path.toUri());
    } catch (MalformedURLException e) {
        return ResponseEntity.badRequest().build();
    }
    
    if (!resource.exists() || !resource.isReadable()) {
        return ResponseEntity.notFound().build();
    }
    
    long fileLength;
    try {
        fileLength = resource.contentLength();
    } catch (IOException e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }
    
    HttpHeaders headers = new HttpHeaders();
    headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"");
    headers.add(HttpHeaders.ACCEPT_RANGES, "bytes");
    
    // 处理范围请求
    if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
        String[] ranges = rangeHeader.substring(6).split("-");
        long start = Long.parseLong(ranges[0]);
        long end = ranges.length > 1 ? Long.parseLong(ranges[1]) : fileLength - 1;
        
        if (start >= fileLength) {
            return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
                .header(HttpHeaders.CONTENT_RANGE, "bytes */" + fileLength)
                .build();
        }
        
        if (end >= fileLength) {
            end = fileLength - 1;
        }
        
        long contentLength = end - start + 1;
        
        try {
            InputStream inputStream = resource.getInputStream();
            inputStream.skip(start);
            
            return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
                .headers(headers)
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .contentLength(contentLength)
                .header(HttpHeaders.CONTENT_RANGE, "bytes " + start + "-" + end + "/" + fileLength)
                .body(new InputStreamResource(new BoundedInputStream(inputStream, contentLength)));
        } catch (IOException e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }
    
    // 正常下载
    return ResponseEntity.ok()
        .headers(headers)
        .contentLength(fileLength)
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
}

// 辅助类,限制输入流大小
class BoundedInputStream extends InputStream {
    private final InputStream delegate;
    private long remaining;
    
    public BoundedInputStream(InputStream delegate, long limit) {
        this.delegate = delegate;
        this.remaining = limit;
    }
    
    @Override
    public int read() throws IOException {
        if (remaining <= 0) {
            return -1;
        }
        
        int result = delegate.read();
        if (result != -1) {
            remaining--;
        }
        
        return result;
    }
    
    @Override
    public int read(byte[] b, int off, int len) throws IOException {
        if (remaining <= 0) {
            return -1;
        }
        
        int maxLen = (int) Math.min(len, remaining);
        int result = delegate.read(b, off, maxLen);
        if (result != -1) {
            remaining -= result;
        }
        
        return result;
    }
    
    @Override
    public void close() throws IOException {
        delegate.close();
    }
}

异步文件上传与下载

java
@PostMapping("/uploadAsync")
public DeferredResult<ResponseEntity<String>> uploadAsync(@RequestParam("file") MultipartFile file) {
    DeferredResult<ResponseEntity<String>> deferredResult = new DeferredResult<>();
    
    CompletableFuture.runAsync(() -> {
        try {
            // 文件保存逻辑
            String originalFilename = file.getOriginalFilename();
            String uploadDir = "uploads/";
            Path path = Paths.get(uploadDir);
            if (!Files.exists(path)) {
                Files.createDirectories(path);
            }
            
            Files.copy(file.getInputStream(), 
                     path.resolve(originalFilename),
                     StandardCopyOption.REPLACE_EXISTING);
            
            deferredResult.setResult(
                ResponseEntity.ok("文件上传成功: " + originalFilename));
                
        } catch (Exception e) {
            deferredResult.setResult(
                ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body("文件上传失败: " + e.getMessage()));
        }
    });
    
    return deferredResult;
}

总结

Spring MVC提供了完善的文件上传和下载支持,可以满足各种复杂场景的需求。在实现这些功能时,应注意以下几点:

  1. 适当配置MultipartResolver以支持文件上传
  2. 根据需求选择合适的文件上传和下载策略
  3. 对于大文件传输,使用流式处理
  4. 注意文件上传和下载的安全隐患
  5. 通过异步处理和断点续传等技术提高用户体验