17.1 处理器的选择

Spring对文件上传做了封装,我们可以更加方便的实现文件上传。从 Spring3.1 开始,对于文件上传,提供了两个处理器:

  • CommonsMultipartResolver
    这个处理器兼容性较好,可以兼容 Servlet3.0 之前的版本,但是它依赖了 commons-fileupload 这个第三方工具。
  • StandardServletMultipartResolver
    这个处理器只适用于 Servlet3.0 之后的版本,但它不依赖第三方工具,可以直接开发文件上传功能。

本章讨论的功能均使用StandardServletMultipartResolver处理器。

17.2 管理单文个件上传

17.2.1 API设计目标

在本节我们实现Restful风格的APIs并提供以下的功能:

  • 在服务端保存客户端上传的单个文
  • 限制客户端上传文件的大小
  • 获得已上传文件名和下载地址的列表
  • 根据地址下载文件

下面是教程所实现的API列表:

请求方式 URL地址 说明
POST /api/file/upload 上传一个文件
GET /api/file/list 获取已上传文件列表
GET /api/file/download/{filename} 根据地址下载文件

本节后文所有的代码都在包 com.longser.union.cloud.service.fileuplad 下。

17.2.2 文件上传管理代码

创建FileStorageService文件管理接口

  1. package com.longser.union.cloud.service.fileuplad;
  2. import org.springframework.core.io.Resource;
  3. import org.springframework.web.multipart.MultipartFile;
  4. import java.nio.file.Path;
  5. import java.util.stream.Stream;
  6. public interface FileStorageService {
  7. void setFilePath(String filePath);
  8. void save(MultipartFile multipartFile);
  9. void save(MultipartFile multipartFile, String filePath);
  10. Resource file(String filename, String filePath);
  11. Resource file(String filename);
  12. Stream<Path> list(String filePath);
  13. Stream<Path> list();
  14. }

以及接口的实现类:

  1. package com.longser.union.cloud.service.fileuplad;
  2. import org.springframework.core.io.Resource;
  3. import org.springframework.core.io.UrlResource;
  4. import org.springframework.stereotype.Service;
  5. import org.springframework.util.Assert;
  6. import org.springframework.web.multipart.MultipartFile;
  7. import java.io.IOException;
  8. import java.net.MalformedURLException;
  9. import java.nio.file.Files;
  10. import java.nio.file.Path;
  11. import java.nio.file.Paths;
  12. import java.util.UUID;
  13. import java.util.stream.Stream;
  14. @Service("fileStorageService")
  15. public class FileStorageServiceImpl implements FileStorageService {
  16. private final String defaultPath = "uploadFiles";
  17. private Path path = Paths.get(defaultPath);
  18. private String makeUniqueName(String originalName) {
  19. String fileSuffix = originalName.substring(originalName.lastIndexOf("."));
  20. return UUID.randomUUID().toString() + fileSuffix;
  21. }
  22. @Override
  23. public void setFilePath(String filePath) {
  24. if(filePath != null && !"".equals(filePath)) {
  25. this.path = Paths.get(filePath);
  26. }
  27. }
  28. @Override
  29. public void save(MultipartFile file, String filePath) {
  30. try {
  31. String originalFilename = file.getOriginalFilename();
  32. Assert.notNull(originalFilename,"Could not get original filename.");
  33. if(filePath == null || "".equals(filePath)) {
  34. Files.copy(file.getInputStream(), this.path.resolve(makeUniqueName(originalFilename)));
  35. } else {
  36. Files.copy(file.getInputStream(), Paths.get(filePath).resolve(makeUniqueName(originalFilename)));
  37. }
  38. } catch (IOException e) {
  39. throw new RuntimeException("无法保存文件,错误:"+e.getMessage());
  40. }
  41. }
  42. @Override
  43. public void save(MultipartFile multipartFile) {
  44. this.save(multipartFile, "");
  45. }
  46. @Override
  47. public Resource file(String filename, String filePath) {
  48. Path file;
  49. if(filePath == null || "".equals(filePath)) {
  50. file = this.path.resolve(filename);
  51. } else {
  52. file = Paths.get(filePath).resolve(filename);
  53. }
  54. try {
  55. Resource resource = new UrlResource(file.toUri());
  56. if(resource.exists() || resource.isReadable()){
  57. return resource;
  58. }else{
  59. throw new RuntimeException("无法读文件");
  60. }
  61. } catch (MalformedURLException e) {
  62. throw new RuntimeException("错误:"+e.getMessage());
  63. }
  64. }
  65. @Override
  66. public Resource file(String filename) {
  67. return file(filename, "");
  68. }
  69. @Override
  70. public Stream<Path> list(String filePath) {
  71. try {
  72. if(filePath == null || "".equals(filePath)) {
  73. return Files.walk(this.path,1)
  74. .filter(path -> !path.equals(this.path))
  75. .map(this.path::relativize);
  76. } else {
  77. Path thePath = Paths.get(filePath);
  78. return Files.walk(thePath,1)
  79. .filter(path -> !path.equals(thePath))
  80. .map(thePath::relativize);
  81. }
  82. } catch (IOException e) {
  83. throw new RuntimeException("无法加载文件");
  84. }
  85. }
  86. @Override
  87. public Stream<Path> list() {
  88. return list("");
  89. }
  90. }

17.2.3 文件对象及路径配置

文件对象类

  1. package com.longser.union.cloud.service.fileuplad;
  2. public class UploadFile {
  3. private String fileName;
  4. private String url;
  5. public UploadFile(String fileName, String url) {
  6. this.fileName = fileName;
  7. this.url = url;
  8. }
  9. public String getFileName() {
  10. return fileName;
  11. }
  12. public void setFileName(String fileName) {
  13. this.fileName = fileName;
  14. }
  15. public String getUrl() {
  16. return url;
  17. }
  18. public void setUrl(String url) {
  19. this.url = url;
  20. }
  21. }

对应配置文件的上传文件路径类

  1. package com.longser.union.cloud.service.fileuplad;
  2. import org.springframework.beans.factory.annotation.Value;
  3. import org.springframework.stereotype.Component;
  4. @Component
  5. public class FilePath {
  6. @Value("${application.file.upload.path}")
  7. private String path;
  8. public String getPath() {
  9. return path;
  10. }
  11. }

17.2.4 文件上传管理基类

  1. package com.longser.union.cloud.service.fileuplad;
  2. import com.longser.restful.result.RestfulResult;
  3. import org.springframework.beans.factory.annotation.Autowired;
  4. import org.springframework.core.io.Resource;
  5. import org.springframework.http.HttpHeaders;
  6. import org.springframework.http.ResponseEntity;
  7. import org.springframework.web.bind.annotation.RequestParam;
  8. import org.springframework.web.multipart.MultipartFile;
  9. import java.util.List;
  10. import java.util.stream.Collectors;
  11. public class FileUploadManager {
  12. private final FileStorageService fileStorageService;
  13. private FilePath filePath;
  14. @Autowired
  15. public void setFilePath(FilePath filePath) {
  16. this.filePath = filePath;
  17. }
  18. public FileUploadManager(FileStorageService fileStorageService) {
  19. this.fileStorageService = fileStorageService;
  20. }
  21. public RestfulResult<String> saveFile(@RequestParam("file") MultipartFile file){
  22. try {
  23. fileStorageService.save(file, filePath.getPath());
  24. return RestfulResult.success("上传成功: "+file.getOriginalFilename());
  25. }catch (Exception e){
  26. e.printStackTrace();
  27. return RestfulResult.fail(-1, "上传失败:"+file.getOriginalFilename());
  28. }
  29. }
  30. public List<UploadFile> getLst(String pathUrl){
  31. return fileStorageService.list(filePath.getPath())
  32. .map(path -> {
  33. String fileName = path.getFileName().toString();
  34. String url = pathUrl + path.getFileName().toString();
  35. return new UploadFile(fileName,url);
  36. }).collect(Collectors.toList());
  37. }
  38. public ResponseEntity<Resource> getFile(String filename){
  39. Resource file = fileStorageService.file(filename,filePath.getPath());
  40. return ResponseEntity.ok()
  41. .header(HttpHeaders.CONTENT_DISPOSITION,
  42. "attachment;filename=\""+file.getFilename()+"\"")
  43. .body(file);
  44. }
  45. }

17.2.5 文件上传管理API

在应用程序根目录下创建目录fileStorage,然后在application.yml中增加如下的配置内容

  1. application:
  2. file:
  3. upload:
  4. path: fileStorage

这时我们会看到在 path 上IDA 标记了一个有其它颜色的背景
image.png
把鼠标放上去,你会看到下面的提示,也就是说IDEA不认识这个配置信息
image.png
点击 “Define configuration key ‘application.file.upload.pah’”,IDEA可以帮你常见一个下面文件
image.png
并且会生成下面的内容

{
  "properties": [
    {
      "name": "application.file.upload.path",
      "type": "java.lang.String",
      "description": "Description for application.file.upload.path."
    }
  ] }

把description修改成更有意义的内容

{
  "properties": [
    {
      "name": "application.file.upload.path",
      "type": "java.lang.String",
      "description": "应用程序保存上传文件的目录."
    }
  ] }

注意这里的字符串要求一定以 . 结尾(很傻的要求)

保存修改后,在菜单中选择 [Build - Rebuild Project] 去重新创建整个应用,然后IEDA就可以争取识别这个配置了。

尽管不做这个工作的也能让配置的属性工作,但还是这样做比较好,也便于让项目组的其他人(以及后来的人)准确了解这个自定义配置的数据类型和用途。

com.longser.union.cloud.controller 中创建类 FileUploadController

package com.longser.union.cloud.controller;

import com.longser.restful.annotation.IgnoreRestful;
import com.longser.restful.result.RestfulResult;
import com.longser.union.cloud.service.fileuplad.*;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;

import java.util.List;

@RestController
@RequestMapping("/api/file")
public class FileUploadController extends FileUploadManager {

    public FileUploadController(FileStorageService fileStorageService) {
        super(fileStorageService);
    }

    @PostMapping("/upload")
    public RestfulResult<String> upload(@RequestParam("file") MultipartFile file){
        return super.saveFile(file);
    }

    @GetMapping("/list")
    public List<UploadFile> list(){
        String url = MvcUriComponentsBuilder
                .fromMethodName(FileUploadController.class,
                        "getOne",
                        ""
                ).build().toString();
        return super.getLst(url);
    }

    @IgnoreRestful
    @GetMapping("/download/{filename:.+}")
    public ResponseEntity<Resource> getOne(@PathVariable("filename")String filename){
        return getFile(filename);
    }
}

17.2.6 功能测试

运行整体应用程序,然后在Postman中执行如下的测试:

  • 测试上传单个文件

image.png

  • 测试获得文件列表

image.png

  • 测试下载单个文件

把文件列表中某个具体文件的地址复制到浏览器地址栏,执行下载操作。

17.3 管理多个文件上传

多文件上传分为两种,一种是 key 相同的文件,另一种是 key 不同的文件。

17.3.1 key 相同的文件

这种上传,前端页面一般如下:

<form action="/upload2" method="post" enctype="multipart/form-data">
    <input type="file" name="files" multiple>
    <input type="submit" value="上传">
</form>

主要是 input 节点中多了 multiple 属性,后端用一个数组来接收文件即可:

    @PostMapping("/upload2")
    public QueryResult upload(@RequestParam("file") MultipartFile[] files){
        StringBuilder message = new StringBuilder();
        for (MultipartFile file : files) {
            message.append(super.saveFile(file).getData()).append(",");
        }
        return new QueryResult(message.toString());
    }

测试
image.png

17.3.2 key 不同的文件

如果多个文件的key 不同的,在后端用不同的变量来接收即可。

17.4 编写上传文件的单元测试

我们用前文讨论过的 @AutoConfigureMockMvc 注解自动配置 Mock 环境完成上传文件 API 的测试。具体代码如下:

package com.longser.union.cloud.extended;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

import java.io.File;
import java.io.FileInputStream;

import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
public class FileUploadTest {

    @Autowired
    private MockMvc mvc;

    @Test
    @DisplayName("File Upload Test")
    public void fileUploadTest() {
        try {
            ClassLoader classLoader = getClass().getClassLoader();
            File file = new File(classLoader.getResource("static/007.JPG").getFile());

            FileInputStream inputStream = new FileInputStream(file);
            MockMultipartFile multipartFile = new MockMultipartFile(
                    "file",
                    "007.JPG",
                    MediaType.IMAGE_JPEG_VALUE,
                    inputStream
            );

            ResultActions resultActions = mvc.perform(
                    MockMvcRequestBuilders
                            .multipart("/api/file/upload")
                            .file(multipartFile));
            resultActions.andReturn().getResponse().setCharacterEncoding("UTF-8");
            resultActions.andDo(print()).andExpect(status().isOk());
        }
        catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

代码中文件 007.JPG 是用来测试的,他放在src/test/resources/static目录下。

运行测试代码,结果如下:


MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /api/file/upload
       Parameters = {}
          Headers = [Content-Type:"multipart/form-data;charset=UTF-8"]
             Body = null
    Session Attrs = {}

Handler:
             Type = com.longser.union.cloud.controller.FileUploadController
           Method = com.longser.union.cloud.controller.FileUploadController#upload(MultipartFile)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json;charset=UTF-8"]
     Content type = application/json;charset=UTF-8
             Body = {"success":true,"errorCode":0,"errorMessage":"","data":"上传成功: 007.JPG"}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

17.5 限制文件大小及处理异常

17.5.1 限制上传文件的大小

默认的情况,Spring Boot限制上传文件的大小不能超过 1MB,通过参数配置可以重新定义上传文件的大小限制。

在aplication.yml中做如下设置:

spring:

  servlet:
    multipart:
      max-request-size: 2MB
      max-file-size: 2MB

重启应用程序,再用Postman(或单元测试代码)上传超过2M的文件,会得到状态码为500的信息:

{
    "success": true,
    "errorCode": 0,
    "errorMessage": "",
    "data": {
        "timestamp": "2021-11-09T15:47:45.196+00:00",
        "status": 500,
        "error": "Internal Server Error",
        "path": "/api/file/upload"
    }
}

在控制台窗口可见如下的出错信息

org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException: the request was rejected because its size (2217273) exceeds the configured maximum (2097152)

可见关于文件大小的参数配置起了作用。

17.5.2 友好地处理异常

在实际的项目中,我们并不希望把后端的500错误直接展示给前端用户,而是通过定义自定义异常处理的方法实现友好的反馈。现在创建如下类:

package com.longser.union.cloud.service.fileuplad;

import com.longser.restful.result.RestfulResult;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@ControllerAdvice
public class FileUploadExceptionAdvice extends ResponseEntityExceptionHandler {

    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ResponseEntity<RestfulResult> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e){
        return ResponseEntity.badRequest().body(RestfulResult.fail(400,"Upload file too large."));
    }
}

上面的代码通过@ControllerAdvice 注解用来监控是否发生 MaxUploadSizeExceededException 异常,当出现此异常时在前端页面给出提示。利用 @ControllerAdvice 可以做很多东西,比如全局的统一异常处理等,在后面的章节中我会展开讨论。

现在用Postman再次上传大小超过限制的文件,可以看到前端获得了400错误代码,并且获得了后端的提示信息
image.png

17.6 浏览器中显示特定类型文件

前文与文件下载有关的代码中,所有的文件都统一作为附件而由浏览器直接保存。有时候,我们希望类似图片、PDF等格式的文件直接在浏览器显式,为此可对代码修改如下:

    public ResponseEntity<Resource> getFile(String filename){
        String fileSuffix = filename.substring(filename.lastIndexOf(".") + 1 , filename.length()).toUpperCase();
        Resource file = fileStorageService.file(filename,filePath.getPath());

        String ContentDisposition = "attachment";
        MediaType mediaType;
        if("JPG".equals(fileSuffix) || "JPEG".equals(fileSuffix)) {
            mediaType = MediaType.IMAGE_JPEG;
            ContentDisposition = "inline";
        } else if("PNG".equals(fileSuffix)) {
            mediaType = MediaType.IMAGE_PNG;
            ContentDisposition = "inline";
        } else if("PDF".equals(fileSuffix)) {
            mediaType = MediaType.APPLICATION_PDF;
            ContentDisposition = "inline";
        } else if("GIF".equals(fileSuffix)) {
            mediaType = MediaType.IMAGE_GIF;
            ContentDisposition = "inline";
        } else if("GIF".equals(fileSuffix)) {
            mediaType = MediaType.IMAGE_GIF;
            ContentDisposition = "inline";
        } else {
            mediaType = MediaType.APPLICATION_OCTET_STREAM;
        }

        return ResponseEntity.ok()
                .contentType(mediaType)
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        ContentDisposition + ";filename=\""+file.getFilename()+"\"")
                .body(file);
    }

代码中根据文件的扩展名判断文件类型,把JPG、PNG、GIF、PDF这些格式的文件都直接在浏览器中显示。

17.7 其它功能的讨论

本章主要展示了文件上传的主要流程,在实际项目要开发场景中还有更多内容要考虑,比如限制允许上传的文件类型、在数据库中记录关于文件的信息、限制单个目录中文件的数量、把上传的文件提交CDN服务管理、对上传和下载进行权限控制、在分布式系统中处理文件上传及共享访问等。

版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。