⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 juejin.cn/post/7308621051524546597 「翼飞」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

一、引言

为了满足不同环境和需求的变化,我们需要让自己写的代码涉及面更加广泛,为了支持不同平台的对象,我决定设计一个支持各种平台的文件上传,删除功能,相信一点能满足你的需求。

二、介绍主角

  • @ConditionalOnProperty:根据指定的属性值条件,决定是否创建该组件的实例。这使得组件的创建可以根据配置文件中的属性进行动态控制。
  • @ConfigurationProperties:将配置文件中的属性值绑定到该类的字段上,实现属性的自动注入。这样可以方便地从配置文件中读取和使用属性值。

下面是一个简单的案例

首先,定义一个文件上传接口 FileUploadService,其中包含文件上传的方法:

public interface FileUploadService {
void uploadFile(MultipartFile file);
}

然后,创建不同平台的文件上传实现类,例如 LocalFileUploadService 和 S3FileUploadService:

@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 属性的值,可以选择性地使用不同平台的文件上传实现类:

file.upload.platform=local

或

file.upload.platform=s3

根据配置的不同,将使用相应的文件上传实现类。

这样,你就可以根据需要选择性地实现不同平台的文件上传接口,并通过配置文件来控制使用哪个实现类。

三、具体编写

给大家一个供参考的文件夹路径:

3.1 构思

我们需要简化的一些方面:

  1. 文件需要校验文件可用性
  2. 上传的文件是否需要重命名
  3. 接口的设计是否应该更广,比如支持上传到指定目录
  4. 使用者如何选择自己项目对应的平台等等

对于以上需求,我们先分析后,再进行实现。

我们需要对上传文件进行自定义校验,比如文件名,文件大小、文件后缀判断、对文件重命名等等,这些都是每个接口可能需要实现的内容,我们不可能让每个接口都去实现,这样就会造成以下情况:

// 伪代码展示冗余操作

// 本地
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 点和第 2 点。

第 3 点:构思的解决方案主要是在接口上进行扩展,很好解决。

第 4 点:在文章开始已经说明,采用 @ConditionalOnProperty(..) 即可.

下面让我们来具体实现一下吧。

3.2 “赛前” 准备工作

项目中使用的 hutool 工具类,可自行导入。

媒体类型定义

MimeTypeConstant.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

/**
* 文件上传配置类
* 如果需要限制单个文件大小和最大文件大小:
* 请通过 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 工具类编写

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.3 编写代码

定义 FileStorageService 接口

  • 这个接口提供了一组方法来处理文件上传和删除的操作,并提供了一些默认实现来简化使用。你可以根据自己的需求实现该接口,并在实现类中提供具体的上传和删除逻辑。
  • 虽然看着接口内容很多,但是实现者只需要实现 uploadFile() , deleteFile() 即可。
/**
* 文件上传接口
*
* @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);
}

}

本地上传进行实现

接口对应实现,这里只给出本地文件上传的实现方法,其他方法类似。

/**
* 本地文件上传 ( 默认 )
* 注: 设置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() 方法:校验文件名长度和文件类型是否允许上传。

这段代码通过切面编程的方式,在文件上传方法执行前后进行了增强操作,包括校验文件名、文件类型和文件重命名等。

/**
* 增强 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);
}
}
}

四、 结束语

这只是一个简单的上传、删除的实现,对于项目中包含大文件处理的需求,还是无法满足。像大文件传输、断点续传、文件校验等有待后续的工作。

文章目录
  1. 1. 一、引言
  2. 2. 二、介绍主角
    1. 2.1. 下面是一个简单的案例
  3. 3. 三、具体编写
    1. 3.1. 3.1 构思
    2. 3.2. 3.2 “赛前” 准备工作
      1. 3.2.1. 媒体类型定义
      2. 3.2.2. 配置类定义
      3. 3.2.3. FileUtils 工具类编写
    3. 3.3. 3.3 编写代码
      1. 3.3.1. 定义 FileStorageService 接口
      2. 3.3.2. 本地上传进行实现
      3. 3.3.3. 对 FileStorageService 切面编程
  4. 4. 四、 结束语