# scaffold 项目之文件存储
# 简介
文件存储通常涉及将文件保存到服务器的文件系统或云存储服务。
文件存储的最佳实践
- 安全性:确保文件上传功能有适当的安全措施,如验证文件类型、大小限制和防注入。
- 性能:对于大型文件或高并发场景,考虑使用异步处理和缓存策略。
- 备份和恢复:定期备份文件存储,并确保可以恢复数据。
- 监控和日志:监控文件存储的使用情况,并记录关键操作的日志
# 文件存储(上传下载)
项目支持将文件上传到三类存储器:
- 兼容 S3 协议的对象存储:支持
MinIO
、腾讯云COS
、七牛云Kodo
、华为云OBS
、亚马逊S3
等等。 - 磁盘存储:本地、 FTP 服务器、 SFTP 服务器。
- 数据存储:SQL Server、 MySQL、PostgreSQL 等等
上面三种方案的对比与选择
在选择文件存储方案时,需要考虑多个因素,包括成本、性能、可用性、安全性、可扩展性以及与现有系统的兼容性。以下是对您提到的三种方案的对比:
# 1. 兼容 S3 协议的对象存储
优点:
- 可扩展性:对象存储通常设计为高度可扩展,能够处理大量的数据和高并发请求。
- 持久性:许多对象存储服务提供数据的高持久性保证。
- 多区域部署:支持全球多个数据中心,有助于实现数据的地理冗余和低延迟访问。
- 成本效益:对于大规模数据存储,对象存储通常比传统的块存储或文件存储更经济。
- 兼容性:由于兼容 S3 协议,可以轻松迁移或集成不同的云服务提供商。
缺点:
- 访问速度:对于需要频繁访问的小文件,对象存储可能不如本地磁盘或块存储快。
- 复杂性:配置和管理可能比传统的文件存储更复杂。
适用场景:适合大规模数据存储、备份、归档、大数据分析等。
# 2. 磁盘存储
优点:
- 性能:本地磁盘存储通常提供最快的数据访问速度。
- 简单性:配置和管理相对简单,易于理解和操作。
- 成本:对于小规模部署,成本可能较低。
缺点:
- 可扩展性:扩展存储容量可能涉及复杂的硬件升级。
- 持久性:相比云存储,本地存储更容易受到物理损害的影响。
- 维护:需要定期维护硬件,如备份和硬件更换。
适用场景:适合对性能要求高、数据量较小的应用,如开发测试环境、小型企业的内部系统等。
# 3. 数据存储(数据库)
优点:
- 结构化数据管理:数据库擅长管理结构化数据,提供强大的查询和事务处理能力。
- 数据一致性:通过 ACID 属性保证数据的一致性和完整性。
- 安全性:提供细粒度的安全控制和访问权限管理。
缺点:
- 成本:对于非结构化数据,使用数据库存储可能成本较高。
- 性能:对于大量非结构化数据,数据库可能不是最优选择。
适用场景:适合需要结构化查询、事务处理和数据一致性要求高的应用,如客户信息管理、订单处理等。
# 选择建议:
- 成本:考虑预算和长期成本,对象存储和数据库可能需要更多的投资。
- 性能需求:如果需要快速访问数据,本地磁盘可能是更好的选择。
- 数据类型:对于非结构化数据,对象存储是更好的选择;对于结构化数据,数据库是更好的选择。
- 可扩展性和灵活性:对象存储提供了更好的可扩展性和灵活性。
- 安全性和合规性:确保选择的存储解决方案符合数据保护法规和安全要求。
- 这里优先推荐
兼容S3协议的对象存储
方案, 如果没有云存储服务器,可以自己搭建MinIO
。 - 其次推荐第三种
数据存储
方案,这种方式好备份还原,适合少的文件。 - 不推荐
磁盘存储
方案, 实现高可用比较困难,故障转移也比较困难。
# 快速入门
本项目对文件存储做了封装,可以通过配置切换使用上传的类型
封装了一个专门用于文件上传下载的组件,步骤如下:
定义一个文件客户端接口,具体逻辑由具体实现类来实现
比如有
DBFileClient
、FTPFileClient
、S3FileClient
等具体实现。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;
}
定义一个文件配置接口,具体的配置信息由实现类实现
比如有
FtpFileClientConfig
、S3FileClientConfig
具体配置内容就是链接的一些配置信息。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 {
}
定义一个抽象类来实现文件客户端接口,提供通用的方法
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);
}
}
具体的实现类,列如 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;
}
}
其他类型如
本地存储
,数据库存储
类似提供工厂类来管理这些实现,在我们需要用指定上传下载方式可以方便切换使用
定义接口,提供两个方法,
获取客户端
和更新修改客户端
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);
}
}
创建启动类
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()))); | |
} |