一、引言:
在现代软件开发中,配置的灵活性和可扩展性是至关重要的。为了满足不同环境和需求的变化,开发人员需要一种简洁而强大的方式来配置和管理组件。本文将介绍一种基于注解的编写方式,通过使用Spring框架提供的注解,实现可扩展的配置组件。
二、介绍主角
@ConditionalOnProperty
:根据指定的属性值条件,决定是否创建该组件的实例。这使得组件的创建可以根据配置文件中的属性进行动态控制。@ConfigurationProperties
:将配置文件中的属性值绑定到该类的字段上,实现属性的自动注入。这样可以方便地从配置文件中读取和使用属性值。
下面是一个简单的案例
首先,定义一个文件上传接口FileUploadService
,其中包含文件上传的方法:
java
public interface FileUploadService {
void uploadFile(MultipartFile file);
}
然后,创建不同平台的文件上传实现类,例如LocalFileUploadService
和S3FileUploadService
:
java
@Component
@ConditionalOnProperty(value = "file.upload.platform", havingValue = "local")
public class LocalFileUploadService implements FileUploadService {
@Override
public void uploadFile(MultipartFile file) {
// 在本地平台上实现文件上传逻辑
System.out.println("Uploading file to local platform...");
}
}
@Component
@ConditionalOnProperty(value = "file.upload.platform", havingValue = "s3")
public class S3FileUploadService implements FileUploadService {
@Override
public void uploadFile(MultipartFile file) {
// 在S3平台上实现文件上传逻辑
System.out.println("Uploading file to S3 platform...");
}
}
通过在配置文件(例如application.properties)中设置file.upload.platform
属性的值,可以选择性地使用不同平台的文件上传实现类:
properties
file.upload.platform=local
或
properties
file.upload.platform=s3
根据配置的不同,将使用相应的文件上传实现类。
这样,你就可以根据需要选择性地实现不同平台的文件上传接口,并通过配置文件来控制使用哪个实现类。
三、具体的编写
给大家一个供参考的文件夹路径:

1. 构思
- 我们需要简化的一些方面
- 1.1 文件需要校验文件可用性
- 1.2 上传的文件是否需要重命名
- 1.3 接口的设计是否应该更广,比如支持上传到指定目录
- 1.4 使用者如何选择自己项目对应的平台
- ...
对于以上需求,我们先分析后,再进行实现
我们需要对上传文件进行自定义校验,比如文件名,文件大小、文件后缀判断、对文件重命名等等,这些都是每个接口可能需要实现的内容,我们不可能让每个接口都去实现,这样就会造成以下情况:
java
// 伪代码展示冗余操作
// 本地
public class LocalFileStorage{
public String uploadFile(String dir, MultipartFile file, String[] allowedExtension) {
// 1. 校验文件大小
// 2. 判断文件类型
// 3. 重命名文件防止覆盖
}
}
// 阿里云
public class AliyunFileStorage{
public String uploadFile(String dir, MultipartFile file, String[] allowedExtension) {
// 1. 校验文件大小
// 2. 判断文件类型
// 3. 重命名文件防止覆盖
}
}
// 腾讯云
public class TencentFileStorage{
public String uploadFile(String dir, MultipartFile file, String[] allowedExtension) {
// 1. 校验文件大小
// 2. 判断文件类型
// 3. 重命名文件防止覆盖
}
}
// 其他的实现 ...
这让我想到了,AOP(Aspect Oriented Programming),我们可以对接口进行切面,在切面中实现这些不就好了?这样一下就解决了1.1和1.2
1.3 构思的解决方案主要是在接口上进行扩展,很好解决
1.4 在文章开始已经说明,采用@ConditionalOnProperty(..) 即可
下面让我们来具体实现一下吧
2. "赛前" 准备工作
- 项目中使用的 hutool 工具类,可自行导入
媒体类型定义
MimeTypeConstant.java
java
/**
* 媒体类型常量
*
* @author yiFei
*/
public class MimeTypeConstant {
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"};
}
配置类定义
- 用于读取配置类中一些基础配置方便后续根据用户配置进行校验
FileStorageConfig.java
java
/**
* 文件上传配置类
* 如果需要限制单个文件大小和最大文件大小:
* 请通过 spring.servlet.multipart.max-file-size / max-request-size 设置
*
* @author yiFei
*/
@Component
@ConfigurationProperties(prefix = "file.storage")
@Data
public class FileStorageConfig {
/**
* 上传服务器类型: 本地上传(local) / Minio(minio) / 七牛云(qiniu) / 阿里云(aliyun) / 腾讯云(tencent)
*/
private String type = "local";
/**
* 默认支持文件上传类型:
* 可在调用上传方法时,覆盖该属性
*/
private String[] allowedExtension = IMAGE_EXTENSION;
/**
* 上传文件名最大值
*/
private int fileNameLength = 100;
/**
* 是否覆盖文件名
*/
private boolean coverFileName = true;
// /**
// * 单个文件最大值
// */
// private String maxFileSize = "";
// /**
// * 多个文件最大值
// */
// private String maxRequestSize = "";
}
FileUtils 工具类编写
java
public class FileUtils {
public static final String DOT = ".";
public static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy" + File.separator + "MM" + File.separator + "dd" + File.separator);
/**
* 判断MIME类型是否是允许的MIME类型
*
* @param extension 文件类型
* @param allowedExtension 允许的文件类型
* @return 是否允许
*/
public static boolean isAllowedExtension(String extension, String[] allowedExtension) {
for (String str : allowedExtension) {
if (str.equalsIgnoreCase(extension)) {
return true;
}
}
return false;
}
/**
* 获取文件名的后缀
* 举例: *.jpg ==== > jpg
*
* @param file 文件
* @return 后缀名
*/
public static String getFileExtension(MultipartFile file) {
// 1. 获取文件名
String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
throw new RuntimeException("FileUtils: originalFilename is null");
}
String fileExtension;
int lastDotIndex = originalFilename.lastIndexOf('.');
if (lastDotIndex > 0) {
// 1.1 如果是 *.jpg ==== > jpg
fileExtension = originalFilename.substring(lastDotIndex + 1);
} else {
// 1.2 如果用户未传入后缀,根据上传类型判断后缀
MimeType mimeType = MimeTypeUtils.parseMimeType(Objects.requireNonNull(file.getContentType()));
fileExtension = mimeType.getSubtype();
}
return fileExtension;
}
/**
* 矫正用户传入的路径 ( 会自动拼接后缀 File.separator , 会矫正 // 或者 /// 等 )
*
* @param paths 路径集合
* @return 矫正后的路径
*/
public static String correctAndJoinPaths(String... paths) {
StringBuilder result = new StringBuilder();
for (String path : paths) {
if (path != null && !path.isEmpty()) {
if (!result.isEmpty() && result.charAt(result.length() - 1) != File.separatorChar && !path.startsWith(File.separator)) {
result.append(File.separator);
}
result.append(path.replaceAll("[" + File.separator + "/\\]+$", ""));
}
}
if (!result.isEmpty() && result.charAt(result.length() - 1) != File.separatorChar) {
result.append(File.separator);
}
return result.toString();
}
/**
* 返回生成的日期路径
* "yyyy" + File.separator + "MM" + File.separator + "dd" + File.separator
*
* @return 日期路径
*/
public static String datePath() {
return LocalDate.now().format(FORMATTER);
}
}
3. 编写代码

定义 FileStorageService 接口
-
这个接口提供了一组方法来处理文件上传和删除的操作,并提供了一些默认实现来简化使用。你可以根据自己的需求实现该接口,并在实现类中提供具体的上传和删除逻辑。
-
虽然看着接口内容很多,但是实现者只需要实现 uploadFile() , deleteFile() 即可
java
/**
* 文件上传接口
*
* @author yiFei
*/
public interface FileStorageService {
/**
* 上传单个文件
*
* @param dir 文件存放路径 ( 用于调用者动态分类 ) ( 编写时请注意 dir 可能为空 )
* @param file 文件
* @param allowedExtension 允许上传的文件类型
* @return 文件上传后的访问路径
*/
String uploadFile(String dir, MultipartFile file, String[] allowedExtension);
/**
* 上传多个文件
*
* @param dir 保存路径
* @param files 文件
* @param allowedExtension 允许上传的文件类型
* @return 文件上传后的访问路径数组
*/
default String[] uploadFiles(String dir, MultipartFile[] files, String[] allowedExtension) {
return Arrays.stream(files).map(file -> this.uploadFile(dir, file, allowedExtension)).toArray(String[]::new);
}
/**
* 删除单个文件
*
* @param dir 保存路径
* @param url 文件访问路径
* @return 是否删除成功,注: 不报错则返回 true
*/
boolean deleteFile(String dir, String url);
/**
* 删除多个文件
*
* @param dir 保存路径
* @param urls 文件访问路径集合
* @return 是否删除成功,注: 不报错则返回 true
*/
default boolean deleteFiles(String dir, String[] urls) {
return Arrays.stream(urls).allMatch(url -> this.deleteFile(dir, url));
}
/**
* 删除单个文件
*
* @param url 文件访问路径
* @return 是否删除成功,注: 不报错则返回 true
*/
default boolean deleteFile(String url) {
return deleteFile("", url);
}
/**
* 删除多个文件
*
* @param urls 文件访问路径集合
* @return 是否删除成功,注: 不报错则返回 true
*/
default boolean deleteFiles(String[] urls) {
return Arrays.stream(urls).allMatch(this::deleteFile);
}
/**
* 上传单个文件( 使用 FileStorageConfig 中允许的文件类型)
*
* @param dir 文件存放路径
* @param file 文件
* @return 文件上传后的访问路径
*/
default String uploadFile(String dir, MultipartFile file) {
return uploadFile(dir, file, null);
}
/**
* 上传单个文件( 使用 FileStorageConfig 中允许的文件类型、直接存储在 baseDir 文件夹下)
*
* @param file 文件
* @param allowedExtension 允许上传的文件类型
* @return 文件上传后的访问路径
*/
default String uploadFile(MultipartFile file, String[] allowedExtension) {
return uploadFile("", file, allowedExtension);
}
/**
* 上传单个文件( 使用 FileStorageConfig 中允许的文件类型、直接存储在 baseDir 文件夹下)
*
* @param file 文件
* @return 文件上传后的访问路径
*/
default String uploadFile(MultipartFile file) {
return uploadFiles("", new MultipartFile[]{file}, null)[0];
}
/**
* 上传多个文件( 使用 FileStorageConfig 的 allowedExtension)
*
* @param dir 文件存放路径
* @param files 文件集合
* @return 文件上传后的访问路径
*/
default String[] uploadFiles(String dir, MultipartFile[] files) {
return Arrays.stream(files).map(file -> this.uploadFile(dir, file)).toArray(String[]::new);
}
/**
* 上传多个文件( 使用 FileStorageConfig 的 allowedExtension)
*
* @param files 文件集合
* @param allowedExtension 允许上传的文件类型
* @return 文件上传后的访问路径
*/
default String[] uploadFiles(MultipartFile[] files, String[] allowedExtension) {
return Arrays.stream(files).map(file -> this.uploadFile(file, allowedExtension)).toArray(String[]::new);
}
/**
* 上传多个文件( 使用 FileStorageConfig 的 allowedExtension)
*
* @param files 文件集合
* @return 文件上传后的访问路径
*/
default String[] uploadFiles(MultipartFile[] files) {
return Arrays.stream(files).map(this::uploadFile).toArray(String[]::new);
}
}
本地上传进行实现
接口对应实现,这里只给出本地文件上传的实现方法,其他方法类似
java
/**
* 本地文件上传 ( 默认 )
* 注: 设置matchIfMissing = true会使havingValue失效。这里只是为表明此类加载的是 local
*
* @author yiFei
*/
@Component
@ConditionalOnProperty(value = "file.storage.type", havingValue = "local", matchIfMissing = true)
@ConfigurationProperties(prefix = "file.storage.local")
@RequiredArgsConstructor
@Data
public class LocalFileStorageImpl implements FileStorageService {
private static final Logger log = LoggerFactory.getLogger(LocalFileStorageImpl.class);
/**
* 上传路径
*/
private String uploadPath;
/**
* 访问的路径名 ( 请根据项目情况控制是否放行文件,比如允许不登录即可访问 /images )
*/
private String accessUrl = "/images";
/**
* 配置文件访问路径和实际文件存储路径的映射关系,使得通过指定的访问路径可以访问到对应的文件系统中的资源
*
* @return WebMvcConfigurer
*/
@Bean
public WebMvcConfigurer resourceHandlerConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
/*
映射: /images/** ---------> file:uploadPath
举例: http://ip:host/images/1.png ---------> file:\www\images\1.png
*/
String fileUploadPath = FileUtils.correctAndJoinPaths(uploadPath);
registry.addResourceHandler(accessUrl + "/**").addResourceLocations("file:" + fileUploadPath);
}
};
}
/**
* 上传单个文件
*
* @param dir 文件存放路径 ( 用于调用者动态分类 ) ( 编写时请注意 dir 可能为空 )
* @param file 文件
* @param allowedExtension 允许上传的文件类型
* @return 文件上传后的访问路径
*/
@Override
public String uploadFile(String dir, MultipartFile file, String[] allowedExtension) {
// 获取上传文件的绝对路径
String absolutePath = FileUtils.correctAndJoinPaths(uploadPath, dir, FileUtils.datePath());
try {
// 1.1 获取文件对象上传
File absoluteFile = getAbsoluteFile(absolutePath, file.getOriginalFilename());
// 1.2 上传文件
file.transferTo(absoluteFile);
} catch (IOException e) {
log.error("上传文件失败, 常见错误: 未开启路径权限: {}", absolutePath);
throw new ServiceException(ResultCode.FILE_UPLOAD_ERROR);
}
// 2. 返回给前端一个访问链接
return getRequestFileName(file, accessUrl + "/" + dir + "/" + FileUtils.datePath());
}
/**
* 删除单个文件
*
* @param dir 保存路径
* @param url 文件访问路径
* @return 是否删除成功,注: 不报错则返回 true
*/
@Override
public boolean deleteFile(String dir, String url) {
// 1. 从 url 中获取文件名
String fileName = extractFileNameFromUrl(url);
// 2. 获取文件所在路径
String absolutePath = FileUtils.correctAndJoinPaths(uploadPath, dir, fileName);
// 3. 构建文件对象
File file = new File(absolutePath);
// 4. 删除文件
if (file.exists()) {
// 4.1 文件存在,进行删除
if (!file.delete()) {
log.error("File deletion failed: {}", absolutePath);
return false;
}
} else {
// 4.2 文件不存在,返回 false
log.warn("File does not exist for deletion: {}", absolutePath);
return false;
}
return true;
}
/**
* 获取该文件的访问路径
*
* @param file 文件
* @param accessUrl 访问路径
* @return url
*/
private String getRequestFileName(MultipartFile file, String accessUrl) {
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
String baseUrl = request.getRequestURL().toString().replace(request.getRequestURI(), request.getContextPath());
String originalFilename = file.getOriginalFilename();
// 注: 拼接访问路径为 "/"
String requestFileName = baseUrl + accessUrl + originalFilename;
return requestFileName.replace("//", "/").replace(File.separator, "/");
}
/**
* @param uploadDir 上传文件路径
* @param fileName 文件名
* @return File
*/
private File getAbsoluteFile(String uploadDir, String fileName) {
File desc = new File(uploadDir + fileName);
// 创建上传文件需要的文件夹
if (!desc.exists()) {
if (!desc.getParentFile().exists()) {
desc.getParentFile().mkdirs();
}
}
return desc;
}
/**
* 从文件访问路径中提取文件名
*
* @param url 文件访问路径
* @return 文件名
*/
private String extractFileNameFromUrl(String url) {
// 举例: 如果 url 是 "/images/dir1/1.png",则提取出的文件名是 "1.png"
return url.substring(url.lastIndexOf('/') + 1);
}
}
到这里其实已经算是一个简单的实现了,但是还不够,我们需要
通过FileStorageConfig的配置信息对文件进行校验
,通过判断 FileStorageConfig.coverFileName 属性 决定是否使文件更改文件名
对 FileStorageService 切面编程
-
采用 AspectJ 进行切面编程,看代码太麻烦,我对每个方法进行口述一下
-
@Around
注解:用于定义环绕通知,指定切入点表达式为execution(* com.yifei.service.FileStorageService.uploadFile*(..))
,表示匹配FileStorageService
接口中以"uploadFile"开头的方法。 -
aroundUploadFile()
方法:环绕通知方法,在目标方法执行前后进行增强操作。该方法的参数为ProceedingJoinPoint
类型,用于获取目标方法的信息。 -
在
aroundUploadFile()
方法中,首先获取目标方法的参数对象、参数名和参数类型。 -
然后遍历参数对象,判断是否为文件对象、文件数组对象或允许上传的文件类型。
-
根据配置文件参数类型,对文件对象或文件数组对象进行修改和校验。
-
modifyArguments()
方法:根据文件对象或文件数组对象的情况,对参数列表进行修改。 -
modifyFile()
方法:校验并修改文件对象。首先获取文件名和后缀进行校验,然后根据配置决定是否重命名文件。 -
isAllowedFile()
方法:校验文件名长度和文件类型是否允许上传。
这段代码通过切面编程的方式,在文件上传方法执行前后进行了增强操作,包括校验文件名、文件类型和文件重命名等。
java
/**
* 增强 FileStorageService 上传文件方法 ( 校验 ,修改文件名 ... )
*
* @author yiFei
*/
@Aspect
@Component
public class FileStorageAspect {
@Autowired
private FileStorageConfig fileStorageConfig;
@Around("execution(* com.yifei.service.FileStorageService.uploadFile*(..))")
public Object aroundUploadFile(ProceedingJoinPoint joinPoint) throws Throwable {
// 1.1 获取切面方法的参数对象
Object[] args = joinPoint.getArgs();
// 1.2 获取切面方法的参数对象的参数名
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
// 1.3 获取切面方法的参数对象的类型
Class[] parameterTypes = signature.getParameterTypes();
// 2. 遍历对象,进行增强 ( XXX: 提供思路,可通过 Java 8 的 Optional 类和 Stream API 进行简化和优化 )
// 注 : filesIndex 标识 file 在 args 中的位置 ( file 和 files 在参数中只存在一个 )
int fileArgsIndex = -1;
MultipartFile file = null;
MultipartFile[] files = null;
String[] allowedExtension = null;
for (int i = 0; i < args.length; i++) {
// 可校验: "file".equals(parameterNames[i]) && parameterTypes[i].equals(MultipartFile.class)
if (parameterTypes[i].equals(MultipartFile.class)) {
// 2.1 如果为 file ( 重构 MultipartFile )
file = (MultipartFile) args[i];
// 2.1.1 标记 fileArgsIndex
fileArgsIndex = i;
} else if (parameterTypes[i].equals(MultipartFile[].class)) {
// 2.2 如果为 files ( 遍历重构 MultipartFile )
files = (MultipartFile[]) args[i];
// 2.2.1 标记 fileArgsIndex
fileArgsIndex = i;
} else if (parameterTypes[i].equals(String[].class)) {
// 2.3 如果为 allowedExtension ( 记录 allowedExtension 用于校验 )
allowedExtension = (String[]) args[i];
}
}
// 3. 判断是否修改文件 & 校验文件是否可以上传
modifyArguments(file, files, args, fileArgsIndex, allowedExtension);
// 4. 调用目标方法,并传入修改后的参数列表
return joinPoint.proceed(args);
}
private void modifyArguments(MultipartFile file, MultipartFile[] files, Object[] args, int fileArgsIndex, String[] allowedExtension) {
if (file != null || files != null) {
if (file != null) {
// 对 file 操作
args[fileArgsIndex] = modifyFile(file, allowedExtension);
} else {
// 对 files 操作
for (int i = 0; i < files.length; i++) {
files[i] = modifyFile(files[i], allowedExtension);
}
args[fileArgsIndex] = files;
}
}
}
/**
* 校验以及修改文件
*
* @param file 上传的文件
* @param allowedExtension 允许上传的文件类型
* @return 修改后的文件
*/
private MultipartFile modifyFile(MultipartFile file, String[] allowedExtension) {
// 1. 获取文件名和后缀进行校验
String originalFilename = Objects.requireNonNull(file.getOriginalFilename());
String extension = FileUtils.getFileExtension(file);
// 2. 校验文件
isAllowedFile(allowedExtension, originalFilename, extension);
// 3. 是否重命名文件
if (fileStorageConfig.isCoverFileName()) {
String uuid = UUID.fastUUID().toString().replace("-", "");
// 3.1 重命名文件
String realName = originalFilename.substring(0, originalFilename.lastIndexOf(FileUtils.DOT));
file = new CustomMultipartFile(file, realName + "_" + uuid + FileUtils.DOT + extension);
}
return file;
}
/**
* 校验是否允许上传
*
* @param allowedExtension 允许的类型
* @param originalFilename 文件名
* @param extension 文件类型
*/
private void isAllowedFile(String[] allowedExtension, String originalFilename, String extension) {
// 1. 校验文件名是否过长
if (originalFilename.length() > fileStorageConfig.getFileNameLength()) {
throw new ServiceException(ResultCode.FILE_NAME_TOO_LONG);
}
// 2. 校验是否允许上传
String[] defaultAllowedExtension = fileStorageConfig.getAllowedExtension();
if (allowedExtension != null && !FileUtils.isAllowedExtension(extension, allowedExtension)) {
throw new ServiceException(ResultCode.FILE_TYPE_ERROR);
} else if (defaultAllowedExtension != null && !FileUtils.isAllowedExtension(extension, defaultAllowedExtension)) {
throw new ServiceException(ResultCode.FILE_TYPE_ERROR);
}
}
}
四、 结束语
这只是一个简单的上传、删除的实现,对于项目中包含大文件处理的需求,还是无法满足。所以在基础项目开源的时候,我会对这些接口进行二次开发。后续支持的基础功能为: 大文件传输、断点续传、文件校验... ,以便于处理更复杂的文件上传逻辑。
目前设计的项目未开源,预计会延后很久,主要我编写前端过慢,想实现的功能比较多,这里只是介绍一下开源项目中的文件存储服务类应该怎么设计。后续更新其他功能文章,比如自动生成vue3+ts,react+ts的基本CRUD代码和后端的CRUD代码等等 。目前已有文章Redis + lua 多规则限流、优雅打印日志、后端多级菜单、多级部门...。如果本文章写的有所不对,请各位大佬指出。
作者 : yiFei注意 : 遵循版权规定,尊重原作者的权益,并在转载文章时标明作者和文章来源