若依框架-添加OSS
前言
若依框架是一款开源且十分优秀的国产开源软件,功能十分强大而且免费开源可商用
RuoYi: 🎉 基于SpringBoot的权限管理系统 易读易懂、界面简洁美观。 核心技术采用Spring、MyBatis、Shiro没有任何其它重度依赖。直接运行即可用 (gitee.com)
因为RuoYi里面的文件是存储在本地的,这云原生时代,业务需求是将文件资源存储在OSS对象存储服务器的,所以就依照这若依的源码更改了一下,希望对你有用!
环境
OSS:docker 部署的minio,并提前将例子中使用的bucket设为了public minio/minio:RELEASE.2022-10-15T19-57-03Z
若依:前后端分离单体版本,Vue2+Element UI + 后端单体版
在线快速搭建: Fastbuild Factory 快速项目搭建平台
分析
在若依框架中使用文件上传下载的地方主要有:用户头像的上传
,admin模块中common包下的CommonController
,后者是框架给开发者预留的资源上传、下载的接口,我并没有在前端找到对应的使用的地方。
由用户头像上传得到文件上传功能实现的位置
用户头像上传的后端地址:
/**
* 头像上传
*/
@Log(title = "用户头像", businessType = BusinessType.UPDATE)
@PostMapping("/avatar")
public AjaxResult avatar(@RequestParam("avatarfile") MultipartFile file) throws Exception
{
if (!file.isEmpty())
{
// 获取当前访问的用户
LoginUser loginUser = getLoginUser();
// 获取我们在application.yml配置的地址
String baseDir = CloudstudyConfig.getProfile();
// 需要修改的地方 关键
String avatar = FileUploadUtils.upload(baseDir, file, MimeTypeUtils.IMAGE_EXTENSION);
// 将上传成功的头像路径 更新进数据库
if (userService.updateUserAvatar(loginUser.getUsername(), avatar))
{
AjaxResult ajax = AjaxResult.success();
ajax.put("imgUrl", avatar);
// 更新redis服务器中 缓存的用户头像
loginUser.getUser().setAvatar(avatar);
tokenService.setLoginUser(loginUser);
// 返回给前端通用 响应对象
return ajax;
}
}
return AjaxResult.error("上传图片异常,请联系管理员");
}
- 所以
FileUploadUtils.upload()
,是若依上传用户头像的关键,且admin模块中common包下的CommonController
中关于文件的通用上传也是调用的FileUploadUtils.upload()
@PostMapping("/upload")
@ApiOperation("通用上传请求(单个)")
public AjaxResult uploadFile(@RequestPart MultipartFile file) throws Exception
{
try
{
// 上传文件路径
String filePath = CloudstudyConfig.getUploadPath();
// 上传并返回新文件名称
String fileName = FileUploadUtils.upload(filePath, file);
AjaxResult ajax = AjaxResult.success();
ajax.put("url", fileName);
ajax.put("fileName", fileName);
ajax.put("newFileName", FileUtils.getName(fileName));
ajax.put("originalFilename", file.getOriginalFilename());
return ajax;
}
catch (Exception e)
{
return AjaxResult.error(e.getMessage());
}
}
FileUploadUtils.upload()
/**
* 文件上传
* TODO 文件上传逻辑
* @param baseDir 相对应用的基目录
* @param file 上传的文件
* @param allowedExtension 上传文件类型
* @return 返回上传成功的文件名
* @throws FileSizeLimitExceededException 如果超出最大大小
* @throws FileNameLengthLimitExceededException 文件名太长
* @throws IOException 比如读写文件出错时
* @throws InvalidExtensionException 文件校验异常
*/
public static final String upload(String baseDir, MultipartFile file, String[] allowedExtension)
throws FileSizeLimitExceededException, IOException, FileNameLengthLimitExceededException,
InvalidExtensionException
{
// 是否为null
int fileNamelength = Objects.requireNonNull(file.getOriginalFilename()).length();
// 文件名是否超出了指定长度
if (fileNamelength > FileUploadUtils.DEFAULT_FILE_NAME_LENGTH)
{
throw new FileNameLengthLimitExceededException(FileUploadUtils.DEFAULT_FILE_NAME_LENGTH);
}
// 文件大小校验
assertAllowed(file, allowedExtension);
// 编码文件名
String fileName = extractFilename(file);
// 返回绝对路径的文件
String absPath = getAbsoluteFile(baseDir, fileName).getAbsolutePath();
// 将文件复制到服务器本地文件系统
// TODO 待替换为oss服务器的内容
file.transferTo(Paths.get(absPath));
// 返回文件路径
return getPathFileName(baseDir, fileName);
}
替换自己的OSS
新建oss
模块并添加到common
模块,
<dependencies>
<!-- Spring框架基本的核心工具 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
<!-- SpringWeb模块 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<!-- 自定义验证注解 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- yml解析器 -->
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<exclusions>
<exclusion>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</dependency>
</dependencies>
将我们自己的OSS模块添加进common模块。
创建配置minio连接信息的配置类,记得在admin
模块中为自己的配置类添加信息,这里就不展示了
@Data
@Component
// 读取 admin模块中application.yml配置的信息
@ConfigurationProperties(prefix = "oss.minio")
public class MinioConfig {
// 连接地址+端口号
private String url;
// 用户名
private String access;
// 密码
private String secret;
// 桶名
private String bucket;
}
定义一个简单的连接池
@Data
@Component
@ConfigurationProperties(prefix = "oss.pool")
public class MinioPool {
// 线程安全队列
private ConcurrentLinkedQueue<MinioClient> minioPool = new ConcurrentLinkedQueue<>();
@Autowired
private MinioConfig minioConfig;
// 核心客户端数量
private Integer coreSize = 10;
// 队列最大的
private Integer maxSize = 20;
// 连接池的大小
private int poolSize = 0;
/**
* 返回一个minio客户端
* @return 配置好的客户端对象
*/
public synchronized MinioClient getMinioClient() {
// 判断队列是否为null
if (poolSize == 0){
// 为空,初始化连接池
for (int i =0 ;i < coreSize;i++){
MinioClient client = MinioClient.builder()
.httpClient(new OkHttpClient())
.endpoint(minioConfig.getUrl())
.credentials(minioConfig.getAccess(),minioConfig.getSecret())
.build();
minioPool.add(client);
// 对应的连接池数量+1
poolSize++;
}
}
// 返回连接的客户端
MinioClient client = null;
// 连接池不为null
if (!minioPool.isEmpty()){
// 连接队列不为null ,直接返回
return minioPool.poll();
}
// 连接池队列为null了
if(poolSize >= coreSize && poolSize <= maxSize){
// 在队列所能容纳的范围内
client = MinioClient.builder()
.httpClient(new OkHttpClient())
.endpoint(minioConfig.getUrl())
.credentials(minioConfig.getAccess(),minioConfig.getSecret())
.build();
poolSize++;
}else {
// 如果已经超过了队列所能容纳的最多的客户端,抛出异常
throw new RuntimeException("系统需要的minio客户端数量超过了连接池的最大容量,请跳转连接池的数量");
}
return client;
}
/**
* 回收客户端
* @param client 客户端
*/
public synchronized void recycleMinioClient(MinioClient client) {
// 回收的为核心客户端
if (0 < poolSize && poolSize <= coreSize){
// 回收至队列中
minioPool.add(client);
}
// 其余情况不需要回收至队列,对应的标记数量-1即可
poolSize--;
}
}
声明一个OSS接口,规范若依中的操作
OSSCient
class interface OSSClient{
/**
* 上传文件到oss
* @param file 要上传的文件
* @param filename 文件名 common模块对文件进行文件名编码
* @param prefix 要上传的前缀
* @return 资源的访问路径
*/
public String uploadFile(MultipartFile file,String filename, String prefix);
/**
* 下载文件
*
* @param prefix 前缀
* @param filename 要下载文件名
* @param response 输出流对象
*/
public void download(String prefix, String filename, HttpServletResponse response) throws Exception;
/**
* 通用下载
* @param resource 资源路径
* @param response 响应体
*/
public void download(String resource,HttpServletResponse response);
/**
* 删除指定文件
*
* @param prefix 文件前缀路径
* @param filename 文件名
* @return true:删除成功;false:删除失败
*/
public boolean deleteFile(String prefix, String filename);
}
这里只使用了minio OSS一种,其余厂商的OSS产品实现该接口应该也是可以完成响应的功能的。
定义minioOss的实现客户端实现
@Component
public class OSSClientByMinio {
@Autowired
private MinioConfig minioConfig;
@Autowired
private MinioPool minioPool;
/**
* 上传文件到oss
*
* @param file 要上传的文件
* @param filename 文件名
* @param prefix 要上传的前缀
* @return 桶加+filepath
*/
public String uploadFile(MultipartFile file,String filename, String prefix) {
// 文件编码
StringBuilder randomName = new StringBuilder();
// 创建minio客户端
MinioClient client = minioPool.getMinioClient();
try {
randomName.append(minioConfig.getUrl());
randomName.append("/");
randomName.append(minioConfig.getBucket());
randomName.append("/");
randomName.append(prefix);
randomName.append("/");
// 获取文件stream流
InputStream stream = file.getInputStream();
// 文件类型
String contentType = file.getContentType();
// 上传对象
client.putObject(
PutObjectArgs
.builder()
.stream(stream, file.getSize(), -1)
.contentType(contentType)
.bucket(minioConfig.getBucket())
.object(prefix+"/"+filename)
.build());
randomName.append(filename);
// 推送成功
return randomName.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 回收连接对象
minioPool.recycleMinioClient(client);
}
}
/**
* 下载文件
*
* @param prefix 前缀
* @param filename 要下载文件名
* @param response 输出流对象
*/
public void download(String prefix, String filename, HttpServletResponse response) throws Exception {
// 创建minio客户端
download(prefix+"/"+filename,response);
}
/**
* 通用下载
* @param resource 资源路径
* @param response 响应体
*/
public void download(String resource,HttpServletResponse response){
MinioClient client = minioPool.getMinioClient();
GetObjectResponse object = null;
BufferedOutputStream outputStream = null;
// 获取输出流
try {
outputStream = new BufferedOutputStream(response.getOutputStream());
object = client.getObject(GetObjectArgs.builder()
.object(resource)
.bucket(minioConfig.getBucket())
.build());
// 设置输出的文件名和文件类型
response.setContentType("application/x-msdownload;");
response.setHeader("Content-disposition", "attachment;filename=" + resource.substring(resource.lastIndexOf("/")));
byte[] bytes = new byte[2048];
int len;
while ((len =object.read(bytes,0,bytes.length)) != -1) {
outputStream.write(bytes,0,len);
outputStream.flush();
}
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
if(object != null){
try {
object.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
if(outputStream!= null){
try {
outputStream.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// 回收客户端
minioPool.recycleMinioClient(client);
}
}
/**
* 删除指定文件
*
* @param prefix 文件前缀路径
* @param filename 文件名
* @return true:删除成功;false:删除失败
*/
public boolean deleteFile(String prefix, String filename) {
MinioClient client = minioPool.getMinioClient();
try {
client.removeObject(RemoveObjectArgs.builder()
.bucket(minioConfig.getBucket())
.object(prefix+"/"+filename)
.build());
return true;
} catch (Exception e) {
return false;
}finally {
// 回收
minioPool.recycleMinioClient(client);
}
}
}
定义oss服务的服务业务接口
/**
* OSS服务端的接口定义
*/
public interface IOSSService {
// 通用上传文件
String uploadFile(MultipartFile file,String filename,String prefix);
// 基本下载
void downloadFile(String filename, HttpServletResponse response);
// 通用下载
void generalDownload(String resource,HttpServletResponse response);
boolean deleteFile(String filename);
}
实现:
@Component
public class OSSServiceImpl implements IOSSService {
@Autowired
private OSSClientByMinio ossClientByMinio;
/**
* 上传用户对象
* @param file 要上传的对象
* @return 资源的访问路径
*/
@Override
public String uploadFile(MultipartFile file,String filename,String prefix) {
return ossClientByMinio.uploadFile(file,filename, prefix);
}
/**
* 通用下载实现
* @param filename 文件名
*/
@Override
public void downloadFile(String filename, HttpServletResponse response) {
try {
ossClientByMinio.download("common",filename,response);
} catch (Exception e) {
throw new RuntimeException("文件不存在");
}
}
/**
* 通用资源下载
* @param resource 带有文件前缀的资源路径
* @param response 响应
*/
@Override
public void generalDownload(String resource, HttpServletResponse response) {
ossClientByMinio.download(resource,response);
}
@Override
public boolean deleteFile(String filename) {
return ossClientByMinio.deleteFile("common",filename);
}
}
替换若依的资源上传下载功能
common模块的文件上传
/**
* 文件上传
*
* @param baseDir 相对应用的基目录
* @param file 上传的文件
* @param allowedExtension 上传文件类型
* @return 返回上传成功的文件名
* @throws FileSizeLimitExceededException 如果超出最大大小
* @throws FileNameLengthLimitExceededException 文件名太长
* @throws IOException 比如读写文件出错时
* @throws InvalidExtensionException 文件校验异常
*/
public static final String upload(String baseDir, MultipartFile file, String[] allowedExtension)
throws FileSizeLimitExceededException, IOException, FileNameLengthLimitExceededException,
InvalidExtensionException
{
// 判断是否存在文件名
int fileNamelength = Objects.requireNonNull(file.getOriginalFilename()).length();
// 判断文件名的长度是否超过了指定的长度 100
if (fileNamelength > FileUploadUtils.DEFAULT_FILE_NAME_LENGTH)
{
throw new FileNameLengthLimitExceededException(FileUploadUtils.DEFAULT_FILE_NAME_LENGTH);
}
// 校验文件大小
assertAllowed(file, allowedExtension);
// 文件编码
String fileName = FileUploadUtils.extractFilename(file);
// 获取ioc容器的minio客户端 这里为替换的内容
// 因为方法是 static类型的,所以获取bean的方式有写不同,但若依已经封装好了对应的方法了
IOSSService iossService = SpringUtils.getBean(IOSSService.class);
// 调用自己实现的oss资源上传
return iossService.uploadFile(file,fileName,baseDir);
}
由于若依的文件名的编码前缀过多,在minio不便后面的下载操作,所以我改了一下FileUploadUtils.extractFilename
/**
* 编码文件名
*/
public static final String extractFilename(MultipartFile file)
{
// 将原来的 {YYYY}/{MM}/{DD}/{filename}_{时间}.{文件后缀},改为了{filename}_{时间}.{文件后缀}格式
return StringUtils.format("{}_{}.{}",
FilenameUtils.getBaseName(file.getOriginalFilename()), Seq.getId(Seq.uploadSeqType), getExtension(file));
}
为了好管理,将用户头像统一放在avatar
文件夹下,通过资源放在common
文件夹下,所以这里要修改一下调用upload()是传入的baseDir
的实参,原来传入的是我们在yml中配置的本地目录,现在在/system/user/profile/avatar
中传入的是avatar
========[修改之前]===========
LoginUser loginUser = getLoginUser();
String baseDir = CloudstudyConfig.getProfile();
String avatar = FileUploadUtils.upload(baseDir, file, MimeTypeUtils.IMAGE_EXTENSION);
===== [修改之后] =======
LoginUser loginUser = getLoginUser();
String avatar = FileUploadUtils.upload("avatar", file, MimeTypeUtils.IMAGE_EXTENSION);
admin
模块中也是如此,将之前的baseDir
,替换为"common"
@RestController
@RequestMapping("/common")
@Api("文件上传下载管理")
@Anonymous
public class CommonController
{
private static final Logger log = LoggerFactory.getLogger(CommonController.class);
private static final String FILE_DELIMETER = ",";
@Autowired
private IOSSService iossService;
/**
* 通用下载请求
*
* @param fileName 文件名称
* @param delete 是否删除
*/
@GetMapping("/download")
@ApiOperation("通用下载请求")
public void fileDownload(String fileName, Boolean delete, HttpServletResponse response, HttpServletRequest request)
{
try
{
if (!FileUtils.checkAllowDownload(fileName))
{
throw new Exception(StringUtils.format("文件名称({})非法,不允许下载。 ", fileName));
}
// 下载
iossService.downloadFile(fileName,response);
// 是否删除
if (delete){
iossService.deleteFile(fileName);
}
// String realFileName = System.currentTimeMillis() + fileName.substring(fileName.indexOf("_") + 1);
// String filePath = CloudstudyConfig.getDownloadPath() + fileName;
// response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
// FileUtils.setAttachmentResponseHeader(response, realFileName);
// FileUtils.writeBytes(filePath, response.getOutputStream());
// if (delete)
// {
FileUtils.deleteFile(filePath);
// }
}
catch (Exception e)
{
log.error("下载文件失败", e);
}
}
/**
* 通用上传请求(单个)
*/
@PostMapping("/upload")
@Anonymous
@ApiOperation("通用上传请求(单个)")
public AjaxResult uploadFile(@RequestPart MultipartFile file) throws Exception
{
try
{
// 上传文件路径
// String filePath = CloudstudyConfig.getUploadPath();
// 上传并返回新文件名称
String fileName = FileUploadUtils.upload("common", file);
AjaxResult ajax = AjaxResult.success();
ajax.put("url", fileName);
ajax.put("fileName", fileName);
ajax.put("newFileName", FileUtils.getName(fileName));
ajax.put("originalFilename", file.getOriginalFilename());
return ajax;
}
catch (Exception e)
{
return AjaxResult.error(e.getMessage());
}
}
/**
* 通用上传请求(多个)
*/
@PostMapping("/uploads")
@ApiOperation("通用上传请求(多个)")
public AjaxResult uploadFiles(@RequestPart List<MultipartFile> files) throws Exception
{
try
{
// 上传文件路径
List<String> urls = new ArrayList<String>();
List<String> fileNames = new ArrayList<String>();
List<String> newFileNames = new ArrayList<String>();
List<String> originalFilenames = new ArrayList<String>();
for (MultipartFile file : files)
{
// 上传并返回新文件名称
String fileName = FileUploadUtils.upload("common", file);
urls.add(fileName);
fileNames.add(fileName);
newFileNames.add(FileUtils.getName(fileName));
originalFilenames.add(file.getOriginalFilename());
}
AjaxResult ajax = AjaxResult.success();
ajax.put("urls", StringUtils.join(urls, FILE_DELIMETER));
ajax.put("fileNames", StringUtils.join(fileNames, FILE_DELIMETER));
ajax.put("newFileNames", StringUtils.join(newFileNames, FILE_DELIMETER));
ajax.put("originalFilenames", StringUtils.join(originalFilenames, FILE_DELIMETER));
return ajax;
}
catch (Exception e)
{
return AjaxResult.error(e.getMessage());
}
}
/**
* 本地资源通用下载
*/
@GetMapping("/download/resource")
@ApiOperation("本地资源通用下载")
public void resourceDownload(String resource, HttpServletRequest request, HttpServletResponse response)
throws Exception
{
try
{
// 检查资源路径是否可以下载
if (!FileUtils.checkAllowDownload(resource))
{
throw new Exception(StringUtils.format("资源文件({})非法,不允许下载。 ", resource));
}
iossService.generalDownload(resource,response);
// // 本地资源路径
// String localPath = CloudstudyConfig.getProfile();
// // 数据库资源地址
// String downloadPath = localPath + StringUtils.substringAfter(resource, Constants.RESOURCE_PREFIX);
// // 下载名称
// String downloadName = StringUtils.substringAfterLast(downloadPath, "/");
// response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
// FileUtils.setAttachmentResponseHeader(response, downloadName);
// FileUtils.writeBytes(downloadPath, response.getOutputStream());
}
catch (Exception e)
{
log.error("下载文件失败", e);
}
}
}
最后一步–修改前端
因为我们已经使用了自己的OSS服务器,但前端的请求地址仍以/dev-api/**
的方式获得用户的头像,所以这里要修改一下前端的请求地址的拼接,
位置:/src/views/system/user/userAvatar.vue
// 上传图片
uploadImg() {
this.$refs.cropper.getCropBlob(data => {
let formData = new FormData();
formData.append("avatarfile", data);
uploadAvatar(formData).then(response => {
this.open = false;
// 这里之前是获取 .env中后端的请求地址 + response.imgUrl,现在要改为 response.imgUrl
this.options.img = response.imgUrl;
store.commit('SET_AVATAR', this.options.img);
this.$modal.msgSuccess("修改成功");
this.visible = false;
});
});
},
上传之后回显搞定了,还有未上传时,用户头像的请求
位置:/src/api/store/module/user.js
SET_AVATAR: (state, avatar) => {
state.avatar = avatar
},
文章写的不好,还请谅解!
oss替换之后,还有就是日志服务器了,这个我还没做,但其业务逻辑在:
system模块下的service.impl.SysLogininforServiceImpl
中