封装 yudao-spring-boot-starter-file 组件,初步实现 S3 对接云存储的能力

This commit is contained in:
YunaiV
2022-03-13 21:23:03 +08:00
parent 446f601c8a
commit ed53ca3de9
15 changed files with 498 additions and 13 deletions

View File

@ -0,0 +1,41 @@
package cn.iocoder.yudao.framework.file.core.client;
/**
* 文件客户端
*
* @author 芋道源码
*/
public interface FileClient {
/**
* 获得客户端编号
*
* @return 客户端编号
*/
Long getId();
/**
* 上传文件
*
* @param content 文件流
* @param path 相对路径
* @return 完整路径,即 HTTP 访问地址
*/
String upload(byte[] content, String path);
/**
* 删除文件
*
* @param path 相对路径
*/
void delete(String path);
/**
* 获得文件的内容
*
* @param path 相对路径
* @return 文件的内容
*/
byte[] getContent(String path);
}

View File

@ -0,0 +1,16 @@
package cn.iocoder.yudao.framework.file.core.client;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
/**
* 文件客户端的配置
* 不同实现的客户端,需要不同的配置,通过子类来定义
*
* @author 芋道源码
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
// @JsonTypeInfo 注解的作用Jackson 多态
// 1. 序列化到时数据库时,增加 @class 属性。
// 2. 反序列化到内存对象时,通过 @class 属性,可以创建出正确的类型
public interface FileClientConfig {
}

View File

@ -0,0 +1,58 @@
package cn.iocoder.yudao.framework.file.core.client.impl;
import cn.iocoder.yudao.framework.file.core.client.FileClient;
import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
import lombok.extern.slf4j.Slf4j;
/**
* 文件客户端的抽象类,提供模板方法,减少子类的冗余代码
*
* @author 芋道源码
*/
@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.info("[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;
}
}

View File

@ -0,0 +1,95 @@
package cn.iocoder.yudao.framework.file.core.client.impl.s3;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.file.core.client.impl.AbstractFileClient;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import java.net.URI;
import static cn.iocoder.yudao.framework.file.core.client.impl.s3.S3FileClientConfig.ENDPOINT_QINIU;
/**
* 基于 S3 协议,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务
*
* S3 协议的客户端,采用亚马逊提供的 software.amazon.awssdk.s3 库
*
* @author 芋道源码
*/
public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
private S3Client client;
public S3FileClient(Long id, S3FileClientConfig config) {
super(id, config);
}
@Override
protected void doInit() {
// 补全 domain
if (StrUtil.isEmpty(config.getDomain())) {
config.setDomain(createDomain());
}
// 初始化客户端
client = S3Client.builder()
.serviceConfiguration(sb -> sb.pathStyleAccessEnabled(false) // 关闭路径风格
.chunkedEncodingEnabled(false)) // 禁用 chunk
.endpointOverride(createURI()) // 上传地址
.region(Region.of(config.getRegion())) // Region
.credentialsProvider(StaticCredentialsProvider.create( // 认证密钥
AwsBasicCredentials.create(config.getAccessKey(), config.getAccessSecret())))
.overrideConfiguration(cb -> cb.addExecutionInterceptor(new S3ModifyPathInterceptor(config.getBucket())))
.build();
}
/**
* 基于 endpoint 构建调用云服务的 URI 地址
*
* @return URI 地址
*/
private URI createURI() {
String uri;
// 如果是七牛,无需拼接 bucket
if (config.getEndpoint().contains(ENDPOINT_QINIU)) {
uri = StrUtil.format("https://{}", config.getEndpoint());
} else {
uri = StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint());
}
return URI.create(uri);
}
/**
* 基于 bucket + endpoint 构建访问的 Domain 地址
*
* @return Domain 地址
*/
private String createDomain() {
return StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint());
}
@Override
public String upload(byte[] content, String path) {
// 执行上传
PutObjectRequest.Builder request = PutObjectRequest.builder()
.bucket(config.getBucket()) // bucket 必须传递
.key(path); // 相对路径作为 key
client.putObject(request.build(), RequestBody.fromBytes(content));
// 拼接返回路径
return config.getDomain() + "/" + path;
}
@Override
public void delete(String path) {
}
@Override
public byte[] getContent(String path) {
return new byte[0];
}
}

View File

@ -0,0 +1,83 @@
package cn.iocoder.yudao.framework.file.core.client.impl.s3;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.file.core.client.FileClientConfig;
import lombok.Data;
import org.hibernate.validator.constraints.URL;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.NotNull;
/**
* S3 文件客户端的配置类
*
* @author 芋道源码
*/
@Data
public class S3FileClientConfig implements FileClientConfig {
public static final String ENDPOINT_QINIU = "qiniucs.com";
/**
* 节点地址
* 1. MinIO
* 2. 阿里云https://help.aliyun.com/document_detail/31837.html
* 3. 腾讯云:
* 4. 七牛云https://developer.qiniu.com/kodo/4088/s3-access-domainname
* 5. 华为云:
*/
@NotNull(message = "endpoint 不能为空")
private String endpoint;
/**
* 自定义域名
* 1. MinIO
* 2. 阿里云https://help.aliyun.com/document_detail/31836.html
* 3. 腾讯云https://cloud.tencent.com/document/product/436/11142
* 4. 七牛云https://developer.qiniu.com/kodo/8556/set-the-custom-source-domain-name
* 5. 华为云:
*/
@URL(message = "domain 必须是 URL 格式")
private String domain;
/**
* 区域
* 1. MinIO
* 2. 阿里云https://help.aliyun.com/document_detail/31837.html
* 3. 腾讯云:
* 4. 七牛云https://developer.qiniu.com/kodo/4088/s3-access-domainname
* 5. 华为云:
*/
@NotNull(message = "region 不能为空")
private String region;
/**
* 存储 Bucket
*/
@NotNull(message = "bucket 不能为空")
private String bucket;
/**
* 访问 Key
* 1. MinIO
* 2. 阿里云:
* 3. 腾讯云https://console.cloud.tencent.com/cam/capi
* 4. 七牛云https://portal.qiniu.com/user/key
* 5. 华为云:
*/
@NotNull(message = "accessKey 不能为空")
private String accessKey;
/**
* 访问 Secret
*/
@NotNull(message = "accessSecret 不能为空")
private String accessSecret;
@AssertTrue(message = "domain 不能为空")
@SuppressWarnings("RedundantIfStatement")
public boolean isDomainValid() {
// 如果是七牛,必须带有 domain
if (endpoint.contains(ENDPOINT_QINIU) && StrUtil.isEmpty(domain)) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,36 @@
package cn.iocoder.yudao.framework.file.core.client.impl.s3;
import software.amazon.awssdk.core.interceptor.Context;
import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
import software.amazon.awssdk.core.interceptor.ExecutionInterceptor;
import software.amazon.awssdk.http.SdkHttpRequest;
/**
* S3 修改路径的拦截器,移除多余的 Bucket 前缀。
* 如果不使用该拦截器,希望上传的路径是 /tudou.jpg 时,会被添加成 /bucket/tudou.jpg
*
* @author 芋道源码
*/
public class S3ModifyPathInterceptor implements ExecutionInterceptor {
private final String bucket;
public S3ModifyPathInterceptor(String bucket) {
this.bucket = "/" + bucket;
}
@Override
public SdkHttpRequest modifyHttpRequest(Context.ModifyHttpRequest context, ExecutionAttributes executionAttributes) {
SdkHttpRequest request = context.httpRequest();
SdkHttpRequest.Builder rb = SdkHttpRequest.builder().protocol(request.protocol()).host(request.host()).port(request.port())
.method(request.method()).headers(request.headers()).rawQueryParameters(request.rawQueryParameters());
// 移除 path 前的 bucket 路径
if (request.encodedPath().startsWith(bucket)) {
rb.encodedPath(request.encodedPath().substring(bucket.length()));
} else {
rb.encodedPath(request.encodedPath());
}
return rb.build();
}
}

View File

@ -0,0 +1,4 @@
/**
* 占位,避免 package 无法提交到 Git 仓库
*/
package cn.iocoder.yudao.framework.file.config;

View File

@ -0,0 +1,4 @@
/**
* 占位,避免 package 无法提交到 Git 仓库
*/
package cn.iocoder.yudao.framework.file.core.client;

View File

@ -0,0 +1,81 @@
package cn.iocoder.yudao.framework.file.core.client.s3;
import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
import cn.iocoder.yudao.framework.file.core.client.impl.s3.S3FileClient;
import cn.iocoder.yudao.framework.file.core.client.impl.s3.S3FileClientConfig;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import javax.validation.Validation;
public class S3FileClientTest {
@Test
@Disabled // 阿里云 OSS如果要集成测试可以注释本行
public void testAliyun() {
S3FileClientConfig config = new S3FileClientConfig();
// 配置成你自己的
config.setAccessKey(System.getenv("ALIYUN_ACCESS_KEY"));
config.setAccessSecret(System.getenv("ALIYUN_SECRET_KEY"));
config.setBucket("yunai-aoteman");
config.setDomain(null); // 如果有自定义域名则可以设置。http://ali-oss.iocoder.cn
// 默认北京的 endpoint
config.setEndpoint("oss-cn-beijing.aliyuncs.com");
// 执行上传
testExecuteUpload(config);
}
@Test
@Disabled // 腾讯云 COS如果要集成测试可以注释本行
public void testQCloud() {
S3FileClientConfig config = new S3FileClientConfig();
// 配置成你自己的
config.setAccessKey(System.getenv("QCLOUD_ACCESS_KEY"));
config.setAccessSecret(System.getenv("QCLOUD_SECRET_KEY"));
config.setBucket("aoteman-1255880240");
config.setDomain(null); // 如果有自定义域名则可以设置。http://tengxun-oss.iocoder.cn
// 默认上海的 endpoint
config.setEndpoint("cos.ap-shanghai.myqcloud.com");
config.setRegion("ap-shanghai");
// 执行上传
testExecuteUpload(config);
}
@Test
@Disabled // 七牛云存储,如果要集成测试,可以注释本行
public void testQiniu() {
S3FileClientConfig config = new S3FileClientConfig();
// 配置成你自己的
config.setAccessKey(System.getenv("QINIU_ACCESS_KEY"));
config.setAccessSecret(System.getenv("QINIU_SECRET_KEY"));
config.setBucket("s3-test-yudao");
config.setDomain("http://r8oo8po1q.hn-bkt.clouddn.com"); // 如果有自定义域名则可以设置。http://static.yudao.iocoder.cn
// 默认上海的 endpoint
config.setEndpoint("s3-cn-south-1.qiniucs.com");
// 执行上传
testExecuteUpload(config);
}
private void testExecuteUpload(S3FileClientConfig config) {
// 补全配置
if (config.getRegion() == null) {
config.setRegion(StrUtil.subBefore(config.getEndpoint(), '.', false));
}
ValidationUtils.validate(Validation.buildDefaultValidatorFactory().getValidator(), config);
// 创建 Client
S3FileClient client = new S3FileClient(0L, config);
client.init();
// 上传文件
String path = IdUtil.fastSimpleUUID() + ".jpg";
byte[] content = ResourceUtil.readBytes("file/erweima.jpg");
String fullPath = client.upload(content, path);
System.out.println("访问地址:" + fullPath);
}
}

View File

@ -0,0 +1,4 @@
/**
* 占位,避免 package 无法提交到 Git 仓库
*/
package cn.iocoder.yudao.framework.file.core.enums;

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB