當前位置: 妍妍網 > 碼農

SpringBoot+Minio實作上傳憑證、分片上傳、秒傳和斷點續傳

2024-03-09碼農

👉 歡迎 ,你將獲得: 專屬的計畫實戰 / Java 學習路線 / 一對一提問 / 學習打卡 / 贈書福利

全棧前後端分離部落格計畫 1.0 版本完結啦,2.0 正在更新中 ... , 演示連結 http://116.62.199.48/ ,全程手摸手,後端 + 前端全棧開發,從 0 到 1 講解每個功能點開發步驟,1v1 答疑,直到計畫上線。 目前已更新了219小節,累計36w+字,講解圖:1492張,還在持續爆肝中.. 後續還會上新更多計畫,目標是將Java領域典型的計畫都整一波,如秒殺系統, 線上商城, IM即時通訊,Spring Cloud Alibaba 等等,

Spring Boot整合Minio後,前端的檔上傳有兩種方式:

1、檔上傳到後端,由後端保存到Minio

這種方式好處是完全由後端集中管理,可以很好的做到、身份驗證、許可權控制、檔與處理等,並且可以做一些額外的業務邏輯,比如生成縮圖、提取後設資料等。

缺點也很明顯:

  • 延遲時間高了,本來花費上傳一次檔的時間,現在多了後端保存到Minio的時間

  • 後端資源占用,後端本來可以只處理業務請求,現在還要負責檔流,增加了效能壓力

  • 單點故障,Minio即便做了集群,但是如果後端伺服器故障,也會導致Minio不可用

  • 所以,實際上我們不會把檔傳到後端,而是直接傳給Minio,其實這也符合OSS服務的使用方式。

    2、檔向後端申請上傳憑證,然後直接上傳到Minio

    為了避免Minio被攻擊,我們需要結合後端,讓後端生成並返回一個有時效的上傳憑證,前端拿著這個憑證才能去上傳,透過這種方式,我們可以做到一定程度的許可權控制,本文要分享的就是這種方式。

    環境準備

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

    Spring Boot整合Minio

    簡單過一下整合方式把。

    先引入Minio依賴

    <dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>7.1.0</version>
    </dependency>

    然後定義配置資訊

    # application.yml
    minio:
    endpoint: http://mylocalhost:9001
    accessKey: minio
    secretKey: minio123
    bucket: demo

    定義一個內容類

    @Component
    @ConfigurationProperties(prefix = "minio")
    public class MinioProperties {
    /**
    * 物件儲存服務的URL
    */
    private String endpoint;
    /**
    * Access key就像使用者ID,可以唯一標識你的帳戶
    */
    private String accessKey;
    /**
    * Secret key是你帳戶的密碼
    */
    private String secretKey;
    /**
    * 預設檔桶
    */
    private String bucket;
    ...
    }

    定義Minio配置類

    @Configuration
    public class MinioConfig {
    @Bean
    public MinioClient minioClient(MinioProperties properties){
    try {
    MinioClient.Builder builder = MinioClient.builder();
    builder.endpoint(properties.getEndpoint());
    if (StringUtils.hasLength(properties.getAccessKey()) && StringUtils.hasLength(properties.getSecretKey())) {
    builder.credentials(properties.getAccessKey(),properties.getSecretKey());
    }
    return builder.build();
    } catch (Exception e) {
    return null;
    }
    }
    }

    現在啟動服務即可。

    上傳憑證

    寫一個介面,返回上傳憑證:

    @RequestMapping(value = "/presign", method = {RequestMethod.POST})
    public Map<String, String> presign(@RequestBody PresignParam presignParam) {
    // 如果前端不指定桶,那麽給一個預設的
    if (StringUtils.isEmpty(presignParam.getBucket())) {
    presignParam.setBucket("demo");
    }
    // 前端不指定檔名稱,就給一個UUID
    if (StringUtils.isEmpty(presignParam.getFilename())) {
    presignParam.setFilename(UUID.randomUUID().toString());
    }
    // 如果想要以子目錄的方式保存,就在前面加上斜杠來表示
    // presignParam.setFilename("/2023/" + presignParam.getFilename());
    // 設定憑證過期時間
    ZonedDateTime expirationDate = ZonedDateTime.now().plusMinutes(10);
    // 建立一個憑證
    PostPolicy policy = new PostPolicy(presignParam.getBucket(), presignParam.getFilename(), expirationDate);
    // 限制檔大小,單位是字節byte,也就是說可以設定如:只允許10M以內的檔上傳
    // policy.setContentRange(1, 10 * 1024);
    // 限制上傳檔請求的ContentType
    // policy.setContentType("image/png");
    try {
    // 生成憑證並返回
    final Map<String, String> map = minioClient.presignedPostPolicy(policy);
    for (Map.Entry<String, String> entry : map.entrySet()) {
    System.out.println(entry.getKey() + " = " + entry.getValue());
    }
    return map;
    } catch (MinioException | InvalidKeyException | IOException | NoSuchAlgorithmException e) {
    e.printStackTrace();
    }
    return null;
    }



    上面的範例程式碼可以知道,我們還可以加一些許可權認證,以判斷使用者是否有以下許可權:

  • 上傳許可權

  • 可上傳的檔大小

  • 可上傳的檔型別

  • 請求參數類:

    public class PresignParam {
    // 桶名
    private String bucket;
    // 檔名
    private String filename;
    ...
    }

    這個介面的返回結果是:

    bucket: demo
    x-amz-date: 20230831T042351Z
    x-amz-signature: 79cc2ae0baee274d1d47cb29bdd5e99127059033503c2a02f904f0478a73ecac
    key: 寂寞的季節.mp4
    x-amz-algorithm: AWS4-HMAC-SHA256
    x-amz-credential: minio/20230831/us-east-1/s3/aws4_request
    policy: eyJleHBpcmF0aW9uIjoiMjAyMy0wOC0zMVQwNDozMzo1MS42MzZaIiwiY29uZGl0aW9ucyI6W1siZXEiLCIkYnVja2V0IiwiZGVtbyJdLFsiZXEiLCIka2V5Iiwi5a+C5a+e55qE5a2j6IqCLm1wNCJdLFsiZXEiLCIkeC1hbXotYWxnb3JpdGhtIiwiQVdTNC1ITUFDLVNIQTI1NiJdLFsiZXEiLCIkeC1hbXotY3JlZGVudGlhbCIsIm1pbmlvLzIwMjMwODMxL3VzLWVhc3QtMS9zMy9hd3M0X3JlcXVlc3QiXSxbImVxIiwiJHgtYW16LWRhdGUiLCIyMDIzMDgzMVQwNDIzNTFaIl1dfQ==

  • bucket :表示目標桶

  • x-amz-date :時間戳

  • x-amz-signature :簽名

  • key :檔名

  • x-amz-algorithm :簽名演算法

  • x-amz-credential :認證授權

  • policy :憑證token

  • 前端收到後,將該憑證連同檔流一並上傳到Minio伺服器:

    uploadFile(file, policy) {
    console.log("準備上傳檔:")
    console.log("file:" + file)
    console.log("policy:" + policy)
    var formData = new FormData()
    formData.append('file', file)
    formData.append('key', policy['key'])
    formData.append('x-amz-algorithm', policy['x-amz-algorithm'])
    formData.append('x-amz-credential', policy['x-amz-credential'])
    formData.append('x-amz-signature', policy['x-amz-signature'])
    formData.append('x-amz-date', policy['x-amz-date'])
    formData.append('policy', policy['policy'])
    return new Promise(((resolve, reject) => {
    $.ajax({
    method: 'POST',
    url: 'http://mylocalhost:9001/' + policy['bucket'],
    data: formData,
    dataType: 'json',
    contentType: false, // 必須設定為 false,不設定 contentType,讓瀏覽器自動設定
    processData: false, // 必須設定為 false,不對 FormData 進行序列化處理
    // async: false, // 設定同步,方便等下做分片上傳
    xhr: functionxhr() {
    //獲取原生的xhr物件
    var xhr = $.ajaxSettings.xhr();
    if (xhr.upload) {
    //添加 progress 事件監聽
    xhr.upload.addEventListener('progress'function (e) {
    //e.loaded 已上傳檔字節數
    //e.total 檔總字節數
    var percentage = parseInt(e.loaded / e.total * 100)
    vm.uploadResult = percentage + "%" + ":" + policy['key']
    }, false);
    }
    return xhr;
    },
    success: function (result) {
    vm.uploadResult = '檔上傳成功:' + policy['key']
    resolve(result)
    },
    error: function (e) {
    reject()
    }
    })
    }))
    },

    這樣就完成了獲取上傳憑證並上傳檔。

    分片上傳、秒傳、斷點續傳

    分片上傳

    分片上傳可以用在大檔上傳上,一個100M的檔可以分成10份,每份10M,一共傳輸10次,這有以下好處:

  • Minio做了集群,用Nginx轉發,那麽分片上傳可以降低單台Minio伺服器的效能壓力

  • 多執行緒上傳可以加快上傳效率

  • 秒傳

    現在說說秒傳,我們上傳一個檔之前,可以用工具生成MD5字串,就好像這樣:

    3cc1f3c3c2d1a29ecf60ffad4de278c7

    然後拼接上檔名:

    3cc1f3c3c2d1a29ecf60ffad4de278c7_寂寞的季節.mp4

    這時候去向後端申請上傳憑證的時候,後端可以先去看看檔是否已存在,如果檔已存在,就不用生成憑證了,直接告訴前端該檔已經上傳完畢,由此實作檔秒傳。

    這樣的好處是:

  • 降低Minio伺服器壓力

  • 響應秒回,使用者體驗提高

  • 斷點續傳

    結合分片上傳和秒傳的原理,我們可以來做到斷點續傳。

    場景: 當我們要上傳一個大檔的時候,進度到一半了,這時候網路掉線導致上傳失敗,網路恢復後又要重新上傳,這就很崩潰。

    處理方式: 大檔也可以分成一個個小檔來上傳,這樣即便上傳到一半網路掉線,恢復上傳的時候可以跳過前一半已上傳的部份,接著上傳後面一半。

    檔合並

    當我們分片上傳後,後端還需要提供介面,來將所有分片數據合並:

    @GetMapping("/compose")
    public void merge() {
    List<ComposeSource> sources = new ArrayList<>();
    // 分片數據放到另一個桶裏面:slice
    sources.add(ComposeSource.builder()
    .bucket("slice")
    .object("0寂寞的季節.mp4")
    .build());
    sources.add(ComposeSource.builder()
    .bucket("slice")
    .object("1寂寞的季節.mp4")
    .build());
    sources.add(ComposeSource.builder()
    .bucket("slice")
    .object("2寂寞的季節.mp4")
    .build());
    final ComposeObjectArgs args = ComposeObjectArgs.builder()
    .bucket("demo")
    .object("寂寞的季節.mp4")
    .sources(sources)
    .build();
    try {
    minioClient.composeObject(args);
    } catch (MinioException | InvalidKeyException | IOException | NoSuchAlgorithmException e) {
    e.printStackTrace();
    }
    }

    上面的範例很簡單,因為只做演示說明。

    前端需要傳的參數是:

  • 分片桶:slice

  • 分片數據陣列:

  • 0寂寞的季節.mp4

  • 1寂寞的季節.mp4

  • 2寂寞的季節.mp4

  • 目標桶:demo

  • 然後呼叫composeObject函式完成合並。

    前端範例代分碼享

    上面就是關於實戰經驗分享的全部了,因為需要前端配置來使用,所以這裏給出我這篇文章的前端範例,很簡單的單頁面(技術棧就別吐槽了):

    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.14/vue.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.4/jquery.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
    </head>
    <body>
    <div id="app">
    <h1>{{title}}</h1>
    <br>
    <form @submit.prevent="getPolicyForm">
    <label>
    桶名
    <input type="text" v-model="policyParams.bucket">
    </label>
    <br>
    <label>
    檔名
    <input type="text" v-model="policyParams.filename">
    </label>
    <br>
    <button type="submit">獲取上傳憑證</button>
    <br>
    <div v-for="(val, key) in policy" :key="key">{{ key }}: <span>{{ val }}</span></div>
    </form>
    <br>
    <form @submit.prevent="uploadFileForm" v-show="policy != null">
    <label>

    <input type="file" @change="fileChange">
    </label>
    <br>
    <br>
    <button type="submit" v-show="file != null">上傳檔</button>
    </form>
    ---
    <br>

    <div v-show="file != null">
    <button @click="sliceEvent">測試檔分片上傳</button>
    |
    <button @click="sliceComposeEvent">分片檔合並</button>
    </div>

    <br>
    <br>
    <br>
    <p>{{uploadResult}}</p>
    <ul>
    <!-- <li v-for="item in sliceUploadResult">{{ item }}</li>-->
    <li v-for="(item, index) in sliceUploadResult" :key="index">{{ item }}</li>
    </ul>
    <br>
    </div>
    <script>
    var vm = new Vue({
    el: "#app",
    data() {
    return {
    title: "Minio測試"
    // 請求憑證參數
    , policyParams: {
    bucket: null
    , filename: null
    }
    // 請求到的憑證
    , policy: null
    // 待上傳檔
    , file: null
    // 上傳檔參數
    , uploadParams: {
    file: null
    }
    // 分片上傳參數
    , sliceParams: {
    bucket: ""
    , filename: ""
    , file: null
    }
    , slicePolicys: []
    , sliceCount: 0
    // 上傳結果回呼
    , uploadResult: null
    // 分片上傳結果回呼
    , sliceUploadResult: null
    };
    },
    methods: {
    getPolicyForm() {
    this.policyParams.bucket = "demo"
    this.policyParams.filename = "寂寞的季節.mp4"
    this.requestPolicy(this.policyParams)
    },
    requestPolicy(params) {
    return new Promise(((resolve, reject) => {
    $.ajax({
    type"POST",
    url: "http://localhost:8888/presign",
    contentType: "application/json",
    data: JSON.stringify(params),
    // async: false,
    success: function (result) {
    console.log(result)
    vm.policy = result;
    resolve(result)
    },
    error: function (e) {
    reject()
    }
    });
    }))
    },
    fileChange(event) {
    const file = event.target.files[0]
    this.file = file
    },
    uploadFileForm() {
    this.uploadFile(this.file, this.policy)
    },
    uploadFile(file, policy) {
    console.log("準備上傳檔:")
    console.log("file:" + file)
    console.log("policy:" + policy)
    var formData = new FormData()
    formData.append('file', file)
    formData.append('key', policy['key'])
    formData.append('x-amz-algorithm', policy['x-amz-algorithm'])
    formData.append('x-amz-credential', policy['x-amz-credential'])
    formData.append('x-amz-signature', policy['x-amz-signature'])
    formData.append('x-amz-date', policy['x-amz-date'])
    formData.append('policy', policy['policy'])
    return new Promise(((resolve, reject) => {
    $.ajax({
    method: 'POST',
    url: 'http://mylocalhost:9001/' + policy['bucket'],
    data: formData,
    dataType: 'json',
    contentType: false, // 必須設定為 false,不設定 contentType,讓瀏覽器自動設定
    processData: false, // 必須設定為 false,不對 FormData 進行序列化處理
    // async: false, // 設定同步,方便等下做分片上傳
    xhr: functionxhr() {
    //獲取原生的xhr物件
    var xhr = $.ajaxSettings.xhr();
    if (xhr.upload) {
    //添加 progress 事件監聽
    xhr.upload.addEventListener('progress'function (e) {
    //e.loaded 已上傳檔字節數
    //e.total 檔總字節數
    var percentage = parseInt(e.loaded / e.total * 100)
    vm.uploadResult = percentage + "%" + ":" + policy['key']
    }, false);
    }
    return xhr;
    },
    success: function (result) {
    vm.uploadResult = '檔上傳成功:' + policy['key']
    resolve(result)
    },
    error: function (e) {
    reject()
    }
    })
    }))
    },
    sliceEvent() {
    // 獲取檔
    var file = this.file
    // 設定分片大小:5MB
    var chunkSize = 5 * 1024 * 1024
    // 計算總共有多少個分片
    var totalChunk = Math.ceil(file.size / chunkSize)
    // 陣列存放所有分片
    var chunks = []
    // 遍歷所有分片
    for (var i = 0; i < totalChunk; i++) {
    // 利用slice獲取分片
    var start = i * chunkSize
    var end = Math.min(file.size, start + chunkSize)
    var blob = file.slice(start, end)
    // 添加分片到陣列
    chunks.push(blob)
    }
    console.log(totalChunk)
    this.sliceUploadResult = Array(totalChunk).fill(0)
    for (let i = 0; i < chunks.length; i++) {
    var file = chunks[i];
    this.calculateMD5(file)
    .then((md5) => {
    console.log(md5); // 輸出計算出的 MD5 值
    })
    .catch((error) => {
    console.error(error); // 處理錯誤
    });
    }

    return
    // 建立序號
    var index = 0;
    // 迴圈上傳分片
    while (index < totalChunk) {
    console.log('------------------------------')
    params = {
    "bucket""slice",
    "filename": index + "寂寞的季節.mp4"
    }
    var policyPromise = this.requestPolicy(params);
    (function (index) {
    var file = chunks[index]
    policyPromise.then(function (result) {
    var filename = result['key']
    console.log('準備上傳檔:', filename, ',序號為:', index)
    vm.uploadFile(file, result).then(function (result) {
    console.log('上傳完成:' + filename)
    vm.sliceUploadResult[index] = ('分片檔上傳成功:' + filename)
    })
    })
    })(index)
    index++
    }
    },
    sliceComposeEvent() {
    var parmas = {}
    $.ajax({
    method: 'POST',
    url: 'http://localhost:8888/compose',
    data: formData,
    dataType: 'json',
    contentType: false, // 必須設定為 false,不設定 contentType,讓瀏覽器自動設定
    processData: false, // 必須設定為 false,不對 FormData 進行序列化處理
    // async: false, // 設定同步,方便等下做分片上傳
    xhr: functionxhr() {
    //獲取原生的xhr物件
    var xhr = $.ajaxSettings.xhr();
    if (xhr.upload) {
    //添加 progress 事件監聽
    xhr.upload.addEventListener('progress'function (e) {
    //e.loaded 已上傳檔字節數
    //e.total 檔總字節數
    var percentage = parseInt(e.loaded / e.total * 100)
    vm.uploadResult = percentage + "%" + ":" + policy['key']
    }, false);
    }
    return xhr;
    },
    success: function (result) {
    vm.uploadResult = '檔上傳成功:' + policy['key']
    resolve(result)
    },
    error: function (e) {
    reject()
    }
    })
    },
    calculateMD5(file) {
    return new Promise((resolve, reject) => {
    const reader = new FileReader();
    // 讀取檔內容
    reader.readAsArrayBuffer(file);
    reader.onload = () => {
    const spark = new SparkMD5.ArrayBuffer();
    spark.append(reader.result); // 將檔內容添加到 MD5 小算盤中
    const md5 = spark.end(); // 計算 MD5 值
    resolve(md5);
    };
    reader.onerror = (error) => {
    reject(error);
    };
    });
    }
    },
    mounted() {
    },
    created() {
    },
    });
    </script>
    </body>
    </html>





























    👉 歡迎 ,你將獲得: 專屬的計畫實戰 / Java 學習路線 / 一對一提問 / 學習打卡 / 贈書福利

    全棧前後端分離部落格計畫 1.0 版本完結啦,2.0 正在更新中 ... , 演示連結 http://116.62.199.48/ ,全程手摸手,後端 + 前端全棧開發,從 0 到 1 講解每個功能點開發步驟,1v1 答疑,直到計畫上線。 目前已更新了219小節,累計35w+字,講解圖:1492張,還在持續爆肝中.. 後續還會上新更多計畫,目標是將Java領域典型的計畫都整一波,如秒殺系統, 線上商城, IM即時通訊,Spring Cloud Alibaba 等等,


    1. 

    2. 

    3. 

    4. 

    最近面試BAT,整理一份面試資料Java面試BATJ通關手冊,覆蓋了Java核心技術、JVM、Java並行、SSM、微服務、資料庫、數據結構等等。

    獲取方式:點「在看」,關註公眾號並回復 Java 領取,更多內容陸續奉上。

    PS:因公眾號平台更改了推播規則,如果不想錯過內容,記得讀完點一下在看,加個星標,這樣每次新文章推播才會第一時間出現在你的訂閱列表裏。

    「在看」支持小哈呀,謝謝啦