• SpringBoot+Minio实现上传凭证、分片上传、秒传和断点续传


    Spring Boot整合Minio后,前端的文件上传有两种方式:

    1、文件上传到后端,由后端保存到Minio

    这种方式好处是完全由后端集中管理,可以很好的做到、身份验证、权限控制、文件与处理等,并且可以做一些额外的业务逻辑,比如生成缩略图、提取元数据等。

    缺点也很明显:

    • 延迟时间高了,本来花费上传一次文件的时间,现在多了后端保存到Minio的时间

    • 后端资源占用,后端本来可以只处理业务请求,现在还要负责文件流,增加了性能压力

    • 单点故障,Minio即便做了集群,但是如果后端服务器故障,也会导致Minio不可用

    所以,实际上我们不会把文件传到后端,而是直接传给Minio,其实这也符合OSS服务的使用方式。

    2、文件向后端申请上传凭证,然后直接上传到Minio

    为了避免Minio被攻击,我们需要结合后端,让后端生成并返回一个有时效的上传凭证,前端拿着这个凭证才能去上传,通过这种方式,我们可以做到一定程度的权限控制,本文要分享的就是这种方式。

    环境准备

    部署好的Minio环境:http://mylocalhost:9001

    Spring Boot整合Minio

    简单过一下整合方式把。

    先引入Minio依赖
    1. <dependency>
    2.     <groupId>io.minio</groupId>
    3.     <artifactId>minio</artifactId>
    4.     <version>7.1.0</version>
    5. </dependency>
    然后定义配置信息
    1. # application.yml
    2. minio:
    3.   endpoint: http://mylocalhost:9001
    4.   accessKey: minio
    5.   secretKey: minio123
    6.   bucket: demo
    定义一个属性类
    1. @Component
    2. @ConfigurationProperties(prefix = "minio")
    3. public class MinioProperties {
    4.     /**
    5.      * 对象存储服务的URL
    6.      */
    7.     private String endpoint;
    8.     /**
    9.      * Access key就像用户ID,可以唯一标识你的账户
    10.      */
    11.     private String accessKey;
    12.     /**
    13.      * Secret key是你账户的密码
    14.      */
    15.     private String secretKey;
    16.     /**
    17.      * 默认文件桶
    18.      */
    19.     private String bucket;
    20.     
    21.     ...
    22. }
    定义Minio配置类
    1. @Configuration
    2. public class MinioConfig {
    3.     @Bean
    4.     public MinioClient minioClient(MinioProperties properties){
    5.         try {
    6.             MinioClient.Builder builder = MinioClient.builder();
    7.             builder.endpoint(properties.getEndpoint());
    8.             if (StringUtils.hasLength(properties.getAccessKey()) && StringUtils.hasLength(properties.getSecretKey())) {
    9.                 builder.credentials(properties.getAccessKey(),properties.getSecretKey());
    10.             }
    11.             return builder.build();
    12.         } catch (Exception e) {
    13.             return null;
    14.         }
    15.     }
    16. }

    现在启动服务即可。

    上传凭证

    写一个接口,返回上传凭证:

    1. @RequestMapping(value = "/presign"method = {RequestMethod.POST})
    2. public Map<StringString> presign(@RequestBody PresignParam presignParam) {
    3.     // 如果前端不指定桶,那么给一个默认的
    4.     if (StringUtils.isEmpty(presignParam.getBucket())) {
    5.         presignParam.setBucket("demo");
    6.     }
    7.     // 前端不指定文件名称,就给一个UUID
    8.     if (StringUtils.isEmpty(presignParam.getFilename())) {
    9.         presignParam.setFilename(UUID.randomUUID().toString());
    10.     }
    11.     // 如果想要以子目录的方式保存,就在前面加上斜杠来表示
    12.     //        presignParam.setFilename("/2023/" + presignParam.getFilename());
    13.     // 设置凭证过期时间
    14.     ZonedDateTime expirationDate = ZonedDateTime.now().plusMinutes(10);
    15.     // 创建一个凭证
    16.     PostPolicy policy = new PostPolicy(presignParam.getBucket(), presignParam.getFilename(), expirationDate);
    17.     // 限制文件大小,单位是字节byte,也就是说可以设置如:只允许10M以内的文件上传
    18.     //        policy.setContentRange(110 * 1024);
    19.     // 限制上传文件请求的ContentType
    20.     //        policy.setContentType("image/png");
    21.     try {
    22.         // 生成凭证并返回
    23.         final Map<StringString> map = minioClient.presignedPostPolicy(policy);
    24.         for (Map.Entry<StringString> entry : map.entrySet()) {
    25.             System.out.println(entry.getKey() + " = " + entry.getValue());
    26.         }
    27.         return map;
    28.     } catch (MinioException | InvalidKeyException | IOException | NoSuchAlgorithmException e) {
    29.         e.printStackTrace();
    30.     }
    31.     return null;
    32. }

    上面的示例代码可以知道,关注公众号:码猿技术专栏,回复关键词:1111 获取阿里内部Java性能调优手册的!我们还可以加一些权限认证,以判断用户是否有以下权限:

    • 上传权限

    • 可上传的文件大小

    • 可上传的文件类型

    请求参数类:

    1. public class PresignParam {
    2.     // 桶名
    3.     private String bucket;
    4.     // 文件名
    5.     private String filename;
    6.     
    7.     ...
    8. }

    这个接口的返回结果是:

    1. bucket: demo
    2. x-amz-date: 20230831T042351Z
    3. x-amz-signature: 79cc2ae0baee274d1d47cb29bdd5e99127059033503c2a02f904f0478a73ecac
    4. key: 寂寞的季节.mp4
    5. x-amz-algorithm: AWS4-HMAC-SHA256
    6. x-amz-credential: minio/20230831/us-east-1/s3/aws4_request
    7. policy: eyJleHBpcmF0aW9uIjoiMjAyMy0wOC0zMVQwNDozMzo1MS42MzZaIiwiY29uZGl0aW9ucyI6W1siZXEiLCIkYnVja2V0IiwiZGVtbyJdLFsiZXEiLCIka2V5Iiwi5a+C5a+e55qE5a2j6IqCLm1wNCJdLFsiZXEiLCIkeC1hbXotYWxnb3JpdGhtIiwiQVdTNC1ITUFDLVNIQTI1NiJdLFsiZXEiLCIkeC1hbXotY3JlZGVudGlhbCIsIm1pbmlvLzIwMjMwODMxL3VzLWVhc3QtMS9zMy9hd3M0X3JlcXVlc3QiXSxbImVxIiwiJHgtYW16LWRhdGUiLCIyMDIzMDgzMVQwNDIzNTFaIl1dfQ==
    • bucket:表示目标桶

    • x-amz-date:时间戳

    • x-amz-signature:签名

    • key:文件名

    • x-amz-algorithm:签名算法

    • x-amz-credential:认证授权

    • policy:凭证token

    前端收到后,将该凭证连同文件流一并上传到Minio服务器:

    1. uploadFile(file, policy) {
    2.     console.log("准备上传文件:")
    3.     console.log("file:" + file)
    4.     console.log("policy:" + policy)
    5.     var formData = new FormData()
    6.     formData.append('file'file)
    7.     formData.append('key', policy['key'])
    8.     formData.append('x-amz-algorithm', policy['x-amz-algorithm'])
    9.     formData.append('x-amz-credential', policy['x-amz-credential'])
    10.     formData.append('x-amz-signature', policy['x-amz-signature'])
    11.     formData.append('x-amz-date', policy['x-amz-date'])
    12.     formData.append('policy', policy['policy'])
    13.     return new Promise(((resolve, reject) => {
    14.         $.ajax({
    15.             method'POST',
    16.             url: 'http://mylocalhost:9001/' + policy['bucket'],
    17.             data: formData,
    18.             dataType: 'json',
    19.             contentType: false// 必须设置为 false,不设置 contentType,让浏览器自动设置
    20.             processData: false// 必须设置为 false,不对 FormData 进行序列化处理
    21.             // async: false// 设置同步,方便等下做分片上传
    22.             xhr: function xhr() {
    23.                 //获取原生的xhr对象
    24.                 var xhr = $.ajaxSettings.xhr();
    25.                 if (xhr.upload) {
    26.                     //添加 progress 事件监听
    27.                     xhr.upload.addEventListener('progress'function (e) {
    28.                         //e.loaded 已上传文件字节数
    29.                         //e.total 文件总字节数
    30.                         var percentage = parseInt(e.loaded / e.total * 100)
    31.                         vm.uploadResult = percentage + "%" + ":" + policy['key']
    32.                     }, false);
    33.                 }
    34.                 return xhr;
    35.             },
    36.             success: function (result) {
    37.                 vm.uploadResult = '文件上传成功:' + policy['key']
    38.                 resolve(result)
    39.             },
    40.             errorfunction (e) {
    41.                 reject()
    42.             }
    43.         })
    44.     }))
    45. },

    这样就完成了获取上传凭证并上传文件。

    分片上传、秒传、断点续传

    分片上传

    分片上传可以用在大文件上传上,一个100M的文件可以分成10份,每份10M,一共传输10次,这有以下好处:

    • Minio做了集群,用Nginx转发,那么分片上传可以降低单台Minio服务器的性能压力

    • 多线程上传可以加快上传效率

    秒传

    现在说说秒传,我们上传一个文件之前,可以用工具生成MD5字符串,就好像这样:

    3cc1f3c3c2d1a29ecf60ffad4de278c7
    

    然后拼接上文件名:

    3cc1f3c3c2d1a29ecf60ffad4de278c7_寂寞的季节.mp4
    

    这时候去向后端申请上传凭证的时候,后端可以先去看看文件是否已存在,如果文件已存在,就不用生成凭证了,直接告诉前端该文件已经上传完毕,由此实现文件秒传。

    这样的好处是:

    • 降低Minio服务器压力

    • 响应秒回,用户体验提高

    断点续传

    结合分片上传和秒传的原理,我们可以来做到断点续传。

    场景: 当我们要上传一个大文件的时候,进度到一半了,这时候网络掉线导致上传失败,网络恢复后又要重新上传,这就很崩溃。

    处理方式: 大文件也可以分成一个个小文件来上传,这样即便上传到一半网络掉线,恢复上传的时候可以跳过前一半已上传的部分,接着上传后面一半。

    文件合并

    当我们分片上传后,后端还需要提供接口,来将所有分片数据合并:

    1. @GetMapping("/compose")
    2. public void merge() {
    3.     List<ComposeSource> sources = new ArrayList<>();
    4.     // 分片数据放到另一个桶里面:slice
    5.     sources.add(ComposeSource.builder()
    6.                 .bucket("slice")
    7.                 .object("0寂寞的季节.mp4")
    8.                 .build());
    9.     sources.add(ComposeSource.builder()
    10.                 .bucket("slice")
    11.                 .object("1寂寞的季节.mp4")
    12.                 .build());
    13.     sources.add(ComposeSource.builder()
    14.                 .bucket("slice")
    15.                 .object("2寂寞的季节.mp4")
    16.                 .build());
    17.     final ComposeObjectArgs args = ComposeObjectArgs.builder()
    18.         .bucket("demo")
    19.         .object("寂寞的季节.mp4")
    20.         .sources(sources)
    21.         .build();
    22.     try {
    23.         minioClient.composeObject(args);
    24.     } catch (MinioException | InvalidKeyException | IOException | NoSuchAlgorithmException e) {
    25.         e.printStackTrace();
    26.     }
    27. }

    上面的示例很简单,因为只做演示说明。

    前端需要传的参数是:

    • 分片桶:slice

    • 分片数据数组:

      • 0寂寞的季节.mp4

      • 1寂寞的季节.mp4

      • 2寂寞的季节.mp4

    • 目标桶:demo

    然后调用composeObject函数完成合并。

    前端示例代码分享

    上面就是关于实战经验分享的全部了,因为需要前端配置来使用,所以这里给出我这篇文章的前端示例,很简单的单页面(技术栈就别吐槽了):

    1. <!DOCTYPE html>
    2. <html lang="en">
    3. <head>
    4.     <meta charset="UTF-8">
    5.     <title>Title</title>
    6.     <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.14/vue.js"></script>
    7.     <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.4/jquery.js"></script>
    8.     <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
    9. </head>
    10. <body>
    11. <div id="app">
    12.     <h1>{{title}}</h1>
    13.     <br>
    14.     <form @submit.prevent="getPolicyForm">
    15.         <label>
    16.             桶名
    17.             <input type="text" v-model="policyParams.bucket">
    18.         </label>
    19.         <br>
    20.         <label>
    21.             文件名
    22.             <input type="text" v-model="policyParams.filename">
    23.         </label>
    24.         <br>
    25.         <button type="submit">获取上传凭证</button>
    26.         <br>
    27.         <div v-for="(val, key) in policy" :key="key">{{ key }}: <span>{{ val }}</span></div>
    28.     </form>
    29.     <br>
    30.     <form @submit.prevent="uploadFileForm" v-show="policy != null">
    31.         <label>
    32.             文件
    33.             <input type="file" @change="fileChange">
    34.         </label>
    35.         <br>
    36.         <br>
    37.         <button type="submit" v-show="file != null">上传文件</button>
    38.     </form>
    39.     ---
    40.     <br>
    41.     <div v-show="file != null">
    42.         <button @click="sliceEvent">测试文件分片上传</button>
    43.         |
    44.         <button @click="sliceComposeEvent">分片文件合并</button>
    45.     </div>
    46.     <br>
    47.     <br>
    48.     <br>
    49.     <p>{{uploadResult}}</p>
    50.     <ul>
    51.         <!--        <li v-for="item in sliceUploadResult">{{ item }}</li>-->
    52.         <li v-for="(item, index) in sliceUploadResult" :key="index">{{ item }}</li>
    53.     </ul>
    54.     <br>
    55. </div>
    56. <script>
    57.     var vm = new Vue({
    58.         el: "#app",
    59.         data() {
    60.             return {
    61.                 title: "Minio测试"
    62.                 // 请求凭证参数
    63.                 , policyParams: {
    64.                     bucket: null
    65.                     , filename: null
    66.                 }
    67.                 // 请求到的凭证
    68.                 , policy: null
    69.                 // 待上传文件
    70.                 , filenull
    71.                 // 上传文件参数
    72.                 , uploadParams: {
    73.                     filenull
    74.                 }
    75.                 // 分片上传参数
    76.                 , sliceParams: {
    77.                     bucket: ""
    78.                     , filename: ""
    79.                     , filenull
    80.                 }
    81.                 , slicePolicys: []
    82.                 , sliceCount: 0
    83.                 // 上传结果回调
    84.                 , uploadResult: null
    85.                 // 分片上传结果回调
    86.                 , sliceUploadResult: null
    87.             };
    88.         },
    89.         methods: {
    90.             getPolicyForm() {
    91.                 this.policyParams.bucket = "demo"
    92.                 this.policyParams.filename = "寂寞的季节.mp4"
    93.                 this.requestPolicy(this.policyParams)
    94.             },
    95.             requestPolicy(params) {
    96.                 return new Promise(((resolve, reject) => {
    97.                     $.ajax({
    98.                         type"POST",
    99.                         url: "http://localhost:8888/presign",
    100.                         contentType: "application/json",
    101.                         data: JSON.stringify(params),
    102.                         // async: false,
    103.                         success: function (result) {
    104.                             console.log(result)
    105.                             vm.policy = result;
    106.                             resolve(result)
    107.                         },
    108.                         errorfunction (e) {
    109.                             reject()
    110.                         }
    111.                     });
    112.                 }))
    113.             },
    114.             fileChange(event) {
    115.                 const file = event.target.files[0]
    116.                 this.file = file
    117.             },
    118.             uploadFileForm() {
    119.                 this.uploadFile(this.file, this.policy)
    120.             },
    121.             uploadFile(file, policy) {
    122.                 console.log("准备上传文件:")
    123.                 console.log("file:" + file)
    124.                 console.log("policy:" + policy)
    125.                 var formData = new FormData()
    126.                 formData.append('file'file)
    127.                 formData.append('key', policy['key'])
    128.                 formData.append('x-amz-algorithm', policy['x-amz-algorithm'])
    129.                 formData.append('x-amz-credential', policy['x-amz-credential'])
    130.                 formData.append('x-amz-signature', policy['x-amz-signature'])
    131.                 formData.append('x-amz-date', policy['x-amz-date'])
    132.                 formData.append('policy', policy['policy'])
    133.                 return new Promise(((resolve, reject) => {
    134.                     $.ajax({
    135.                         method'POST',
    136.                         url: 'http://mylocalhost:9001/' + policy['bucket'],
    137.                         data: formData,
    138.                         dataType: 'json',
    139.                         contentType: false// 必须设置为 false,不设置 contentType,让浏览器自动设置
    140.                         processData: false// 必须设置为 false,不对 FormData 进行序列化处理
    141.                         // async: false// 设置同步,方便等下做分片上传
    142.                         xhr: function xhr() {
    143.                             //获取原生的xhr对象
    144.                             var xhr = $.ajaxSettings.xhr();
    145.                             if (xhr.upload) {
    146.                                 //添加 progress 事件监听
    147.                                 xhr.upload.addEventListener('progress'function (e) {
    148.                                     //e.loaded 已上传文件字节数
    149.                                     //e.total 文件总字节数
    150.                                     var percentage = parseInt(e.loaded / e.total * 100)
    151.                                     vm.uploadResult = percentage + "%" + ":" + policy['key']
    152.                                 }, false);
    153.                             }
    154.                             return xhr;
    155.                         },
    156.                         success: function (result) {
    157.                             vm.uploadResult = '文件上传成功:' + policy['key']
    158.                             resolve(result)
    159.                         },
    160.                         errorfunction (e) {
    161.                             reject()
    162.                         }
    163.                     })
    164.                 }))
    165.             },
    166.             sliceEvent() {
    167.                 // 获取文件
    168.                 var file = this.file
    169.                 // 设置分片大小:5MB
    170.                 var chunkSize = 5 * 1024 * 1024
    171.                 // 计算总共有多少个分片
    172.                 var totalChunk = Math.ceil(file.size / chunkSize)
    173.                 // 数组存放所有分片
    174.                 var chunks = []
    175.                 // 遍历所有分片
    176.                 for (var i = 0; i < totalChunk; i++) {
    177.                     // 利用slice获取分片
    178.                     var start = i * chunkSize
    179.                     var end = Math.min(file.sizestart + chunkSize)
    180.                     var blob = file.slice(startend)
    181.                     // 添加分片到数组
    182.                     chunks.push(blob)
    183.                 }
    184.                 console.log(totalChunk)
    185.                 this.sliceUploadResult = Array(totalChunk).fill(0)
    186.                 for (let i = 0; i < chunks.length; i++) {
    187.                     var file = chunks[i];
    188.                     this.calculateMD5(file)
    189.                         .then((md5=> {
    190.                             console.log(md5);  // 输出计算出的 MD5 值
    191.                         })
    192.                         .catch((error=> {
    193.                             console.error(error);  // 处理错误
    194.                         });
    195.                 }
    196.                 return
    197.                 // 创建序号
    198.                 var index = 0;
    199.                 // 循环上传分片
    200.                 while (index < totalChunk) {
    201.                     console.log('------------------------------')
    202.                     params = {
    203.                         "bucket""slice",
    204.                         "filename"index + "寂寞的季节.mp4"
    205.                     }
    206.                     var policyPromise = this.requestPolicy(params);
    207.                     (function (index) {
    208.                         var file = chunks[index]
    209.                         policyPromise.then(function (result) {
    210.                             var filename = result['key']
    211.                             console.log('准备上传文件:', filename, ',序号为:'index)
    212.                             vm.uploadFile(file, result).then(function (result) {
    213.                                 console.log('上传完成:' + filename)
    214.                                 vm.sliceUploadResult[index= ('分片文件上传成功:' + filename)
    215.                             })
    216.                         })
    217.                     })(index)
    218.                     index++
    219.                 }
    220.             },
    221.             sliceComposeEvent() {
    222.                 var parmas = {}
    223.                 $.ajax({
    224.                     method'POST',
    225.                     url: 'http://localhost:8888/compose',
    226.                     data: formData,
    227.                     dataType: 'json',
    228.                     contentType: false// 必须设置为 false,不设置 contentType,让浏览器自动设置
    229.                     processData: false// 必须设置为 false,不对 FormData 进行序列化处理
    230.                     // async: false// 设置同步,方便等下做分片上传
    231.                     xhr: function xhr() {
    232.                         //获取原生的xhr对象
    233.                         var xhr = $.ajaxSettings.xhr();
    234.                         if (xhr.upload) {
    235.                             //添加 progress 事件监听
    236.                             xhr.upload.addEventListener('progress'function (e) {
    237.                                 //e.loaded 已上传文件字节数
    238.                                 //e.total 文件总字节数
    239.                                 var percentage = parseInt(e.loaded / e.total * 100)
    240.                                 vm.uploadResult = percentage + "%" + ":" + policy['key']
    241.                             }, false);
    242.                         }
    243.                         return xhr;
    244.                     },
    245.                     success: function (result) {
    246.                         vm.uploadResult = '文件上传成功:' + policy['key']
    247.                         resolve(result)
    248.                     },
    249.                     errorfunction (e) {
    250.                         reject()
    251.                     }
    252.                 })
    253.             },
    254.             calculateMD5(file) {
    255.                 return new Promise((resolve, reject) => {
    256.                     const reader = new FileReader();
    257.                     // 读取文件内容
    258.                     reader.readAsArrayBuffer(file);
    259.                     reader.onload = () => {
    260.                         const spark = new SparkMD5.ArrayBuffer();
    261.                         spark.append(reader.result);  // 将文件内容添加到 MD5 计算器中
    262.                         const md5 = spark.end();  // 计算 MD5 值
    263.                         resolve(md5);
    264.                     };
    265.                     reader.onerror = (error=> {
    266.                         reject(error);
    267.                     };
    268.                 });
    269.             }
    270.         },
    271.         mounted() {
    272.         },
    273.         created() {
    274.         },
    275.     });
    276. </script>
    277. </body>
    278. </html>
  • 相关阅读:
    搭建WAMP网站教程(Windows+Apache+MySQL+PHP)
    运动耳机哪种佩戴方式好?佩戴稳固舒适的运动耳机
    sql报错:sql injection violation, syntax error
    有一说一,外包公司到底值不值得去?
    HDU 1009 FatMouse‘ Trade (贪心算法)
    小任务:简单实现银行业务系统(附 ideal编辑器固定序列化版本方法)
    分布式事务----seata
    Java面试题——继承,多态
    需求分析与系统设计 原书第3版
    MySQL InnoDB 引擎底层解析(三)
  • 原文地址:https://blog.csdn.net/m0_69632475/article/details/136476088