偷偷摘套内射激情视频,久久精品99国产国产精,中文字幕无线乱码人妻,中文在线中文a,性爽19p

程序員狂喜!最強(qiáng) OSS 分片上傳實(shí)戰(zhàn):斷點(diǎn)續(xù)傳、去重防毒一站搞定

開發(fā) 前端
那天產(chǎn)品經(jīng)理又來了句經(jīng)典臺詞:“用戶要傳 10G 的設(shè)計(jì)圖,你讓數(shù)據(jù)庫幫忙存一下?”?答案顯然是否定的——數(shù)據(jù)庫不是為大文件設(shè)計(jì)的。對象存儲(OSS/S3)才是文件的“云端豪宅”:?能抗(高可用)、能裝(海量)、自帶 CDN 加速,按需計(jì)費(fèi)更省錢。

那天產(chǎn)品經(jīng)理又來了句經(jīng)典臺詞:“用戶要傳 10G 的設(shè)計(jì)圖,你讓數(shù)據(jù)庫幫忙存一下?” 答案顯然是否定的——數(shù)據(jù)庫不是為大文件設(shè)計(jì)的。對象存儲(OSS/S3)才是文件的“云端豪宅”: 能抗(高可用)、能裝(海量)、自帶 CDN 加速,按需計(jì)費(fèi)更省錢。

本篇從入門到進(jìn)階、從概念到實(shí)戰(zhàn),帶你掌握對象存儲在后端文件上傳中的完整流程:

  • 快速起手:簡單上傳
  • 大文件方案:分片上傳 + 斷點(diǎn)續(xù)傳
  • 秒傳去重:MD5 指紋檢測
  • 文件安全:文件頭 + 內(nèi)容檢測 + 隔離策略
  • 前端實(shí)戰(zhàn):Thymeleaf + JS + Bootstrap(分片、MD5、進(jìn)度條)
  • 后端實(shí)戰(zhàn):Spring Boot 控制器 /check-file、/upload-chunk、/merge-chunks

對象存儲(OSS)不是“遠(yuǎn)程文件夾”

對象存儲的文件是以對象形式保存(數(shù)據(jù) + 元數(shù)據(jù)),通常分布式存儲在集群里,與傳統(tǒng)文件系統(tǒng)概念不同。

特點(diǎn)與適用場景

  • 多副本容災(zāi)、PB 級容量、按使用量付費(fèi)
  • 適合:圖片/視頻托管、日志歸檔、用戶文件上傳、靜態(tài)資源分發(fā)(結(jié)合 CDN)

項(xiàng)目依賴(pom.xml 摘要)

在 Spring Boot 項(xiàng)目中添加阿里云 OSS 依賴(示例):

<!-- pom.xml 中添加 -->
<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.15.1</version>
</dependency>

安全提示: AK/SK 不要硬編碼在代碼中。優(yōu)先使用 application.yml、環(huán)境變量或憑證服務(wù)(RAM、STS)。

快速體驗(yàn):OSS 簡單上傳(Java)

文件較小、無需斷點(diǎn)續(xù)傳時可直接用簡單上傳邏輯:

文件路徑(示例):oss/src/main/java/com/icoderoad/oss/OssSimpleUpload.java`

package com.icoderoad.oss;


import com.aliyun.oss.*;
import java.io.*;
import java.util.Date;


public class OssSimpleUpload {


    private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
    private static final String ACCESS_KEY = System.getenv("ALIYUN_ACCESS_KEY");
    private static final String SECRET_KEY = System.getenv("ALIYUN_SECRET_KEY");
    private static final String BUCKET_NAME = "your-bucket-name";


    public static void uploadFile(File file) {
        OSS ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY, SECRET_KEY);
        try (FileInputStream fis = new FileInputStream(file)) {
            String key = "user-uploads/" + file.getName();
            ossClient.putObject(BUCKET_NAME, key, fis);


            Date expiration = new Date(System.currentTimeMillis() + 3600 * 1000); // 1 hour
            String url = ossClient.generatePresignedUrl(BUCKET_NAME, key, expiration).toString();
            System.out.println("上傳成功,訪問鏈接:" + url);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            ossClient.shutdown();
        }
    }
}

大文件痛點(diǎn)與分片上傳思路

直接上傳大文件的問題:網(wǎng)絡(luò)中斷、重傳代價高、超時等。分片(multipart)上傳將大文件切成小塊,支持?jǐn)帱c(diǎn)續(xù)傳,只補(bǔ)傳丟失的分片。

分片上傳核心三步:

  1. 前端切片(File.slice()
  2. 單片上傳(攜帶 fileMd5、chunkIndex、totalChunks
  3. 后端合并(或 OSS 提供的 completeMultipartUpload

后端合并示例(合并臨時分片并可擴(kuò)展為上傳至 OSS)

文件路徑(示例):oss/src/main/java/com/icoderoad/oss/OssMultipartMerge.java

package com.icoderoad.oss;


import com.aliyun.oss.*;
import com.aliyun.oss.model.*;
import org.apache.commons.io.FileUtils;
import java.io.*;
import java.util.*;


public class OssMultipartMerge {
    private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
    private static final String ACCESS_KEY = System.getenv("ALIYUN_ACCESS_KEY");
    private static final String SECRET_KEY = System.getenv("ALIYUN_SECRET_KEY");
    private static final String BUCKET_NAME = "your-bucket-name";


    /**
     * 將本地臨時分片上傳到 OSS 并發(fā)起合并(示例:從 /tmp/oss-chunks/{fileMd5}/{index} 讀?。?     */
    public void mergeChunksToOss(String fileMd5, String fileName, int totalChunks) throws Exception {
        OSS ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY, SECRET_KEY);
        try {
            InitiateMultipartUploadResult init = ossClient.initiateMultipartUpload(
                    new InitiateMultipartUploadRequest(BUCKET_NAME, "user-uploads/" + fileName)
            );
            String uploadId = init.getUploadId();
            List<PartETag> partETags = new ArrayList<>();


            for (int i = 0; i < totalChunks; i++) {
                File chunkFile = new File("/tmp/oss-chunks/" + fileMd5 + "/" + i + ".part");
                if (!chunkFile.exists()) {
                    throw new RuntimeException("缺少分片: " + i);
                }
                UploadPartRequest uploadPartRequest = new UploadPartRequest()
                        .withBucketName(BUCKET_NAME)
                        .withKey("user-uploads/" + fileName)
                        .withUploadId(uploadId)
                        .withPartNumber(i + 1)
                        .withInputStream(new FileInputStream(chunkFile))
                        .withPartSize(chunkFile.length());


                UploadPartResult uploadPartResult = ossClient.uploadPart(uploadPartRequest);
                partETags.add(uploadPartResult.getPartETag());
            }


            ossClient.completeMultipartUpload(new CompleteMultipartUploadRequest(
                    BUCKET_NAME, "user-uploads/" + fileName, uploadId, partETags
            ));
        } finally {
            ossClient.shutdown();
            // 可選:刪除本地臨時分片
            FileUtils.deleteDirectory(new File("/tmp/oss-chunks/" + fileMd5));
        }
    }
}

也可以選擇在后端僅合并到本地磁盤(示例控制器中展示),再上傳整文件到 OSS。

避免重復(fù)上傳(秒傳):MD5 指紋機(jī)制

實(shí)現(xiàn)思路:

  1. 前端計(jì)算文件 MD5(分片計(jì)算以避免卡 UI)。
  2. 調(diào)用后端接口 /file/check-file(發(fā)送 fileMd5)。
  3. 后端查詢數(shù)據(jù)庫或文件存儲映射:若存在則返回文件 URL(秒傳),否則允許上傳。

注意:OSS 的 ETag 在分片上傳時不一定等同于完整文件 MD5(取決于實(shí)現(xiàn)),最好自行維護(hù) MD5 映射表。

防毒機(jī)制(文件安全三道防線)

  1. 文件頭校驗(yàn)(Magic Number):不信任擴(kuò)展名,讀取文件前若干字節(jié)判斷實(shí)際類型。
  2. 內(nèi)容掃描:接入 ClamAV 或云安全 API(阿里云內(nèi)容安全等)進(jìn)行深度檢測。
  3. 權(quán)限隔離(雙桶策略):上傳到臨時桶,檢測通過后再轉(zhuǎn)移到正式桶;正式桶限制執(zhí)行與強(qiáng)制下載頭 Content-Disposition: attachment。

示例:文件頭檢測(Java)

public boolean checkFileHeader(File file) {
    byte[] header = new byte[8];
    try (FileInputStream fis = new FileInputStream(file)) {
        fis.read(header);
        String headerHex = bytesToHex(header);
        return headerHex.startsWith("FFD8FF")  // JPG
                || headerHex.startsWith("89504E47")  // PNG
                || headerHex.startsWith("47494638"); // GIF
    } catch (Exception e) {
        return false;
    }
}

Spring Boot 后端接口(完整控制層實(shí)現(xiàn))

路徑(示例):oss/src/main/java/com/icoderoad/controller/FileUploadController.java包名:com.icoderoad.controller`

說明:示例實(shí)現(xiàn)將分片保存到本地臨時目錄(可配置),并支持 /check-file、/upload-chunk/merge-chunks。合并后可擴(kuò)展為將文件推到 OSS 或入庫記錄。

package com.icoderoad.controller;


import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;


import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Collectors;


/**
 * 文件上傳控制器:支持分片上傳、斷點(diǎn)續(xù)傳、MD5 秒傳檢查、合并分片
 */
@RestController
@RequestMapping("/file")
public class FileUploadController {


    @Value("${upload.temp-dir:uploads/temp}")
    private String tempDir;


    @Value("${upload.final-dir:uploads/merged}")
    private String finalDir;


    /**
     * 檢查文件是否已存在或已上傳的分片(前端通過 fileMd5 調(diào)用)
     * 返回示例:
     * { "skipUpload": true, "uploadedChunks": [] } 或
     * { "skipUpload": false, "uploadedChunks": [0,1,2] }
     */
    @PostMapping(value = "/check-file", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public Map<String, Object> checkFile(@RequestParam("fileMd5") String fileMd5) {
        Map<String, Object> result = new HashMap<>();
        if (!StringUtils.hasText(fileMd5)) {
            result.put("skipUpload", false);
            result.put("uploadedChunks", Collections.emptyList());
            return result;
        }


        // 1)檢查最終目錄是否已存在(實(shí)現(xiàn)秒傳)
        File mergedFile = new File(finalDir, fileMd5 + ".dat");
        if (mergedFile.exists()) {
            result.put("skipUpload", true);
            result.put("uploadedChunks", Collections.emptyList());
            return result;
        }


        // 2)檢查臨時目錄已上傳的分片
        File chunkFolder = new File(tempDir, fileMd5);
        if (chunkFolder.exists() && chunkFolder.isDirectory()) {
            File[] files = chunkFolder.listFiles((dir, name) -> name.endsWith(".part"));
            List<Integer> uploaded = new ArrayList<>();
            if (files != null) {
                for (File f : files) {
                    String name = f.getName(); // e.g., "0.part"
                    try {
                        String idxStr = name.split("\\.")[0];
                        uploaded.add(Integer.parseInt(idxStr));
                    } catch (Exception ignored) {}
                }
                Collections.sort(uploaded);
            }
            result.put("uploadedChunks", uploaded);
        } else {
            result.put("uploadedChunks", Collections.emptyList());
        }
        result.put("skipUpload", false);
        return result;
    }


    /**
     * 接收并保存單個分片
     * 參數(shù):
     * - file (MultipartFile) : 分片內(nèi)容
     * - fileMd5 (String) : 整個文件 MD5
     * - chunkIndex (int) : 當(dāng)前分片編號(從 0 開始)
     */
    @PostMapping("/upload-chunk")
    public Map<String, Object> uploadChunk(@RequestParam("file") MultipartFile chunk,
                                           @RequestParam("fileMd5") String fileMd5,
                                           @RequestParam("chunkIndex") int chunkIndex) throws IOException {


        if (chunk == null || chunk.isEmpty()) {
            throw new RuntimeException("上傳分片為空");
        }


        File chunkFolder = new File(tempDir, fileMd5);
        if (!chunkFolder.exists()) {
            chunkFolder.mkdirs();
        }


        File chunkFile = new File(chunkFolder, chunkIndex + ".part");
        try (InputStream in = chunk.getInputStream();
             OutputStream out = new FileOutputStream(chunkFile)) {
            byte[] buffer = new byte[8192];
            int len;
            while ((len = in.read(buffer)) != -1) {
                out.write(buffer, 0, len);
            }
        }


        Map<String, Object> result = new HashMap<>();
        result.put("uploaded", true);
        result.put("chunkIndex", chunkIndex);
        return result;
    }


    /**
     * 合并分片到最終文件(可選:再上傳到 OSS)
     * 參數(shù):
     * - fileMd5
     * - fileName
     * - totalChunks
     */
    @PostMapping("/merge-chunks")
    public Map<String, Object> mergeChunks(@RequestParam("fileMd5") String fileMd5,
                                           @RequestParam("fileName") String fileName,
                                           @RequestParam("totalChunks") int totalChunks) throws IOException {
        File chunkFolder = new File(tempDir, fileMd5);
        if (!chunkFolder.exists() || !chunkFolder.isDirectory()) {
            throw new RuntimeException("分片目錄不存在");
        }


        File mergedFolder = new File(finalDir);
        if (!mergedFolder.exists()) mergedFolder.mkdirs();


        File mergedFile = new File(mergedFolder, fileName);
        try (OutputStream os = new BufferedOutputStream(new FileOutputStream(mergedFile, true))) {
            for (int i = 0; i < totalChunks; i++) {
                File part = new File(chunkFolder, i + ".part");
                if (!part.exists()) {
                    throw new RuntimeException("缺少分片: " + i);
                }
                Files.copy(part.toPath(), os);
            }
        }


        // 合并完成后刪除分片(清理)
        for (File f : Objects.requireNonNull(chunkFolder.listFiles())) {
            f.delete();
        }
        chunkFolder.delete();


        // TODO: 可在此處調(diào)用 OSS Client 上傳 mergedFile 到對象存儲,并保存 MD5 -> URL 的映射到數(shù)據(jù)庫。
        Map<String, Object> result = new HashMap<>();
        result.put("merged", true);
        result.put("filePath", mergedFile.getAbsolutePath());
        return result;
    }
}

前端(Thymeleaf + Bootstrap + JS)完整示例

路徑/src/main/resources/templates/upload.html

該頁面功能:

  • 計(jì)算文件 MD5(分片計(jì)算,避免卡 UI)
  • 調(diào)用 /file/check-file 判斷是否秒傳或哪些分片已存在
  • 逐片上傳 /file/upload-chunk
  • 上傳進(jìn)度條顯示
  • 上傳完成后觸發(fā) /file/merge-chunks
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>OSS 分片上傳演示</title>
    <link  rel="stylesheet"/>
    <script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
</head>
<body class="bg-light">
<div class="container mt-5">
    <h2 class="mb-4 text-center">OSS 分片上傳 + 秒傳 + 進(jìn)度條演示</h2>
    <div class="card p-4 shadow-sm">
        <input type="file" id="fileInput" class="form-control mb-3"/>
        <button id="uploadBtn" class="btn btn-primary w-100">開始上傳</button>


        <div class="progress mt-3" style="height: 25px;">
            <div id="uploadProgress" class="progress-bar progress-bar-striped progress-bar-animated" 
                 style="width: 0%;">0%</div>
        </div>
        <div id="uploadStatus" class="mt-3 text-center text-secondary"></div>
    </div>
</div>


<script>
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB per chunk


document.getElementById("uploadBtn").addEventListener("click", async () => {
    const file = document.getElementById("fileInput").files[0];
    if (!file) return alert("請選擇文件");


    // 1. 計(jì)算 MD5(分片方式)
    document.getElementById("uploadStatus").innerText = "計(jì)算文件 MD5 中...";
    const fileMd5 = await calcFileMd5(file);
    const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
    document.getElementById("uploadStatus").innerText = `文件 MD5: ${fileMd5}`;


    // 2. 檢查文件是否存在或已上傳分片
    const checkResp = await fetch('/file/check-file', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({ fileMd5 })
    });
    const checkData = await checkResp.json();


    if (checkData.skipUpload) {
        document.getElementById("uploadStatus").innerText = "文件已存在,跳過上傳 ";
        return;
    }


    const uploaded = new Set(checkData.uploadedChunks || []);


    // 3. 分片上傳
    for (let i = 0; i < totalChunks; i++) {
        if (uploaded.has(i)) {
            updateProgress(i + 1, totalChunks);
            continue;
        }
        const start = i * CHUNK_SIZE;
        const end = Math.min(file.size, start + CHUNK_SIZE);
        const chunk = file.slice(start, end);


        const formData = new FormData();
        formData.append("file", chunk);
        formData.append("fileMd5", fileMd5);
        formData.append("chunkIndex", i);


        try {
            const res = await fetch('/file/upload-chunk', { method: 'POST', body: formData });
            if (!res.ok) throw new Error("上傳分片失敗");
        } catch (e) {
            alert("上傳失敗: " + e.message);
            return;
        }


        updateProgress(i + 1, totalChunks);
    }


    // 4. 合并分片
    const mergeResp = await fetch('/file/merge-chunks', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
            fileMd5,
            fileName: file.name,
            totalChunks
        })
    });
    const mergeData = await mergeResp.json();
    if (mergeData.merged) {
        document.getElementById("uploadStatus").innerText = "上傳并合并完成 ,路徑:" + mergeData.filePath;
    } else {
        document.getElementById("uploadStatus").innerText = "合并失敗";
    }
});


function updateProgress(done, total) {
    const pct = Math.floor((done / total) * 100);
    const bar = document.getElementById("uploadProgress");
    bar.style.width = pct + "%";
    bar.innerText = pct + "%";
}


async function calcFileMd5(file) {
    return new Promise((resolve, reject) => {
        const chunkSize = 10 * 1024 * 1024;
        const chunks = Math.ceil(file.size / chunkSize);
        const spark = new SparkMD5.ArrayBuffer();
        let currentChunk = 0;
        const reader = new FileReader();


        reader.onload = (e) => {
            spark.append(e.target.result);
            currentChunk++;
            if (currentChunk < chunks) loadNext();
            else resolve(spark.end());
        };


        reader.onerror = () => reject("MD5計(jì)算失敗");


        function loadNext() {
            const start = currentChunk * chunkSize;
            const end = Math.min(file.size, start + chunkSize);
            reader.readAsArrayBuffer(file.slice(start, end));
        }
        loadNext();
    });
}
</script>
</body>
</html>

配置(application.yml 示例)

server:
  port: 8080


upload:
  temp-dir: /var/data/uploads/temp   # 臨時分片存儲目錄(Linux 風(fēng)格)
  final-dir: /var/data/uploads/merged # 合并后文件存儲目錄

請確保運(yùn)行的服務(wù)賬號對上述目錄有讀寫權(quán)限。

實(shí)戰(zhàn)建議與優(yōu)化點(diǎn)

  • 密鑰管理:用 RAM/STS 或環(huán)境變量,不要寫死 AK/SK。生產(chǎn)建議使用臨時憑證或密鑰輪換策略。
  • 分片大小:根據(jù)網(wǎng)絡(luò)與客戶端能力調(diào)整(常用 5MB / 10MB / 20MB)。分片過小增加請求開銷,過大增加單次傳輸失敗代價。
  • 并發(fā)上傳:前端可并發(fā)上傳 N 個分片,后端限制并發(fā)流量與速率,避免突發(fā)流量打垮服務(wù)器。
  • 斷點(diǎn)續(xù)傳/check-file 返回已上傳分片索引,前端跳過已上傳分片實(shí)現(xiàn)續(xù)傳。
  • 重試策略:前端對失敗的分片做指數(shù)退避重試(例如 3 次),避免短暫網(wǎng)絡(luò)抖動導(dǎo)致整體失敗。
  • 分布式合并:在高并發(fā)場景可采用 OSS 的 multipart 合并接口,或?qū)⒑喜⑷蝿?wù)異步化(消息隊(duì)列 + 后臺 worker)。
  • 安全檢測:合并完成或上傳到臨時桶后,觸發(fā)內(nèi)容掃描(ClamAV / 云 API),通過后再移到正式桶并記錄元數(shù)據(jù)。
  • 數(shù)據(jù)庫映射:在合并成功后,把 fileMd5 -> 文件 URL 寫入數(shù)據(jù)庫,支持秒傳和查看歷史記錄。

后端 ER 的“文件上傳生存指南”

  • 小文件:直接簡單上傳,輕量快速。
  • 大文件:分片上傳(切片→傳片→合并),支持?jǐn)帱c(diǎn)續(xù)傳與并發(fā)上傳。
  • 去重(秒傳):前端 MD5 + 后端校驗(yàn)(存在則跳過上傳)。
  • 防毒:文件頭檢測 + 內(nèi)容掃描 + 桶隔離(臨時桶→正式桶)。

掌握以上套路后,再被產(chǎn)品經(jīng)理逼著“讓用戶上傳 20G 設(shè)計(jì)圖”時,你只需淡定回應(yīng):“放心,OSS 已經(jīng)幫我們扛住了?!?/span>

責(zé)任編輯:武曉燕 來源: 路條編程
相關(guān)推薦

2024-11-12 09:54:23

2021-01-15 11:40:44

文件Java秒傳

2022-06-15 09:01:45

大文件秒傳分片上傳

2011-03-04 16:41:57

FileZilla

2017-08-08 08:45:44

前端文件斷點(diǎn)續(xù)傳

2015-02-05 17:22:12

年終獎程序員

2013-07-22 14:02:17

iOS開發(fā)ASIHTTPRequ

2023-06-20 19:57:13

2009-08-28 15:38:49

C#實(shí)現(xiàn)斷點(diǎn)續(xù)傳

2016-11-24 15:32:35

云計(jì)算

2020-04-02 20:07:17

前端vuenote.js

2025-07-02 00:00:00

2021-01-18 05:19:11

數(shù)字指紋

2011-03-01 14:12:12

FreebsdProftpd

2013-03-28 15:50:37

程序員Java

2018-08-02 17:00:15

Vue.js學(xué)習(xí)iOS開發(fā)

2023-03-09 12:04:38

Spring文件校驗(yàn)

2015-05-29 09:56:11

慧聰電子網(wǎng)

2010-08-11 13:56:03

Flex程序員

2021-03-03 08:13:23

程序員分布式網(wǎng)絡(luò)
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號