Springboot结合minio搭建简单的存储服务

前言

最近在做私有化存储,需要在本地搭建一个简单的文件存储服务器。由于是私有化部署,大部分情况下是不走公网的,所以不考虑公有云的对象存储服务(如阿里云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),由存储服务进行实际上传(上传至对象存储)。
image.png
其优点就是: 前端对接简单,只需要对接存储服务提供的上传接口;后端代码也相对简单,上传步骤可能就是对接oss的sdk进行文件上传即可。

缺点很明显: 一次文件上传需要经过后端服务中转,相当于进行了两次上传操作,上传耗时可能会增加一倍;同时后端中转其实是没必要的,不但浪费资源,同时受限于后端服务的网络速度以及性能。

比如网站需要上传一些小文件需求,上传量不大的情况下,这种方式是最简单的。如果想作为公司的存储公共服务,或者需要上传大文件,以及上传量比较大的情况下,建议不要考虑这种方式。

优化后的上传流程

本文主要是介绍一种优化后的上传流程: 前端或者客户端(client)先去后端服务(store-server)获取表单上传策略(会返回一些表单上传需要的字段以及签名参数),client根据返回的表单字段以及待上传的文件直接上传至云存储。
image.png
优点: 文件上传不需要经过后端服务中转,提高上传速度;由于存储服务只是负责下发策略,所以可以应对更高的并发量,是大型存储服务上传流程首选。

缺点: 前端对接较复杂,本来只需要调用一次接口,变为需要调用两次(获取上传策略、执行真正的上传);后端开发难度稍为增加(由于sdk一般不会提供post上传的方法,需要自己去官方文档看post接口生成对应的表单参数)。

文件信息记录

上面展示的只是简单的上传流程,但是细心的小伙伴可能发现,这个流程缺少了记录上传成功的文件信息的步骤。

有对接过oss的朋友应该知道,如阿里云oss或者七牛云,文件上传是可以设置回调后端服务的,可以在回调的步骤进行文件记录。但是 s3都表单上传是没有回调操作的

这里提供两种思路:

1、客户端主动通知: 客户端是可以感知到文件上传成功的,所以后端服务可以提供一个通知接口。client在上传成功之后,把成功的文件信息(fileKey)传到后端进行信息记录;当然后端服务需要判断文件是否真正上传成功,可以调用head方法去获取下文件元信息,判断文件是否真实存在(任何时候都需要持怀疑态度,如果前端同学文件还没上传成功就调用通知接口,会产生脏数据)
image.png

2、增加网关执行回调: 在云存储前加一个前置网关,增加回调后端服务的逻辑
image.png

项目集成

存储服务最核心的就是上传和下载流程,本文只介绍这两个功能。其他功能可以自己看官网。

相关文档:

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)进行表单上传:

image.png

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"
}
复制代码

将返回的下载链接放到浏览器上请求(由于测试文件链接是本地链接,你们是访问不了的):

image.png

写在最后

以上就是本文的所有内容,如果文章帮助到你,希望可以 点赞关注 支持下~

本文的demo已经上传到github上,感兴趣的小伙伴可以去看下:点击查看源码

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享