开源项目中设计优雅的文件上传功能 : 支持多平台文件上传

758 阅读6分钟

一、简介

在之前的文章 开源项目的文件上传是如何设计的,支持本地、Minio、阿里云、七牛云、腾讯云... 中,我介绍了如何在开源项目中实现多平台文件上传功能,并支持本地存储及各大云服务平台的集成。然而,在实际项目中应用该设计时,我发现原有的文件校验功能并不够优雅。

之前的文章中,我使用了 AOP 进行方法级别的校验,这种方式在代码维护和扩展性上存在一些不足。为了解决这些问题,我进行了以下优化:

  1. 文件校验功能的改进:我使用了自定义的 JSR303 注解来进行文件校验。这种方式不仅提高了代码的可读性,还减少了重复代码的出现。
  2. 文件名更改操作的优化:更改文件名等操作通过工具类的形式进行封装调用,从而提高了代码的复用性与模块化程度。

虽然具体的上传逻辑保持不变,但这些优化使代码更加清晰和易于维护。在这篇文章中,我将详细介绍这些优化的设计思路和实现过程,以帮助大家在实际项目中实现更优雅的文件上传功能。

二、设计整体框架

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);
        }
    }
}
代码解析
  1. 服务初始化

    • @PostConstruct 标注的方法 init() 用于在服务启动时初始化 Minio 客户端,并确保指定的存储桶存在。如果存储桶不存在,则自动创建。
  2. 文件上传

    • uploadFile() 方法负责接收文件,并将其上传至 Minio。文件路径根据当前日期动态生成,上传成功后返回文件的访问 URL。
  3. 文件删除

    • deleteFile() 方法根据提供的文件路径和文件名删除存储在 Minio 中的文件。
  4. 配置管理

    • 使用 @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 切面更符合开发人员的直觉,也更容易维护和理解。

给大家一个供参考的结构:

image.png

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功能

六、源码

源码地址 | 👀 在线演示 | 觉得不错可以给个start

前端源码位置 : 任意一文件上传功能

后端源码位置 :

yf/ yf-boot-admin / yf-integration / yf-file

注意事项 :

    1. 平台一人一号,账号可以通过邮箱、第三方平台自动注册。用户名密码方式登录请联系管理员手动添加、手机号不可用。(敏感数据以做信息脱敏)
    1. 在线聊天功能(消息已做脏词过滤,群发、系统、AI消息不会被平台记录)
    1. 欢迎大家提出意见,欢迎畅聊与项目相关问题