Spring - 文件上传与下载:真正的企业开发高频需求——Spring Boot文件上传与下载全场景实践指南

Spring Boot文件上传与下载全场景实践指南

引言

在企业级Web应用开发中,文件上传与下载是最常见的业务场景之一。从用户头像上传到合同文档下载,从Excel数据导入到日志文件导出,文件操作贯穿于几乎所有业务系统的生命周期。Spring Boot作为Java领域最流行的Web开发框架,其对文件上传下载的支持既保持了Spring生态的规范性,又通过自动化配置简化了开发复杂度。本文将从协议基础、环境搭建、核心实现、扩展场景到生产优化,系统性讲解Spring Boot处理文件请求的全流程,帮助开发者掌握从基础应用到高级实战的完整能力。


一、文件上传下载的基础认知

1.1 HTTP协议中的文件传输原理

文件作为二进制数据,无法直接通过普通表单提交(application/x-www-form-urlencoded)传输,必须使用multipart/form-data格式。该格式通过以下机制实现文件传输:

  • 多部分分隔 :每个表单字段(包括文件)被boundary分隔符分割成独立部分
  • 头部元信息 :每部分包含Content-Disposition头(标识字段名、文件名)和Content-Type头(文件MIME类型)
  • 二进制内容:文件的原始字节流紧随头部之后

示例请求包结构

http 复制代码
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="description"

测试文档
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="example.pdf"
Content-Type: application/pdf

%PDF-1.5
...(文件二进制内容)...
------WebKitFormBoundary7MA4YWxkTrZu0gW--

1.2 Spring Boot的文件处理核心组件

Spring Boot通过spring-web模块提供文件处理支持,核心组件包括:

  • MultipartResolver :负责解析HTTP请求中的multipart数据,默认实现为StandardServletMultipartResolver(基于Servlet 3.0+规范)
  • MultipartFile接口:封装上传文件的元数据(文件名、大小、MIME类型)和操作方法(获取输入流、转存文件)
  • MultipartConfigElement:配置文件上传的全局参数(最大文件大小、最大请求大小、临时存储目录等)

1.3 开发环境准备

创建Spring Boot项目时需勾选Spring Web依赖(自动包含文件处理所需组件)。对于需要更精细控制的场景(如传统MultipartResolver),可添加commons-fileupload依赖:

xml 复制代码
<!-- 基础Web依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- 可选:使用Apache Commons FileUpload解析器 -->
<dependency>
    <groupId>commons-fileupload</groupId>
    <artifactId>commons-fileupload</artifactId>
</dependency>

二、文件上传核心实现

2.1 单文件上传基础实现

2.1.1 控制器接口设计

通过@PostMapping注解定义上传接口,使用@RequestParam接收MultipartFile类型参数:

java 复制代码
@RestController
@RequestMapping("/file")
public class FileController {

    private static final Logger log = LoggerFactory.getLogger(FileController.class);

    /**
     * 单文件上传接口
     * @param file 上传的文件
     * @param description 文件描述(普通表单字段)
     * @return 上传结果
     */
    @PostMapping("/upload")
    public ResponseEntity<Map<String, Object>> uploadFile(
            @RequestParam("file") MultipartFile file,
            @RequestParam(required = false) String description) {

        // 1. 校验文件是否为空
        if (file.isEmpty()) {
            throw new IllegalArgumentException("上传文件不能为空");
        }

        // 2. 获取文件元信息
        String originalFilename = file.getOriginalFilename();
        long size = file.getSize();
        String contentType = file.getContentType();
        log.info("接收文件:{},大小:{} bytes,类型:{}", originalFilename, size, contentType);

        // 3. 定义存储路径(示例使用项目运行目录的upload文件夹)
        String storePath = System.getProperty("user.dir") + "/upload/" + originalFilename;
        File dest = new File(storePath);

        try {
            // 4. 转存文件(自动创建父目录)
            if (!dest.getParentFile().exists()) {
                dest.getParentFile().mkdirs();
            }
            file.transferTo(dest);
        } catch (IOException e) {
            log.error("文件保存失败", e);
            throw new RuntimeException("文件保存失败");
        }

        // 5. 返回结果
        Map<String, Object> result = new HashMap<>();
        result.put("filename", originalFilename);
        result.put("size", size);
        result.put("path", storePath);
        return ResponseEntity.ok(result);
    }
}
2.1.2 关键步骤说明
  • 参数接收@RequestParam("file")对应表单中name="file"的文件字段
  • 空文件校验file.isEmpty()判断是否为有效文件(避免空请求)
  • 文件转存transferTo()方法将临时文件移动到目标路径(Servlet容器会在请求处理完成后删除临时文件)
  • 路径处理 :使用System.getProperty("user.dir")获取项目运行目录,确保路径跨平台兼容

2.2 多文件上传实现

多文件上传通过MultipartFile[]List<MultipartFile>接收,处理逻辑与单文件类似:

java 复制代码
@PostMapping("/upload/batch")
public ResponseEntity<List<Map<String, Object>>> batchUpload(
        @RequestParam("files") MultipartFile[] files) {

    List<Map<String, Object>> resultList = new ArrayList<>();

    for (MultipartFile file : files) {
        if (file.isEmpty()) {
            continue; // 跳过空文件(可根据业务需求调整)
        }
        // 复用单文件处理逻辑
        Map<String, Object> result = processSingleFile(file);
        resultList.add(result);
    }

    return ResponseEntity.ok(resultList);
}

private Map<String, Object> processSingleFile(MultipartFile file) {
    // 与单文件上传中的处理逻辑一致
    // ...(省略具体实现)
}

2.3 全局参数配置

通过application.properties配置文件上传的全局限制,Spring Boot会自动装配MultipartConfigElement

properties 复制代码
# 单个文件最大大小(默认1MB)
spring.servlet.multipart.max-file-size=50MB
# 整个请求最大大小(默认10MB)
spring.servlet.multipart.max-request-size=200MB
# 是否启用multipart解析(默认true)
spring.servlet.multipart.enabled=true
# 超过该大小的文件会写入临时目录(默认0,所有文件都写入临时目录)
spring.servlet.multipart.file-size-threshold=2MB
# 临时存储目录(默认使用Servlet容器的临时目录)
spring.servlet.multipart.location=/tmp/upload-temp

参数作用说明

  • max-file-size:防止单个文件过大导致内存溢出
  • max-request-size:限制整个请求的总大小,防御大文件攻击
  • file-size-threshold:小文件直接在内存中处理,大文件写入临时目录(平衡内存与IO)

2.4 异常处理优化

文件上传可能抛出多种异常,需通过@ControllerAdvice全局捕获并返回友好提示:

java 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MultipartException.class)
    public ResponseEntity<Map<String, String>> handleMultipartException(MultipartException ex) {
        Map<String, String> error = new HashMap<>();
        if (ex instanceof MaxUploadSizeExceededException) {
            error.put("code", "413");
            error.put("message", "文件大小超过限制");
        } else {
            error.put("code", "500");
            error.put("message", "文件上传失败:" + ex.getMessage());
        }
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Map<String, String>> handleIllegalArgument(IllegalArgumentException ex) {
        Map<String, String> error = new HashMap<>();
        error.put("code", "400");
        error.put("message", ex.getMessage());
        return ResponseEntity.badRequest().body(error);
    }
}

三、文件下载核心实现

3.1 本地文件下载基础实现

下载接口需设置正确的响应头,告知浏览器文件类型和下载方式:

java 复制代码
@GetMapping("/download")
public ResponseEntity<Resource> downloadFile(
        @RequestParam String filename) {

    // 1. 构造文件路径(需校验文件名防止路径遍历攻击)
    String safeFilename = sanitizeFilename(filename);
    File file = new File(System.getProperty("user.dir") + "/upload/" + safeFilename);

    // 2. 校验文件是否存在
    if (!file.exists()) {
        return ResponseEntity.notFound().build();
    }

    // 3. 创建资源对象
    Resource resource = new FileSystemResource(file);

    // 4. 设置响应头
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, 
                    "attachment; filename=\"" + safeFilename + "\"")
            .header(HttpHeaders.CONTENT_TYPE, 
                    Files.probeContentType(file.toPath()))
            .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(file.length()))
            .body(resource);
}

/**
 * 文件名 sanitize 方法(防御路径遍历攻击)
 */
private String sanitizeFilename(String filename) {
    // 移除路径分隔符
    return filename.replaceAll("[\\\\/]", "");
}

3.2 关键响应头说明

  • Content-Dispositionattachment表示文件应被下载,filename指定下载后的文件名(需处理编码,避免中文乱码)
  • Content-Type :指定文件MIME类型(Files.probeContentType()自动检测,或手动指定如application/octet-stream
  • Content-Length:告知浏览器文件大小,显示下载进度条

3.3 动态生成文件下载

对于需要动态生成的文件(如实时报表、临时文件),可通过InputStreamResource直接输出流:

java 复制代码
@GetMapping("/download/generate")
public ResponseEntity<Resource> downloadGeneratedFile() {
    // 1. 动态生成文件内容(示例:生成CSV)
    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
    try (CSVPrinter csvPrinter = new CSVPrinter(new OutputStreamWriter(outputStream), CSVFormat.DEFAULT)) {
        csvPrinter.printRecord("姓名", "年龄", "邮箱");
        csvPrinter.printRecord("张三", 28, "zhangsan@example.com");
        csvPrinter.printRecord("李四", 32, "lisi@example.com");
    } catch (IOException e) {
        throw new RuntimeException("生成CSV失败", e);
    }

    // 2. 封装为Resource
    InputStreamResource resource = new InputStreamResource(
        new ByteArrayInputStream(outputStream.toByteArray())
    );

    // 3. 设置响应头(注意文件名编码)
    String encodedFilename = URLEncoder.encode("用户列表.csv", StandardCharsets.UTF_8);
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, 
                    "attachment; filename*=UTF-8''" + encodedFilename)
            .header(HttpHeaders.CONTENT_TYPE, "text/csv")
            .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(outputStream.size()))
            .body(resource);
}

文件名编码处理

  • 对于中文文件名,使用URLEncoder.encode()配合filename*=UTF-8''格式(兼容现代浏览器)
  • 传统方式可使用new String(filename.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1),但推荐新标准

3.4 大文件流式下载

直接读取大文件到内存会导致OOM,需使用流式传输:

java 复制代码
@GetMapping("/download/large")
public ResponseEntity<Resource> downloadLargeFile(
        @RequestParam String filename) {

    File file = new File(System.getProperty("user.dir") + "/upload/" + filename);
    if (!file.exists()) {
        return ResponseEntity.notFound().build();
    }

    // 使用FileSystemResource(内部使用RandomAccessFile,支持流式读取)
    Resource resource = new FileSystemResource(file);

    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(resource);
}

四、常见场景与解决方案

4.1 大文件分片上传

对于超过单文件大小限制的文件(如GB级文件),需采用分片上传方案:

  1. 前端分片:将文件分割为多个chunk(如每片5MB),并行上传
  2. 服务端接收:接收每个chunk并保存到临时目录
  3. 合并分片:所有chunk上传完成后,按顺序合并为完整文件

服务端核心接口示例

java 复制代码
@PostMapping("/upload/chunk")
public ResponseEntity<Map<String, Object>> uploadChunk(
        @RequestParam("chunk") MultipartFile chunk,
        @RequestParam("chunkNumber") Integer chunkNumber,
        @RequestParam("totalChunks") Integer totalChunks,
        @RequestParam("identifier") String identifier) { // 唯一标识(如文件MD5)

    // 1. 构造临时存储路径(按identifier分组)
    String tempDirPath = System.getProperty("user.dir") + "/upload/temp/" + identifier;
    File tempDir = new File(tempDirPath);
    if (!tempDir.exists()) {
        tempDir.mkdirs();
    }

    // 2. 保存分片(文件名格式:chunkNumber_identifier)
    File chunkFile = new File(tempDir, chunkNumber + "_" + identifier);
    try {
        chunk.transferTo(chunkFile);
    } catch (IOException e) {
        throw new RuntimeException("分片保存失败");
    }

    // 3. 检查是否所有分片已上传
    File[] chunks = tempDir.listFiles((dir, name) -> name.startsWith(chunkNumber + "_"));
    if (chunks != null && chunks.length == totalChunks) {
        mergeChunks(tempDir, identifier);
    }

    return ResponseEntity.ok(Collections.singletonMap("status", "chunk_uploaded"));
}

private void mergeChunks(File tempDir, String identifier) {
    String targetPath = System.getProperty("user.dir") + "/upload/" + identifier;
    try (RandomAccessFile targetFile = new RandomAccessFile(targetPath, "rw")) {
        // 按分片顺序合并
        for (int i = 0; i < totalChunks; i++) {
            File chunkFile = new File(tempDir, i + "_" + identifier);
            try (FileInputStream fis = new FileInputStream(chunkFile)) {
                byte[] buffer = new byte[1024 * 1024]; // 1MB缓冲区
                int len;
                while ((len = fis.read(buffer)) != -1) {
                    targetFile.write(buffer, 0, len);
                }
            }
            // 删除临时分片
            chunkFile.delete();
        }
    } catch (IOException e) {
        throw new RuntimeException("分片合并失败", e);
    }
    // 删除临时目录
    tempDir.delete();
}

4.2 断点续传

在分片上传基础上,通过记录已上传的分片编号实现断点续传:

  • 前端上传前检查服务端已存在的分片
  • 仅上传未完成的分片
  • 服务端需提供GET /upload/check接口返回已上传的分片列表

服务端检查接口实现

java 复制代码
@GetMapping("/upload/check")
public ResponseEntity<Map<String, Object>> checkUploadStatus(
        @RequestParam("identifier") String identifier) {
    
    String tempDirPath = System.getProperty("user.dir") + "/upload/temp/" + identifier;
    File tempDir = new File(tempDirPath);
    Set<Integer> uploadedChunks = new HashSet<>();

    if (tempDir.exists()) {
        File[] chunks = tempDir.listFiles();
        if (chunks != null) {
            for (File chunk : chunks) {
                // 文件名格式:chunkNumber_identifier
                String[] parts = chunk.getName().split("_");
                if (parts.length == 2) {
                    uploadedChunks.add(Integer.parseInt(parts[0]));
                }
            }
        }
    }

    Map<String, Object> result = new HashMap<>();
    result.put("uploadedChunks", uploadedChunks);
    result.put("isComplete", uploadedChunks.size() == totalChunks); // totalChunks需从前端传递或缓存获取
    return ResponseEntity.ok(result);
}

前端配合逻辑

  1. 上传前调用/upload/check接口获取已上传分片
  2. 仅上传未在uploadedChunks中的分片
  3. 所有分片上传完成后触发合并操作

4.3 文件类型校验与安全检测

仅依赖前端传递的Content-Type或文件名后缀存在安全风险,需通过文件头(Magic Number)进行真实类型校验:

文件类型校验工具类

java 复制代码
public class FileTypeValidator {
    // 常见文件类型Magic Number映射(部分示例)
    private static final Map<String, String> MAGIC_NUMBER_MAP = new HashMap<>();
    static {
        MAGIC_NUMBER_MAP.put("PDF", "25504446");    // %PDF
        MAGIC_NUMBER_MAP.put("ZIP", "504B0304");    // PK..
        MAGIC_NUMBER_MAP.put("JPEG", "FFD8FFE0");   // ÿØÿà
        MAGIC_NUMBER_MAP.put("PNG", "89504E47");    // .PNG
    }

    public static boolean validateFileType(MultipartFile file, Set<String> allowedTypes) {
        // 校验文件名后缀
        String extension = FilenameUtils.getExtension(file.getOriginalFilename()).toUpperCase();
        if (!allowedTypes.contains(extension)) {
            return false;
        }

        // 校验文件头Magic Number
        try (InputStream is = file.getInputStream()) {
            byte[] header = new byte[4];
            int bytesRead = is.read(header);
            if (bytesRead < 4) {
                return false;
            }
            String magicNumber = bytesToHex(header);
            String expectedMagic = MAGIC_NUMBER_MAP.get(extension);
            return expectedMagic != null && magicNumber.startsWith(expectedMagic);
        } catch (IOException e) {
            throw new RuntimeException("文件类型校验失败", e);
        }
    }

    private static String bytesToHex(byte[] bytes) {
        StringBuilder hex = new StringBuilder();
        for (byte b : bytes) {
            hex.append(String.format("%02X", b));
        }
        return hex.toString();
    }
}

在上传接口中使用

java 复制代码
@PostMapping("/upload/secure")
public ResponseEntity<?> secureUpload(@RequestParam("file") MultipartFile file) {
    Set<String> allowedTypes = Set.of("PDF", "ZIP", "JPEG", "PNG");
    if (!FileTypeValidator.validateFileType(file, allowedTypes)) {
        throw new IllegalArgumentException("非法文件类型");
    }
    // 继续上传逻辑...
}

4.4 上传进度监控

通过Commons FileUploadProgressListener实现上传进度追踪,前端通过轮询或WebSocket获取实时进度:

进度监听配置

java 复制代码
@Configuration
public class MultipartConfig {
    @Bean
    public CommonsMultipartResolver multipartResolver(ProgressListener progressListener) {
        CommonsMultipartResolver resolver = new CommonsMultipartResolver();
        resolver.setProgressListener(progressListener);
        resolver.setMaxUploadSize(200 * 1024 * 1024); // 200MB
        return resolver;
    }

    @Bean
    public ProgressListener progressListener() {
        return new UploadProgressListener();
    }

    public static class UploadProgressListener implements ProgressListener {
        private final ConcurrentHashMap<String, UploadProgress> progressMap = new ConcurrentHashMap<>();

        @Override
        public void update(long bytesRead, long contentLength, int items) {
            String requestId = RequestContextHolder.currentRequestAttributes().getSessionId();
            UploadProgress progress = new UploadProgress();
            progress.setBytesRead(bytesRead);
            progress.setTotalSize(contentLength);
            progress.setProgress(contentLength == 0 ? 0 : (int) (bytesRead * 100L / contentLength));
            progressMap.put(requestId, progress);
        }

        public UploadProgress getProgress(String requestId) {
            return progressMap.getOrDefault(requestId, new UploadProgress());
        }
    }

    @Data
    public static class UploadProgress {
        private long bytesRead;
        private long totalSize;
        private int progress;
    }
}

进度查询接口

java 复制代码
@GetMapping("/upload/progress")
public ResponseEntity<UploadProgress> getUploadProgress() {
    String requestId = RequestContextHolder.currentRequestAttributes().getSessionId();
    UploadProgress progress = progressListener.getProgress(requestId);
    return ResponseEntity.ok(progress);
}

4.5 云存储集成(以MinIO为例)

将文件存储从本地迁移至分布式对象存储,提升扩展性和可靠性:

MinIO配置与操作类

java 复制代码
@Configuration
public class MinioConfig {
    @Value("${minio.endpoint}")
    private String endpoint;
    @Value("${minio.access-key}")
    private String accessKey;
    @Value("${minio.secret-key}")
    private String secretKey;
    @Value("${minio.bucket}")
    private String bucket;

    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }

    @Bean
    public MinioTemplate minioTemplate(MinioClient client) throws Exception {
        if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
            client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
        }
        return new MinioTemplate(client, bucket);
    }
}

@Service
public class MinioTemplate {
    private final MinioClient client;
    private final String bucket;

    public MinioTemplate(MinioClient client, String bucket) {
        this.client = client;
        this.bucket = bucket;
    }

    public void upload(MultipartFile file, String objectName) throws Exception {
        client.putObject(PutObjectArgs.builder()
                .bucket(bucket)
                .object(objectName)
                .stream(file.getInputStream(), file.getSize(), -1)
                .contentType(file.getContentType())
                .build());
    }

    public InputStream download(String objectName) throws Exception {
        return client.getObject(GetObjectArgs.builder()
                .bucket(bucket)
                .object(objectName)
                .build());
    }

    public String getPresignedUrl(String objectName, int expires) throws Exception {
        return client.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
                .bucket(bucket)
                .object(objectName)
                .expiry(expires)
                .method(Method.GET)
                .build());
    }
}

在上传接口中使用MinIO

java 复制代码
@PostMapping("/upload/minio")
public ResponseEntity<?> uploadToMinio(@RequestParam("file") MultipartFile file) {
    String objectName = UUID.randomUUID() + "_" + file.getOriginalFilename();
    minioTemplate.upload(file, objectName);
    String downloadUrl = minioTemplate.getPresignedUrl(objectName, 3600); // 生成1小时有效下载链接
    return ResponseEntity.ok(Collections.singletonMap("downloadUrl", downloadUrl));
}