实现本地文件上传服务器的需求

前言

在本地测试端口的时候,发现直接传入文件夹路径就可以上传一整个文件夹了。但是增加前端之后就不行了,这是为什么?我有该怎么做呢?

浏览器的限制

在上传的过程中,我们往往能够看到的是一个input控件,typefile,是专门用于上传文件的控件。ElementUIElementPlus等优秀的前端框架都给出了优秀的上传控件,能够上传多个,还能预览。

但是,当浏览器接收一个文件路径的时候,后台本来按照正常逻辑就是读取本地文件了。结果是,浏览器报错:Cannot access local file。实际上就是由于浏览器的限制。

基于RuoYi-Vue的解决方案

当然,RuoYi-Vue本身没有提供解决方案,只是单纯的想说明,本文提到的解决方案是基于ElementUI以及jdk8实现的。

前端逻辑

首先我们采用el-upload控件做出一个多文件上传预览的控件:

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
<template>
<div class="app-container">
<el-upload action="#" list-type="picture-card" :on-change="handleChange" :file-list="previewImageList" :auto-upload="false"
:before-upload="beforeUpload" multiple>
<i slot="default" class="el-icon-plus"></i>
<div slot="file" slot-scope="{file}">
<img class="el-upload-list__item-thumbnail" :src="file.url" alt="">
<span class="el-upload-list__item-actions">
<span class="el-upload-list__item-preview" @click="handlePictureCardPreview(file)">
<i class="el-icon-zoom-in"></i>
</span>
<span class="el-upload-list__item-delete" @click="handleRemove(file)" >
<i class="el-icon-delete"></i>
</span>
</span>
</div>
</el-upload>
<el-dialog :visible.sync="dialogVisible">
<img width="100%" :src="dialogImageUrl" alt="">
</el-dialog>
</div>
</template>
<script>
export default {
data() {
return {
dialogImageUrl: '',
dialogVisible: false,
allowedImageTypes: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'ico', 'tiff', 'jfif'],
previewImageList: [],
};
},
methods: {
beforeUpload(file) {
// 上传前检查文件是否是图片
const fileType = file.name.split('.').pop().toLowerCase();
return this.allowedImageTypes.includes(fileType);
},
handleRemove (file) {
// 删除预览
const index = this.previewImageList.indexOf(file);
if (index !== -1)
this.previewImageList.splice(index, 1);
},
handlePictureCardPreview (file) {
// 点击大图预览
this.dialogImageUrl = file.url;
this.dialogVisible = true;
},
handleChange (file, fileList) {
// 更新文件列表
this.previewImageList = fileList;
}
}
}
</script>

这其实就是官方案例,只是将某些变量名稍微改了名字而已,再就是删除了一个小图标,仅此而已。

后端逻辑

在这里我采取的方式是在每一个Service中抛出异常,在Controller中捕获异常之后返回一个ResponseEntity,或者返回一个RuoYi-Vue自带的AjaxResult

你可能会问为什么不用统一错误处理注解@ControllerAdvice,因为@ControllerAdvice只能处理Controller抛出的异常,而Service抛出的异常无法被@ControllerAdvice捕获。如果我一定需要捕获这个异常,我需要将这个异常提交到Controller中,然后在Controller中再抛出异常。

从逻辑上来说这样做能够更好地处理异常,这样也确实实现了低耦合高内聚的设计思想。但是这玩意也实实在在地减少了代码量,这可降低了个人工作量了。

回到正题。

其实比较尴尬的是,后端逻辑目前而言只有将上传文件保存到后端所在目录中的某个文件夹下,不能放在其他目录下。如果需要将文件上传到nginx的管辖范围内,可能需要额外采用复制文件的逻辑完成。或者说,你可以直接将nginx反向代理到RuoYi-Vue保存文件的目录下,这样听起来简单一点。

在接收文件的过程中,我们有这么几个比较关键的东西。

多文件提交

首先,我们前端使用的是多文件提交的内容,所以后端接收文件时使用的文件类型为MultipartFile类,这是SpringFramework提供的一个类。为了同时接收多个文件,将类改为数组,可以是MultipartFile[],也可以是List<MultipartFile>。两者在原理上没有区别,就只是在成员方法的使用上存在一点点区别。于是,定义接口:

1
2
3
public interface ImageService {
int uploadImage(MultipartFile[] images) throws Exception;
}

然后实现接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class ImageServiceImpl implements ImageService {

private final SSHProperties sshProperties;
private final ImageMapper imageMapper;

@Autowired
public ImageServiceImpl(SSHProperties sshProperties, ImageMapper imageMapper) {
this.sshProperties = sshProperties;
this.imageMapper = imageMapper;
}

@Override
@SuppressWarnings("在本地创建服务器接收文件并上传到云端服务器,所以应当在云服务器以外的地方额外部署")
public int uploadImage(MultipartFile[] images) throws Exception {
return 0;
}
}

在这里,关于构造函数注入可以查看这篇文章,这里就不详细展开了。

路径构造

其次,我们在定义变量的过程中,需要定义一个Path类型的变量,用来存储路径。同时,我们可以使用Paths类本身提供的相对位置获取到当前项目下的子目录路径,就像这样:

1
2
// 设置上传目录
Path uploadDirectory = Paths.get("uploads"), destinationPath;

这样设置之后,我们就有两个文件夹:

uploadDirectory是指项目目录下的uploads文件夹,而destinationPath则是指上传文件的详细保存路径,一直需要完善到文件后缀名为止。

比如,uploadDirectory~/RuoYi-Vue/uploads,那么destinationPath就是image.jpgdestinationPath在使用前需要将uploadDirectorydestinationPath进行拼接,就像这样:

1
2
3
4
5
6
7
// 设置上传目录
Path uploadDirectory = Paths.get("uploads"), destinationPath;
// 确保目录存在
if (!Files.exists(uploadDirectory))
Files.createDirectories(uploadDirectory);
// 由于我们传入的`MultipartFile[],所以这里取第0个为例进行说明:
// destinationPath = uploadDirectory + "/" + images[0].getOriginalFilename();

处理文件

当然,我们可不能忘了错误处理,不然上传了null可是一件很难受的事情:

1
2
3
4
5
6
// 先搜一遍空文件
List<MultipartFile> nullFile = Arrays.stream(images)
.filter(MultipartFile::isEmpty)
.collect(Collectors.toList());
if (!nullFile.isEmpty())
throw new IllegalStateException("上传的文件不能为空");

如果你的参数并不是MultipartFile[] images,而是List<MultipartFile> images,那么就不需要这么麻烦了:

1
2
3
4
5
List<MultipartFile> nullFile = images.stream()
.filter(MultipartFile::isEmpty)
.collect(Collectors.toList());
if (!nullFile.isEmpty())
throw new IllegalStateException("上传的文件不能为空");

轮询处理

既然是轮询处理,那么每次处理都需要具有针对性的给出每一个的处理过程。

首先,别忘了我们的destinationPath,每一个文件都有这个路径,依靠这个路径进一步处理。

处理流程实际上是文件流的处理过程。前端上传文件后,首先将文件转变为字节流,字节流再转变为Base64编码的字符串,给到后端。后端获取到Base64编码的字符串之后,先解码为字节流,然后再用文件输入流转换为文件。

当然从字节流改为文件流也是有一点方法的。比如,如果使用MultipartFile,那么可以直接通过getInputStream方法获取到文件输入流,同时,如果你仔细观察源码,你会发现,该输入流使用的是FileItem类的getInputStream方法。

这玩意有个什么特点吗?看源码就会发现有这么一段注释:

After retrieving an instance of this class from a FileUpload instance (see #parseRequest(javax.servlet.http.HttpServletRequest)), you may either request all contents of the file at once using get() or request an InputStream with getInputStream() and process the file without attempting to load it into memory, which may come handy with large files.

发现了吗?能够避免将文件读入内存,这一点在内存使用效率上还是相当可以的。

那就上代码:

1
2
3
4
5
6
7
8
9
10
11
// 依然以`images`中的第一个文件`file`为例
// 构建目标文件的完整路径
destinationPath = uploadDirectory
.resolve(Paths.get(Objects.requireNonNull(file.getOriginalFilename())))
.normalize().toAbsolutePath();
// 保存文件到目标目录
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, destinationPath, StandardCopyOption.REPLACE_EXISTING);
} catch (Exception e) {
throw new Exception("无法保存文件: " + file.getOriginalFilename(), e);
}

别忘了,我们刚刚提到过,要拼接destinationPathuploadDirectory。这也就是Paths.resolve()的作用。同时,由于第一行代码,即destinationPath的构建过程中,有太多对象在其中,我们将可能为null的对象增加一个Objects.requireNonNull(),从而让空对象错误能够更快地暴露出来,不然的话我们可能始终会卡在destinationPath的构建上。

其次,采用try-catch-resource语法糖,减少finally的麻烦。然后,我们直接利用MultipartFile类型的file对象中的getInputStream()方法,获取文件输入流,简单方便。

最后,将输入流的东西保存到destinationPath文件中。其中,复制的过程直接采用NIOFiles.copy()方法,使得大文件下效率更高。有关Files的一点信息可以参考这篇文章

所以,单个文件的处理就会像这样:

1
2
3
4
5
6
7
8
9
10
// 构建目标文件的完整路径
destinationPath = uploadDirectory
.resolve(Paths.get(Objects.requireNonNull(file.getOriginalFilename())))
.normalize().toAbsolutePath();
// 保存文件到目标目录
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, destinationPath, StandardCopyOption.REPLACE_EXISTING);
} catch (Exception e) {
throw new Exception("无法保存文件: " + file.getOriginalFilename(), e);
}

如果你将图片信息保存入数据库,后续就可以使用之前注入的imageMapper进行数据库操作;如果你需要额外将图片保存到nginx的管辖范围内,就可以参照这篇文章构建上传的业务逻辑。

虽然说jsch用户调用scp命令实现本地文件与远程文件的互通,但经测试,远程服务器的本地文件与本地文件的互通依然可行,即以scp的形式实现了远程服务器的cp命令。

所以,给出全部:

1
2
3
4
5
6
7
8
9
10
11
12
for (MultipartFile file : images) {
// 构建目标文件的完整路径
destinationPath = uploadDirectory
.resolve(Paths.get(Objects.requireNonNull(file.getOriginalFilename())))
.normalize().toAbsolutePath();
// 保存文件到目标目录
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, destinationPath, StandardCopyOption.REPLACE_EXISTING);
} catch (Exception e) {
throw new Exception("无法保存文件: " + file.getOriginalFilename(), e);
}
}

删除临时文件

当然,你可能会觉得,我都scp过去了,我还留着目录里的文件干啥呢?

那就在finally中,使用destinationPath传递到ChannelSftp对象中的rm方法,就可以了:

1
2
3
4
if (channel != null) {
channel.rm(destinationPath);
channel.disconnect();
}

文件删完了,对象本身也该删了。

基本就是这样了。