第10章 - 文件服务与存储
第10章 - 文件服务与存储
10.1 文件服务概述
10.1.1 功能介绍
RuoYi-Cloud的文件服务模块(ruoyi-file)提供统一的文件上传和下载功能,支持多种存储方式:
- 本地存储
- MinIO分布式存储
- FastDFS分布式存储
- 阿里云OSS
- 腾讯云COS
- 七牛云存储
10.1.2 项目结构
ruoyi-modules/ruoyi-file/
├── src/main/java/com/ruoyi/file/
│ ├── RuoYiFileApplication.java # 启动类
│ ├── config/
│ │ ├── MinioConfig.java # MinIO配置
│ │ └── ResourcesConfig.java # 资源配置
│ ├── controller/
│ │ └── SysFileController.java # 文件控制器
│ └── service/
│ ├── ISysFileService.java # 文件服务接口
│ └── impl/
│ ├── FastDfsSysFileServiceImpl.java # FastDFS实现
│ ├── LocalSysFileServiceImpl.java # 本地存储实现
│ └── MinioSysFileServiceImpl.java # MinIO实现
└── src/main/resources/
└── bootstrap.yml
10.2 本地文件存储
10.2.1 配置
# 文件服务配置
file:
# 文件存储路径
path: /home/ruoyi/uploadPath
# 文件访问前缀
prefix: http://127.0.0.1:9300
# 域名(可选,用于访问时替换prefix)
domain:
10.2.2 本地存储实现
@Primary
@Service
public class LocalSysFileServiceImpl implements ISysFileService {
@Value("${file.path}")
private String localFilePath;
@Value("${file.domain}")
private String domain;
@Value("${file.prefix}")
private String localFilePrefix;
/**
* 本地文件上传接口
*
* @param file 上传的文件
* @return 访问地址
*/
@Override
public String uploadFile(MultipartFile file) throws Exception {
String name = FileUploadUtils.upload(localFilePath, file);
String url = domain + localFilePrefix + name;
return url;
}
}
10.2.3 文件上传工具类
public class FileUploadUtils {
/**
* 默认大小 50M
*/
public static final long DEFAULT_MAX_SIZE = 50 * 1024 * 1024;
/**
* 默认的文件名最大长度 100
*/
public static final int DEFAULT_FILE_NAME_LENGTH = 100;
/**
* 根据文件路径上传
*/
public static String upload(String baseDir, MultipartFile file) throws IOException {
try {
return upload(baseDir, file, MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION);
} catch (Exception e) {
throw new IOException(e.getMessage(), e);
}
}
/**
* 文件上传
*/
public static String upload(String baseDir, MultipartFile file, String[] allowedExtension)
throws FileSizeLimitExceededException, InvalidExtensionException, IOException {
int fileNameLength = Objects.requireNonNull(file.getOriginalFilename()).length();
if (fileNameLength > DEFAULT_FILE_NAME_LENGTH) {
throw new FileNameLengthLimitExceededException(DEFAULT_FILE_NAME_LENGTH);
}
assertAllowed(file, allowedExtension);
String fileName = extractFilename(file);
String absPath = getAbsoluteFile(baseDir, fileName).getAbsolutePath();
file.transferTo(Paths.get(absPath));
return getPathFileName(baseDir, fileName);
}
/**
* 编码文件名
*/
public static String extractFilename(MultipartFile file) {
return StringUtils.format("{}/{}_{}.{}",
DateUtils.datePath(),
FilenameUtils.getBaseName(file.getOriginalFilename()),
Seq.getId(Seq.uploadSeqType),
FileTypeUtils.getExtension(file));
}
private static File getAbsoluteFile(String uploadDir, String fileName) throws IOException {
File desc = new File(uploadDir + File.separator + fileName);
if (!desc.exists()) {
if (!desc.getParentFile().exists()) {
desc.getParentFile().mkdirs();
}
}
return desc;
}
private static String getPathFileName(String uploadDir, String fileName) {
int dirLastIndex = uploadDir.lastIndexOf("/") + 1;
String currentDir = StringUtils.substring(uploadDir, dirLastIndex);
return "/" + currentDir + "/" + fileName;
}
/**
* 文件大小校验
*/
public static void assertAllowed(MultipartFile file, String[] allowedExtension)
throws FileSizeLimitExceededException, InvalidExtensionException {
long size = file.getSize();
if (size > DEFAULT_MAX_SIZE) {
throw new FileSizeLimitExceededException(DEFAULT_MAX_SIZE / 1024 / 1024);
}
String fileName = file.getOriginalFilename();
String extension = FileTypeUtils.getExtension(file);
if (allowedExtension != null && !isAllowedExtension(extension, allowedExtension)) {
if (allowedExtension == MimeTypeUtils.IMAGE_EXTENSION) {
throw new InvalidExtensionException.InvalidImageExtensionException(allowedExtension, extension, fileName);
} else if (allowedExtension == MimeTypeUtils.FLASH_EXTENSION) {
throw new InvalidExtensionException.InvalidFlashExtensionException(allowedExtension, extension, fileName);
} else if (allowedExtension == MimeTypeUtils.MEDIA_EXTENSION) {
throw new InvalidExtensionException.InvalidMediaExtensionException(allowedExtension, extension, fileName);
} else if (allowedExtension == MimeTypeUtils.VIDEO_EXTENSION) {
throw new InvalidExtensionException.InvalidVideoExtensionException(allowedExtension, extension, fileName);
} else {
throw new InvalidExtensionException(allowedExtension, extension, fileName);
}
}
}
/**
* 判断MIME类型是否是允许的MIME类型
*/
public static boolean isAllowedExtension(String extension, String[] allowedExtension) {
for (String str : allowedExtension) {
if (str.equalsIgnoreCase(extension)) {
return true;
}
}
return false;
}
}
10.3 MinIO分布式存储
10.3.1 MinIO简介
MinIO是一个高性能的对象存储服务器,兼容Amazon S3 API,适合存储非结构化数据如图片、视频、日志文件等。
10.3.2 MinIO配置
# MinIO配置
minio:
url: http://localhost:9000
accessKey: minioadmin
secretKey: minioadmin
bucketName: ruoyi
10.3.3 MinIO配置类
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {
/** 服务地址 */
private String url;
/** 用户名 */
private String accessKey;
/** 密码 */
private String secretKey;
/** 存储桶名称 */
private String bucketName;
@Bean
public MinioClient getMinioClient() {
return MinioClient.builder()
.endpoint(url)
.credentials(accessKey, secretKey)
.build();
}
// getter/setter...
}
10.3.4 MinIO存储实现
@Service
public class MinioSysFileServiceImpl implements ISysFileService {
@Autowired
private MinioConfig minioConfig;
@Autowired
private MinioClient minioClient;
/**
* MinIO文件上传接口
*/
@Override
public String uploadFile(MultipartFile file) throws Exception {
String fileName = extractFilename(file);
PutObjectArgs args = PutObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(fileName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build();
minioClient.putObject(args);
return minioConfig.getUrl() + "/" + minioConfig.getBucketName() + "/" + fileName;
}
/**
* 提取文件名
*/
private String extractFilename(MultipartFile file) {
return StringUtils.format("{}/{}_{}.{}",
DateUtils.datePath(),
FilenameUtils.getBaseName(file.getOriginalFilename()),
Seq.getId(Seq.uploadSeqType),
FileTypeUtils.getExtension(file));
}
}
10.3.5 MinIO常用操作
@Component
public class MinioUtil {
@Autowired
private MinioClient minioClient;
/**
* 创建桶
*/
public void createBucket(String bucketName) throws Exception {
boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if (!found) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
}
}
/**
* 上传文件
*/
public void upload(String bucketName, String objectName, InputStream inputStream, String contentType) throws Exception {
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(inputStream, inputStream.available(), -1)
.contentType(contentType)
.build());
}
/**
* 下载文件
*/
public InputStream download(String bucketName, String objectName) throws Exception {
return minioClient.getObject(GetObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
}
/**
* 删除文件
*/
public void delete(String bucketName, String objectName) throws Exception {
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
}
/**
* 获取文件访问URL
*/
public String getPresignedUrl(String bucketName, String objectName) throws Exception {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucketName)
.object(objectName)
.expiry(7, TimeUnit.DAYS)
.build());
}
}
10.4 FastDFS分布式存储
10.4.1 FastDFS简介
FastDFS是一个开源的高性能分布式文件系统,主要功能包括:文件存储、文件同步、文件访问(文件上传、文件下载)等。
10.4.2 FastDFS配置
# FastDFS配置
fdfs:
domain: http://localhost:8080
soTimeout: 1500
connectTimeout: 600
pool:
jmxEnabled: false
trackerList:
- localhost:22122
10.4.3 FastDFS存储实现
@Service
public class FastDfsSysFileServiceImpl implements ISysFileService {
@Autowired
private FastFileStorageClient storageClient;
@Value("${fdfs.domain}")
private String domain;
/**
* FastDFS文件上传接口
*/
@Override
public String uploadFile(MultipartFile file) throws Exception {
StorePath storePath = storageClient.uploadFile(file.getInputStream(), file.getSize(),
FileTypeUtils.getExtension(file), null);
return domain + "/" + storePath.getFullPath();
}
}
10.5 文件控制器
10.5.1 文件上传接口
@RestController
public class SysFileController {
private static final Logger log = LoggerFactory.getLogger(SysFileController.class);
@Autowired
private ISysFileService sysFileService;
/**
* 文件上传请求
*/
@PostMapping("upload")
public R<SysFile> upload(MultipartFile file) {
try {
// 上传并返回访问地址
String url = sysFileService.uploadFile(file);
SysFile sysFile = new SysFile();
sysFile.setName(FileUtils.getName(url));
sysFile.setUrl(url);
return R.ok(sysFile);
} catch (Exception e) {
log.error("上传文件失败", e);
return R.fail(e.getMessage());
}
}
}
10.5.2 文件实体
public class SysFile {
/**
* 文件名称
*/
private String name;
/**
* 文件地址
*/
private String url;
// getter/setter...
}
10.6 远程文件服务调用
10.6.1 远程服务接口
@FeignClient(contextId = "remoteFileService",
value = ServiceNameConstants.FILE_SERVICE,
fallbackFactory = RemoteFileFallbackFactory.class)
public interface RemoteFileService {
/**
* 上传文件
*/
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
R<SysFile> upload(@RequestPart(value = "file") MultipartFile file);
}
10.6.2 降级处理
@Component
public class RemoteFileFallbackFactory implements FallbackFactory<RemoteFileService> {
private static final Logger log = LoggerFactory.getLogger(RemoteFileFallbackFactory.class);
@Override
public RemoteFileService create(Throwable throwable) {
log.error("文件服务调用失败:{}", throwable.getMessage());
return new RemoteFileService() {
@Override
public R<SysFile> upload(MultipartFile file) {
return R.fail("上传文件失败:" + throwable.getMessage());
}
};
}
}
10.6.3 调用示例
@RestController
@RequestMapping("/user/profile")
public class SysProfileController extends BaseController {
@Autowired
private RemoteFileService remoteFileService;
/**
* 头像上传
*/
@Log(title = "用户头像", businessType = BusinessType.UPDATE)
@PostMapping("/avatar")
public AjaxResult avatar(@RequestParam("avatarfile") MultipartFile file) {
if (!file.isEmpty()) {
LoginUser loginUser = SecurityUtils.getLoginUser();
R<SysFile> fileResult = remoteFileService.upload(file);
if (R.isSuccess(fileResult)) {
String avatar = fileResult.getData().getUrl();
if (userService.updateUserAvatar(loginUser.getUsername(), avatar)) {
AjaxResult ajax = AjaxResult.success();
ajax.put("imgUrl", avatar);
// 更新缓存用户头像
loginUser.getSysUser().setAvatar(avatar);
tokenService.setLoginUser(loginUser);
return ajax;
}
}
return AjaxResult.error(fileResult.getMsg());
}
return AjaxResult.error("上传图片异常,请联系管理员");
}
}
10.7 前端文件上传组件
10.7.1 图片上传组件
<template>
<div class="component-upload-image">
<el-upload
multiple
:action="uploadImgUrl"
list-type="picture-card"
:on-success="handleUploadSuccess"
:before-upload="handleBeforeUpload"
:limit="limit"
:on-error="handleUploadError"
:on-exceed="handleExceed"
ref="imageUpload"
:on-remove="handleDelete"
:show-file-list="true"
:headers="headers"
:file-list="fileList"
:on-preview="handlePictureCardPreview"
:class="{ hide: fileList.length >= limit }"
>
<el-icon class="avatar-uploader-icon"><plus /></el-icon>
</el-upload>
<!-- 上传提示 -->
<div class="el-upload__tip" v-if="showTip">
请上传
<template v-if="fileSize">
大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
</template>
<template v-if="fileType">
格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b>
</template>
的文件
</div>
<el-dialog
v-model="dialogVisible"
title="预览"
width="800px"
append-to-body
>
<img
:src="dialogImageUrl"
style="display: block; max-width: 100%; margin: 0 auto"
/>
</el-dialog>
</div>
</template>
<script setup>
import { getToken } from "@/utils/auth";
const props = defineProps({
modelValue: [String, Object, Array],
// 图片数量限制
limit: {
type: Number,
default: 5,
},
// 大小限制(MB)
fileSize: {
type: Number,
default: 5,
},
// 文件类型, 例如['png', 'jpg', 'jpeg']
fileType: {
type: Array,
default: () => ["png", "jpg", "jpeg"],
},
// 是否显示提示
isShowTip: {
type: Boolean,
default: true
}
});
const { proxy } = getCurrentInstance();
const emit = defineEmits();
const number = ref(0);
const uploadList = ref([]);
const dialogImageUrl = ref("");
const dialogVisible = ref(false);
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const uploadImgUrl = ref(baseUrl + "/file/upload");
const headers = ref({ Authorization: "Bearer " + getToken() });
const fileList = ref([]);
const showTip = computed(
() => props.isShowTip && (props.fileType || props.fileSize)
);
// 其他方法...
</script>
10.7.2 文件上传组件
<template>
<div class="upload-file">
<el-upload
multiple
:action="uploadFileUrl"
:before-upload="handleBeforeUpload"
:file-list="fileList"
:limit="limit"
:on-error="handleUploadError"
:on-exceed="handleExceed"
:on-success="handleUploadSuccess"
:show-file-list="false"
:headers="headers"
class="upload-file-uploader"
ref="fileUpload"
>
<!-- 上传按钮 -->
<el-button type="primary">选取文件</el-button>
</el-upload>
<!-- 上传提示 -->
<div class="el-upload__tip" v-if="showTip">
请上传
<template v-if="fileSize">
大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
</template>
<template v-if="fileType">
格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b>
</template>
的文件
</div>
<!-- 文件列表 -->
<transition-group
class="upload-file-list el-upload-list el-upload-list--text"
name="el-fade-in-linear"
tag="ul"
>
<li
:key="file.url"
class="el-upload-list__item ele-upload-list__item-content"
v-for="(file, index) in fileList"
>
<el-link :href="`${baseUrl}${file.url}`" :underline="false" target="_blank">
<span class="el-icon-document"> {{ getFileName(file.name) }} </span>
</el-link>
<div class="ele-upload-list__item-content-action">
<el-link :underline="false" @click="handleDelete(index)" type="danger">删除</el-link>
</div>
</li>
</transition-group>
</div>
</template>
<script setup>
import { getToken } from "@/utils/auth";
// 组件逻辑...
</script>
10.8 文件安全
10.8.1 文件类型限制
public class MimeTypeUtils {
public static final String IMAGE_PNG = "image/png";
public static final String IMAGE_JPG = "image/jpg";
public static final String IMAGE_JPEG = "image/jpeg";
public static final String IMAGE_BMP = "image/bmp";
public static final String IMAGE_GIF = "image/gif";
public static final String[] IMAGE_EXTENSION = { "bmp", "gif", "jpg", "jpeg", "png" };
public static final String[] FLASH_EXTENSION = { "swf", "flv" };
public static final String[] MEDIA_EXTENSION = { "swf", "flv", "mp3", "wav", "wma", "wmv", "mid", "avi", "mpg",
"asf", "rm", "rmvb" };
public static final String[] VIDEO_EXTENSION = { "mp4", "avi", "rmvb" };
public static final String[] DEFAULT_ALLOWED_EXTENSION = {
// 图片
"bmp", "gif", "jpg", "jpeg", "png",
// word excel powerpoint
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "html", "htm", "txt",
// 压缩文件
"rar", "zip", "gz", "bz2",
// 视频格式
"mp4", "avi", "rmvb",
// pdf
"pdf" };
}
10.8.2 文件名安全处理
public class FileTypeUtils {
/**
* 获取文件类型
*/
public static String getExtension(MultipartFile file) {
String extension = FilenameUtils.getExtension(file.getOriginalFilename());
if (StringUtils.isEmpty(extension)) {
extension = MimeTypeUtils.getExtension(Objects.requireNonNull(file.getContentType()));
}
return extension;
}
/**
* 获取文件名称(不含路径)
*/
public static String getName(String fileName) {
if (fileName == null) {
return null;
}
int lastUnixPos = fileName.lastIndexOf('/');
int lastWindowsPos = fileName.lastIndexOf('\\');
int index = Math.max(lastUnixPos, lastWindowsPos);
return fileName.substring(index + 1);
}
}
10.9 小结
本章详细介绍了RuoYi-Cloud的文件服务模块,包括:
- 功能概述:支持多种存储方式
- 本地存储:文件上传工具和配置
- MinIO存储:分布式对象存储配置和使用
- FastDFS存储:高性能分布式文件系统
- 远程调用:文件服务的Feign调用
- 前端组件:图片和文件上传组件
- 安全处理:文件类型和名称安全
文件服务是系统中的基础服务,理解其实现原理对于扩展其他存储方式非常重要。

浙公网安备 33010602011771号