前言
最近在做私有化存储,需要在本地搭建一个简单的文件存储服务器。由于是私有化部署,大部分情况下是不走公网的,所以不考虑公有云的对象存储服务(如阿里云oss、七牛云等)。
选取minio的理由
- 部署简单: 这个是私有化部署的关键,可以减低运维成本和使用成本。minio的部署方式很简单,开箱即用。可以参考 docker部署的方式,集群部署啥的自己看下文档即可;
- 文档齐全: minio的文档还是相对齐全的,而且提供了中文文档(但是中文文档已经没有维护了,英文差的老铁可以结合最新的英文文档来看)。由于其兼容aws s3,可以结合 aws s3官方文档 指导开发;
- 社区活跃: 目前 github社区 已经有28.2k star了,社区活跃度很高。而且之前我在社区提过一个 关于兼容s3的issues,很快就被分配了;
- 兼容AWS S3标准: 这个也很重要,s3是目前比较主流的对象存储标准,目前大部分对象存储厂商或者私有化对象存储服务如(ceph、minio等),都兼容s3标准。这可以很方便的对底层的对象存储进行替换而无需改动代码。
安装minio
为了快速演示,我是直接使用docker来运行minio:docs.minio.org.cn/docs/master… 。
对于集群部署或者设置启动参数什么的,各位自己看官方文档吧,里面有详细步骤,我这里就不演示了。
存储流程
一般的上传流程
这种是比较常见的存储上传流程: 前端或者客户端(client)将需要上传的文件先上传到存储服务(store-server),由存储服务进行实际上传(上传至对象存储)。
其优点就是: 前端对接简单,只需要对接存储服务提供的上传接口;后端代码也相对简单,上传步骤可能就是对接oss的sdk进行文件上传即可。
缺点很明显: 一次文件上传需要经过后端服务中转,相当于进行了两次上传操作,上传耗时可能会增加一倍;同时后端中转其实是没必要的,不但浪费资源,同时受限于后端服务的网络速度以及性能。
比如网站需要上传一些小文件需求,上传量不大的情况下,这种方式是最简单的。如果想作为公司的存储公共服务,或者需要上传大文件,以及上传量比较大的情况下,建议不要考虑这种方式。
优化后的上传流程
本文主要是介绍一种优化后的上传流程: 前端或者客户端(client)先去后端服务(store-server)获取表单上传策略(会返回一些表单上传需要的字段以及签名参数),client根据返回的表单字段以及待上传的文件直接上传至云存储。
优点: 文件上传不需要经过后端服务中转,提高上传速度;由于存储服务只是负责下发策略,所以可以应对更高的并发量,是大型存储服务上传流程首选。
缺点: 前端对接较复杂,本来只需要调用一次接口,变为需要调用两次(获取上传策略、执行真正的上传);后端开发难度稍为增加(由于sdk一般不会提供post上传的方法,需要自己去官方文档看post接口生成对应的表单参数)。
文件信息记录
上面展示的只是简单的上传流程,但是细心的小伙伴可能发现,这个流程缺少了记录上传成功的文件信息的步骤。
有对接过oss的朋友应该知道,如阿里云oss或者七牛云,文件上传是可以设置回调后端服务的,可以在回调的步骤进行文件记录。但是 s3都表单上传是没有回调操作的。
这里提供两种思路:
1、客户端主动通知: 客户端是可以感知到文件上传成功的,所以后端服务可以提供一个通知接口。client在上传成功之后,把成功的文件信息(fileKey)传到后端进行信息记录;当然后端服务需要判断文件是否真正上传成功,可以调用head方法去获取下文件元信息,判断文件是否真实存在(任何时候都需要持怀疑态度,如果前端同学文件还没上传成功就调用通知接口,会产生脏数据)
2、增加网关执行回调: 在云存储前加一个前置网关,增加回调后端服务的逻辑
项目集成
存储服务最核心的就是上传和下载流程,本文只介绍这两个功能。其他功能可以自己看官网。
相关文档:
- 官方中文文档:docs.minio.org.cn/docs/master…
- 最新的官方文档:docs.min.io/docs/java-c…
1、引入maven库
官方提供了 minio-java-sdk,但是由于minio兼容aws s3 sdk,这里就直接使用aws的sdk,这样方便以后平滑替换其他兼容s3标准的云存储。
<!-- aws s3 sdk -->
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-s3</artifactId>
<version>1.12.12</version>
</dependency>
复制代码
2、设置相关属性
application.properties:
# MinIO 配置
s3.endpoint = http://127.0.0.1:9000
s3.access.key = minioadmin
s3.secret.key = minioadmin
s3.region = cn-northwest-1
# 存储桶信息
s3.private.bucket = minio-test-pri
s3.public.bucket = minio-test-pub
复制代码
3、封装文件service
1、定义存储接口(StoreService)
后续需要对接其他厂商的云存储,可以直接继承该接口实现相应的方法,可以实现多云提供服务。
/**
* 存储功能抽象接口
*/
public interface StoreService {
/**
* 获取下载链接
*
* @param fileKey 文件唯一标识
* @param isPrivate 是否私有资源
*/
String getDownloadUrl(String fileKey, boolean isPrivate);
/**
* 获取表单上传策略
*
* @param isPrivate 是否私有资源
*/
UploadPolicyResDto getFormUploadPolicy(boolean isPrivate);
}
复制代码
2、s3存储具体实现
表单上传策略参数参照:aws post object文档
/**
* S3 存储服务
*/
@Service
public class S3StoreServiceImpl implements StoreService {
/**
* oss服务地址
*/
@Value("${s3.endpoint}")
private String endpoint;
@Value("${s3.access.key}")
private String accessKey;
@Value("${s3.secret.key}")
private String secretKey;
@Value("${s3.region}")
private String region;
/**
* 私有存储桶
*/
@Value("${s3.private.bucket}")
private String privateBucket;
/**
* 公有存储桶
*/
@Value("${s3.public.bucket}")
private String publicBucket;
/**
* s3 客户端
*/
private AmazonS3 s3Client;
/**
* 简单上传凭证过期时间
*/
private static final Long SIMPLE_UPLOAD_TOKEN_EXPIRE_SECONDS = 7200L;
/**
* 私有下载链接过期时间
*/
private static final Long DOWNLOAD_EXPIRED_EXPIRE_SECONDS = 3600L;
@PostConstruct
private void init() {
// 初始化s3客户端
ClientConfiguration clientConfig = new com.amazonaws.ClientConfiguration();
clientConfig.setProtocol(Protocol.HTTP);
AwsClientBuilder.EndpointConfiguration endpointConfiguration = new
AwsClientBuilder.EndpointConfiguration(endpoint, region);
BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
s3Client = AmazonS3ClientBuilder.standard()
.withClientConfiguration(clientConfig)
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
.withEndpointConfiguration(endpointConfiguration)
.build();
}
/**
* 获取存储桶名称
*/
private String getBucketName(boolean isPrivate) {
return isPrivate ? privateBucket : publicBucket;
}
@Override
public String getDownloadUrl(String fileKey, boolean isPrivate) {
String downloadUrl;
String bucketName = this.getBucketName(isPrivate);
if (isPrivate) {
Date expireDate = Date.from(Instant.now().plusSeconds(DOWNLOAD_EXPIRED_EXPIRE_SECONDS));
downloadUrl = s3Client.generatePresignedUrl(bucketName, fileKey, expireDate).toString();
} else {
downloadUrl = endpoint + "/" + fileKey;
}
return downloadUrl;
}
@Override
public UploadPolicyResDto getFormUploadPolicy(boolean isPrivate) {
String bucketName = this.getBucketName(isPrivate);
String fileKey = UUID.randomUUID().toString();
UploadPolicyResDto resDto = new UploadPolicyResDto();
resDto.setUploadUrl(endpoint + "/" + bucketName);
resDto.setFormFields(generateFormFields(bucketName, fileKey));
return resDto;
}
/**
* 生成表单上传字段
*/
private List<KeyAndValueResDto> generateFormFields(String bucketName, String fileKey) {
Date expireDate = TimeUtils.expireDateTime(SIMPLE_UPLOAD_TOKEN_EXPIRE_SECONDS);
String expiration = TimeUtils.getISO8601Timestamp(expireDate);
String date = TimeUtils.getISO8601TimeWithoutSplit(expireDate);
String day = date.split("T")[0];
String credential = accessKey + "/" + day + "/" + region + "/s3/aws4_request";
String policy = this.generatePolicy(bucketName, expiration, date, credential);
String signature = calculateSignature(policy, day, accessKey, region);
List<KeyAndValueResDto> formFields = new ArrayList<>();
formFields.add(new KeyAndValueResDto("key", fileKey));
formFields.add(new KeyAndValueResDto("policy", policy));
formFields.add(new KeyAndValueResDto("x-amz-signature", signature));
formFields.add(new KeyAndValueResDto("x-amz-algorithm", "AWS4-HMAC-SHA256"));
formFields.add(new KeyAndValueResDto("x-amz-credential", credential));
formFields.add(new KeyAndValueResDto("x-amz-date", date));
formFields.add(new KeyAndValueResDto("success_action_status", "200"));
return formFields;
}
/**
* 生成policy
*/
private String generatePolicy(String bucketName, String expiration, String date, String credential) {
JSONObject policy = new JSONObject();
policy.put("expiration", expiration);
JSONArray conditions = new JSONArray();
JSONObject bucketJson = new JSONObject();
bucketJson.put("bucket", bucketName);
conditions.add(bucketJson);
JSONArray keyPrefixArr = new JSONArray();
keyPrefixArr.add("starts-with");
keyPrefixArr.add("$key");
keyPrefixArr.add("");
conditions.add(keyPrefixArr);
JSONObject credentialJson = new JSONObject();
credentialJson.put("x-amz-credential", credential);
conditions.add(credentialJson);
JSONObject algorithmJson = new JSONObject();
algorithmJson.put("x-amz-algorithm", "AWS4-HMAC-SHA256");
conditions.add(algorithmJson);
JSONObject dateJson = new JSONObject();
dateJson.put("x-amz-date", date);
conditions.add(dateJson);
JSONArray redirectArr = new JSONArray();
redirectArr.add("eq");
redirectArr.add("$success_action_status");
redirectArr.add("200");
conditions.add(redirectArr);
JSONArray mimeTypeJson = new JSONArray();
mimeTypeJson.add("starts-with");
mimeTypeJson.add("$content-type");
mimeTypeJson.add("");
conditions.add(mimeTypeJson);
policy.put("conditions", conditions);
return BinaryUtils.toBase64(policy.toJSONString().getBytes(StandardCharsets.UTF_8));
}
/**
* 计算签名
*/
private String calculateSignature(String encodePolicy, String dateStr, String secretKey, String region) {
try {
byte[] kSecret = ("AWS4" + secretKey).getBytes(StandardCharsets.UTF_8);
byte[] kDate = this.hmacSHA256(dateStr, kSecret);
byte[] kRegion = this.hmacSHA256(region, kDate);
byte[] kService = this.hmacSHA256("s3", kRegion);
byte[] keyBytes = this.hmacSHA256("aws4_request", kService);
return this.byte2hex(this.hmacSHA256(encodePolicy, keyBytes));
} catch (Exception e) {
System.out.println("计算签名失败" + e.getMessage());
return null;
}
}
/**
* 执行加密
*/
private byte[] hmacSHA256(String data, byte[] key) throws Exception {
String algorithm = "HmacSHA256";
Mac mac = Mac.getInstance(algorithm);
mac.init(new SecretKeySpec(key, algorithm));
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
}
/**
* 将二进制转换为小写十六进制
*/
private String byte2hex(byte[] b) {
StringBuilder hs = new StringBuilder();
String stmp;
for (int n = 0; b != null && n < b.length; n++) {
stmp = Integer.toHexString(b[n] & 0XFF);
if (stmp.length() == 1) {
hs.append('0');
}
hs.append(stmp);
}
return hs.toString().toLowerCase();
}
}
复制代码
4、定义http接口
@RestController
@RequestMapping("/store")
public class StoreController {
@Autowired
private StoreService storeService;
/**
* 获取文件下载链接
*
* @param fileKey 文件唯一标识
* @param isPrivate 是否私有资源
*/
@GetMapping("/downloadUrl")
public ApiResult getDownloadUrl(@RequestParam("fileKey") String fileKey,
@RequestParam("isPrivate") boolean isPrivate) {
return ApiResult.success(storeService.getDownloadUrl(fileKey, isPrivate));
}
/**
* 获取表单上传策略
*
* @param isPrivate 是否私有资源
*/
@GetMapping("/uploadPolicy")
public ApiResult getUploadPolicy(@RequestParam("isPrivate") boolean isPrivate) {
return ApiResult.success(storeService.getFormUploadPolicy(isPrivate));
}
}
复制代码
5、测试
1、文件上传测试
获取上传策略: http://127.0.0.1:8080/store/uploadPolicy?isPrivate=true
接口响应:
{
"code": 0,
"data": {
"uploadUrl": "http://127.0.0.1:9001/minio-test-pri",
"formFields": [
{
"key": "key",
"value": "c57e32cc-c4fb-4e4d-86f0-3b9b1e79ba1e"
},
{
"key": "policy",
"value": "eyJleHBpcmF0aW9uIjoiMjAyMS0wNi0yNlQxNjozNToyNy4xMjRaIiwiY29uZGl0aW9ucyI6W3siYnVja2V0IjoibWluaW8tdGVzdC1wcmkifSxbInN0YXJ0cy13aXRoIiwiJGtleSIsIiJdLHsieC1hbXotY3JlZGVudGlhbCI6Im1pbmlvYWRtaW4vMjAyMTA2MjYvY24tbm9ydGh3ZXN0LTEvczMvYXdzNF9yZXF1ZXN0In0seyJ4LWFtei1hbGdvcml0aG0iOiJBV1M0LUhNQUMtU0hBMjU2In0seyJ4LWFtei1kYXRlIjoiMjAyMTA2MjZUMTYzNTI3WiJ9LFsiZXEiLCIkc3VjY2Vzc19hY3Rpb25fc3RhdHVzIiwiMjAwIl0sWyJzdGFydHMtd2l0aCIsIiRjb250ZW50LXR5cGUiLCIiXV19"
},
{
"key": "x-amz-signature",
"value": "d8c5267e94efd51370d54e3a9e8e949d08f3cb7a3df255d7df7c5675c05b91b5"
},
{
"key": "x-amz-algorithm",
"value": "AWS4-HMAC-SHA256"
},
{
"key": "x-amz-credential",
"value": "minioadmin/20210626/cn-northwest-1/s3/aws4_request"
},
{
"key": "x-amz-date",
"value": "20210626T163527Z"
},
{
"key": "success_action_status",
"value": "200"
}
]
},
"message": "Ok"
}
复制代码
根据返回的表单字段(formFields)进行表单上传:
2、资源访问测试
将刚刚上传成功的 fileky:c57e32cc-c4fb-4e4d-86f0-3b9b1e79ba1e 作为参数,请求获取下载链接接口:http://127.0.0.1:8080/store/downloadUrl?isPrivate=true&fileKey=c57e32cc-c4fb-4e4d-86f0-3b9b1e79ba1e
接口响应:
{
"code": 0,
"data": "http://127.0.0.1:9001/minio-test-pri/c57e32cc-c4fb-4e4d-86f0-3b9b1e79ba1e?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20210626T144411Z&X-Amz-SignedHeaders=host&X-Amz-Expires=3599&X-Amz-Credential=minioadmin%2F20210626%2Fcn-northwest-1%2Fs3%2Faws4_request&X-Amz-Signature=05c4d287e35c31bc6aed2b2219575596e15704e833834abff78c3cfeffc91905",
"message": "Ok"
}
复制代码
将返回的下载链接放到浏览器上请求(由于测试文件链接是本地链接,你们是访问不了的):
写在最后
以上就是本文的所有内容,如果文章帮助到你,希望可以 点赞关注 支持下~
本文的demo已经上传到github上,感兴趣的小伙伴可以去看下:点击查看源码