# scaffold 项目之文件存储

# 简介

文件存储通常涉及将文件保存到服务器的文件系统或云存储服务。

文件存储的最佳实践

  • 安全性:确保文件上传功能有适当的安全措施,如验证文件类型、大小限制和防注入。
  • 性能:对于大型文件或高并发场景,考虑使用异步处理和缓存策略。
  • 备份和恢复:定期备份文件存储,并确保可以恢复数据。
  • 监控和日志:监控文件存储的使用情况,并记录关键操作的日志

# 文件存储(上传下载)

项目支持将文件上传到三类存储器:

  1. 兼容 S3 协议的对象存储:支持 MinIO腾讯云COS七牛云Kodo华为云OBS亚马逊S3 等等。
  2. 磁盘存储:本地、 FTP 服务器、 SFTP 服务器。
  3. 数据存储:SQL Server、 MySQL、PostgreSQL 等等

上面三种方案的对比与选择

在选择文件存储方案时,需要考虑多个因素,包括成本、性能、可用性、安全性、可扩展性以及与现有系统的兼容性。以下是对您提到的三种方案的对比:

# 1. 兼容 S3 协议的对象存储

优点

  • 可扩展性:对象存储通常设计为高度可扩展,能够处理大量的数据和高并发请求。
  • 持久性:许多对象存储服务提供数据的高持久性保证。
  • 多区域部署:支持全球多个数据中心,有助于实现数据的地理冗余和低延迟访问。
  • 成本效益:对于大规模数据存储,对象存储通常比传统的块存储或文件存储更经济。
  • 兼容性:由于兼容 S3 协议,可以轻松迁移或集成不同的云服务提供商。

缺点

  • 访问速度:对于需要频繁访问的小文件,对象存储可能不如本地磁盘或块存储快。
  • 复杂性:配置和管理可能比传统的文件存储更复杂。

适用场景:适合大规模数据存储、备份、归档、大数据分析等。

# 2. 磁盘存储

优点

  • 性能:本地磁盘存储通常提供最快的数据访问速度。
  • 简单性:配置和管理相对简单,易于理解和操作。
  • 成本:对于小规模部署,成本可能较低。

缺点

  • 可扩展性:扩展存储容量可能涉及复杂的硬件升级。
  • 持久性:相比云存储,本地存储更容易受到物理损害的影响。
  • 维护:需要定期维护硬件,如备份和硬件更换。

适用场景:适合对性能要求高、数据量较小的应用,如开发测试环境、小型企业的内部系统等。

# 3. 数据存储(数据库)

优点

  • 结构化数据管理:数据库擅长管理结构化数据,提供强大的查询和事务处理能力。
  • 数据一致性:通过 ACID 属性保证数据的一致性和完整性。
  • 安全性:提供细粒度的安全控制和访问权限管理。

缺点

  • 成本:对于非结构化数据,使用数据库存储可能成本较高。
  • 性能:对于大量非结构化数据,数据库可能不是最优选择。

适用场景:适合需要结构化查询、事务处理和数据一致性要求高的应用,如客户信息管理、订单处理等。

# 选择建议:

  • 成本:考虑预算和长期成本,对象存储和数据库可能需要更多的投资。
  • 性能需求:如果需要快速访问数据,本地磁盘可能是更好的选择。
  • 数据类型:对于非结构化数据,对象存储是更好的选择;对于结构化数据,数据库是更好的选择。
  • 可扩展性和灵活性:对象存储提供了更好的可扩展性和灵活性。
  • 安全性和合规性:确保选择的存储解决方案符合数据保护法规和安全要求。
  • 这里优先推荐 兼容S3协议的对象存储 方案, 如果没有云存储服务器,可以自己搭建 MinIO
  • 其次推荐第三种 数据存储 方案,这种方式好备份还原,适合少的文件。
  • 不推荐 磁盘存储 方案, 实现高可用比较困难,故障转移也比较困难。

# 快速入门

本项目对文件存储做了封装,可以通过配置切换使用上传的类型

封装了一个专门用于文件上传下载的组件,步骤如下:

  1. 定义一个文件客户端接口,具体逻辑由具体实现类来实现

    比如有 DBFileClientFTPFileClientS3FileClient 等具体实现。

    package com.tz.scaffold.framework.file.core.client;
    /**
     * <p> Project: scaffold - FileClient </p>
     *
     * 文件客户端
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    public interface FileClient {
        /**
         * 获得客户端编号
         *
         * @return 客户端编号
         */
        Long getId();
        /**
         * 上传文件
         *
         * @param content 文件流
         * @param path 相对路径
         * @return 完整路径,即 HTTP 访问地址
         * @throws Exception 上传文件时,抛出 Exception 异常
         */
        String upload(byte[] content, String path, String type) throws  Exception;
        /**
         * 删除文件
         *
         * @param path 相对路径
         * @throws Exception 删除文件时,抛出 Exception 异常
         */
        void delete(String path) throws Exception;
        /**
         * 获得文件的内容
         *
         * @param path 相对路径
         * @return 文件的内容
         */
        byte[] getContent(String path) throws Exception;
    }
  2. 定义一个文件配置接口,具体的配置信息由实现类实现

    比如有 FtpFileClientConfigS3FileClientConfig 具体配置内容就是链接的一些配置信息。

    package com.tz.scaffold.framework.file.core.client;
    import com.fasterxml.jackson.annotation.JsonTypeInfo;
    /**
     * <p> Project: scaffold - FileClientConfig </p>
     *
     * 文件客户端的配置
     * <p>
     * 不同实现的客户端,需要不同的配置,通过子类来定义
     * <p>
     * JsonTypeInfo: 注解的作用,Jackson 多态
     * <li>
     *     1. 序列化到时数据库时,增加 @class 属性。
     * <li>
     *     2. 反序列化到内存对象时,通过 @class 属性,可以创建出正确的类型
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
    public interface FileClientConfig {
    }
  3. 定义一个抽象类来实现文件客户端接口,提供通用的方法

    package com.tz.scaffold.framework.file.core.client;
    import cn.hutool.core.util.StrUtil;
    import lombok.extern.slf4j.Slf4j;
    /**
     * <p> Project: scaffold - AbstractFileClient </p>
     *
     * 文件客户端的抽象类,提供模板方法,减少子类的冗余代码
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    @Slf4j
    public abstract class AbstractFileClient<Config extends FileClientConfig> implements FileClient {
        /**
         * 配置编号
         */
        private final Long id;
        /**
         * 文件配置
         */
        protected Config config;
        public AbstractFileClient(Long id, Config config) {
            this.id = id;
            this.config = config;
        }
        /**
         * 初始化
         */
        public final void init() {
            doInit();
            log.debug("[init][配置({}) 初始化完成]", config);
        }
        /**
         * 自定义初始化
         */
        protected abstract void doInit();
        public final void refresh(Config config) {
            // 判断是否更新
            if (config.equals(this.config)) {
                return;
            }
            log.info("[refresh][配置({})发生变化,重新初始化]", config);
            this.config = config;
            // 初始化
            this.init();
        }
        @Override
        public Long getId() {
            return id;
        }
        /**
         * 格式化文件的 URL 访问地址
         * 使用场景:local、ftp、db,通过 FileController 的 getFile 来获取文件内容
         *
         * @param domain 自定义域名
         * @param path 文件路径
         * @return URL 访问地址
         */
        protected String formatFileUrl(String domain, String path) {
            return StrUtil.format("{}/admin-api/infra/file/{}/get/{}", domain, getId(), path);
        }
    }
  4. 具体的实现类,列如 S3 协议的实现

    需要实现自己的配置如: S3FileClientConfig 和 具体上传下载逻辑 S3FileClient

    package com.tz.scaffold.framework.file.core.client.s3;
    import cn.hutool.core.io.IoUtil;
    import cn.hutool.core.util.StrUtil;
    import cn.hutool.http.HttpUtil;
    import com.tz.scaffold.framework.file.core.client.AbstractFileClient;
    import io.minio.*;
    import java.io.ByteArrayInputStream;
    import static com.tz.scaffold.framework.file.core.client.s3.S3FileClientConfig.ENDPOINT_ALIYUN;
    import static com.tz.scaffold.framework.file.core.client.s3.S3FileClientConfig.ENDPOINT_TENCENT;
    /**
     * <p> Project: scaffold - S3FileClient </p>
     *
     * 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务
     * <p>
     * S3 协议的客户端,采用亚马逊提供的 software.amazon.awssdk.s3 库
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
        private MinioClient client;
        public S3FileClient(Long id, S3FileClientConfig config) {
            super(id, config);
        }
        @Override
        protected void doInit() {
            // 补全 domain
            if (StrUtil.isEmpty(config.getDomain())) {
                config.setDomain(buildDomain());
            }
            // 初始化客户端
            client = MinioClient.builder()
                    // Endpoint URL
                    .endpoint(buildEndpointURL())
                    // Region
                    .region(buildRegion())
                    // 认证密钥
                    .credentials(config.getAccessKey(), config.getAccessSecret())
                    .build();
        }
        /**
         * 基于 endpoint 构建调用云服务的 URL 地址
         *
         * @return URI 地址
         */
        private String buildEndpointURL() {
            // 如果已经是 http 或者 https,则不进行拼接。主要适配 MinIO
            if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) {
                return config.getEndpoint();
            }
            return StrUtil.format("https://{}", config.getEndpoint());
        }
        /**
         * 基于 bucket + endpoint 构建访问的 Domain 地址
         *
         * @return Domain 地址
         */
        private String buildDomain() {
            // 如果已经是 http 或者 https,则不进行拼接。主要适配 MinIO
            if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) {
                return StrUtil.format("{}/{}", config.getEndpoint(), config.getBucket());
            }
            // 阿里云、腾讯云、华为云都适合。七牛云比较特殊,必须有自定义域名
            return StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint());
        }
        /**
         * 基于 bucket 构建 region 地区
         *
         * @return region 地区
         */
        private String buildRegion() {
            // 阿里云必须有 region,否则会报错
            if (config.getEndpoint().contains(ENDPOINT_ALIYUN)) {
                return StrUtil.subBefore(config.getEndpoint(), '.', false)
                        // 去除内网 Endpoint 的后缀
                        .replaceAll("-internal", "")
                        .replaceAll("https://", "");
            }
            // 腾讯云必须有 region,否则会报错
            if (config.getEndpoint().contains(ENDPOINT_TENCENT)) {
                return StrUtil.subAfter(config.getEndpoint(), ".cos.", false)
                        // 去除 Endpoint
                        .replaceAll("." + ENDPOINT_TENCENT, "");
            }
            return null;
        }
        @Override
        public String upload(byte[] content, String path, String type) throws Exception {
            // 执行上传
            client.putObject(PutObjectArgs.builder()
                    //bucket 必须传递
                    .bucket(config.getBucket())
                    .contentType(type)
                    // 相对路径作为 key
                    .object(path)
                    // 文件内容
                    .stream(new ByteArrayInputStream(content), content.length, -1)
                    .build());
            // 拼接返回路径
            return config.getDomain() + "/" + path;
        }
        @Override
        public void delete(String path) throws Exception {
            client.removeObject(RemoveObjectArgs.builder()
                    //bucket 必须传递
                    .bucket(config.getBucket())
                    // 相对路径作为 key
                    .object(path)
                    .build());
        }
        @Override
        public byte[] getContent(String path) throws Exception {
            GetObjectResponse response = client.getObject(GetObjectArgs.builder()
                    //bucket 必须传递
                    .bucket(config.getBucket())
                    // 相对路径作为 key
                    .object(path)
                    .build());
            return IoUtil.readBytes(response);
        }
    }
    package com.tz.scaffold.framework.file.core.client.s3;
    import cn.hutool.core.util.StrUtil;
    import com.tz.scaffold.framework.file.core.client.FileClientConfig;
    import com.fasterxml.jackson.annotation.JsonIgnore;
    import lombok.Data;
    import org.hibernate.validator.constraints.URL;
    import javax.validation.constraints.AssertTrue;
    import javax.validation.constraints.NotNull;
    /**
     * <p> Project: scaffold - S3FileClientConfig </p>
     *
     * S3 文件客户端的配置类
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    @Data
    public class S3FileClientConfig implements FileClientConfig {
        public static final String ENDPOINT_QINIU = "qiniucs.com";
        public static final String ENDPOINT_ALIYUN = "aliyuncs.com";
        public static final String ENDPOINT_TENCENT = "myqcloud.com";
        /**
         * 节点地址
         * <p>
         * <li>
         *     1. MinIO:https://scaffold.tzzfj.cn/Spring-Boot/MinIO 。例如说,http://127.0.0.1:9000
         * <li>
         *     2. 阿里云:https://help.aliyun.com/document_detail/31837.html
         * <li>
         *     3. 腾讯云:https://cloud.tencent.com/document/product/436/6224
         * <li>
         *     4. 七牛云:https://developer.qiniu.com/kodo/4088/s3-access-domainname
         * <li>
         *     5. 华为云:https://developer.huaweicloud.com/endpoint?OBS
         */
        @NotNull(message = "endpoint 不能为空")
        private String endpoint;
        /**
         * 自定义域名
         * <p>
         * <li>
         *     1. MinIO:通过 Nginx 配置
         * <li>
         *     2. 阿里云:https://help.aliyun.com/document_detail/31836.html
         * <li>
         *     3. 腾讯云:https://cloud.tencent.com/document/product/436/11142
         * <li>
         *     4. 七牛云:https://developer.qiniu.com/kodo/8556/set-the-custom-source-domain-name
         * <li>
         *     5. 华为云:https://support.huaweicloud.com/usermanual-obs/obs_03_0032.html
         */
        @URL(message = "domain 必须是 URL 格式")
        private String domain;
        /**
         * 存储 Bucket
         */
        @NotNull(message = "bucket 不能为空")
        private String bucket;
        /**
         * 访问 Key
         * <p>
         * <li>
         *     1. MinIO:https://scaffold.tzzfj.cn/Spring-Boot/MinIO
         * <li>
         *     2. 阿里云:https://ram.console.aliyun.com/manage/ak
         * <li>
         *     3. 腾讯云:https://console.cloud.tencent.com/cam/capi
         * <li>
         *     4. 七牛云:https://portal.qiniu.com/user/key
         * <li>
         *     5. 华为云:https://support.huaweicloud.com/qs-obs/obs_qs_0005.html
         */
        @NotNull(message = "accessKey 不能为空")
        private String accessKey;
        /**
         * 访问 Secret
         */
        @NotNull(message = "accessSecret 不能为空")
        private String accessSecret;
        @SuppressWarnings("RedundantIfStatement")
        @AssertTrue(message = "domain 不能为空")
        @JsonIgnore
        public boolean isDomainValid() {
            // 如果是七牛,必须带有 domain
            if (StrUtil.contains(endpoint, ENDPOINT_QINIU) && StrUtil.isEmpty(domain)) {
                return false;
            }
            return true;
        }
    }

    其他类型如 本地存储数据库存储 类似

  5. 提供工厂类来管理这些实现,在我们需要用指定上传下载方式可以方便切换使用

    定义接口,提供两个方法, 获取客户端更新修改客户端

    package com.tz.scaffold.framework.file.core.client;
    /**
     * <p> Project: scaffold - FileClientFactory </p>
     *
     * 文件客户端的工厂类
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    public interface FileClientFactory {
        /**
         * 获得文件客户端
         *
         * @param configId 配置编号
         * @return 文件客户端
         */
        FileClient getFileClient(Long configId);
        /**
         * 创建文件客户端
         *
         * @param configId 配置编号
         * @param storage 存储器的枚举 {@link com.tz.scaffold.framework.file.core.enums.FileStorageEnum}
         * @param config 文件配置
         */
        <Config extends FileClientConfig> void createOrUpdateFileClient(Long configId, Integer storage, Config config);
    }

    具体实现

    package com.tz.scaffold.framework.file.core.client;
    import cn.hutool.core.lang.Assert;
    import cn.hutool.core.util.ReflectUtil;
    import com.tz.scaffold.framework.file.core.enums.FileStorageEnum;
    import lombok.extern.slf4j.Slf4j;
    import java.util.concurrent.ConcurrentHashMap;
    import java.util.concurrent.ConcurrentMap;
    /**
     * <p> Project: scaffold - FileClientFactoryImpl </p>
     *
     * 文件客户端的工厂实现类
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    @Slf4j
    public class FileClientFactoryImpl implements FileClientFactory {
        /**
         * 文件客户端 Map
         * key:配置编号
         */
        private final ConcurrentMap<Long, AbstractFileClient<?>> clients = new ConcurrentHashMap<>();
        @Override
        public FileClient getFileClient(Long configId) {
            AbstractFileClient<?> client = clients.get(configId);
            if (client == null) {
                log.error("[getFileClient][配置编号({}) 找不到客户端]", configId);
            }
            return client;
        }
        @Override
        @SuppressWarnings("unchecked")
        public <Config extends FileClientConfig> void createOrUpdateFileClient(Long configId, Integer storage, Config config) {
            AbstractFileClient<Config> client = (AbstractFileClient<Config>) clients.get(configId);
            if (client == null) {
                client = this.createFileClient(configId, storage, config);
                client.init();
                clients.put(client.getId(), client);
            } else {
                client.refresh(config);
            }
        }
        @SuppressWarnings("unchecked")
        private <Config extends FileClientConfig> AbstractFileClient<Config> createFileClient(
                Long configId, Integer storage, Config config) {
            FileStorageEnum storageEnum = FileStorageEnum.getByStorage(storage);
            Assert.notNull(storageEnum, String.format("文件配置(%s) 为空", storageEnum));
            // 创建客户端
            return (AbstractFileClient<Config>) ReflectUtil.newInstance(storageEnum.getClientClass(), configId, config);
        }
    }
  6. 创建启动类

    package com.tz.scaffold.framework.file.config;
    import com.tz.scaffold.framework.file.core.client.FileClientFactory;
    import com.tz.scaffold.framework.file.core.client.FileClientFactoryImpl;
    import org.springframework.boot.autoconfigure.AutoConfiguration;
    import org.springframework.context.annotation.Bean;
    /**
     * <p> Project: scaffold - ScaffoldFileAutoConfiguration </p>
     *
     * 文件配置类
     * @author Tz
     * @date 2024/01/09 23:45
     * @version 1.0.0
     * @since 1.0.0
     */
    @AutoConfiguration
    public class ScaffoldFileAutoConfiguration {
        @Bean
        public FileClientFactory fileClientFactory() {
            return new FileClientFactoryImpl();
        }
    }

    这样在项目启动的时候就将 FileClientFactory 注入到容器中了, 其他需要上传的 service 就可以直接从工厂中获取具体上传下载客户端了。

使用例子:

这里封装了一个上传文件的 service

package com.tz.scaffold.module.infra.service.file;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil;
import com.tz.scaffold.framework.common.pojo.PageResult;
import com.tz.scaffold.framework.common.util.io.FileUtils;
import com.tz.scaffold.framework.file.core.client.FileClient;
import com.tz.scaffold.framework.file.core.utils.FileTypeUtils;
import com.tz.scaffold.module.infra.controller.admin.file.vo.file.FilePageReqVO;
import com.tz.scaffold.module.infra.dal.dataobject.file.FileDO;
import com.tz.scaffold.module.infra.dal.mysql.file.FileMapper;
import lombok.SneakyThrows;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import static com.tz.scaffold.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.tz.scaffold.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS;
/**
 * <p> Project: scaffold - FileServiceImpl </p>
 *
 * 文件 Service 实现类
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@Service
public class FileServiceImpl implements FileService {
    @Resource
    private FileConfigService fileConfigService;
    @Resource
    private FileMapper fileMapper;
    @Override
    public PageResult<FileDO> getFilePage(FilePageReqVO pageReqVO) {
        return fileMapper.selectPage(pageReqVO);
    }
    @Override
    @SneakyThrows
    public String createFile(String name, String path, byte[] content) {
        // 计算默认的 path 名
        String type = FileTypeUtils.getMineType(content, name);
        if (StrUtil.isEmpty(path)) {
            path = FileUtils.generatePath(content, name);
        }
        // 如果 name 为空,则使用 path 填充
        if (StrUtil.isEmpty(name)) {
            name = path;
        }
        // 上传到文件存储器
        FileClient client = fileConfigService.getMasterFileClient();
        Assert.notNull(client, "客户端(master) 不能为空");
        String url = client.upload(content, path, type);
        // 保存到数据库
        FileDO file = new FileDO();
        file.setConfigId(client.getId());
        file.setName(name);
        file.setPath(path);
        file.setUrl(url);
        file.setType(type);
        file.setSize(content.length);
        fileMapper.insert(file);
        return url;
    }
    @Override
    public void deleteFile(Long id) throws Exception {
        // 校验存在
        FileDO file = validateFileExists(id);
        // 从文件存储器中删除
        FileClient client = fileConfigService.getFileClient(file.getConfigId());
        Assert.notNull(client, "客户端({}) 不能为空", file.getConfigId());
        client.delete(file.getPath());
        // 删除记录
        fileMapper.deleteById(id);
    }
    private FileDO validateFileExists(Long id) {
        FileDO fileDO = fileMapper.selectById(id);
        if (fileDO == null) {
            throw exception(FILE_NOT_EXISTS);
        }
        return fileDO;
    }
    @Override
    public byte[] getFileContent(Long configId, String path) throws Exception {
        FileClient client = fileConfigService.getFileClient(configId);
        Assert.notNull(client, "客户端({}) 不能为空", configId);
        return client.getContent(path);
    }
}

上传文件接口:

/**
     * OperateLog (logArgs = false): 上传文件,没有记录操作日志的必要
     */
    @PostMapping("/upload")
    @Operation(summary = "上传文件")
    @OperateLog(logArgs = false)
    public CommonResult<String> uploadFile(FileUploadReqVO uploadReqVO) throws Exception {
        MultipartFile file = uploadReqVO.getFile();
        String path = uploadReqVO.getPath();
        return success(fileService.createFile(file.getOriginalFilename(), path, IoUtil.readBytes(file.getInputStream())));
    }