SpringBoot和Vue的图片上传的解决方案
本文章的概述
前端上传图片,后端接收和存储图片,以及前端渲染图片的一套常规的解决方案
涉及的技术什么?
前端: vue , elementPlus , axios
后端: springboot配置资源访问映射 , springboot.MultipartFile类接收文件对象
第三方: springboot集成阿里云oss对象存储
大概的流程是什么?
如图所示: 图1是本文章即将探讨的图片上传解决方案,图2是另外一种解决方案(只是贴上来了解即可,和本文章核心内容无关)
前端接受用户的一个图片,并且通过接口传递给服务端,服务端拿到图片需要做2件事情
1.立即存储图片 并 得到一个可访问图片的url链接
2.给该图片建立相关的业务逻辑关系,即:生成id 将id和url存储同一个表中,并将id存储在另外的关联表中,方便服务端后续自己拿id得到图片链接 然后返回给前端 用作访问图片
(图1:)
(图2:)
具体代码细节实现 : java的每个类 每个配置 每个操作 每个js文件 ....
贸然贴代码不可取,我们这里带入一个小的功能需求:
"
用户可以发布新增一个文章,文章包含:img字段,id字段,title字段等....
用户发布新增文章成功之后,可以点击文章编辑按钮 并查看到文章详情
"
功能如下图所示:
用户新增文章:
用户编辑文章时 可以回显图片:
首先前端 定义组件 这里使用了 element的el-upload组件
<el-form-item label="文章图片上传">
<!-- auto-upload是否开启自动上传? show-file-list是否展示历史上传的记录? on-change当上传图片至组件时会触发的方法-->
<el-upload
class="avatar-uploader"
:auto-upload="false"
:show-file-list="true"
:on-change="onUploadFile"
>
<!--v-if和src当formModel.imgUrl有值时则渲染该图片链接 alt当imgUrl无法访问时,展示的文字 -->
<img alt="这个图片丢失了喵~" v-if="formModel.imgUrl" :src="formModel.imgUrl" class="avatar"/>
<!--v-else 当formModel.imgUrl无值时则渲染一个"+"号-->
<el-icon v-else class="avatar-uploader-icon">
<Plus/>
</el-icon>
</el-upload>
</el-form-item>
注意了哈 上面图片渲染时 使用的是formModel.imgUrl,这意味着所有可直接访问的图片链接 都应该交给此值
也就是说后端返回的文章对象的imgUrl需要绑定给formModel.imgUrl,而前端本地临时浏览的图片对象也要绑定给formModel.imgUrl,
formModel定义如下
//新增或更新的表单 用于提交给服务端
const formModel = ref({
id: "",
name: "",
categoryId: "",
content: "",
status: "",
imgUrl: "",
})
上传图片前端代码定义
//单独使用const绑定绑定图片文件,这样的话等下容易进行后端接口传递
const imgModel = ref({
imgFile: null,
})
//当el-upload组件的图片上传时触发的逻辑
const onUploadFile = async (file) => {
ElMessage.success("选择了一个图片")
//1.绑定到表单 等下提交给后端
imgModel.value.imgFile = file.raw
//2.使用浏览器自带的方法URL.createObjectURL() 将文件对象转换为一个浏览器本地可访问的url图片链接对象
formModel.value.imgUrl = URL.createObjectURL(file.raw)
ElMessage.success("本地渲染图片成功:" + formModel.value.imgUrl)
}
至此 前端上传图片和渲染图片的静态逻辑定义完毕
接下来定义前端请求后端时 提交表单的逻辑
//点击新增或修改文章按钮的逻辑
const emit = defineEmits(["success"])
const onInsertOrUpdate = async () => {
ElMessage.success("新增或修改文章")
//有id则是修改
if (formModel.value.id) {
//将formModel绑定的文章的普通数据 和 文章的图片file 分为2个参数传递给后端
await updateArticleService(formModel.value, imgModel.value.imgFile)
ElMessage.success("修改成功")
emit("success")
showForm.value = false;
return
}
//没有id则是新增
await insertArticleService(formModel.value, imgModel.value.imgFile)
emit("success")
ElMessage.success("新增成功")
showForm.value = false;
}
还有前端请求后端时的axiosApi封装的代码如下
//请求服务端 新增文章
export const insertArticleService = (articleForm, file) => {
//使用formData表单对象 来传递给服务端参数 对应springboot的@RequestParams
const formData = new FormData();
//将表单转为json传递
formData.append('formJson', JSON.stringify(articleForm));
//传递文件对象
formData.append('file', file);
//请求
myAxios.post("/ArticleController/insert", formData).then()
};
//请求服务端 更新文章
export const updateArticleService = (articleForm, file) => {
const formData = new FormData();
formData.append('formJson', JSON.stringify(articleForm));
//如果用户不点击图片上传逻辑的话 组件传递过来的file对象是null,如果是null的话,服务端那边的springBoot接口就会报400 bad request
//因此这里需要判断一下file是不是null 如果是 则将其设置为一个空的txt文件
if (file) {
formData.append('file', file);
}else {
// 创建一个空的 Blob 对象,指定文件名匿名为空文件
const emptyFile= new Blob([], { type: 'application/octet-stream' });
formData.append('file', emptyFile, 'empty.txt'); // 'empty.txt' 是文件名,你可以根据需要更改
}
myAxios.post("/ArticleController/update", formData).then()
};
至此 前端的上传图片,本地预览图片,和点击按钮请求后端新增图片功能完毕
接下来是后端的接口定义如下:
这里主要是接受json并转为服务端的java对象 以及接收前端传递的file文件对象
//新增文章
@PostMapping("insert")
public Result<Boolean> insert(
@RequestParam(value = "formJson") String formJson,
@RequestParam(value = "file") MultipartFile file
){
ArticleForm form = RoleJson.to(formJson,ArticleForm.class);
articleService.insert(form,file);
return Result.success();
}
//修改文章
@PostMapping("update")
public Result<Boolean> update(
@RequestParam(value = "formJson") String formJson,
@RequestParam(value = "file") MultipartFile file
){
ArticleForm form = RoleJson.to(formJson,ArticleForm.class);
articleService.update(form,file);
return Result.success();
}
我们不必要关心article文章对象的其他字段是如何存储的 只需要关心后端是如何操作file文件即可
因此这里只展示一下insert方法,如下
public void insert(ArticleForm form, MultipartFile file) {
ArticlePo po = new ArticlePo();
//设置 id name
po.setId(RoleId.idUUIDTrim());
po.setName(form.getName());
//设置 分类id
po.setCategoryId(form.getCategoryId());
//设置 imgId
Result<String> stringResult = imgFileUploadingController.handleFileUpload(file);
String data = stringResult.getData();
String id = imgStorageService.insert(data);
po.setImg(id);
//设置 富文本
po.setContent(form.getContent());
//设置 文章发布状态
po.setStatus(form.getStatus());
//存入数据库
RoleMybatisPlus.insert(mapper,po);
}
最主要就是设置imgId这里 , 我们是如何将file文件对象 变成 string类型的id呢 ?
这里会带出几个问题
1.直接将整个file文件序列号转为二进制存储在数据库可以吗? 可以,但不优雅
2.将file对象转为url链接之后 再存储到数据库可以吗? 可以 比第一个方案优雅 但是不规范 会造成图片字段散落在各个表中
因此我们采取的做法是将file对象先转为url链接 再生成1个id 再将id和url绑定在一个img_storage关系表中,这样其他业务表:比如article文章表的imgId字段就可以存储img_storage表的id 依然可以关联到图片的访问url 最终返回给前端的组件进行渲染
因此
img_storage表定义如下
article文章表最终存储如下
那么现在让我们回归最初的问题; 如何将file文件变成可访问的url链接呢 ?
我们有2种方案
第一种: 基于阿里云的OSS对象存储 , 优点是:简单好用且稳定,缺点是,如果图片要被公网流量访问就需要钱
第二种: 将文件存储在本地服务器的文件夹上,并且设置相关资源映射 ,优点是:不要钱 缺点是实现较为复杂
我们先来实现第一种 基于阿里云OSS存储文件 需要如下操作
阿里云OSS视图化页面配置
1.创建bucket,得到bucket名称 ①
2.查看bucket详情 得到Endpoint②
2.创建accessKey ID 和 accessKey Secret ③ ④
至此阿里云视图化页面配置完毕 现在你已经得到了以下4个参数
endpoint: oss-cn-hangzhou.aliyuncs.com
bucket-name: xiaokeerservice
access-key-id: LTAI5tQ*********bhmjPZup
access-key-secret: EkBgLcV************WpDwaNv
然后我们进入spring boot配置
1.引入aliyun.sdk.oss依赖
<!--阿里云OSS文件上传-->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>${aliyun.sdk.oss}</version> <!--<aliyun.sdk.oss>3.18.1</aliyun.sdk.oss>-->
</dependency>
2.application.yml中定义配置
xiaokeer:
uploading:
select: "1"
#阿里云OSS相关配置 @see:AliOssProperties.java
alioss:
endpoint: oss-cn-hangzhou.aliyuncs.com
bucket-name: xiaokeerservice
access-key-id: LTAI5tQeEqrRkgStbhmjPZup
access-key-secret: EkBgLcVyYkiiZNKp8FIiVqaWpDwaNv
3 定义配置类
/**
* 阿里云OSS文件配置属性
*/
@Component
@ConfigurationProperties(prefix = "xiaokeer.alioss")
@Data
public class AliOssProperties {
/**
* 1.阿里服务器地址 2.bucket文件夹名称 3.授权秘钥id 4.授权秘钥值
*/
private String endpoint;
private String bucketName;
private String accessKeyId;
private String accessKeySecret;
}
4.定义阿里云OSS工具 并使用配置
/**
* 阿里云OSS文件上传类
*/
@Component
public class RoleAliOss {
@Resource
private AliOssProperties aliOssProperties;
/**
* 文件上传
* 1.ossClient 创建OSS代理对象 需要指定必要属性 (1.endpoint服务器名 2.accessKeyId授权秘钥id 3.accessKeySecret授权秘钥值)
* 2.代理对象调用putObject方法 准备上传文件 传入参数(1.bucket文件夹名称 2.文件字节流bytes)
* 3.出现异常需要打印异常信息
*/
public String upload(byte[] bytes, String objectName) {
OSS ossClient = new OSSClientBuilder().build(aliOssProperties.getEndpoint(), aliOssProperties.getAccessKeyId(), aliOssProperties.getAccessKeySecret());
try {
ossClient.putObject(aliOssProperties.getBucketName(), objectName, new ByteArrayInputStream(bytes));
} catch (Exception e) {
throw XiaoKeerException.newException(e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
//当文件上传成功后 需要返回文件的访问路径 visitPath , 阿里云OSS文件的访问路径由以下部分组成 : "https://" + bucket文件名称 + "." + 服务器名称 + "/" + 文件全名
return "https://" + aliOssProperties.getBucketName() + "." + aliOssProperties.getEndpoint() + "/" + objectName;
}
}
5.使用阿里云OSS上传
@PostMapping("/upload")
public Result<String> handleFileUpload(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
throw XiaoKeerException.newInvalidParamsException();
}
//生成新的唯一文件名
String newFileName = RoleFile.newFileName(file);
//1.阿里云OSS存储
if(uploadingSelect.equals("1")){
try {
/*
* 调用aliOssUtil工具对象的upload方法 传入文件的字节数组 以及新的文件名
* 会将文件上传至阿里云OSS 最后返回文件的访问路径
*/
String OSSPath = aliOssUtil.upload(file.getBytes(), newFileName);
return Result.success(OSSPath);
} catch (Exception e) {
throw XiaoKeerException.newException(e);
}
}
throw XiaoKeerException.newUnknownBizException();
}
至此 阿里云OSS集成完毕 我们传入file文件 得到一个url地址 我们可以在表中给这个url建立关联关系 也可以之间返回给前端 让其访问
那么 再来说一下第二种文件上传方式 : 上传到服务器的文件夹
1.首先在你服务器选一个风水宝地的文件夹位置 我这里假设项目已经部署到服务器了 我选择了当前项目src同等级下新建了一个upload_file.img文件夹
2.然后定义我们的上传方法
public static final String UPLOADED_IMG_FILE_PATH = System.getProperty("user.dir") + "\\uploaded_file\\img\\";
//2.当前服务器文件夹存储
if(uploadingSelect.equals("2")){
try {
// 获取项目的根目录,并将文件保存到目标目录
Path filePath = Paths.get( UPLOADED_IMG_FILE_PATH, newFileName);
Files.write(filePath, file.getBytes());
//todo 上传完毕之后 如何得到url呢?
} catch (Exception e) {
throw XiaoKeerException.newException(e);
}
}
3.那么问题来了 文件已经上传完毕 该如何得到文件的url访问链接呢?
我们继续补全方法如下
//浏览img文件时的路径
public static final String VISIT_IMG_FILE_PATH = "/xiaokeer_resource/uploaded_file/img/";
//img文件上传的路径
public static final String UPLOADED_IMG_FILE_PATH = System.getProperty("user.dir") + "\\uploaded_file\\img\\";
//2.当前服务器文件夹存储
if(uploadingSelect.equals("2")){
try {
// 获取项目的根目录,并将文件保存到目标目录
Path filePath = Paths.get( UPLOADED_IMG_FILE_PATH, newFileName);
Files.write(filePath, file.getBytes());
// 构造静态文件访问路径
String fileAccessUrl = "http://localhost:8081" + VISIT_IMG_FILE_PATH + newFileName;
// 返回文件访问路径
return Result.success(fileAccessUrl);
} catch (Exception e) {
throw XiaoKeerException.newException(e);
}
}
4.最终我们返回给前端的是这样的url地址
http://localhost:8081/xiaokeer_resource/uploaded_file/img/635898103123283968.png
5. ok问题来了 现在这种地址如果前端直接拿到服务端来访问 肯定是不通的 会被报404
原因很简单 这个地址确实不存在 , 我们的文件实际上存储在了 服务器的这个位置
D:\xiaokeer\xiaokeerServer\uploaded_file\img\635898103123283968.png
6.这时候前端就要暴躁了"你tm上传的位置和你返回的位置不一样啊喂 我怎么访问! "
别急别急 我们还差最后一步资源映射的配置 如下
@Configuration
public class WebConfig implements WebMvcConfigurer {
//资源路径映射
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
//将 http://localhost:8081/xiaokeer_resource/uploaded_file/635864958617391104.png
//映射为: D:/xiaokeer/xiaokeerServer/uploaded_file/img/635864958617391104.png
registry
.addResourceHandler(
ImgFileUploadingController.VISIT_IMG_FILE_PATH + "*.jpg",
ImgFileUploadingController.VISIT_IMG_FILE_PATH + "*.png" )
.addResourceLocations("file:" + ImgFileUploadingController.UPLOADED_IMG_FILE_PATH);
}
}
这样的话 前端就可以美滋滋的拿这样的路径
http://localhost:8081/xiaokeer_resource/uploaded_file/img/635898103123283968.png
来访问我们服务器文件夹的资源了 而且又避免了 我们将服务器的文件夹暴露给外边
至此文章结束
踩坑
1 body和file同时请求controller接口
一般来说如果有body入参和文件入参 还是采用这样的写法吧
//新增文章
@PostMapping("insert")
public Result<Boolean> insert(
@RequestParam(value = "formJson") String formJson,
@RequestParam(value = "file") MultipartFile file
){
ArticleForm form = RoleJson.to(formJson,ArticleForm.class);
articleService.insert(form,file);
return Result.success();
}
2 将文件存储到静态文件夹中 借助spring自己的映射
在编写代码初期 我是将文件们存储在springboot的resource文件夹下的
也就是这些个位置
存储在这里的好处就是 你不用自己再去定义WebMvcConfigurer的映射配置了
因为这地方 spring有默认的映射规则 大概如下
spring:
servlet:
multipart:
max-file-size: 10MB # 设置controller上传单个文件的大小为10M
max-request-size: 50MB # 设置controller上传总上传的数据大小为50M
mvc:
#定义访问静态文件时,必须携带的前缀
#例如:http://localhost:8081/xiaokeer_resource/uploaded_file/img/cat.png
#最终这个访问到的是: src\main\resources\resources\uploaded_file\img\cat.png
#相当于 "http://localhost:8081/xiaokeer_resource/" 这一节 直接被替换成了: src\main\resources\
#然后拼接 ["my_static_resource"或者"resources" 或者 "static" ...(按照下面配置的顺序)] 再拼接 "/uploaded_file/img/cat.png"
#最终得到"src\main\resources\resources\uploaded_file\img\cat.png"
static-path-pattern: /xiaokeer_resource/**
web:
resources:
#定义所有存放静态资源的文件夹们 默认从这些文件夹里面找文件
#例如:http://localhost:8081/xiaokeer_resource/cat.png
#最终得到的就是 src\main\resources\resources\cat.png这个
#因为my_static_resource没有叫这个的 , 然后就访问resources,发现了cat.png
static-locations:
- classpath:/my_static_resource/
- classpath:/resources/
- classpath:/static/
- classpath:/public/
也就意味着 你可以直接拿一个这样的链接
http://localhost:8081/xiaokeer_resource/uploaded_file/img/cat.png
然后你就能直接访问到 src\main\resources\resources\uploaded_file\img\cat.png这个位置
听起来很方便是不是 但是这里有坑 因为这些地方 resource/resources resource/public resource/resources/static 它是存储静态资源的地方 不是存储业务文件的地方 , 所以在项目启动之后 这地方的文件会被编译到target文件夹中 也就是这里
而spring所有的映射访问 都是在访问这里的文件 并不是我们原始上传的src文件夹下的那里
所以最终就会导致 你上传了一个图片成功了 但是去访问的时候被报404 然后你需要重新启动一下springboot项目 你就又发现 图片可以正常访问了 这就是因为springboot需要编译target文件夹
因此最终结论就是 这地方不要存储业务的file对象