mirror of
https://gitee.com/dromara/RuoYi-Vue-Plus.git
synced 2026-03-22 21:39:01 +08:00
rebuild 重构 新的OSS客户端
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package org.dromara.common.oss.s3.builder;
|
||||
|
||||
/**
|
||||
* 构建器
|
||||
*
|
||||
* @param <T> 参数类型
|
||||
* @param <R> 构建目标类型
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
public interface Builder<T,R> {
|
||||
|
||||
R build(T param);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.dromara.common.oss.s3.builder;
|
||||
|
||||
import org.dromara.common.oss.s3.config.S3StorageClientConfig;
|
||||
import org.dromara.common.oss.s3.exception.S3StorageException;
|
||||
import org.dromara.common.oss.s3.util.BucketUrlUtil;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.lang.StringBuilder;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 云服务商文件对象桶URL构建器
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public enum CloudServiceBucketUrlBuilder implements StringBuilder<S3StorageClientConfig> {
|
||||
|
||||
INSTANCE;
|
||||
|
||||
@Override
|
||||
public String build(S3StorageClientConfig config) {
|
||||
boolean useHttps = config.useHttps();
|
||||
Optional<String> domainOpt = config.domain().filter(s -> !s.isBlank());
|
||||
// 如果已经配置了自定义域名,则优先使用域名
|
||||
if (domainOpt.isPresent()) {
|
||||
// 云服务商一般都支持桶映射到域名,这里不再特殊处理,仅处理链接的协议头即可
|
||||
return BucketUrlUtil.getDomainUrl(useHttps, domainOpt.get());
|
||||
}
|
||||
// 否则使用站点
|
||||
String endpoint = config.endpoint()
|
||||
.filter(s -> !s.isBlank())
|
||||
.orElseThrow(() -> S3StorageException.of("endpoint is not configured."));
|
||||
// 如果未配置桶,则抛异常
|
||||
String bucket = config.bucket()
|
||||
.filter(s -> !s.isBlank())
|
||||
.orElseThrow(() -> S3StorageException.of("bucket is not configured."));
|
||||
return BucketUrlUtil.getSiteStyleBucketUrl(useHttps, endpoint, bucket);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.dromara.common.oss.s3.builder;
|
||||
|
||||
import org.dromara.common.oss.s3.client.S3StorageClient;
|
||||
import org.dromara.common.oss.s3.client.S3StorageClientImpl;
|
||||
import org.dromara.common.oss.s3.config.S3StorageClientConfig;
|
||||
import org.dromara.common.oss.s3.exception.S3StorageException;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3AsyncClient;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
import software.amazon.awssdk.transfer.s3.S3TransferManager;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 默认S3存储客户端构建器
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
public enum DefaultS3StorageClientBuilder implements S3StorageClientBuilder<S3StorageClientConfig> {
|
||||
INSTANCE;
|
||||
|
||||
@Override
|
||||
public S3StorageClient build(S3StorageClientConfig config) {
|
||||
String accessKey = config.accessKey()
|
||||
.filter(bucket -> !bucket.isBlank())
|
||||
.orElseThrow(() -> S3StorageException.of("accessKey is not configured."));
|
||||
String secretKey = config.secretKey()
|
||||
.filter(bucket -> !bucket.isBlank())
|
||||
.orElseThrow(() -> S3StorageException.of("secretKey is not configured."));
|
||||
Region region = config.region()
|
||||
.orElse(Region.US_EAST_1);
|
||||
String endpointUrl = config.getEndpointUrl();
|
||||
String domainUrl = config.getDomainUrl();
|
||||
// MinIO 使用 HTTPS 限制使用域名访问,站点填域名。需要启用路径样式访问
|
||||
boolean usePathStyleAccess = config.usePathStyleAccess();
|
||||
try {
|
||||
// 创建 AWS 认证信息
|
||||
StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey));
|
||||
|
||||
// 创建AWS基于 Netty 的 S3 客户端
|
||||
S3AsyncClient client = S3AsyncClient.builder()
|
||||
.credentialsProvider(credentialsProvider)
|
||||
.endpointOverride(URI.create(endpointUrl))
|
||||
.region(region)
|
||||
.forcePathStyle(usePathStyleAccess)
|
||||
.httpClient(
|
||||
NettyNioAsyncHttpClient.builder()
|
||||
.connectionTimeout(Duration.ofSeconds(60))
|
||||
.connectionAcquisitionTimeout(Duration.ofSeconds(30))
|
||||
.maxConcurrency(100)
|
||||
.maxPendingConnectionAcquires(1000)
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
|
||||
//AWS基于 CRT 的 S3 AsyncClient 实例用作 S3 传输管理器的底层客户端
|
||||
S3TransferManager transferManager = S3TransferManager.builder().s3Client(client).build();
|
||||
|
||||
// 创建 预签名 URL 的生成器 实例,用于生成 S3 预签名 URL
|
||||
S3Presigner presigner = S3Presigner.builder()
|
||||
.region(region)
|
||||
.credentialsProvider(credentialsProvider)
|
||||
.endpointOverride(URI.create(domainUrl))
|
||||
.serviceConfiguration(
|
||||
// 创建 S3 配置对象
|
||||
S3Configuration.builder()
|
||||
.chunkedEncodingEnabled(false)
|
||||
.pathStyleAccessEnabled(usePathStyleAccess)
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
|
||||
return new S3StorageClientImpl(config,client,transferManager,presigner);
|
||||
} catch (Exception e) {
|
||||
if (e instanceof S3StorageException) {
|
||||
throw e;
|
||||
}
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.dromara.common.oss.s3.builder;
|
||||
|
||||
import org.dromara.common.oss.s3.config.S3StorageClientConfig;
|
||||
import org.dromara.common.oss.s3.exception.S3StorageException;
|
||||
import org.dromara.common.oss.s3.util.BucketUrlUtil;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.lang.StringBuilder;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* MinIO文件对象桶URL构建器
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public enum MinioBucketUrlBuilder implements StringBuilder<S3StorageClientConfig> {
|
||||
|
||||
INSTANCE;
|
||||
|
||||
@Override
|
||||
public String build(S3StorageClientConfig config) {
|
||||
boolean useHttps = config.useHttps();
|
||||
// 如果未配置桶,则抛异常
|
||||
String bucket = config.bucket()
|
||||
.filter(s -> !s.isBlank())
|
||||
.orElseThrow(() -> S3StorageException.of("bucket is not configured."));
|
||||
Optional<String> domainOpt = config.domain().filter(s -> !s.isBlank());
|
||||
// 如果已经配置了自定义域名,则优先使用域名
|
||||
if (domainOpt.isPresent()) {
|
||||
return BucketUrlUtil.getPathStyleBucketUrl(useHttps, domainOpt.get(), bucket);
|
||||
}
|
||||
// 否则使用站点
|
||||
String endpoint = config.endpoint()
|
||||
.filter(s -> !s.isBlank())
|
||||
.orElseThrow(() -> S3StorageException.of("endpoint is not configured."));
|
||||
return BucketUrlUtil.getPathStyleBucketUrl(useHttps, endpoint, bucket);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.dromara.common.oss.s3.builder;
|
||||
|
||||
import org.dromara.common.oss.s3.client.S3StorageClient;
|
||||
|
||||
/**
|
||||
* S3存储客户端构建器
|
||||
*
|
||||
* @param <T> 参数类型
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
public interface S3StorageClientBuilder<T> extends Builder<T,S3StorageClient> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.dromara.common.oss.s3.builder;
|
||||
|
||||
import org.dromara.common.oss.s3.config.S3StorageClientConfig;
|
||||
|
||||
/**
|
||||
* S3存储客户端配置构建器
|
||||
*
|
||||
* @param <T> 参数类型
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
public interface S3StorageClientConfigBuilder<T> extends Builder<T, S3StorageClientConfig> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.dromara.common.oss.s3.builder;
|
||||
|
||||
/**
|
||||
* 字符串构建器
|
||||
*
|
||||
* @param <T> 参数类型
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
public interface StringBuilder<T> extends Builder<T,String> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package org.dromara.common.oss.s3.client;
|
||||
|
||||
import org.dromara.common.oss.s3.config.S3StorageClientConfig;
|
||||
import org.dromara.common.oss.s3.domain.GetObjectResult;
|
||||
import org.dromara.common.oss.s3.domain.HandleAsyncResult;
|
||||
import org.dromara.common.oss.s3.domain.PutObjectResult;
|
||||
import org.dromara.common.oss.s3.io.OutputStreamDownloadSubscriber;
|
||||
import software.amazon.awssdk.core.async.AsyncRequestBody;
|
||||
import software.amazon.awssdk.core.async.AsyncResponseTransformer;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
|
||||
import software.amazon.awssdk.transfer.s3.model.CompletedUpload;
|
||||
import software.amazon.awssdk.transfer.s3.progress.TransferListener;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.channels.ReadableByteChannel;
|
||||
import java.nio.channels.WritableByteChannel;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* S3 存储客户端接口。
|
||||
* <p>
|
||||
* 本接口同时提供两套对象操作 API:
|
||||
* 一套通过 {@code bucketXxx(...)} 显式指定存储桶,
|
||||
* 另一套通过无前缀方法使用默认存储桶。
|
||||
* </p>
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
public interface S3StorageClient extends AutoCloseable {
|
||||
|
||||
/**
|
||||
* 执行自定义上传请求。
|
||||
*
|
||||
* @param body 上传请求体
|
||||
* @param putObjectRequestBuilderConsumer PutObject 请求构建回调
|
||||
* @param transferListeners 传输监听器集合
|
||||
* @param handleAsyncAction 上传完成后的结果处理函数
|
||||
* @param <T> 返回值类型
|
||||
* @return 处理后的结果
|
||||
*/
|
||||
<T> T doCustomUpload(AsyncRequestBody body, Consumer<PutObjectRequest.Builder> putObjectRequestBuilderConsumer, Collection<TransferListener> transferListeners, BiFunction<CompletedUpload, Throwable, T> handleAsyncAction);
|
||||
|
||||
/**
|
||||
* 执行自定义上传请求。
|
||||
*
|
||||
* @param body 上传请求体
|
||||
* @param putObjectRequestBuilderConsumer PutObject 请求构建回调
|
||||
* @param handleAsyncAction 上传完成后的结果处理函数
|
||||
* @param <T> 返回值类型
|
||||
* @return 处理后的结果
|
||||
*/
|
||||
<T> T doCustomUpload(AsyncRequestBody body, Consumer<PutObjectRequest.Builder> putObjectRequestBuilderConsumer, BiFunction<CompletedUpload, Throwable, T> handleAsyncAction);
|
||||
|
||||
/**
|
||||
* 执行自定义上传请求,并返回统一异步处理结果。
|
||||
*
|
||||
* @param body 上传请求体
|
||||
* @param putObjectRequestBuilderConsumer PutObject 请求构建回调
|
||||
* @param transferListeners 传输监听器集合
|
||||
* @return 上传结果
|
||||
*/
|
||||
HandleAsyncResult<PutObjectResponse> doCustomUpload(AsyncRequestBody body, Consumer<PutObjectRequest.Builder> putObjectRequestBuilderConsumer, Collection<TransferListener> transferListeners);
|
||||
|
||||
/**
|
||||
* 执行自定义上传请求,并返回统一异步处理结果。
|
||||
*
|
||||
* @param body 上传请求体
|
||||
* @param putObjectRequestBuilderConsumer PutObject 请求构建回调
|
||||
* @return 上传结果
|
||||
*/
|
||||
HandleAsyncResult<PutObjectResponse> doCustomUpload(AsyncRequestBody body, Consumer<PutObjectRequest.Builder> putObjectRequestBuilderConsumer);
|
||||
|
||||
/**
|
||||
* 将本地路径对应的文件上传到指定存储桶。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param path 文件路径
|
||||
* @return 上传结果
|
||||
*/
|
||||
PutObjectResult bucketUpload(String bucket, String key, Path path);
|
||||
|
||||
/**
|
||||
* 将文件上传到指定存储桶。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param file 文件对象
|
||||
* @return 上传结果
|
||||
*/
|
||||
PutObjectResult bucketUpload(String bucket, String key, File file);
|
||||
|
||||
/**
|
||||
* 将随机访问文件上传到指定存储桶。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param file 随机访问文件
|
||||
* @return 上传结果
|
||||
*/
|
||||
PutObjectResult bucketUpload(String bucket, String key, RandomAccessFile file);
|
||||
|
||||
/**
|
||||
* 将可读通道中的数据上传到指定存储桶。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param channel 数据通道
|
||||
* @param contentLength 内容长度
|
||||
* @return 上传结果
|
||||
*/
|
||||
PutObjectResult bucketUpload(String bucket, String key, ReadableByteChannel channel, long contentLength);
|
||||
|
||||
/**
|
||||
* 将输入流中的数据上传到指定存储桶。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param in 输入流
|
||||
* @param contentLength 内容长度
|
||||
* @return 上传结果
|
||||
*/
|
||||
PutObjectResult bucketUpload(String bucket, String key, InputStream in, long contentLength);
|
||||
|
||||
/**
|
||||
* 将字节数组上传到指定存储桶。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param data 字节数组
|
||||
* @return 上传结果
|
||||
*/
|
||||
PutObjectResult bucketUpload(String bucket, String key, byte[] data);
|
||||
|
||||
/**
|
||||
* 执行自定义下载请求。
|
||||
*
|
||||
* @param getObjectRequestBuilderConsumer GetObject 请求构建回调
|
||||
* @param responseTransformer 下载响应转换器
|
||||
* @param transferListeners 传输监听器集合
|
||||
* @param <T> 下载结果类型
|
||||
* @return 下载结果
|
||||
*/
|
||||
<T> T doCustomDownload(Consumer<GetObjectRequest.Builder> getObjectRequestBuilderConsumer, AsyncResponseTransformer<GetObjectResponse, T> responseTransformer, Collection<TransferListener> transferListeners);
|
||||
|
||||
/**
|
||||
* 将指定存储桶中的对象下载到订阅器。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param downloadSubscriber 下载订阅器
|
||||
* @return 下载结果
|
||||
*/
|
||||
GetObjectResult bucketDownload(String bucket, String key, OutputStreamDownloadSubscriber downloadSubscriber);
|
||||
|
||||
/**
|
||||
* 将指定存储桶中的对象下载到本地路径。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param path 本地路径
|
||||
* @return 下载结果
|
||||
*/
|
||||
GetObjectResult bucketDownload(String bucket, String key, Path path);
|
||||
|
||||
/**
|
||||
* 将指定存储桶中的对象下载到文件。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param file 本地文件
|
||||
* @return 下载结果
|
||||
*/
|
||||
GetObjectResult bucketDownload(String bucket, String key, File file);
|
||||
|
||||
/**
|
||||
* 将指定存储桶中的对象下载到随机访问文件。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param file 随机访问文件
|
||||
* @return 下载结果
|
||||
*/
|
||||
GetObjectResult bucketDownload(String bucket, String key, RandomAccessFile file);
|
||||
|
||||
/**
|
||||
* 将指定存储桶中的对象下载到可写通道。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param channel 可写通道
|
||||
* @return 下载结果
|
||||
*/
|
||||
GetObjectResult bucketDownload(String bucket, String key, WritableByteChannel channel);
|
||||
|
||||
/**
|
||||
* 将指定存储桶中的对象下载到输出流。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param out 输出流
|
||||
* @return 下载结果
|
||||
*/
|
||||
GetObjectResult bucketDownload(String bucket, String key, OutputStream out);
|
||||
|
||||
/**
|
||||
* 删除指定存储桶中的对象。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @return 是否删除成功
|
||||
*/
|
||||
boolean bucketDelete(String bucket, String key);
|
||||
|
||||
/**
|
||||
* 生成指定存储桶中文件下载的预签名 URL。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param expiredTime URL 过期时间
|
||||
* @return 预签名下载 URL
|
||||
*/
|
||||
String bucketPresignGetUrl(String bucket, String key, Duration expiredTime);
|
||||
|
||||
/**
|
||||
* 生成指定存储桶中文件上传的预签名 URL。
|
||||
*
|
||||
* @param bucket 存储桶名称
|
||||
* @param key 对象键
|
||||
* @param expiredTime URL 过期时间
|
||||
* @param metadata 对象元数据
|
||||
* @return 预签名上传 URL
|
||||
*/
|
||||
String bucketPresignPutUrl(String bucket, String key, Duration expiredTime, Map<String, String> metadata);
|
||||
|
||||
/**
|
||||
* 将本地路径对应的文件上传到默认存储桶。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param path 文件路径
|
||||
* @return 上传结果
|
||||
*/
|
||||
PutObjectResult upload(String key, Path path);
|
||||
|
||||
/**
|
||||
* 将文件上传到默认存储桶。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param file 文件对象
|
||||
* @return 上传结果
|
||||
*/
|
||||
PutObjectResult upload(String key, File file);
|
||||
|
||||
/**
|
||||
* 将随机访问文件上传到默认存储桶。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param file 随机访问文件
|
||||
* @return 上传结果
|
||||
*/
|
||||
PutObjectResult upload(String key, RandomAccessFile file);
|
||||
|
||||
/**
|
||||
* 将可读通道中的数据上传到默认存储桶。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param channel 数据通道
|
||||
* @param contentLength 内容长度
|
||||
* @return 上传结果
|
||||
*/
|
||||
PutObjectResult upload(String key, ReadableByteChannel channel, long contentLength);
|
||||
|
||||
/**
|
||||
* 将输入流中的数据上传到默认存储桶。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param in 输入流
|
||||
* @param contentLength 内容长度
|
||||
* @return 上传结果
|
||||
*/
|
||||
PutObjectResult upload(String key, InputStream in, long contentLength);
|
||||
|
||||
/**
|
||||
* 将字节数组上传到默认存储桶。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param data 字节数组
|
||||
* @return 上传结果
|
||||
*/
|
||||
PutObjectResult upload(String key, byte[] data);
|
||||
|
||||
/**
|
||||
* 将默认存储桶中的对象下载到订阅器。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param downloadSubscriber 下载订阅器
|
||||
* @return 下载结果
|
||||
*/
|
||||
GetObjectResult download(String key, OutputStreamDownloadSubscriber downloadSubscriber);
|
||||
|
||||
/**
|
||||
* 将默认存储桶中的对象下载到本地路径。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param path 本地路径
|
||||
* @return 下载结果
|
||||
*/
|
||||
GetObjectResult download(String key, Path path);
|
||||
|
||||
/**
|
||||
* 将默认存储桶中的对象下载到文件。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param file 本地文件
|
||||
* @return 下载结果
|
||||
*/
|
||||
GetObjectResult download(String key, File file);
|
||||
|
||||
/**
|
||||
* 将默认存储桶中的对象下载到随机访问文件。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param file 随机访问文件
|
||||
* @return 下载结果
|
||||
*/
|
||||
GetObjectResult download(String key, RandomAccessFile file);
|
||||
|
||||
/**
|
||||
* 将默认存储桶中的对象下载到可写通道。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param channel 可写通道
|
||||
* @return 下载结果
|
||||
*/
|
||||
GetObjectResult download(String key, WritableByteChannel channel);
|
||||
|
||||
/**
|
||||
* 将默认存储桶中的对象下载到输出流。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param out 输出流
|
||||
* @return 下载结果
|
||||
*/
|
||||
GetObjectResult download(String key, OutputStream out);
|
||||
|
||||
/**
|
||||
* 删除默认存储桶中的对象。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @return 是否删除成功
|
||||
*/
|
||||
boolean delete(String key);
|
||||
|
||||
/**
|
||||
* 生成默认存储桶中文件下载的预签名 URL。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param expiredTime URL 过期时间
|
||||
* @return 预签名下载 URL
|
||||
*/
|
||||
String presignGetUrl(String key, Duration expiredTime);
|
||||
|
||||
/**
|
||||
* 生成默认存储桶中文件上传的预签名 URL。
|
||||
*
|
||||
* @param key 对象键
|
||||
* @param expiredTime URL 过期时间
|
||||
* @param metadata 对象元数据
|
||||
* @return 预签名上传 URL
|
||||
*/
|
||||
String presignPutUrl(String key, Duration expiredTime, Map<String, String> metadata);
|
||||
|
||||
|
||||
/**
|
||||
* 校验客户端配置与传入的配置是否一致
|
||||
*
|
||||
* @param config 配置
|
||||
* @return 是否一致
|
||||
*/
|
||||
boolean verifyConfig(S3StorageClientConfig config);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package org.dromara.common.oss.s3.client;
|
||||
|
||||
import org.dromara.common.oss.s3.config.S3StorageClientConfig;
|
||||
import org.dromara.common.oss.s3.domain.GetObjectResult;
|
||||
import org.dromara.common.oss.s3.domain.HandleAsyncResult;
|
||||
import org.dromara.common.oss.s3.domain.PutObjectResult;
|
||||
import org.dromara.common.oss.s3.exception.S3StorageException;
|
||||
import org.dromara.common.oss.s3.io.OutputStreamDownloadSubscriber;
|
||||
import software.amazon.awssdk.core.async.AsyncRequestBody;
|
||||
import software.amazon.awssdk.core.async.AsyncResponseTransformer;
|
||||
import software.amazon.awssdk.core.async.ResponsePublisher;
|
||||
import software.amazon.awssdk.services.s3.S3AsyncClient;
|
||||
import software.amazon.awssdk.services.s3.model.*;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
import software.amazon.awssdk.transfer.s3.S3TransferManager;
|
||||
import software.amazon.awssdk.transfer.s3.model.CompletedUpload;
|
||||
import software.amazon.awssdk.transfer.s3.model.DownloadRequest;
|
||||
import software.amazon.awssdk.transfer.s3.progress.TransferListener;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.ReadableByteChannel;
|
||||
import java.nio.channels.WritableByteChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* S3 存储客户端实现类。
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
public class S3StorageClientImpl implements S3StorageClient {
|
||||
|
||||
/**
|
||||
* S3 存储客户端配置。
|
||||
*/
|
||||
private final S3StorageClientConfig config;
|
||||
|
||||
/**
|
||||
* Amazon S3 异步客户端。
|
||||
*/
|
||||
private final S3AsyncClient s3AsyncClient;
|
||||
|
||||
/**
|
||||
* 用于管理 S3 数据传输的高级工具。
|
||||
*/
|
||||
private final S3TransferManager s3TransferManager;
|
||||
|
||||
/**
|
||||
* AWS S3 预签名 URL 生成器。
|
||||
*/
|
||||
private final S3Presigner s3Presigner;
|
||||
|
||||
/**
|
||||
* 异步调度线程池。
|
||||
*/
|
||||
private final ExecutorService executorService;
|
||||
|
||||
public S3StorageClientImpl(S3StorageClientConfig config, S3AsyncClient s3AsyncClient, S3TransferManager s3TransferManager, S3Presigner s3Presigner) {
|
||||
this(config,s3AsyncClient,s3TransferManager,s3Presigner, Executors.newSingleThreadExecutor());
|
||||
}
|
||||
|
||||
public S3StorageClientImpl(S3StorageClientConfig config, S3AsyncClient s3AsyncClient, S3TransferManager s3TransferManager, S3Presigner s3Presigner, ExecutorService executorService) {
|
||||
this.config = config;
|
||||
this.s3AsyncClient = s3AsyncClient;
|
||||
this.s3TransferManager = s3TransferManager;
|
||||
this.s3Presigner = s3Presigner;
|
||||
this.executorService = executorService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T doCustomUpload(AsyncRequestBody body, Consumer<PutObjectRequest.Builder> putObjectRequestBuilderConsumer, Collection<TransferListener> transferListeners, BiFunction<CompletedUpload, Throwable, T> handleAsyncAction) {
|
||||
try {
|
||||
return s3TransferManager.upload(uploadRequestBuilder -> {
|
||||
uploadRequestBuilder.requestBody(body)
|
||||
.putObjectRequest(putObjectRequestBuilderConsumer)
|
||||
.transferListeners(transferListeners);
|
||||
})
|
||||
.completionFuture()
|
||||
.handleAsync(handleAsyncAction)
|
||||
.join();
|
||||
} catch (Exception e) {
|
||||
if (e instanceof S3StorageException ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T doCustomUpload(AsyncRequestBody body, Consumer<PutObjectRequest.Builder> putObjectRequestBuilderConsumer, BiFunction<CompletedUpload, Throwable, T> handleAsyncAction) {
|
||||
return doCustomUpload(body, putObjectRequestBuilderConsumer, null, handleAsyncAction);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HandleAsyncResult<PutObjectResponse> doCustomUpload(AsyncRequestBody body, Consumer<PutObjectRequest.Builder> putObjectRequestBuilderConsumer, Collection<TransferListener> transferListeners) {
|
||||
return doCustomUpload(body, putObjectRequestBuilderConsumer, transferListeners, (completedUpload, throwable) -> HandleAsyncResult.of(completedUpload.response(), throwable));
|
||||
}
|
||||
|
||||
@Override
|
||||
public HandleAsyncResult<PutObjectResponse> doCustomUpload(AsyncRequestBody body, Consumer<PutObjectRequest.Builder> putObjectRequestBuilderConsumer) {
|
||||
return doCustomUpload(body, putObjectRequestBuilderConsumer, null, (completedUpload, throwable) -> HandleAsyncResult.of(completedUpload.response(), throwable));
|
||||
}
|
||||
|
||||
@Override
|
||||
public PutObjectResult bucketUpload(String bucket, String key, Path path) {
|
||||
AsyncRequestBody body = AsyncRequestBody.fromFile(path);
|
||||
return bucketUpload(bucket, key, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PutObjectResult bucketUpload(String bucket, String key, File file) {
|
||||
AsyncRequestBody body = AsyncRequestBody.fromFile(file);
|
||||
return bucketUpload(bucket, key, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PutObjectResult bucketUpload(String bucket, String key, RandomAccessFile file) {
|
||||
try {
|
||||
return bucketUpload(bucket, key, file.getChannel(), -1L);
|
||||
} catch (Exception e) {
|
||||
if (e instanceof S3StorageException ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PutObjectResult bucketUpload(String bucket, String key, ReadableByteChannel channel, long contentLength) {
|
||||
long size = contentLength;
|
||||
try (channel; InputStream in = Channels.newInputStream(channel)) {
|
||||
if (channel instanceof FileChannel fileChannel) {
|
||||
size = fileChannel.size();
|
||||
}
|
||||
return bucketUpload(bucket, key, in, size);
|
||||
} catch (Exception e) {
|
||||
if (e instanceof S3StorageException ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PutObjectResult bucketUpload(String bucket, String key, InputStream in, long contentLength) {
|
||||
AsyncRequestBody body = AsyncRequestBody.fromInputStream(builder -> builder.inputStream(in)
|
||||
.contentLength(contentLength)
|
||||
.executor(executorService));
|
||||
return bucketUpload(bucket, key, body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PutObjectResult bucketUpload(String bucket, String key, byte[] data) {
|
||||
try (ByteArrayInputStream in = new ByteArrayInputStream(data)) {
|
||||
return bucketUpload(bucket, key, in, data.length);
|
||||
} catch (Exception e) {
|
||||
if (e instanceof S3StorageException ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private PutObjectResult bucketUpload(String bucket, String key, AsyncRequestBody body) {
|
||||
HandleAsyncResult<PutObjectResponse> result = doCustomUpload(body, builder -> builder.bucket(bucket).key(key));
|
||||
if (result.isFailure()) {
|
||||
throw S3StorageException.of(result.error());
|
||||
}
|
||||
Optional<PutObjectResponse> opt = result.getResult();
|
||||
if (opt.isEmpty()) {
|
||||
throw S3StorageException.of("response is empty.");
|
||||
}
|
||||
PutObjectResponse response = opt.get();
|
||||
return PutObjectResult.of(null, key, response.eTag(), response.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T doCustomDownload(Consumer<GetObjectRequest.Builder> getObjectRequestBuilderConsumer, AsyncResponseTransformer<GetObjectResponse, T> responseTransformer, Collection<TransferListener> transferListeners) {
|
||||
try {
|
||||
DownloadRequest<T> downloadRequest = DownloadRequest.builder()
|
||||
.responseTransformer(responseTransformer)
|
||||
.getObjectRequest(getObjectRequestBuilderConsumer)
|
||||
.transferListeners(transferListeners)
|
||||
.build();
|
||||
return s3TransferManager.download(downloadRequest)
|
||||
.completionFuture()
|
||||
.join()
|
||||
.result();
|
||||
} catch (Exception e) {
|
||||
if (e instanceof S3StorageException ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetObjectResult bucketDownload(String bucket, String key, OutputStreamDownloadSubscriber downloadSubscriber) {
|
||||
try {
|
||||
ResponsePublisher<GetObjectResponse> publisher = doCustomDownload(builder -> builder.bucket(bucket).key(key), AsyncResponseTransformer.toPublisher(), null);
|
||||
GetObjectResult getObjectResult = buildGetObjectResult(key, publisher.response());
|
||||
publisher.subscribe(downloadSubscriber);
|
||||
return getObjectResult;
|
||||
} catch (Exception e) {
|
||||
if (e instanceof S3StorageException ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetObjectResult bucketDownload(String bucket, String key, Path path) {
|
||||
try (OutputStream out = Files.newOutputStream(path)) {
|
||||
return bucketDownload(bucket, key, out);
|
||||
} catch (Exception e) {
|
||||
if (e instanceof S3StorageException ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetObjectResult bucketDownload(String bucket, String key, File file) {
|
||||
try (FileOutputStream out = new FileOutputStream(file)) {
|
||||
return bucketDownload(bucket, key, out);
|
||||
} catch (Exception e) {
|
||||
if (e instanceof S3StorageException ex) {
|
||||
throw ex;
|
||||
}
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetObjectResult bucketDownload(String bucket, String key, RandomAccessFile file) {
|
||||
return bucketDownload(bucket, key, file.getChannel());
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetObjectResult bucketDownload(String bucket, String key, WritableByteChannel channel) {
|
||||
return bucketDownload(bucket, key, OutputStreamDownloadSubscriber.create(channel));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetObjectResult bucketDownload(String bucket, String key, OutputStream out) {
|
||||
return bucketDownload(bucket, key, OutputStreamDownloadSubscriber.create(out));
|
||||
}
|
||||
|
||||
private GetObjectResult buildGetObjectResult(String key, GetObjectResponse response) {
|
||||
return GetObjectResult.of(
|
||||
key,
|
||||
response.eTag(),
|
||||
LocalDateTime.from(response.lastModified()),
|
||||
response.contentLength(),
|
||||
response.contentType(),
|
||||
response.contentDisposition(),
|
||||
response.contentRange(),
|
||||
response.contentEncoding(),
|
||||
response.contentLanguage(),
|
||||
response.metadata()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean bucketDelete(String bucket, String key) {
|
||||
try {
|
||||
DeleteObjectResponse response = s3AsyncClient.deleteObject(builder -> builder.bucket(bucket).key(key)).join();
|
||||
return Boolean.TRUE.equals(response.deleteMarker());
|
||||
} catch (Exception e) {
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String bucketPresignGetUrl(String bucket, String key, Duration expiredTime) {
|
||||
try {
|
||||
return s3Presigner.presignGetObject(getObjectPresignRequestBuilder -> {
|
||||
getObjectPresignRequestBuilder.signatureDuration(expiredTime)
|
||||
.getObjectRequest(getObjectRequestBuilder -> getObjectRequestBuilder.bucket(bucket).key(key));
|
||||
})
|
||||
.url()
|
||||
.toExternalForm();
|
||||
} catch (Exception e) {
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String bucketPresignPutUrl(String bucket, String key, Duration expiredTime, Map<String, String> metadata) {
|
||||
try {
|
||||
return s3Presigner.presignPutObject(putObjectPresignRequestBuilder -> {
|
||||
putObjectPresignRequestBuilder.signatureDuration(expiredTime)
|
||||
.putObjectRequest(putObjectRequestBuilder -> putObjectRequestBuilder.bucket(bucket).key(key).metadata(metadata));
|
||||
})
|
||||
.url()
|
||||
.toExternalForm();
|
||||
} catch (Exception e) {
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PutObjectResult upload(String key, Path path) {
|
||||
return bucketUpload(defaultBucket(), key, path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PutObjectResult upload(String key, File file) {
|
||||
return bucketUpload(defaultBucket(), key, file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PutObjectResult upload(String key, RandomAccessFile file) {
|
||||
return bucketUpload(defaultBucket(), key, file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PutObjectResult upload(String key, ReadableByteChannel channel, long contentLength) {
|
||||
return bucketUpload(defaultBucket(), key, channel, contentLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PutObjectResult upload(String key, InputStream in, long contentLength) {
|
||||
return bucketUpload(defaultBucket(), key, in, contentLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PutObjectResult upload(String key, byte[] data) {
|
||||
return bucketUpload(defaultBucket(), key, data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetObjectResult download(String key, OutputStreamDownloadSubscriber downloadSubscriber) {
|
||||
return bucketDownload(defaultBucket(), key, downloadSubscriber);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetObjectResult download(String key, Path path) {
|
||||
return bucketDownload(defaultBucket(), key, path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetObjectResult download(String key, File file) {
|
||||
return bucketDownload(defaultBucket(), key, file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetObjectResult download(String key, RandomAccessFile file) {
|
||||
return bucketDownload(defaultBucket(), key, file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetObjectResult download(String key, WritableByteChannel channel) {
|
||||
return bucketDownload(defaultBucket(), key, channel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetObjectResult download(String key, OutputStream out) {
|
||||
return bucketDownload(defaultBucket(), key, out);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String key) {
|
||||
return bucketDelete(defaultBucket(), key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String presignGetUrl(String key, Duration expiredTime) {
|
||||
return bucketPresignGetUrl(defaultBucket(), key, expiredTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String presignPutUrl(String key, Duration expiredTime, Map<String, String> metadata) {
|
||||
return bucketPresignPutUrl(defaultBucket(), key, expiredTime, metadata);
|
||||
}
|
||||
|
||||
private String defaultBucket() {
|
||||
return config.bucket()
|
||||
.filter(bucket -> !bucket.isBlank())
|
||||
.orElseThrow(() -> S3StorageException.of("bucket is not configured."));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyConfig(S3StorageClientConfig config) {
|
||||
return Objects.equals(this.config,config);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
s3TransferManager.close();
|
||||
s3AsyncClient.close();
|
||||
s3Presigner.close();
|
||||
executorService.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.dromara.common.oss.s3.config;
|
||||
|
||||
import org.dromara.common.oss.s3.enums.AccessPolicy;
|
||||
import lombok.Builder;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* ACL访问策略配置
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Builder
|
||||
@EqualsAndHashCode
|
||||
public class S3AclConfig implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 是否启用ACL
|
||||
*/
|
||||
private final boolean enabled;
|
||||
|
||||
/**
|
||||
* 访问策略
|
||||
*/
|
||||
private final AccessPolicy accessPolicy;
|
||||
|
||||
public boolean enabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public Optional<AccessPolicy> accessPolicy() {
|
||||
return Optional.ofNullable(accessPolicy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制ACL访问策略配置对象
|
||||
*/
|
||||
public static S3AclConfig copy(S3AclConfig config) {
|
||||
return toBuilder(config).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 转为ACL访问策略配置构建器对象
|
||||
*/
|
||||
public static S3AclConfigBuilder toBuilder(S3AclConfig config) {
|
||||
return builder()
|
||||
.enabled(config.enabled())
|
||||
.accessPolicy(config.accessPolicy().orElse(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package org.dromara.common.oss.s3.config;
|
||||
|
||||
import org.dromara.common.oss.s3.builder.CloudServiceBucketUrlBuilder;
|
||||
import org.dromara.common.oss.s3.builder.MinioBucketUrlBuilder;
|
||||
import org.dromara.common.oss.s3.exception.S3StorageException;
|
||||
import org.dromara.common.oss.s3.util.BucketUrlUtil;
|
||||
import lombok.Builder;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* S3存储客户端配置
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Builder
|
||||
@EqualsAndHashCode
|
||||
public class S3StorageClientConfig implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 访问端点
|
||||
*/
|
||||
private final String endpoint;
|
||||
|
||||
/**
|
||||
* 自定义域名
|
||||
*/
|
||||
private final String domain;
|
||||
|
||||
/**
|
||||
* 是否使用HTTPS协议
|
||||
*/
|
||||
private final boolean useHttps;
|
||||
|
||||
/**
|
||||
* 是否使用路径样式访问(使用域名需要启用路径样式访问)
|
||||
*/
|
||||
private final boolean usePathStyleAccess;
|
||||
|
||||
/**
|
||||
* ACCESS_KEY
|
||||
*/
|
||||
private final String accessKey;
|
||||
|
||||
/**
|
||||
* SECRET_KEY
|
||||
*/
|
||||
private final String secretKey;
|
||||
|
||||
/**
|
||||
* 存储桶
|
||||
*/
|
||||
private final String bucket;
|
||||
|
||||
/**
|
||||
* 存储区域
|
||||
*/
|
||||
private final Region region;
|
||||
|
||||
/**
|
||||
* 前缀
|
||||
*/
|
||||
private final String prefix;
|
||||
|
||||
/**
|
||||
* ACL访问策略配置
|
||||
*/
|
||||
private final S3AclConfig aclConfig;
|
||||
|
||||
/**
|
||||
* 访问端点
|
||||
*/
|
||||
public Optional<String> endpoint() {
|
||||
return Optional.ofNullable(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义域名
|
||||
*/
|
||||
public Optional<String> domain() {
|
||||
return Optional.ofNullable(domain);
|
||||
}
|
||||
/**
|
||||
* 是否使用HTTPS协议
|
||||
*/
|
||||
public boolean useHttps() {
|
||||
return useHttps;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否使用路径样式访问(使用域名需要启用路径样式访问)
|
||||
*/
|
||||
public boolean usePathStyleAccess() {
|
||||
return usePathStyleAccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* ACCESS_KEY
|
||||
*/
|
||||
public Optional<String> accessKey() {
|
||||
return Optional.ofNullable(accessKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* SECRET_KEY
|
||||
*/
|
||||
public Optional<String> secretKey() {
|
||||
return Optional.ofNullable(secretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储桶
|
||||
*/
|
||||
public Optional<String> bucket() {
|
||||
return Optional.ofNullable(bucket);
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储区域
|
||||
*/
|
||||
public Optional<Region> region() {
|
||||
return Optional.ofNullable(region);
|
||||
}
|
||||
|
||||
/**
|
||||
* 前缀
|
||||
*/
|
||||
public Optional<String> prefix() {
|
||||
return Optional.ofNullable(prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* ACL访问策略配置
|
||||
*/
|
||||
public Optional<S3AclConfig> aclConfig() {
|
||||
return Optional.ofNullable(aclConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取访问站点URL地址
|
||||
*
|
||||
* @return 访问站点URL地址
|
||||
*/
|
||||
public String getEndpointUrl(){
|
||||
String endpoint = endpoint()
|
||||
.filter(s -> !s.isBlank())
|
||||
.orElseThrow(() -> S3StorageException.of("endpoint is not configured."));
|
||||
return BucketUrlUtil.getDomainUrl(useHttps, endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取域名URL地址
|
||||
*
|
||||
* @return 域名URL地址
|
||||
*/
|
||||
public String getDomainUrl(){
|
||||
return domain()
|
||||
.filter(s -> !s.isBlank())
|
||||
// 如果已经配置了自定义域名,则优先使用域名
|
||||
.map(s -> BucketUrlUtil.getDomainUrl(useHttps, s))
|
||||
// 否则使用站点
|
||||
.orElseGet(this::getEndpointUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取桶URL地址
|
||||
*
|
||||
* @param isCloudService 是否是云服务商
|
||||
* @return 桶URL地址
|
||||
*/
|
||||
public String getBucketUrl(boolean isCloudService){
|
||||
// 如果是云服务商,则优先使用云服务商的
|
||||
if (isCloudService) {
|
||||
return CloudServiceBucketUrlBuilder.INSTANCE.build(this);
|
||||
}
|
||||
// 否则默认使用MinIO的
|
||||
return MinioBucketUrlBuilder.INSTANCE.build(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制S3存储客户端配置对象
|
||||
*/
|
||||
public static S3StorageClientConfig copy(S3StorageClientConfig config) {
|
||||
return toBuilder(config).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 转为S3存储客户端配置构建器对象
|
||||
*/
|
||||
public static S3StorageClientConfigBuilder toBuilder(S3StorageClientConfig config) {
|
||||
S3StorageClientConfigBuilder builder = builder()
|
||||
.endpoint(config.endpoint().orElse(null))
|
||||
.domain(config.domain().orElse(null))
|
||||
.useHttps(config.useHttps())
|
||||
.usePathStyleAccess(config.usePathStyleAccess())
|
||||
.accessKey(config.accessKey().orElse(null))
|
||||
.secretKey(config.secretKey().orElse(null))
|
||||
.bucket(config.bucket().orElse(null))
|
||||
.region(config.region().orElse(null))
|
||||
.prefix(config.prefix().orElse(null));
|
||||
config.aclConfig().ifPresent(s3AclConfig -> builder.aclConfig(S3AclConfig.copy(s3AclConfig)));
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.dromara.common.oss.s3.domain;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Get文件对象结果
|
||||
*
|
||||
* @param key
|
||||
* @param eTag
|
||||
* @param lastModified
|
||||
* @param size
|
||||
* @param contentType
|
||||
* @param contentDisposition
|
||||
* @param contentRange
|
||||
* @param contentEncoding
|
||||
* @param contentLanguage
|
||||
* @param metadata
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
public record GetObjectResult(
|
||||
String key,
|
||||
String eTag,
|
||||
LocalDateTime lastModified,
|
||||
long size,
|
||||
String contentType,
|
||||
String contentDisposition,
|
||||
String contentRange,
|
||||
String contentEncoding,
|
||||
String contentLanguage,
|
||||
Map<String, String> metadata
|
||||
) {
|
||||
|
||||
public static GetObjectResult of(String key, String eTag, LocalDateTime lastModified, long size
|
||||
, String contentType, String contentDisposition, String contentRange, String contentEncoding, String contentLanguage
|
||||
, Map<String, String> metadata) {
|
||||
return new GetObjectResult(key, eTag, lastModified, size, contentType, contentDisposition, contentRange, contentEncoding, contentLanguage, metadata);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.dromara.common.oss.s3.domain;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 处理异步结果
|
||||
*
|
||||
* @param result 结果
|
||||
* @param error 异常错误
|
||||
* @param <T> 结果类型
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
public record HandleAsyncResult<T>(
|
||||
T result,
|
||||
Throwable error
|
||||
) {
|
||||
|
||||
public Optional<T> getResult() {
|
||||
return Optional.ofNullable(result);
|
||||
}
|
||||
|
||||
public Optional<Throwable> getError() {
|
||||
return Optional.ofNullable(error);
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return getError().isEmpty();
|
||||
}
|
||||
|
||||
public boolean isFailure() {
|
||||
return getError().isPresent();
|
||||
}
|
||||
|
||||
public static <T> HandleAsyncResult<T> of(T result, Throwable error) {
|
||||
return new HandleAsyncResult<T>(result, error);
|
||||
}
|
||||
|
||||
public static <T> HandleAsyncResult<T> success(T result) {
|
||||
return new HandleAsyncResult<T>(result, null);
|
||||
}
|
||||
|
||||
public static <T> HandleAsyncResult<T> failure(Throwable error) {
|
||||
return new HandleAsyncResult<T>(null, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.dromara.common.oss.s3.domain;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.experimental.Accessors;
|
||||
import software.amazon.awssdk.transfer.s3.progress.TransferListener;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 可选项
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode
|
||||
@Accessors(chain = true)
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public class Options {
|
||||
|
||||
/**
|
||||
* 文件长度
|
||||
*/
|
||||
private Long length;
|
||||
|
||||
/**
|
||||
* 文件MD5摘要
|
||||
*/
|
||||
private String md5Digest;
|
||||
|
||||
/**
|
||||
* 内容类型
|
||||
*/
|
||||
private String contentType;
|
||||
|
||||
/**
|
||||
* 元数据
|
||||
*/
|
||||
private Map<String, String> metadata;
|
||||
|
||||
/**
|
||||
* 传输监听器
|
||||
*/
|
||||
private Collection<TransferListener> transferListeners;
|
||||
|
||||
/**
|
||||
* 添加元数据项
|
||||
*/
|
||||
public Options addMetadataItem(String key, String value) {
|
||||
if (this.metadata == null) {
|
||||
this.metadata = new HashMap<>();
|
||||
}
|
||||
this.metadata.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加监听器
|
||||
*/
|
||||
public Options addTransferListener(TransferListener transferListener) {
|
||||
if (this.transferListeners == null) {
|
||||
this.transferListeners = new ArrayList<>();
|
||||
}
|
||||
this.transferListeners.add(transferListener);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可选项对象
|
||||
*/
|
||||
public static Options builder() {
|
||||
return new Options();
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制一个新的可选项对象
|
||||
*
|
||||
* @param options 可选项对象
|
||||
* @return 新的可选项对象
|
||||
*/
|
||||
public static Options copy(Options options) {
|
||||
return builder()
|
||||
.setLength(options.getLength())
|
||||
.setMd5Digest(options.getMd5Digest())
|
||||
.setContentType(options.getContentType())
|
||||
.setMetadata(options.getMetadata())
|
||||
.setTransferListeners(options.getTransferListeners());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.dromara.common.oss.s3.domain;
|
||||
|
||||
/**
|
||||
* Put文件对象结果
|
||||
*
|
||||
* @param url
|
||||
* @param key
|
||||
* @param eTag
|
||||
* @param size
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
public record PutObjectResult(
|
||||
String url,
|
||||
String key,
|
||||
String eTag,
|
||||
long size
|
||||
) {
|
||||
|
||||
public static PutObjectResult of(String url, String key, String eTag, long size) {
|
||||
return new PutObjectResult(url, key, eTag, size);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.dromara.common.oss.s3.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import software.amazon.awssdk.services.s3.model.BucketCannedACL;
|
||||
import software.amazon.awssdk.services.s3.model.ObjectCannedACL;
|
||||
|
||||
/**
|
||||
* 访问策略
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum AccessPolicy {
|
||||
|
||||
/**
|
||||
* 私有
|
||||
*/
|
||||
PRIVATE(BucketCannedACL.PRIVATE, ObjectCannedACL.PRIVATE),
|
||||
|
||||
/**
|
||||
* 公有读写
|
||||
*/
|
||||
PUBLIC_READ_WRITE(BucketCannedACL.PUBLIC_READ_WRITE, ObjectCannedACL.PUBLIC_READ_WRITE),
|
||||
|
||||
/**
|
||||
* 公有只读
|
||||
*/
|
||||
PUBLIC_READ(BucketCannedACL.PUBLIC_READ, ObjectCannedACL.PUBLIC_READ);
|
||||
|
||||
/**
|
||||
* 桶权限
|
||||
*/
|
||||
private final BucketCannedACL bucketCannedACL;
|
||||
|
||||
/**
|
||||
* 文件对象权限
|
||||
*/
|
||||
private final ObjectCannedACL objectCannedACL;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.dromara.common.oss.s3.exception;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* S3对象存储异常
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
public class S3StorageException extends RuntimeException {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public S3StorageException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public S3StorageException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public S3StorageException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public S3StorageException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
}
|
||||
|
||||
public static S3StorageException of(String message) {
|
||||
return new S3StorageException(message);
|
||||
}
|
||||
|
||||
public static S3StorageException of(String message, Throwable cause) {
|
||||
return new S3StorageException(message, cause);
|
||||
}
|
||||
|
||||
public static S3StorageException of(Throwable cause) {
|
||||
return new S3StorageException(cause);
|
||||
}
|
||||
|
||||
public static S3StorageException of(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
|
||||
return new S3StorageException(message, cause, enableSuppression, writableStackTrace);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.dromara.common.oss.s3.factory;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.constant.CacheNames;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.dromara.common.oss.constant.OssConstant;
|
||||
import org.dromara.common.redis.utils.CacheUtils;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.common.oss.s3.builder.DefaultS3StorageClientBuilder;
|
||||
import org.dromara.common.oss.s3.client.S3StorageClient;
|
||||
import org.dromara.common.oss.s3.config.S3StorageClientConfig;
|
||||
import org.dromara.common.oss.s3.exception.S3StorageException;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* S3存储客户端工厂
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
@Slf4j
|
||||
public class S3StorageClientFactory {
|
||||
|
||||
private static final Map<String, S3StorageClient> CLIENT_CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取默认实例
|
||||
*/
|
||||
public static S3StorageClient instance() {
|
||||
// 获取redis 默认类型
|
||||
String configKey = RedisUtils.getCacheObject(OssConstant.DEFAULT_CONFIG_KEY);
|
||||
if (StringUtils.isEmpty(configKey)) {
|
||||
throw S3StorageException.of("文件存储服务类型无法找到!");
|
||||
}
|
||||
return instance(configKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型获取实例
|
||||
*/
|
||||
public static S3StorageClient instance(String configKey) {
|
||||
// 使用租户标识避免多个租户相同key实例覆盖
|
||||
return CLIENT_CACHE.computeIfAbsent(configKey, S3StorageClientFactory::instanceCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用缓存实例化
|
||||
*/
|
||||
private static S3StorageClient instanceCache(String configKey) {
|
||||
String json = CacheUtils.get(CacheNames.SYS_OSS_CONFIG, configKey);
|
||||
if (json == null) {
|
||||
throw S3StorageException.of("系统异常, '" + configKey + "'配置信息不存在!");
|
||||
}
|
||||
S3StorageClientConfig config = JsonUtils.parseObject(json, S3StorageClientConfig.class);
|
||||
return DefaultS3StorageClientBuilder.INSTANCE.build(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除实例
|
||||
*/
|
||||
public static boolean remove(String configKey) {
|
||||
S3StorageClient client = CLIENT_CACHE.remove(configKey);
|
||||
if (client == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
client.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("S3存储客户端关闭异常,错误信息: {}",e.getMessage(),e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.dromara.common.oss.s3.io;
|
||||
|
||||
import org.dromara.common.oss.s3.exception.S3StorageException;
|
||||
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.WritableByteChannel;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 输出流下载订阅器
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
public class OutputStreamDownloadSubscriber implements Consumer<ByteBuffer>, AutoCloseable {
|
||||
|
||||
private final WritableByteChannel channel;
|
||||
|
||||
private OutputStreamDownloadSubscriber(WritableByteChannel channel) {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
private OutputStreamDownloadSubscriber(OutputStream out) {
|
||||
// 创建可写入的字节通道
|
||||
if (out instanceof FileOutputStream outputStream) {
|
||||
// 如果是文件输入流,直接获取文件输出流的 Channel
|
||||
channel = outputStream.getChannel();
|
||||
} else {
|
||||
channel = Channels.newChannel(out);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void accept(ByteBuffer byteBuffer) {
|
||||
try (channel) {
|
||||
while (byteBuffer.hasRemaining()) {
|
||||
channel.write(byteBuffer);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw S3StorageException.of(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
if (channel.isOpen()) {
|
||||
channel.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个输出流下载订阅器
|
||||
*
|
||||
* @param out 输出流
|
||||
* @return 输出流下载订阅器
|
||||
*/
|
||||
public static OutputStreamDownloadSubscriber create(OutputStream out) {
|
||||
return new OutputStreamDownloadSubscriber(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个输出流下载订阅器
|
||||
*
|
||||
* @param channel 可写字节通道
|
||||
* @return 输出流下载订阅器
|
||||
*/
|
||||
public static OutputStreamDownloadSubscriber create(WritableByteChannel channel) {
|
||||
return new OutputStreamDownloadSubscriber(channel);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.dromara.common.oss.s3.util;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 桶链接工具类
|
||||
*
|
||||
* @author 秋辞未寒
|
||||
*/
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public class BucketUrlUtil {
|
||||
|
||||
public static final String HTTP_PROTOCOL_HEADER = "http://";
|
||||
public static final String HTTPS_PROTOCOL_HEADER = "https://";
|
||||
|
||||
public static final String EMPTY_STRING = "";
|
||||
|
||||
// 路径风格 例:https://s3examples.com/images
|
||||
private static final String PATH_STYLE_HTTP_FORMATE = "http://%s/%s";
|
||||
private static final String PATH_STYLE_HTTPS_FORMATE = "https://%s/%s";
|
||||
|
||||
// 站点风格 例:https://images.oss-cn-beijing.aliyuncs.com
|
||||
private static final String SITE_STYLE_HTTP_FORMATE = "http://%s.%s";
|
||||
private static final String SITE_STYLE_HTTPS_FORMATE = "https://%s.%s";
|
||||
|
||||
/**
|
||||
* 获取域名地址 例:https://s3examples.com
|
||||
*
|
||||
* @param isHttps 是否为HTTP
|
||||
* @param base 基础地址(可以是IP、站点或者域名)
|
||||
* @return 域名地址
|
||||
*/
|
||||
public static String getDomainUrl(boolean isHttps, String base) {
|
||||
String baseUrl = removeHttpProtocolHeader(base);
|
||||
if (isHttps) {
|
||||
return HTTPS_PROTOCOL_HEADER + baseUrl;
|
||||
}
|
||||
return HTTP_PROTOCOL_HEADER + baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路径风格的桶地址 例:https://s3examples.com/images
|
||||
*
|
||||
* @param isHttps 是否为HTTP
|
||||
* @param base 基础地址(可以是IP、站点或者域名)
|
||||
* @param bucketName 桶名称
|
||||
* @return 路径风格的桶地址
|
||||
*/
|
||||
public static String getPathStyleBucketUrl(boolean isHttps, String base, String bucketName) {
|
||||
String baseUrl = removeHttpProtocolHeader(base);
|
||||
if (isHttps) {
|
||||
return String.format(PATH_STYLE_HTTPS_FORMATE, baseUrl, bucketName);
|
||||
}
|
||||
return String.format(PATH_STYLE_HTTP_FORMATE, baseUrl, bucketName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取站点风格的桶地址 例:https://images.oss-cn-beijing.aliyuncs.com
|
||||
*
|
||||
* @param isHttps 是否为HTTP
|
||||
* @param base 基础地址(可以是IP、站点或者域名)
|
||||
* @param bucketName 桶名称
|
||||
* @return 站点风格的桶地址
|
||||
*/
|
||||
public static String getSiteStyleBucketUrl(boolean isHttps, String base, String bucketName) {
|
||||
String baseUrl = removeHttpProtocolHeader(base);
|
||||
if (isHttps) {
|
||||
return String.format(SITE_STYLE_HTTPS_FORMATE, bucketName, baseUrl);
|
||||
}
|
||||
return String.format(SITE_STYLE_HTTP_FORMATE, bucketName, baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除HTTP/HTTPS协议头(如果有的话)
|
||||
*
|
||||
* @param url 链接地址
|
||||
* @return 移除HTTP/HTTPS协议头后的地址
|
||||
*/
|
||||
public static String removeHttpProtocolHeader(String url) {
|
||||
String s = url.toLowerCase();
|
||||
if (s.startsWith(HTTP_PROTOCOL_HEADER) || s.startsWith(HTTPS_PROTOCOL_HEADER)) {
|
||||
return s.replace(HTTP_PROTOCOL_HEADER, EMPTY_STRING)
|
||||
.replace(HTTPS_PROTOCOL_HEADER, EMPTY_STRING);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user