基于SpringBoot框架,如何实现文件的上传与下载查看
提要
本项目借鉴于spring-guides/gs-uploading-files: Uploading Files :: Learn how to build a Spring application that accepts multi-part file uploads. (github.com)SpringBoot官网学习文档关于如何下载文件一章提供的演示代码;
GitHub
本项目使用GitHub作为代码托管平台,友友们,点亮小星星是对博主莫大的支持哦!!!
演示环境
- idea集成开发工具
- JDK21
- Apache Maven 3.9.4
接口设计
URL: http://localhost/files/{filename}
method: GET
query: filename-文件名
function: 查看文件
URL: http://localhost/files/download/{filename}
method: GET
query: filename-文件名
function: 下载文件
URL: http://localhost/upload
method: GET
param: file-多文件对象
function: 上传文件
单元测试
分别使用MockMvc与TestRestTemplate测试工具类,模拟客户端发送HTTP请求,对项目接口与存储服务层进行了测试;
项目文件
具体实现
配置类
配置客户端上传到服务端的文件存储地址,博主是默认存放在存储静态文件的resources目录下;
@Value
注解可以获取properties文件中存储的配置信息;
package test.springboot.demo.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
/**
* 配置存储
**/
@Configuration
public class StorageConfigure {
@Value("${storage.path}")
private String location;
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
}
properties文件
存放配置的信息
spring.servlet.multipart.max-file-size=4MB
spring.servlet.multipart.max-request-size=4MB
storage.path=src/main/resources/localStorage
API层
package test.springboot.demo.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;
import test.springboot.demo.exception.StorageFileNotFoundException;
import test.springboot.demo.service.StorageService;
@Controller
public class FileUploadController {
private final StorageService storageService;
/**
* 采用构造函数来注入StorageService对象,方便进行单测
* @param storageService StorageService对象
**/
@Autowired
public FileUploadController(StorageService storageService) {
this.storageService = storageService;
}
// 与上面的构造函数一样,用于注入StorageService对象
// @Autowired
// private StorageService storageService;
/**
* 做代理,客户端可下载资源或查看资源
* @return 文件后缀
**/
@GetMapping(value = {"/files/{filename:.+}", "/files/{download:.+}/{filename:.+}"})
@ResponseBody
public ResponseEntity<Resource> serveFile(@PathVariable(required = false) String download, @PathVariable String filename) {
// 获取文件数据
Resource file = storageService.loadAsResource(filename);
// 如果文件为空就返回响应404
if (file == null) {
return ResponseEntity.notFound().build();
}
// 创建响应实体,设置状态码为200
ResponseEntity.BodyBuilder req = ResponseEntity.status(HttpStatus.OK);
// 如果download不为空,则执行下载,添加消息头attachment
if (download!=null) {
req.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + file.getFilename() + "\"");
}
// 设置默认文件类型为application/octet-stream,二进制流
String contentType = "application/octet-stream";
if (file.getFilename() != null) {
// 获得文件名后缀
String ext = getFileExtension(file.getFilename());
switch (ext) {
case "pdf":
contentType = "application/pdf";
break;
case "png", "gif", "jpg":
contentType = "image/" + ext;
break;
case "jpeg":
contentType = "image/jpeg";
break;
case "ofd", "zip":
contentType = "application/" + ext;
break;
case "xlsx":
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
break;
}
}
// 返回封装好的响应实体
return req.contentType(MediaType.valueOf(contentType))
.body(file);
}
/**
* 获得文件名后缀
* @param fileName 文件名
* @return 文件后缀
**/
public String getFileExtension(String fileName) {
if (fileName.lastIndexOf(".") != -1 && fileName.lastIndexOf(".") != 0)
return fileName.substring(fileName.lastIndexOf(".") + 1);
else
return "";
}
/**
* 上传文件
* @return 上传是否成功
*/
@PostMapping("/upload")
@ResponseBody
public boolean handleFileUpload(@RequestParam("file") MultipartFile file) {
return storageService.store(file);
}
// 用于处理StorageFileNotFoundException异常。
// 当抛出该异常时,函数会返回一个ResponseEntity对象,其状态码为404 Not Found,
// 表示找不到指定的文件或资源。
// 该函数通过@ExceptionHandler注解指定用于处理特定类型的异常
@ExceptionHandler(StorageFileNotFoundException.class)
public ResponseEntity<?> handleStorageFileNotFound(StorageFileNotFoundException exc) {
return ResponseEntity.notFound().build();
}
}
报错处理
针对存储数据失败,文件查询失败封装的异常信息;
package test.springboot.demo.exception;
/**
* 封装'存储失败'异常信息
**/
public class StorageException extends RuntimeException {
/**
* 构造函数
* @param message 异常原因
**/
public StorageException(String message) {
super(message);
}
/**
* 构造函数
* @param message 异常原因
* @param cause 异常报错
**/
public StorageException(String message, Throwable cause) {
super(message, cause);
}
}
package test.springboot.demo.exception;
/**
* 封装'文件未找到'异常信息
**/
public class StorageFileNotFoundException extends StorageException {
/**
* 构造函数
* @param message 异常原因
**/
public StorageFileNotFoundException(String message) {
super(message);
}
/**
* 构造函数
* @param message 异常原因
* @param cause 异常报错
**/
public StorageFileNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}
服务层接口
package test.springboot.demo.service;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
import test.springboot.demo.exception.StorageException;
import test.springboot.demo.exception.StorageFileNotFoundException;
import java.nio.file.Path;
import java.util.stream.Stream;
public interface StorageService {
/**
* 初始化下载路径
* @exception StorageException 存储异常
**/
void init();
/**
* 存储多文件对象到下载路径下
* @param file MultipartFile类型的多文件对象
* @return 是否存储成功
* @exception StorageException 存储异常
**/
boolean store(MultipartFile file);
/**
* 获得下载路径下的所有文件Path流
* @return Path文件流
* @exception StorageException 存储异常
**/
Stream<Path> loadAll();
/**
* 获得Path文件路径
* @param filename 文件名
* @return Path文件路径
**/
Path load(String filename);
/**
* 获得文件资源
* @param filename 文件名
* @return 文件资源
* @exception StorageFileNotFoundException 文件不存在异常
**/
Resource loadAsResource(String filename);
/**
* 删除下载路径文件夹及其路径下所有文件
**/
void deleteAll();
}
服务层实现
package test.springboot.demo.service.impl;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.util.FileSystemUtils;
import org.springframework.web.multipart.MultipartFile;
import test.springboot.demo.exception.StorageException;
import test.springboot.demo.exception.StorageFileNotFoundException;
import test.springboot.demo.config.StorageConfigure;
import test.springboot.demo.service.StorageService;
@Service
public class FileStorageServiceImpl implements StorageService {
private final Path rootLocation;
/**
* 构造方法
* @param properties StorageProperties对象
**/
@Autowired
public FileStorageServiceImpl(StorageConfigure properties) {
// 若下载路径为空,抛出异常
if(properties.getLocation().trim().length() == 0){
throw new StorageException("File upload location can not be Empty.");
}
// 设置下载路径
this.rootLocation = Paths.get(properties.getLocation());
}
/**
* 存储多文件对象到下载路径下
* @param file MultipartFile类型的多文件对象
* @return 是否存储成功
* @exception StorageException 存储异常
**/
@Override
public boolean store(MultipartFile file) {
try {
// 判断是否为空
if (file.isEmpty()) {
throw new StorageException("Failed to store empty file.");
}
Path destinationFile = this.rootLocation.resolve(
// Paths.get获取文件名并转为Path对象
Paths.get(file.getOriginalFilename()))
// 转为绝对路径
.normalize().toAbsolutePath();
// 判断是否与期望的下载路径一致
if (!destinationFile.getParent().equals(this.rootLocation.toAbsolutePath())) {
// This is a security check
throw new StorageException(
"Cannot store file outside current directory.");
}
// 拷贝文件,到下载路径
try (InputStream inputStream = file.getInputStream()) {
long size = Files.copy(inputStream, destinationFile,
StandardCopyOption.REPLACE_EXISTING);
if (size > 0) {
return true;
}
}
}
catch (IOException e) {
throw new StorageException("Failed to store file.", e);
}
return false;
}
/**
* 获得下载路径下的所有文件Path流
* @return Path文件流
* @exception StorageException 存储异常
**/
@Override
public Stream<Path> loadAll() {
try {
//获得下载路径下的深度为一的所有文件夹与文件
return Files.walk(this.rootLocation, 1)
// 去掉下载路径的文件夹
.filter(path -> !path.equals(this.rootLocation))
// 返回处理成相对路径的Path流
.map(this.rootLocation::relativize);
}
catch (IOException e) {
throw new StorageException("Failed to read stored files", e);
}
}
/**
* 获得Path文件路径
* @param filename 文件名
* @return Path文件路径
**/
@Override
public Path load(String filename) {
// 获得Path文件路径
return rootLocation.resolve(filename);
}
/**
* 获得文件资源
* @param filename 文件名
* @return 文件资源
* @exception StorageFileNotFoundException 文件不存在异常
**/
@Override
public Resource loadAsResource(String filename) {
try {
// Path 是 Java 7 引入的一个接口,它是 java.nio.file 包的一部分,用于表示文件系统中的路径。
// Path 接口定义了一些基本的操作,而具体的实现类如 java.nio.file.Paths 提供了创建 Path 对象的方法。
// 获得本地存储的此文件名的Path
Path file = load(filename);
// 获得文件URL,Resource是Spring Framework中的类,用于封装对资源的访问
// toUri() 方法返回一个 URI 对象,表示文件可访问的网络位置
Resource resource = new UrlResource(file.toUri());
// 检测文件是否存在或可读
if (resource.exists() || resource.isReadable()) {
return resource;
}
else {
throw new StorageFileNotFoundException(
"Could not read file: " + filename);
}
}
catch (MalformedURLException e) {
throw new StorageFileNotFoundException("Could not read file: " + filename, e);
}
}
/**
* 删除下载路径文件夹及其路径下所有文件
**/
@Override
public void deleteAll() {
// toFile()将下载路径转为File对象,并遍历删除所有文件
FileSystemUtils.deleteRecursively(rootLocation.toFile());
}
/**
* 初始化下载路径
* @exception StorageException 存储异常
**/
@Override
public void init() {
try {
// 创建下载路径文件夹,若该文件夹已经存在则不做任何操作
Files.createDirectories(rootLocation);
}
catch (IOException e) {
throw new StorageException("Could not initialize storage", e);
}
}
}
项目启动类
package test.springboot.demo;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import test.springboot.demo.service.StorageService;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
/**
* 初始化存储,CommandLineRunner是Spring Boot提供的接口,允许在应用启动完成后执行一些操作
* 实现该接口的方法会在应用启动后自动运行,通常用于执行启动时的任务,如数据初始化等
* @param storageService-存储服务
* @return
**/
@Bean
CommandLineRunner init(StorageService storageService) {
return (args) -> {
storageService.deleteAll();
storageService.init();
};
}
}
单元测试
测试本地存储服务
package test.springboot.demo.storage;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import test.springboot.demo.service.impl.FileStorageServiceImpl;
import test.springboot.demo.exception.StorageException;
import test.springboot.demo.config.StorageConfigure;
import java.util.Random;
import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 用于测试本地文件存储的服务层
**/
public class FileStorageServiceImplTests {
private StorageConfigure properties = new StorageConfigure();
private FileStorageServiceImpl service;
@BeforeEach
public void init() {
properties.setLocation("src/main/resources/localStorage/files/" + Math.abs(new Random().nextLong()));
service = new FileStorageServiceImpl(properties);
service.init();
}
@AfterEach
public void terminate() {
properties.setLocation("src/main/resources/localStorage/files/");
service = new FileStorageServiceImpl(properties);
service.deleteAll();
}
@Test
public void emptyUploadLocation() {
service = null;
properties.setLocation("");
assertThrows(StorageException.class, () -> {
service = new FileStorageServiceImpl(properties);
});
}
@Test
public void loadNonExistent() {
assertThat(service.load("foo.txt")).doesNotExist();
}
@Test
public void saveAndLoad() {
service.store(new MockMultipartFile("admin", "admin.txt", MediaType.TEXT_PLAIN_VALUE,
"I am cool boy!".getBytes()));
assertThat(service.load("admin.txt")).exists();
}
@Test
public void saveRelativePathNotPermitted() {
assertThrows(StorageException.class, () -> {
service.store(new MockMultipartFile("admin", "../admin.txt",
MediaType.TEXT_PLAIN_VALUE, "I am cool boy!".getBytes()));
});
}
@Test
public void savePermitted() {
service.store(new MockMultipartFile("amdin", "localStorage/../admin.txt",
MediaType.TEXT_PLAIN_VALUE, "I am cool boy!".getBytes()));
}
}
MockMvc测试文件上传下载
package test.springboot.demo;
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import test.springboot.demo.exception.StorageFileNotFoundException;
import test.springboot.demo.service.StorageService;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* 使用MockMvc测试
**/
@AutoConfigureMockMvc
@SpringBootTest
public class FileMockMvcTests {
@Autowired
private MockMvc mvc;
@MockBean
private StorageService storageService;
/**
* 测试上传文件
**/
@Test
public void shouldSaveUploadedFile() throws Exception {
MockMultipartFile multipartFile = new MockMultipartFile("file", "test.txt",
"text/plain", "hello world".getBytes());
this.mvc.perform(multipart("/upload").file(multipartFile))
.andExpect(status().isOk());
then(this.storageService).should().store(multipartFile);
}
/**
* 测试文件不存在时抛出404错误
* @throws Exception
*/
@SuppressWarnings("unchecked") // 抑制编译器对方法体内可能出现的未经检查的警告
@Test
public void should404WhenMissingFile() throws Exception {
given(this.storageService.loadAsResource("test.txt"))
.willThrow(StorageFileNotFoundException.class);
this.mvc.perform(get("/files/test.txt")).andExpect(status().isNotFound());
}
}
TestRestTemplate测试文件上传下载
package test.springboot.demo;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import test.springboot.demo.service.StorageService;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.BDDMockito.given;
/**
* 使用TestRestTemplate测试,模拟Spring Boot应用程序的运行环境,任意端口
**/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class FileRestTemplateTests {
// TestRestTemplate是Spring提供的一个简化HTTP请求的工具类,
// 常用于集成测试中模拟客户端向服务端发送HTTP请求。
// 在这个上下文中,它将被用来执行文件上传和下载的HTTP请求操作。
@Autowired
private TestRestTemplate restTemplate;
// StorageService的mock对象,用于模拟存储服务,避免对实际服务层进行调用
@MockBean
private StorageService storageService;
// 获得应用的端口,用于测试
@LocalServerPort
private int port;
/**
* 测试文件上传功能
**/
@Test
public void shouldUploadFile() throws Exception {
// 获得本地存储的的test.txt文件
ClassPathResource resource = new ClassPathResource("test.txt");
// 放入多文件集合请求中
MultiValueMap<String, Object> map = new LinkedMultiValueMap<>();
map.add("file", resource);
// 执行上传操作
ResponseEntity<String> response = this.restTemplate.postForEntity("/upload", map,
String.class);
// 判断是否存储文件成功
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
/**
* 测试文件下载功能
**/
@Test
public void shouldDownloadFile() throws Exception {
// 获得本地存储的的test.txt文件
ClassPathResource resource = new ClassPathResource("test.txt");
// mock设定调用loadAsResource("test.txt")时返回本地存储的此文件
given(this.storageService.loadAsResource("test.txt")).willReturn(resource);
// 使用RestTemplate对象发起GET请求,下载文件,返回数据类型指定为string
ResponseEntity<String> response = this.restTemplate
.getForEntity("/files/{filename}", String.class, "test.txt");
assertThat(response.getStatusCodeValue()).isEqualTo(200);
assertThat(response.getBody()).isEqualTo("hello world!");
}
}
pom.xml依赖管理
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>test.springboot</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>Demo project for Spring Boot</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
提醒
- 客户端上传的文件均存放在
resources
的localStorage
目录下,可以在application.properties
文件内修改storage.path
来更改下载路径; - 客户端上传的文件设置了最大文件大小4MB,可以在
application.properties
文件内修改最大文件大小; resources
下的learningRecord
目录下存放了author的部分学习知识点,仅供参考;- 本项目为自行编写的代码,撰写了大量注释帮助理解代码。