0%

Spring Boot:上传和下载文件的web应用

Spring Boot 之 编写一个可以上传和下载文件的web应用

最终效果

实现了一个可以上传和下载文件的单页面web应用

选择文件并上传:

上传成功之后顶部会有绿色的提示消息。

上传之后就可以通过自动生成的链接来访问下载,如果所示,也可以通过<img>直接访问。

可能的用途

  • 如果有服务器的话,可以把这个应用部署在服务器上,于是就有了一个私人订制的图床
  • 如果有服务器的话,可以把这个应用部署在服务器上,于是就有了一个私人订制的云盘
  • 如果有服务器的话,可以把这个应用部署在服务器上,于是就有了一个私人订制的XFTP

技术栈

  • Spring Boot
  • Maven
  • Java IO
  • Java Stream
  • Html
  • Bootstrap(美化页面所需,也可以不用)

步骤

创建项目

通过Spring Initializr创建一个Spring Boot项目:

需要的依赖项:Spring Web,ThymeLeaf。

最终的Pom文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?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>2.3.0.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>eternal.fire</groupId>
<artifactId>uploading-files</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>uploading-files</name>
<description>Demo project for Spring Boot</description>

<properties>
<java.version>14</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-configuration-processor</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

创建Application类

为了启动Spring Boot应用,需要一个Application类。spring-boot-starter-thymeleafspring-boot-starter-web已经由Spring Boot自动配置完成,除此之外,我们还需要注册一个MultipartConfigElement类,但是多亏了有Spring Boot,一切都已经自动配置好了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package eternal.fire.uploadingfiles;

import eternal.fire.uploadingfiles.storage.StorageProperties;
import eternal.fire.uploadingfiles.storage.StorageService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
@EnableConfigurationProperties(StorageProperties.class)
public class UploadingFilesApplication {

public static void main(String[] args) {
SpringApplication.run(UploadingFilesApplication.class, args);
}

@Bean
CommandLineRunner init(StorageService storageService) {
return args -> {
storageService.deleteAll();
storageService.init();
};
}
}

@SpringBootApplication注解等价于:@Configuration+@ComponentScan+@EnableAutoConfiguration

@Configuration:告诉Spring被标注的类里有需要注册的定制的@Bean。

@ComponentScan:告诉Spring去寻找用@Component@Configuration@Service标记的类并注册到Spring容器。

@EnableAutoConfiguration:启动自动配置。

1
2
3
4
5
6
7
@Bean
CommandLineRunner init(StorageService storageService) {
return args -> {
storageService.deleteAll();
storageService.init();
};
}

CommandLineRunner类里覆写run方法,就可以在Spring Boot项目启动之后执行run方法内部的代码。上面使用了Lambda函数,其等价于:

1
2
3
4
5
6
7
8
9
10
@Bean
CommandLineRunner init(StorageService storageService) {
return new CommandLineRunner() {
@Override
public void run(String... args) throws Exception {
storageService.deleteAll();
storageService.init();
}
};
}

这一步的目的是在Spring Boot项目启动的时候删除旧的文件夹,创建新的文件夹。

作为Spring Boot自动配置的一部分,MultipartConfigElement bean会被自动创建并处于就绪状态。

创建上传文件的Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package eternal.fire.uploadingfiles;

import eternal.fire.uploadingfiles.storage.StorageFileNotFoundException;
import eternal.fire.uploadingfiles.storage.StorageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.stream.Collectors;

@Controller
public class FileUploadController {
private final StorageService storageService;

@Autowired
public FileUploadController(StorageService storageService) {
this.storageService = storageService;
}


// 从StorageService里找到所有的文件并将其用Thymeleaf渲染到页面
// 用MvcUriComponentsBuilder计算资源的路径,这里的Stream用的很秀,让人眼花缭乱。
@GetMapping("/")
public String listUploadFiles(Model model) {
model.addAttribute("files", storageService.loadAll().map(path -> MvcUriComponentsBuilder.fromMethodName(FileUploadController.class, "serveFile", path.getFileName().toString()).build().toUri().toString()).collect(Collectors.toList()));
return "uploadForm";
}

// 如果有资源,就加载资源并通过Content-Disposition response header向浏览器发送过去。
@GetMapping("/files/{filename}")
@ResponseBody
public ResponseEntity<Resource> serveFile(@PathVariable String filename) {
Resource file = storageService.loadAsResource(filename);
return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getFilename() + "\"").body(file);
}

// 处理浏览器上传的文件并将其保存,渲染上传成功的提示信息。
@PostMapping("/")
public String handleFileUpload(@RequestParam("file") MultipartFile file, RedirectAttributes redirectAttributes) {
storageService.store(file);
redirectAttributes.addFlashAttribute("message", String.format("You successfully uploaded %s!", file.getOriginalFilename()));
return "redirect:/";
}

@ExceptionHandler
public ResponseEntity<?> handleStorageFileNotFound(StorageFileNotFoundException exc) {
return ResponseEntity.notFound().build();
}
}

页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<!DOCTYPE html>
<html lang="en" xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>uploadForm</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.0/dist/css/bootstrap.min.css"
integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.slim.min.js"
integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"
integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.0/dist/js/bootstrap.min.js"
integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI"
crossorigin="anonymous"></script>
</head>
<body>

<div class="container">
<h1 class="text-primary display-2 text-center" style="font-family: Lobster,serif">Upload Files</h1>
<div th:if="${message}">
<h2 th:text="${message}" class="text-success"></h2>
</div>

<div>
<form method="post" enctype="multipart/form-data" action="/">
<table class="table table-bordered table-striped">
<tr>
<td>File up to load:</td>
<td><input type="file" name="file"/></td>
</tr>
<tr>
<td></td>
<td><input class="btn btn-primary" type="submit" value="Upload"/></td>
</tr>
</table>
</form>
</div>

<div>
<ul class="list-group">
<li th:each="file:${files}" class="list-group-item">
<a th:href="${file}" th:text="${file}"></a>
</li>
</ul>
</div>
</div>

</body>
</html>

这个页面包含三个部分:

  1. 可选的上传成功的提示信息
  2. 用来上传文件的表单
  3. 一个list用来显示已经上传的文件

StorageService类

需要一个StorageService类以便于Controller层和Storage层交互。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package eternal.fire.uploadingfiles.storage;

import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;

import java.nio.file.Path;
import java.util.stream.Stream;

public interface StorageService {
void init();

void store(MultipartFile file);

Stream<Path> loadAll();

Path load(String fileName);

Resource loadAsResource(String fileName);

void deleteAll();
}

StorageService的实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package eternal.fire.uploadingfiles.storage;

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 java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;
import java.util.stream.Stream;

@Service // 和@Component还有@Bean的作用一样,不过@Service用于标注业务层组件,@Repository用于标注数据访问组件,即DAO组件.
public class FileSystemStorageService implements StorageService {

private final Path rootLocation;

@Autowired
public FileSystemStorageService(StorageProperties properties) {
this.rootLocation = Paths.get(properties.getLocation());
}

@Override
public void store(MultipartFile file) {
try {
if (file.isEmpty()) {
throw new StorageException("Failed to store empty file " + file.getOriginalFilename());
}
Files.copy(file.getInputStream(), this.rootLocation.resolve(Objects.requireNonNull(file.getOriginalFilename())));
} catch (IOException e) {
throw new StorageException("Failed to store file " + file.getOriginalFilename(), e);
}
}

@Override
public Stream<Path> loadAll() {
try {
return Files.walk(this.rootLocation, 1).filter(path -> !path.equals(this.rootLocation)).map(this.rootLocation::relativize);
} catch (IOException e) {
throw new StorageException("Failed to read stored files", e);
}
}

@Override
public Path load(String filename) {
return rootLocation.resolve(filename);
}

@Override
public Resource loadAsResource(String filename) {
try {
Path file = load(filename);
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() {
FileSystemUtils.deleteRecursively(rootLocation.toFile());
}

@Override
public void init() {
try {
Files.createDirectory(rootLocation);
} catch (IOException e) {
throw new StorageException("Could not initialize storage", e);
}
}
}

配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package eternal.fire.uploadingfiles.storage;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties("storage")
public class StorageProperties {

/**
* Folder location for storing files
*/
private String location = "upload-dir";

public String getLocation() {
return location;
}

public void setLocation(String location) {
this.location = location;
}

}

异常类

1
2
3
4
5
6
7
8
9
10
11
12
package eternal.fire.uploadingfiles.storage;

public class StorageException extends RuntimeException {

public StorageException(String message) {
super(message);
}

public StorageException(String message, Throwable cause) {
super(message, cause);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package eternal.fire.uploadingfiles.storage;

import eternal.fire.uploadingfiles.storage.StorageException;

public class StorageFileNotFoundException extends StorageException {

public StorageFileNotFoundException(String message) {
super(message);
}

public StorageFileNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}

设置上传和下载的文件大小限制

修改application.yml文件为

1
2
3
4
5
spring:
servlet:
multipart:
max-file-size: 1024KB
max-request-size: 1024KB

如果上传或者下载的文件超出了设置的大小,会出错(这个错误我并没有去处理)。

运行

本地测试可以选择在IDEA里直接运行,也可以用maven打包之后再运行。

服务器部署可以选择用maven打包之后用xftp上传到服务器运行。

总结

恭喜你!又写了一堆没有用的垃圾。