一、简介
在之前的文章 开源项目的文件上传是如何设计的,支持本地、Minio、阿里云、七牛云、腾讯云... 中,我介绍了如何在开源项目中实现多平台文件上传功能,并支持本地存储及各大云服务平台的集成。然而,在实际项目中应用该设计时,我发现原有的文件校验功能并不够优雅。
之前的文章中,我使用了 AOP 进行方法级别的校验,这种方式在代码维护和扩展性上存在一些不足。为了解决这些问题,我进行了以下优化:
- 文件校验功能的改进:我使用了自定义的 JSR303 注解来进行文件校验。这种方式不仅提高了代码的可读性,还减少了重复代码的出现。
- 文件名更改操作的优化:更改文件名等操作通过工具类的形式进行封装调用,从而提高了代码的复用性与模块化程度。
虽然具体的上传逻辑保持不变,但这些优化使代码更加清晰和易于维护。在这篇文章中,我将详细介绍这些优化的设计思路和实现过程,以帮助大家在实际项目中实现更优雅的文件上传功能。
二、设计整体框架
1. 注解介绍
@ConditionalOnProperty
:根据配置文件中的特定属性值来决定是否创建某个组件的实例。这允许我们根据运行时环境的配置,动态地控制组件的加载与否,使应用程序更加灵活和可配置。@ConfigurationProperties
:将配置文件中的属性值自动绑定到类的字段上,实现属性的自动注入。这使得我们可以轻松地读取并使用配置文件中的属性值,减少硬编码并提高代码的可维护性。
通过以上两个注解,我们可以设计一个灵活的配置类,能够根据 file.storage.type
的不同值,动态加载对应的平台文件存储实现。例如,通过修改配置文件,我们可以轻松地在本地存储、MinIO、七牛云或阿里云等存储平台之间切换,如下所示:
选择本地存储的配置示例:
file:
storage:
type: local ### 使用本地存储
local:
endpoint: http://localhost:${server.port}
access-url: /images
minio:
host: localhost
bucket-name: ${spring.application.name}
qiniu:
access-key: XXX
secret-key: XXX
bucket-name: ${spring.application.name}
domain: XXX
aliyun:
endpoint: XXX
region: XXX
access-key-id: XXX
secret-access-key: XXX
bucket-name: ${spring.application.name}
在这个配置文件中,只需调整 file.storage.type
的值,就可以灵活地决定使用哪个平台进行文件存储,实现了代码的高度可配置性和扩展性。
2. 编写具体代码
给大家一个供参考的结构:
classDiagram
class FileStorageService {
<<interface>>
...
}
class LocalFileStorageService {
...
}
class TencentFileStorageService {
...
}
class QiniuFileStorageService {
...
}
class MinIoFileStorageService {
...
}
FileStorageService <|-- LocalFileStorageService
FileStorageService <|-- TencentFileStorageService
FileStorageService <|-- QiniuFileStorageService
FileStorageService <|-- MinIoFileStorageService
FileStorageService <|-- XXXService
上篇文章采用本地文件上传讲解,这次采用 Minio 的文件上传进行讲解 ( Minio、OSS、COS、OBS、Qiniu... 几乎都是一样的操作 )
实现 Minio 文件上传服务
让我们跳到 MinIoFileStorageService.java 中
// @formatter:off
/**
* Minio文件存储
* 简易的 Docker 运行 Minio 命令 :
docker run -p 9000:9000 -p 9090:9090 --name minio --restart=always -d \
-e "MINIO_ACCESS_KEY=minioadmin" \
-e "MINIO_SECRET_KEY=minioadmin" \
-v /path/to/local/data:/data \
-v /usr/local/minio/config:/root/.minio \
minio/minio server /data --console-address :9090 --address :9000
*
* @author : YiFei
*/
// @formatter:on
@Slf4j
@Component
@ConditionalOnProperty(value = "file.storage.type", havingValue = "minio")
@ConfigurationProperties(prefix = "file.storage.minio")
@RequiredArgsConstructor
@Data
public class MinIoFileStorageService implements FileStorageService {
/**
* 服务协议
*/
private String protocol = "http";
/**
* MinIO 主机地址
*/
private String host = "localhost";
/**
* MinIO 端口
*/
private int port = 9000;
/**
* 访问凭据
*/
private String accessKey = "minioadmin";
/**
* 凭据密钥
*/
private String secretKey = "minioadmin";
/**
* 服务名
*/
@Value("${spring.application.name}")
private String applicationName;
/**
* 存储桶名称
*/
private String bucketName;
private MinioClient minioClient;
@PostConstruct
public void init() {
// 1. 初始化 minioClient
minioClient = MinioClient.builder()
.endpoint(this.getFileStorageEndpoint())
.credentials(accessKey, secretKey)
.build();
// 2. 初始化 bucket
initBucket();
}
@Override
public String getFileStorageEndpoint() {
return protocol + "://" + host + ":" + port;
}
/**
* 上传单个文件
*
* @param savePath 文件存放路径 (savePath不能以 "/" 开头,请求时校验) (编写时请注意 savePath 可能为空)
* @param file 文件
* @return 文件上传后的访问路径
*/
@Override
public String uploadFile(String savePath, MultipartFile file) {
// 1. 生成文件上传路径
String datePath = FileUtils.datePath() + file.getOriginalFilename();
String objectName = StringUtils.hasText(savePath) ? savePath + DIR_SEPARATOR + datePath : datePath;
try {
HashMap<String, String> tags = new HashMap<>();
tags.put("server", applicationName);
// 2. 上传文件到minio
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.tags(tags)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build());
} catch (Exception e) {
log.error("Failed to upload file to MinIO. Object: {}, Error: {}", objectName, e.getMessage(), e);
throw new ServiceException(ResultCode.FILE_DELETE_ERROR);
}
// 3. 生成上传文件的路径
return getFileStorageEndpoint() + "/" + bucketName + "/" + objectName;
}
/**
* 删除单个文件
*
* @param savePath 保存路径 ( xx/xx )
* @param fileName 文件访问路径 ( xx.jpg )
* @return 是否删除成功,注: 不报错则返回 true
*/
@Override
public boolean deleteFile(String savePath, String fileName) {
// 1. 拼接文件名
String objectName = savePath + DIR_SEPARATOR + fileName;
try {
// 2. 删除文件
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
return true;
} catch (Exception e) {
log.error("Failed to delete file to MinIO. Object: {}, Error: {}", objectName, e.getMessage(), e);
throw new ServiceException(ResultCode.FILE_DELETE_ERROR);
}
}
/**
* 初始化 bucket
*/
private void initBucket() {
try {
boolean found =
minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if (!found) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
log.info("minio bucket {} 自动创建完成, 如果文件可以外部访问请设置bucket为public", bucketName);
}
} catch (Exception e) {
log.error("MinioFileStorageService -> @PostConstruct.init() -> initBucket", e);
}
}
}
代码解析
-
服务初始化:
@PostConstruct
标注的方法init()
用于在服务启动时初始化 Minio 客户端,并确保指定的存储桶存在。如果存储桶不存在,则自动创建。
-
文件上传:
uploadFile()
方法负责接收文件,并将其上传至 Minio。文件路径根据当前日期动态生成,上传成功后返回文件的访问 URL。
-
文件删除:
deleteFile()
方法根据提供的文件路径和文件名删除存储在 Minio 中的文件。
-
配置管理:
- 使用
@ConfigurationProperties
注解将配置文件中的属性值自动绑定到类字段,从而使服务配置更加灵活和可控。
- 使用
3. 编写文件上传RESTful接口
在应用程序中使用文件,上传服务非常简单,因为我们已经使用了接口 FileStorageService
, 而具体加载那个服务是由配置类决定,我们只需要注入FileStorageService
进行使用即可。
@Validated
@RestController
@RequestMapping("file")
@Tag(name = "文件上传服务")
@RequiredArgsConstructor
public class FileStorageController {
private final FileStorageService fileStorageService;
@PostMapping("/upload")
@Operation(summary = "上传文件")
public Result<String> uploadFile(
@RequestParam(required = false, defaultValue = "")
@Pattern(regexp = "^(?!/).*$", message = "存储路径不能以'/'开头") String savePath,
@RequestParam MultipartFile file) {
return Result.success(fileStorageService.uploadFile(savePath, file));
}
}
三、自定义 JSR303 注解进行文件校验
在我们已经实现了文件上传服务之后,为了确保上传的文件符合要求(如文件类型、文件大小等),可以使用 JSR303 的自定义注解进行文件校验。这种方法比 AOP 切面更符合开发人员的直觉,也更容易维护和理解。
给大家一个供参考的结构:
1. 创建自定义注解
/**
* MultipartFile 校验
*
* @author : YiFei
*/
@Documented
@Constraint(validatedBy = {MultipartFilesValidator.class, MultipartFileValidator.class})
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
public @interface MultipartFileValid {
String message() default "Invalid file";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
boolean required() default true;
int maxFileNameLength() default 100;
String[] allowedFileTypes() default {"bmp", "gif", "jpg", "jpeg", "png"};
}
2. 实现校验逻辑
实现 MultipartFilesValidator 校验
/**
* MultipartFiles 校验
*
* @author : YiFei
*/
public class MultipartFilesValidator implements ConstraintValidator<MultipartFileValid, MultipartFile[]> {
private int maxFileNameLength;
private String[] allowedFileTypes;
private boolean required;
@Override
public void initialize(MultipartFileValid constraintAnnotation) {
this.maxFileNameLength = constraintAnnotation.maxFileNameLength();
this.allowedFileTypes = constraintAnnotation.allowedFileTypes();
this.required = constraintAnnotation.required();
}
@Override
public boolean isValid(MultipartFile[] files, ConstraintValidatorContext context) {
boolean exist = ObjectUtils.isEmpty(files);
// 如果文件是必须的,且文件为空,直接返回 false
if (required && exist) {
return false;
}
// 如果文件不是必须的,且文件为空,返回 true
if (exist) {
return true;
}
return Arrays.stream(files).allMatch(file -> {
String originalFilename = file.getOriginalFilename();
// 校验扩展名 && 文件名
return FileUtils.isAllowedExtension(file, this.allowedFileTypes)
&& StringUtils.hasText(originalFilename)
&& originalFilename.length() <= this.maxFileNameLength;
});
}
}
MultipartFileValidator.java 类似省略
3. 注解使用
使用比较简单,JSR303的注解如何使用,我们就如何使用。
public Result<String> updateAvatar(@Validated @MultipartFileValid MultipartFile avatar) {
String avatarUrl = userProfileService.updateAvatar(avatar);
return Result.success(avatarUrl);
}
四、工具类编写
为了更好地管理文件操作,避免在 AOP 中进行文件名修改带来的复杂性,我们可以将文件操作逻辑提取到一个独立的工具类中。FileUtil 工具类就是为此目的而设计的,它将文件名修改、文件校验等逻辑集中管理,使代码更加清晰和易于维护。
其他校验代码省略,大家可以自行观看源码。以下是一个重要的示例:文件名修改方法
/**
* 文件操作工具类
*
* @author : YiFei
*/
@Slf4j
public class FileUtils {
// 省略其他辅助方法
/**
* 更改文件名,并返回更名后的文件对象。
*
* @param file 要更名的文件
* @param fileName 新的文件名
* @return 更名后的文件对象
*/
public static MultipartFile renameFile(MultipartFile file, String fileName) {
return new CustomMultipartFile(file, fileName);
}
}
五、文件存储其他功能介绍
SpringBoot集成TensorFlow : 本地实现图片内容安全检测
SpringBoot集成Tess4j : Java也能轻松实现OCR功能
六、源码
前端源码位置 : 任意一文件上传功能
后端源码位置 :
yf/ yf-boot-admin / yf-integration / yf-file
注意事项 :
- 平台一人一号,账号可以通过邮箱、第三方平台自动注册。用户名密码方式登录请联系管理员手动添加、手机号不可用。(敏感数据以做信息脱敏)
- 在线聊天功能(消息已做脏词过滤,群发、系统、AI消息不会被平台记录)
- 欢迎大家提出意见,欢迎畅聊与项目相关问题