实现本地文件上传服务器的需求
前言
在本地测试端口的时候,发现直接传入文件夹路径就可以上传一整个文件夹了。但是增加前端之后就不行了,这是为什么?我有该怎么做呢?
浏览器的限制
在上传的过程中,我们往往能够看到的是一个input
控件,type
为file
,是专门用于上传文件的控件。ElementUI
、ElementPlus
等优秀的前端框架都给出了优秀的上传控件,能够上传多个,还能预览。
但是,当浏览器接收一个文件路径的时候,后台本来按照正常逻辑就是读取本地文件了。结果是,浏览器报错:Cannot access local file
。实际上就是由于浏览器的限制。
基于RuoYi-Vue的解决方案
当然,RuoYi-Vue
本身没有提供解决方案,只是单纯的想说明,本文提到的解决方案是基于ElementUI
以及jdk8
实现的。
前端逻辑
首先我们采用el-upload
控件做出一个多文件上传预览的控件:
1 | <template> |
这其实就是官方案例,只是将某些变量名稍微改了名字而已,再就是删除了一个小图标,仅此而已。
后端逻辑
在这里我采取的方式是在每一个Service
中抛出异常,在Controller
中捕获异常之后返回一个ResponseEntity
,或者返回一个RuoYi-Vue
自带的AjaxResult
。
你可能会问为什么不用统一错误处理注解@ControllerAdvice
,因为@ControllerAdvice
只能处理Controller
抛出的异常,而Service
抛出的异常无法被@ControllerAdvice
捕获。如果我一定需要捕获这个异常,我需要将这个异常提交到Controller
中,然后在Controller
中再抛出异常。
从逻辑上来说这样做能够更好地处理异常,这样也确实实现了低耦合高内聚的设计思想。但是这玩意也实实在在地减少了代码量,这可降低了个人工作量了。
回到正题。
其实比较尴尬的是,后端逻辑目前而言只有将上传文件保存到后端所在目录中的某个文件夹下,不能放在其他目录下。如果需要将文件上传到nginx
的管辖范围内,可能需要额外采用复制文件的逻辑完成。或者说,你可以直接将nginx
反向代理到RuoYi-Vue
保存文件的目录下,这样听起来简单一点。
在接收文件的过程中,我们有这么几个比较关键的东西。
多文件提交
首先,我们前端使用的是多文件提交的内容,所以后端接收文件时使用的文件类型为MultipartFile
类,这是SpringFramework
提供的一个类。为了同时接收多个文件,将类改为数组,可以是MultipartFile[]
,也可以是List<MultipartFile>
。两者在原理上没有区别,就只是在成员方法的使用上存在一点点区别。于是,定义接口:
1 | public interface ImageService { |
然后实现接口:
1 |
|
在这里,关于构造函数注入可以查看这篇文章,这里就不详细展开了。
路径构造
其次,我们在定义变量的过程中,需要定义一个Path
类型的变量,用来存储路径。同时,我们可以使用Paths
类本身提供的相对位置获取到当前项目下的子目录路径,就像这样:
1 | // 设置上传目录 |
这样设置之后,我们就有两个文件夹:
uploadDirectory
是指项目目录下的uploads
文件夹,而destinationPath
则是指上传文件的详细保存路径,一直需要完善到文件后缀名为止。
比如,uploadDirectory
是~/RuoYi-Vue/uploads
,那么destinationPath
就是image.jpg
,destinationPath
在使用前需要将uploadDirectory
与destinationPath
进行拼接,就像这样:
1 | // 设置上传目录 |
处理文件
当然,我们可不能忘了错误处理,不然上传了null可是一件很难受的事情:
1 | // 先搜一遍空文件 |
如果你的参数并不是MultipartFile[] images
,而是List<MultipartFile> images
,那么就不需要这么麻烦了:
1 | List<MultipartFile> nullFile = images.stream() |
轮询处理
既然是轮询处理,那么每次处理都需要具有针对性的给出每一个的处理过程。
首先,别忘了我们的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 | // 依然以`images`中的第一个文件`file`为例 |
别忘了,我们刚刚提到过,要拼接destinationPath
与uploadDirectory
。这也就是Paths.resolve()
的作用。同时,由于第一行代码,即destinationPath
的构建过程中,有太多对象在其中,我们将可能为null
的对象增加一个Objects.requireNonNull()
,从而让空对象错误能够更快地暴露出来,不然的话我们可能始终会卡在destinationPath
的构建上。
其次,采用try-catch-resource
语法糖,减少finally
的麻烦。然后,我们直接利用MultipartFile
类型的file
对象中的getInputStream()
方法,获取文件输入流,简单方便。
最后,将输入流的东西保存到destinationPath
文件中。其中,复制的过程直接采用NIO
的Files.copy()
方法,使得大文件下效率更高。有关Files
的一点信息可以参考这篇文章。
所以,单个文件的处理就会像这样:
1 | // 构建目标文件的完整路径 |
如果你将图片信息保存入数据库,后续就可以使用之前注入的imageMapper
进行数据库操作;如果你需要额外将图片保存到nginx
的管辖范围内,就可以参照这篇文章构建上传的业务逻辑。
虽然说jsch
用户调用scp
命令实现本地文件与远程文件的互通,但经测试,远程服务器的本地文件与本地文件的互通依然可行,即以scp
的形式实现了远程服务器的cp
命令。
所以,给出全部:
1 | for (MultipartFile file : images) { |
删除临时文件
当然,你可能会觉得,我都scp
过去了,我还留着目录里的文件干啥呢?
那就在finally
中,使用destinationPath
传递到ChannelSftp
对象中的rm
方法,就可以了:
1 | if (channel != null) { |
文件删完了,对象本身也该删了。
基本就是这样了。