minio前后端分离上传视频/上传大文件——前后端分离断点续传minio分片上传实现

news2024/11/15 0:16:38

🍀🍀🍀🍀分布式文件系统-minio:

  • 第一章:分布式文件系统介绍与minio介绍与使用(附minio java client 使用)
  • 第二章:minio&前后端分离上传视频/上传大文件——前后端分离断点续传&minio分片上传实现

文章目录

  • 1.断点续传
  • 2.分块与合并测试
    • 2.1 分块测试
      • 2.1.1 流程分析
      • 2.1.2 代码实现
    • 2.2 合并测试
      • 2.2.1 流程分析
      • 2.2.2 代码实现
  • 3.前后端分离上传视频流程分析
  • 4.实战开发 - 思路分析
  • 5.实战开发 - 准备工作
    • 5.1 数据库设计
    • 5.2 核心 pom.xml
    • 5.3 application.yaml核心配置
    • 5.4 MinioConfig.java配置类
    • 5.5 MinioClientUtils工具类
    • 5.6 FileTypeUtils工具类
    • 5.7 FileFormatUtils工具类
    • 5.8 CipherUtils加密解密工具类
    • 5.9 MediaFile.java
  • 6.实战开发 - 业务代码
    • 6.1 检查文件是否已存在
      • 6.1.1 MediaFileController
      • 6.1.2 MediaFileService
      • 6.1.3 MediaFileServiceImpl
    • 6.2 检查分块文件是否已存在
      • 6.2.1 MediaFileController
      • 6.2.2 MediaFileService
      • 6.2.3 MediaFileServiceImpl
    • 6.3 上传分块文件
      • 6.3.1 MediaFileController
      • 6.3.2 MediaFileService
      • 6.3.3 MediaFileServiceImpl
    • 6.4 合并前下载分块文件(多线程下载)
    • 6.5 合并分块文件并上传
      • 6.5.1 MediaFileController
      • 6.5.2 MediaFileService
      • 6.5.3 MediaFileServiceImpl
  • 7.补充-面试题
    • 7.1 MinIO是什么?
    • 7.2 为什么用MinIO
    • 7.3 怎么样构建这个独立文件服务?
    • 7.4 断点续传是怎么做的?
    • 7.5 分块文件清理问题

1.断点续传

  • 断点续传指的是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性。
  • 通常视频文件都比较大,所以对于媒资系统上传文件的需求要满足大文件的上传要求。http协议本身对上传文件大小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了没有上传完成,需要客户重新上传,用户体验非常差,所以对于大文件上传的要求最基本的是断点续传。

断点续传流程如下图:

image-20230207171003653

流程如下:

  1. 前端上传前先把文件分成块。
  2. 一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传。
  3. 各分块上传完成最后在服务端合并文件。

2.分块与合并测试

为了更好的理解文件分块上传的原理,下边用java代码测试文件的分块与合并。

2.1 分块测试

2.1.1 流程分析

文件分块的流程如下:

  1. 获取源文件长度
  2. 根据设定的分块文件的大小计算出块数
  3. 从源文件读数据依次向每一个块文件写数据。

2.1.2 代码实现

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;

/**
 * 分块文件测试
 *
 * @author 狐狸半面添
 * @create 2023-02-07 17:14
 */
public class BigFileChunkDemo {
    public static void main(String[] args) throws IOException {
        // 1.指定要进行分块的源文件
        File sourceFile = new File("D:\\SystemDefault\\video\\不为谁而作的歌.mp4");

        // 2.指定分块文件存储路径
        File chunkFolderPath = new File("D:\\SystemDefault\\video\\chunk\\");
        // chunk文件夹不存在则创建
        if (!chunkFolderPath.exists()) {
            chunkFolderPath.mkdirs();
        }

        // 3.分块的大小 - 10MB
        int chunkSize = 1024 * 1024 * 10;

        // 4.根据分块大小得到源文件的分块数量(向上转型)
        long chunkNum = (long) Math.ceil(sourceFile.length() * 1.0 / chunkSize);

        /*
            分块思路:使用流对象读取源文件,向分块文件写数据,达到分块大小不再写
         */

        // 5.使用流对象 rafRead 读取源文件
        RandomAccessFile rafRead = new RandomAccessFile(sourceFile, "r");
        // 6.设置每次读取的缓冲区大小
        byte[] b = new byte[1024];

        RandomAccessFile rafWrite;

        // 7.开始分块
        for (long i = 0; i < chunkNum; i++) {
            // 7.1 指定分块文件
            File file = new File("D:\\SystemDefault\\video\\chunk\\" + i);
            // 7.2 如果分块文件存在,则删除
            if (file.exists()) {
                file.delete();
            }
            // 7.3 创建一个空的分块文件
            boolean newFile = file.createNewFile();
            if (newFile) {
                // 7.4 向分块文件写数据流对象
                rafWrite = new RandomAccessFile(file, "rw");
                int len = -1;
                // 7.5 读取源文件,每次读取的大小为设置的缓冲区的大小
                while ((len = rafRead.read(b)) != -1) {
                    // 7.6 将缓冲区的数据写入到分块文件中
                    rafWrite.write(b, 0, len);
                    // 7.7 达到分块大小不再写了,继续下一次循环,将后面的数据写入新的分块文件中
                    if (file.length() >= chunkSize) {
                        break;
                    }
                }
                // 7.8 关闭该分块文件流,释放资源
                rafWrite.close();

            }
        }
        // 8.分块完成,关闭源文件流,释放资源
        rafRead.close();
        System.out.println("分块文件完成");
    }
}

2.2 合并测试

2.2.1 流程分析

  1. 找到要合并的文件并按文件合并的先后进行排序。
  2. 创建合并文件。
  3. 依次从合并的文件中读取数据向合并文件写入数。

2.2.2 代码实现

<dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
    <version>1.13</version>
</dependency>
import org.apache.commons.codec.digest.DigestUtils;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.Arrays;
import java.util.List;

/**
 * 合并文件测试
 *
 * @author 狐狸半面添
 * @create 2023-02-07 17:37
 */
public class BigFileMergeDemo {
    public static void main(String[] args) throws IOException {
        // 1.指定源文件 - 目的是后面比较合并后的文件与源文件是否相同
        File sourceFile = new File("D:\\SystemDefault\\video\\不为谁而作的歌.mp4");

        // 2.指定分块文件存储路径
        File chunkFolderPath = new File("D:\\SystemDefault\\video\\chunk\\");

        // 3.指定并创建合并后的文件
        File mergeFile = new File("D:\\SystemDefault\\video\\不为谁而作的歌(合并).mp4");
        boolean success = mergeFile.createNewFile();
        if (!success) {
            System.out.println("error: 文件创建失败");
            return;
        }

        /*
            思路:使用流对象读取分块文件,按顺序将分块文件依次向合并文件写数据
         */

        // 4.获取分块文件列表,按文件名升序排序
        // 4.1 获取分块文件列表
        File[] chunkFiles = chunkFolderPath.listFiles();
        if (chunkFiles == null) {
            System.out.println("error: 分块文件列表为空");
            return;
        }
        // 4.2 由数组变为list集合
        List<File> chunkFileList = Arrays.asList(chunkFiles);
        // 4.3 按文件名升序排序
        chunkFileList.sort((o1, o2) -> Integer.parseInt(o1.getName()) - Integer.parseInt(o2.getName()));

        // 5.创建合并文件的流对象
        RandomAccessFile rafWrite = new RandomAccessFile(mergeFile, "rw");
        // 6.设置每次读取的缓冲区大小
        byte[] b = new byte[1024];
        // 7.逐一读取分块文件,将数据写入到合并文件中
        for (File file : chunkFileList) {
            // 7.1 获取读取分块文件的流对象
            RandomAccessFile rafRead = new RandomAccessFile(file, "r");
            int len = -1;
            // 7.2 读取当前分块文件,每次读取的大小为设置的缓冲区的大小,直到将当前分块文件读取完毕
            while ((len = rafRead.read(b)) != -1) {
                // 7.3 将缓冲区数据写入到合并文件中
                rafWrite.write(b, 0, len);
            }
            // 7.4 关闭分块文件流对象,释放资源
            rafRead.close();
        }

        // 8.关闭流对象,释放资源
        rafWrite.close();


        // 9.校验合并后的文件是否正确
        FileInputStream sourceFileStream = new FileInputStream(sourceFile);
        FileInputStream mergeFileStream = new FileInputStream(mergeFile);
        String sourceMd5Hex = DigestUtils.md5Hex(sourceFileStream);
        String mergeMd5Hex = DigestUtils.md5Hex(mergeFileStream);
        if (sourceMd5Hex.equals(mergeMd5Hex)) {
            System.out.println("合并成功");
        }
    }
}

3.前后端分离上传视频流程分析

image-20230207180013331

  1. 前端上传文件前请求媒资接口层检查文件是否存在,如果已经存在则不再上传。
  2. 如果文件在系统不存在则前端开始上传,首先对视频文件进行分块。
  3. 前端分块进行上传,上传前首先检查分块是否上传,如已上传则不再上传,如果未上传则开始上传分块。
  4. 前端请求媒资管理接口层请求上传分块。
  5. 接口层请求服务层上传分块。
  6. 服务端将分块信息上传到MinIO。
  7. 前端将分块上传完毕请求接口层合并分块。
  8. 接口层请求服务层合并分块。
  9. 服务层根据文件信息找到MinIO中的分块文件,下载到本地临时目录,将所有分块下载完毕后开始合并 。
  10. 合并完成将合并后的文件上传到MinIO。

4.实战开发 - 思路分析

  1. 前端准备上传一个视频文件,需要先发送请求——检查文件是否已存在,携带参数为文件的Md5十六进制值
  2. 后端根据 md5十六进制值 去数据库查询该文件是否存在。
    • 如果存在,则将文件信息加密返回,前端拿到加密信息再发送其它请求保存到数据库如课程资源表中。流程结束。
    • 如果不存在,就提醒前端不存在该文件,前端接下来就需要进行分块上传处理。
  3. 前端发现不存在该文件,就将文件进行分块,每上传一个分块文件前,都需要发送请求——检查当前分块文件是否存在,携带参数为文件的Md5十六进制值以及当前分块索引(第几块,从0开始)
  4. 后端就根据参数去 minio 中查找是否存在该分块文件。
    • 如果存在了,就告诉前端不需要上传该分块文件了。
    • 如果不存在,就告诉前端需要发送请求上传分块文件。
  5. 前端发现不存在该分块文件,就发送请求——上传分块文件,携带参数为文件的分块文件,Md5十六进制值以及当前分块索引(第几块,从0开始)
  6. 后端根据参数将 分块文件 保存在 minio 中。
  7. 所有分块文件上传完毕,前端发送请求——合并分块文件与上传合并后的文件,携带参数为文件的Md5十六进制值,文件名,文件标签,文件块总数
  8. 后端就根据参数进行合并与上传处理:
    1. 从 minio 下载所有原文件的分块文件。
    2. 将分块文件进行合并处理。
    3. 计算合并后文件的md5值,如果和前端参数中的md5值一致,则说明正确合并。否则上传失败,流程结束。
    4. 再将合并后的文件断点续传到 minio。
    5. 将文件信息保存至数据库中。
    6. 最后将文件信息加密返回,前端拿到加密信息再发送其它请求保存到数据库如课程资源表中。流程结束。

5.实战开发 - 准备工作

5.1 数据库设计

CREATE TABLE service_media_file(
    `id` BIGINT UNSIGNED PRIMARY KEY COMMENT '主键id(雪花算法)',
    `file_name` VARCHAR(255) NOT NULL COMMENT '文件名称',
    `file_type` CHAR(2) NOT NULL COMMENT '文件类型:文本,图片,音频,视频,其它',
    `file_format` VARCHAR(128) NOT NULL COMMENT '文件格式', 
    `tag` VARCHAR(32) NOT NULL COMMENT '标签',
    `bucket` VARCHAR(32) NOT NULL COMMENT '存储桶',
    `file_path` VARCHAR(512) NOT NULL COMMENT '文件存储路径',
    `file_md5` CHAR(32) NOT NULL UNIQUE COMMENT '文件的md5值',
    `file_byte_size` BIGINT UNSIGNED NOT NULL COMMENT '文件的字节大小',
    `file_format_size` VARCHAR(24) NOT NULL COMMENT '文件的格式大小', 
    `user_id` BIGINT NOT NULL COMMENT '上传人id',
    `create_time` DATETIME NOT NULL COMMENT '创建时间(上传时间)',
    `update_time` DATETIME NOT NULL COMMENT '修改时间'
)ENGINE = INNODB  CHARACTER SET = utf8mb4 COMMENT '第三方服务-媒资文件表';

5.2 核心 pom.xml

<!--根据扩展名取mimetype-->
<dependency>
    <groupId>com.j256.simplemagic</groupId>
    <artifactId>simplemagic</artifactId>
    <version>1.17</version>
</dependency>
<!--对象存储服务-->
<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.2.1</version>
</dependency>
<dependency>
    <groupId>me.tongfei</groupId>
    <artifactId>progressbar</artifactId>
    <version>0.5.3</version>
</dependency>
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.8.1</version>
</dependency>
<!--生成文件对象的md5十六进制值-->
<dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
    <version>1.13</version>
</dependency>
<!--用于文件类型判断-->
<dependency>
    <groupId>org.apache.tika</groupId>
    <artifactId>tika-core</artifactId>
    <version>2.4.0</version>
</dependency>

5.3 application.yaml核心配置

spring:
  servlet:
    multipart:
      max-file-size: 3MB
      max-request-size: 5MB
minio:
  # 指定连接的ip和端口
  endpoint: http://192.168.65.129:9000
  # 指定 访问秘钥(也称用户id)
  accessKey: minioadmin
  # 指定 私有秘钥(也称密码)
  secretKey: minioadmin

5.4 MinioConfig.java配置类

package com.zhulang.waveedu.service.config;

import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author 狐狸半面添
 * @create 2023-02-08 16:26
 */
@Configuration
public class MinioConfig {

    /**
     * 连接的ip和端口
     */
    @Value("${minio.endpoint}")
    private String endpoint;
    /**
     * 访问秘钥(也称用户id)
     */
    @Value("${minio.accessKey}")
    private String accessKey;
    /**
     * 私有秘钥(也称密码)
     */
    @Value("${minio.secretKey}")
    private String secretKey;

    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
}

5.5 MinioClientUtils工具类

package com.zhulang.waveedu.common.util;

import com.j256.simplemagic.ContentInfo;
import com.j256.simplemagic.ContentInfoUtil;
import io.minio.GetObjectArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.UploadObjectArgs;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.http.MediaType;

import java.io.*;

/**
 * 操作minio的工具类
 *
 * @author 狐狸半面添
 * @create 2023-02-08 22:08
 */
public class MinioClientUtils {
    private final MinioClient minioClient;

    public MinioClientUtils(MinioClient minioClient) {
        this.minioClient = minioClient;
    }

    /**
     * 获取minio文件的输入流对象
     *
     * @param bucket   桶
     * @param filePath 文件路径
     * @return 输入流
     * @throws Exception 异常
     */
    public InputStream getObject(String bucket, String filePath) throws Exception {
        return minioClient.getObject(GetObjectArgs.builder().bucket(bucket).object(filePath).build());
    }

    /**
     * 将分块文件上传到分布式文件系统
     *
     * @param bytes    文件的字节数组
     * @param bucket   桶
     * @param filePath 存储在桶中的文件路径
     */
    public void uploadChunkFile(byte[] bytes, String bucket, String filePath) throws Exception {
        // 1.指定资源的媒体类型为未知二进制流,以分片形式上传至minio
        try (
                ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes)
        ) {
            minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucket)
                            .object(filePath)
                            // InputStream stream, long objectSize 对象大小, long partSize 分片大小(-1表示5M,最大不要超过5T,最多10000)
                            .stream(byteArrayInputStream, byteArrayInputStream.available(), -1)
                            .contentType(MediaType.APPLICATION_OCTET_STREAM_VALUE)
                            .build()
            );
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 将文件上传到分布式文件系统
     *
     * @param bytes    文件的字节数组
     * @param bucket   桶
     * @param filePath 存储在桶中的文件路径
     */
    public void uploadFile(byte[] bytes, String bucket, String filePath) throws Exception {
        // 1.指定资源的媒体类型,默认未知二进制流
        String contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;

        // 2.判断是否有后缀,有后缀则根据后缀推算出文件类型,否则使用默认的未知二进制流
        if (filePath.contains(".")) {
            // 取objectName中的扩展名
            String extension = filePath.substring(filePath.lastIndexOf("."));
            ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
            if (extensionMatch != null) {
                contentType = extensionMatch.getMimeType();
            }
        }

        // 3.以分片形式上传至minio
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);

        PutObjectArgs putObjectArgs = PutObjectArgs.builder()
                .bucket(bucket)
                .object(filePath)
                // InputStream stream, long objectSize 对象大小, long partSize 分片大小(-1表示5M,最大不要超过5T,最多10000)
                .stream(byteArrayInputStream, byteArrayInputStream.available(), -1)
                .contentType(contentType)
                .build();
        // 上传
        minioClient.putObject(putObjectArgs);
    }


    /**
     * 根据文件路径将文件上传到文件系统
     *
     * @param naiveFilePath 本地文件路径
     * @param bucket        桶
     * @param minioFilePath 保存到minio的文件路径位置
     * @throws Exception 异常
     */
    public void uploadChunkFile(String naiveFilePath, String bucket, String minioFilePath) throws Exception {
        UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
                .bucket(bucket)
                .object(minioFilePath)
                .filename(naiveFilePath)
                .build();
        minioClient.uploadObject(uploadObjectArgs);
    }

    /**
     * 下载文件保存至本地临时文件中
     *
     * @param tempFilePrefix 临时文件的前缀
     * @param tempFileSuffix 临时文件的后缀
     * @param bucket         桶
     * @param filePath       文件路径
     * @return 携带数据的临时文件
     * @throws Exception 异常信息
     */
    public File downloadFile(String tempFilePrefix, String tempFileSuffix, String bucket, String filePath) throws Exception {
        // 1.创建空文件,临时保存下载下来的分块文件数据
        File tempFile = File.createTempFile(tempFilePrefix, tempFileSuffix);
        try (
                // 2.获取目标文件的输入流对象
                InputStream inputStream = getObject(bucket, filePath);
                // 3.获取临时空文件的输出流对象
                FileOutputStream outputStream = new FileOutputStream(tempFile);
        ) {
            // 4.进行数据拷贝
            IOUtils.copy(inputStream, outputStream);
            // 5.返回保存了数据的临时文件
            return tempFile;
        } catch (Exception e) {
            throw new RuntimeException(e.getMessage());
        }
    }

//    public File downloadFile(String tempFilePrefix, String tempFileSuffix, String bucket, String filePath) throws Exception {
//        // 1.创建空文件,临时保存下载下来的分块文件数据
//        File tempFile = File.createTempFile(tempFilePrefix, tempFileSuffix);
//        try {
//            Long start = System.currentTimeMillis();
//            minioClient.downloadObject(
//                    DownloadObjectArgs.builder()
//                            // 指定 bucket 存储桶
//                            .bucket(bucket)
//                            // 指定 哪个文件
//                            .object(filePath)
//                            // 指定存放位置与名称
//                            .filename(tempFile.getPath())
//                            .build());
//            Long end = System.currentTimeMillis();
//            System.out.println("下载分块时间:"+(end-start)+"ms");
//            // 5.返回保存了数据的临时文件
//            return tempFile;
//        } catch (Exception e) {
//            throw new RuntimeException(e.getMessage());
//        }
//    }
}

5.6 FileTypeUtils工具类

package com.zhulang.waveedu.common.util;

import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.AutoDetectParser;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
import org.xml.sax.helpers.DefaultHandler;

import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;

/**
 * 文件类型工具类
 *
 * @author 狐狸半面添
 * @create 2023-02-09 0:13
 */
public class FileTypeUtils {
    private static final Map<String, String> contentType = new HashMap<>();

    /**
     * 获取文件的 mime 类型
     *
     * @param file 文件
     * @return mime类型
     */
    public static String getMimeType(File file) {
        AutoDetectParser parser = new AutoDetectParser();
        parser.setParsers(new HashMap<MediaType, Parser>());
        Metadata metadata = new Metadata();
        metadata.add(TikaCoreProperties.RESOURCE_NAME_KEY, file.getName());
        try (InputStream stream = Files.newInputStream(file.toPath())) {
            parser.parse(stream, new DefaultHandler(), metadata, new ParseContext());
        } catch (Exception e) {
            throw new RuntimeException();
        }
        return metadata.get(HttpHeaders.CONTENT_TYPE);
    }

    /**
     * 根据 mimetype 获取文件的简单类型
     *
     * @param mimeType mime类型
     * @return 简单类型:文本,图片,音频,视频,其它
     */
    public static String getSimpleType(String mimeType) {
        String simpleType = mimeType.split("/")[0];
        switch (simpleType) {
            case "text":
                return "文本";
            case "image":
                return "图片";
            case "audio":
                return "音频";
            case "video":
                return "视频";
            case "application":
                return "其它";
            default:
                throw new RuntimeException("mimeType格式错误");
        }
    }

    // 测试
    public static void main(String[] args) {
        File file = new File("D:\\location语法规则.docx");
        String mimeType = getMimeType(file);
        System.out.println(mimeType);
        System.out.println(getSimpleType(mimeType));
    }

5.7 FileFormatUtils工具类

package com.zhulang.waveedu.common.util;

import java.text.DecimalFormat;

/**
 * 文件格式工具
 *
 * @author 狐狸半面添
 * @create 2023-02-09 19:43
 */
public class FileFormatUtils {
    /**
     * 将字节单位的文件大小转为格式化的文件大小表示
     *
     * @param fileLength 文件字节大小
     * @return 格式化文件大小表示
     */
    public static String formatFileSize(long fileLength) {
        DecimalFormat df = new DecimalFormat("#.00");
        String fileSizeString = "";
        String wrongSize = "0B";
        if (fileLength == 0) {
            return wrongSize;
        }
        if (fileLength < 1024) {
            fileSizeString = df.format((double) fileLength) + " B";
        } else if (fileLength < 1048576) {
            fileSizeString = df.format((double) fileLength / 1024) + " KB";
        } else if (fileLength < 1073741824) {
            fileSizeString = df.format((double) fileLength / 1048576) + " MB";
        } else {
            fileSizeString = df.format((double) fileLength / 1073741824) + " GB";
        }
        return fileSizeString;
    }
}

5.8 CipherUtils加密解密工具类

package com.zhulang.waveedu.common.util;

import com.alibaba.fastjson.JSON;
import org.apache.ibatis.logging.stdout.StdOutImpl;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

/**
 * AES加密解密工具
 *
 * @author 狐狸半面添
 * @create 2023-01-18 20:34
 */
public class CipherUtils {

    private static final String SECRET_KEY = "tangyulang5201314";
    private static final String AES = "AES";
    private static final String CHARSET_NAME = "UTF-8";

    /**
     * 生成密钥 key
     *
     * @param password 加密密码
     * @return
     * @throws Exception
     */
    private static SecretKeySpec generateKey(String password) throws Exception {
        // 1.构造密钥生成器,指定为AES算法,不区分大小写
        KeyGenerator keyGenerator = KeyGenerator.getInstance(AES);
        // 2. 因为AES要求密钥的长度为128,我们需要固定的密码,因此随机源的种子需要设置为我们的密码数组
        // 生成一个128位的随机源, 根据传入的字节数组
        /*
         * 这种方式 windows 下正常, Linux 环境下会解密失败
         * keyGenerator.init(128, new SecureRandom(password.getBytes()));
         */
        // 兼容 Linux
        SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
        random.setSeed(password.getBytes());
        keyGenerator.init(128, random);
        // 3.产生原始对称密钥
        SecretKey original_key = keyGenerator.generateKey();
        // 4. 根据字节数组生成AES密钥
        return new SecretKeySpec(original_key.getEncoded(), AES);
    }

    /**
     * 加密
     *
     * @param content  加密的内容
     * @param password 加密密码
     * @return
     */
    private static String aESEncode(String content, String password) {
        try {
            // 根据指定算法AES自成密码器
            Cipher cipher = Cipher.getInstance(AES);
            // 基于加密模式和密钥初始化Cipher
            cipher.init(Cipher.ENCRYPT_MODE, generateKey(password));
            // 单部分加密结束, 重置Cipher, 获取加密内容的字节数组(这里要设置为UTF-8)防止解密为乱码
            byte[] bytes = cipher.doFinal(content.getBytes(CHARSET_NAME));
            // 将加密后的字节数组转为字符串返回
            return Base64.getUrlEncoder().encodeToString(bytes);
        } catch (Exception e) {
            // 如果有错就返回 null
            return null;
        }
    }

    /**
     * 解密
     *
     * @param content  解密内容
     * @param password 解密密码
     * @return
     */
    private static String AESDecode(String content, String password) {
        try {
            // 将加密并编码后的内容解码成字节数组
            byte[] bytes = Base64.getUrlDecoder().decode(content);
            // 这里指定了算法为AES
            Cipher cipher = Cipher.getInstance(AES);
            // 基于解密模式和密钥初始化Cipher
            cipher.init(Cipher.DECRYPT_MODE, generateKey(password));
            // 单部分加密结束,重置Cipher
            byte[] result = cipher.doFinal(bytes);
            // 将解密后的字节数组转成 UTF-8 编码的字符串返回
            return new String(result, CHARSET_NAME);
        } catch (Exception e) {
            // 如果有错就返回 null
            return null;
        }


    }

    /**
     * 加密
     *
     * @param content 加密内容
     * @return 加密结果
     */
    public static String encrypt(String content) {
        return aESEncode(content, SECRET_KEY);
    }

    /**
     * 解密
     *
     * @param content 解密内容
     * @return 解密结果
     */
    public static String decrypt(String content) {
        try {
            return AESDecode(content, SECRET_KEY);
        } catch (Exception e) {
            return null;
        }
    }
}

5.9 MediaFile.java

package com.zhulang.waveedu.service.po;

import com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * <p>
 * 第三方服务-媒资文件表
 * </p>
 *
 * @author 狐狸半面添
 * @since 2023-02-08
 */
@TableName("service_media_file")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MediaFile implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键id(雪花算法)
     */
    @TableId(value = "id", type = IdType.ASSIGN_ID)
    private Long id;

    /**
     * 文件名称
     */
    private String fileName;

    /**
     * 文件类型:文本,图片,音频,视频,其它
     */
    private String fileType;

    /**
     * 文件格式
     */
    private String fileFormat;

    /**
     * 标签
     */
    private String tag;

    /**
     * 存储桶
     */
    private String bucket;

    /**
     * 文件存储路径
     */
    private String filePath;

    /**
     * 文件的md5值
     */
    private String fileMd5;

    /**
     * 文件字节大小
     */
    private Long fileByteSize;
    /**
     * 文件格式化大小
     */
    private String fileFormatSize;

    /**
     * 上传人id
     */
    private Long userId;

    /**
     * 创建时间(上传时间)
     */
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    /**
     * 修改时间
     */
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;

}

6.实战开发 - 业务代码

6.1 检查文件是否已存在

6.1.1 MediaFileController

package com.zhulang.waveedu.service.controller;


import com.alibaba.fastjson.JSONObject;
import com.zhulang.waveedu.common.entity.Result;
import com.zhulang.waveedu.service.service.MediaFileService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.stereotype.Controller;

import javax.annotation.Resource;

/**
 * <p>
 * 第三方服务-媒资文件表 前端控制器
 * </p>
 *
 * @author 狐狸半面添
 * @since 2023-02-08
 */
@Controller
@RequestMapping("/media-file")
public class MediaFileController {
    @Resource
    private MediaFileService mediaFileService;


    /**
     * 文件上传前检查文件是否存在
     *
     * @param object 需要上传的文件的md5值
     * @return 是否存在, false-不存在 true-存在
     */
    @PostMapping("/upload/checkFile")
    public Result checkFile(@RequestBody JSONObject object) {
        return mediaFileService.checkFile(object.getString("fileMd5"));
    }
}

6.1.2 MediaFileService

package com.zhulang.waveedu.service.service;

import com.zhulang.waveedu.common.entity.Result;
import com.zhulang.waveedu.service.po.MediaFile;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 * <p>
 * 第三方服务-媒资文件表 服务类
 * </p>
 *
 * @author 狐狸半面添
 * @since 2023-02-08
 */
public interface MediaFileService extends IService<MediaFile> {

    /**
     * 文件上传前检查文件是否存在
     *
     * @param fileMd5 需要上传的文件的md5值
     * @return 是否存在,false-不存在 true-存在
     */
    Result checkFile(String fileMd5);
}

6.1.3 MediaFileServiceImpl

package com.zhulang.waveedu.service.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.zhulang.waveedu.common.constant.HttpStatus;
import com.zhulang.waveedu.common.entity.Result;
import com.zhulang.waveedu.common.util.RegexUtils;
import com.zhulang.waveedu.service.po.MediaFile;
import com.zhulang.waveedu.service.dao.MediaFileMapper;
import com.zhulang.waveedu.service.service.MediaFileService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.minio.GetObjectArgs;
import io.minio.MinioClient;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.io.InputStream;

/**
 * <p>
 * 第三方服务-媒资文件表 服务实现类
 * </p>
 *
 * @author 狐狸半面添
 * @since 2023-02-08
 */
@Service
public class MediaFileServiceImpl extends ServiceImpl<MediaFileMapper, MediaFile> implements MediaFileService {
    @Resource
    private MediaFileMapper mediaFileMapper;
    @Resource
    private MinioClientUtils minioClientUtils;

    @Override
    public Result checkFile(String fileMd5) {
        HashMap<String, Object> resultMap = new HashMap<>();

        // 1.校验 fileMd5 合法性
        if (RegexUtils.isMd5HexInvalid(fileMd5)) {
            return Result.error(HttpStatus.HTTP_BAD_REQUEST.getCode(), "文件md5格式错误");
        }

        // 2.在文件表存在,并且在文件系统存在,此文件才存在
        // 2.1 判断是否在文件表中存在
        LambdaQueryWrapper<MediaFile> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(MediaFile::getFileMd5, fileMd5);
        MediaFile mediaFile = mediaFileMapper.selectOne(wrapper);
        if (mediaFile == null) {
            resultMap.put("exist", false);
            return Result.ok(resultMap);
        }
        // 2.2 判断是否在文件系统存在
        try {
            InputStream inputStream = minioClientUtils.getObject(mediaFile.getBucket(), mediaFile.getFilePath());
            if (inputStream == null) {
                // 文件不存在
                resultMap.put("exist", false);
                return Result.ok(resultMap);
            }
        } catch (Exception e) {
            // 文件不存在
            resultMap.put("exist", false);
            return Result.ok(resultMap);
        }
        // 3.走到这里说明文件已存在,返回true
        resultMap.put("exist", true);
        // 4.封装文件信息
        resultMap.put("info", encodeFileInfo(mediaFile));
        // 5.返回结果
        return Result.ok(resultMap);
    }

    /**
     * 将 部分信息进行封装加密
     *
     * @param mediaFile 媒资对象
     * @return 加密结果
     */
    private String encodeFileInfo(MediaFile mediaFile) {
        HashMap<String, Object> fileMap = new HashMap<>(5);
        fileMap.put("fileType", mediaFile.getFileType());
        fileMap.put("filePath", mediaFile.getBucket() + "/" + mediaFile.getFilePath());
        fileMap.put("fileFormat", mediaFile.getFileFormat());
        fileMap.put("fileByteSize", mediaFile.getFileByteSize());
        fileMap.put("fileFormatSize", mediaFile.getFileFormatSize());
        return CipherUtils.encrypt(JSON.toJSONString(fileMap));
    }
}

6.2 检查分块文件是否已存在

6.2.1 MediaFileController

    /**
     * 分块文件上传前检测分块文件是否已存在
     *
     * @param chunkFileVO 分块文件的源文件md5和该文件索引
     * @return 是否存在, false-不存在 true-存在
     * @throws Exception
     */
    @PostMapping("/upload/checkChunk")
    public Result checkChunk(@Validated @RequestBody ChunkFileVO chunkFileVO) throws Exception {
        return mediaFileService.checkChunk(chunkFileVO.getFileMd5(),chunkFileVO.getChunkIndex());
    }

6.2.2 MediaFileService

    /**
     * 分块文件上传前检测分块文件是否已存在
     *
     * @param fileMd5 分块文件的源文件md5
     * @param chunkIndex 分块文件索引
     * @return 是否存在, false-不存在 true-存在
     */
    Result checkChunk(String fileMd5, Integer chunkIndex);

6.2.3 MediaFileServiceImpl

    @Resource
    private MinioClientUtils minioClientUtils;
    @Value("${minio.bucket}")
    private String bucket;

    @Override
    public Result checkChunk(String fileMd5, Integer chunkIndex) {
        // 1.得到分块文件所在目录
        String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
        // 2.分块文件的路径
        String chunkFilePath = chunkFileFolderPath + chunkIndex;

        // 3.查看是否在文件系统存在(注意关闭流)
        try (
                InputStream inputStream = minioClientUtils.getObject(bucket, chunkFilePath)
        ) {

            if (inputStream == null) {
                //文件不存在
                return Result.ok(false);
            }
        } catch (Exception e) {
            //文件不存在
            return Result.ok(false);
        }

        // 4.走到这里说明文件已存在,返回true
        return Result.ok(true);
    }

    /**
     * 得到分块文件的目录
     *
     * @param fileMd5 文件的md5值
     * @return 分块文件所在目录
     */
    private String getChunkFileFolderPath(String fileMd5) {
        return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
    }

6.3 上传分块文件

6.3.1 MediaFileController

    /**
     * 上传分块文件
     *
     * @param file       分块文件
     * @param fileMd5    原文件md5值
     * @param chunkIndex 分块文件索引
     * @return 上传情况
     */
    @PostMapping("/upload/uploadChunk")
    public Result uploadChunk(@RequestParam("file") MultipartFile file,
                              @RequestParam("fileMd5") @Pattern(regexp = RegexUtils.RegexPatterns.MD5_HEX_REGEX, message = "文件md5格式错误") String fileMd5,
                              @RequestParam("chunkIndex") @Min(value = 0, message = "索引必须大于等于0") Integer chunkIndex) throws Exception {
        return mediaFileService.uploadChunk(fileMd5, chunkIndex, file.getBytes());
    }

6.3.2 MediaFileService

    /**
     * 上传分块文件
     *
     * @param fileMd5    原文件md5值
     * @param chunkIndex 分块文件索引
     * @param bytes      分块文件的字节数组形式
     * @return 上传情况
     */
    Result uploadChunk(String fileMd5, Integer chunkIndex, byte[] bytes);

6.3.3 MediaFileServiceImpl

    @Resource
    private MinioClientUtils minioClientUtils;
    @Value("${minio.bucket}")
    private String bucket;
	
	@Override
    public Result uploadChunk(String fileMd5, Integer chunkIndex, byte[] bytes) {
        // 1.得到分块文件所在目录
        String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
        // 2.分块文件的路径
        String chunkFilePath = chunkFileFolderPath + chunkIndex;

        try {
            // 3.将分块上传到文件系统
            minioClientUtils.uploadChunkFile(bytes, bucket, chunkFilePath);
            // 4.上传成功
            return Result.ok();
        } catch (Exception e) {
            // 上传失败
            return Result.error();
        }
    }

    /**
     * 得到分块文件的目录
     *
     * @param fileMd5 文件的md5值
     * @return 分块文件所在目录
     */
    private String getChunkFileFolderPath(String fileMd5) {
        return fileMd5.charAt(0) + "/" + fileMd5.charAt(1) + "/" + fileMd5 + "/" + "chunk" + "/";
    }

6.4 合并前下载分块文件(多线程下载)

合并分块前要检查分块文件是否全部上传完成,如果完成则将已经上传的分块文件从minio下载下来,然后再进行合并。

    /**
     * 创建10个线程数量的线程池
     */
    private final ExecutorService threadPool = Executors.newFixedThreadPool(10);

    /**
     * 下载所有的块文件
     *
     * @param fileMd5    源文件的md5值
     * @param chunkTotal 块总数
     * @return 所有块文件
     */
    private File[] downloadChunkFilesFromMinio(String fileMd5, int chunkTotal) throws Exception {
        // 1.得到分块文件所在目录
        String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
        // 2.分块文件数组
        File[] chunkFiles = new File[chunkTotal];
        // 3.设置计数器
        CountDownLatch countDownLatch = new CountDownLatch(chunkTotal);
        // 4.开始逐个下载
        for (int i = 0; i < chunkTotal; i++) {
            int index = i;
            threadPool.execute(() -> {
                // 4.1 得到分块文件的路径
                String chunkFilePath = chunkFileFolderPath + index;
                // 4.2 下载分块文件
                try {
                    chunkFiles[index] = minioClientUtils.downloadFile("chunk", null, bucket, chunkFilePath);
                } catch (Exception e) {
                    // 计数器减1
                    countDownLatch.countDown();
                    throw new RuntimeException(e);
                }
                // 计数器减1
                countDownLatch.countDown();
            });
        }

        /*
            阻塞到任务执行完成,当countDownLatch计数器归零,这里的阻塞解除等待,
            给一个充裕的超时时间,防止无限等待,到达超时时间还没有处理完成则结束任务
         */
        countDownLatch.await(30, TimeUnit.MINUTES);

        // 5.返回所有块文件
        return chunkFiles;
    }

6.5 合并分块文件并上传

6.5.1 MediaFileController

    /**
     * 合并分块文件
     *
     * @param fileMd5    文件的md5十六进制值
     * @param fileName   文件名
     * @param tag        文件标签
     * @param chunkTotal 文件块总数
     * @return 合并与上传情况
     */
    @PostMapping("/upload/uploadMergeChunks")
    public Result uploadMergeChunks(@RequestParam("fileMd5") @Pattern(regexp = RegexUtils.RegexPatterns.MD5_HEX_REGEX, message = "文件md5格式错误") String fileMd5,
                                    @RequestParam("fileName") @Pattern(regexp = RegexUtils.RegexPatterns.FILE_NAME_REGEX, message = "文件名最多255个字符") String fileName,
                                    @RequestParam("tag") @Pattern(regexp = RegexUtils.RegexPatterns.FILE_TAG_REGEX, message = "文件标签最多32个字符") String tag,
                                    @RequestParam("chunkTotal") @Min(value = 1, message = "块总数必须大于等于1") Integer chunkTotal) {

        return mediaFileService.uploadMergeChunks(fileMd5, fileName, tag, chunkTotal);
    }

6.5.2 MediaFileService

    /**
     * 合并分块文件
     *
     * @param fileMd5    文件的md5十六进制值
     * @param fileName   文件名
     * @param tag        文件标签
     * @param chunkTotal 文件块总数
     * @return 合并与上传情况
     */
    Result uploadMergeChunks(String fileMd5, String fileName, String tag, Integer chunkTotal);

6.5.3 MediaFileServiceImpl

    /**
     * 将 部分信息进行封装加密
     *
     * @param mediaFile 媒资对象
     * @return 加密结果
     */
    private String encodeFileInfo(MediaFile mediaFile) {
        HashMap<String, Object> fileMap = new HashMap<>(5);
        fileMap.put("fileType", mediaFile.getFileType());
        fileMap.put("filePath", mediaFile.getBucket() + "/" + mediaFile.getFilePath());
        fileMap.put("fileFormat", mediaFile.getFileFormat());
        fileMap.put("fileByteSize", mediaFile.getFileByteSize());
        fileMap.put("fileFormatSize", mediaFile.getFileFormatSize());
        return CipherUtils.encrypt(JSON.toJSONString(fileMap));
    }


	@Override
    public Result uploadMergeChunks(String fileMd5, String fileName, String tag, Integer chunkTotal) {
        try {
            // 1.下载分块
            File[] chunkFiles = downloadChunkFilesFromMinio(fileMd5, chunkTotal);

            // 2.根据文件名得到合并后文件的扩展名
            int index = fileName.lastIndexOf(".");
            String extension = index != -1 ? fileName.substring(index) : "";
            File tempMergeFile = null;
            try {
                try {
                    // 3.创建一个临时文件作为合并文件
                    tempMergeFile = File.createTempFile("merge", extension);
                } catch (IOException e) {
                    return Result.error(HttpStatus.HTTP_INTERNAL_ERROR.getCode(), "创建临时合并文件出错");
                }

                // 4.创建合并文件的流对象
                try (RandomAccessFile rafWrite = new RandomAccessFile(tempMergeFile, "rw")) {
                    byte[] b = new byte[1024];
                    for (File file : chunkFiles) {
                        // 5.读取分块文件的流对象
                        try (RandomAccessFile rafRead = new RandomAccessFile(file, "r");) {
                            int len = -1;
                            while ((len = rafRead.read(b)) != -1) {
                                // 6.向合并文件写数据
                                rafWrite.write(b, 0, len);
                            }
                        }

                    }
                } catch (IOException e) {
                    return Result.error(HttpStatus.HTTP_INTERNAL_ERROR.getCode(), "合并文件过程出错");
                }


                // 7.校验合并后的文件是否正确
                try (
                        // 7.1 获取合并后文件的流对象
                        FileInputStream mergeFileStream = new FileInputStream(tempMergeFile);
                ) {

                    // 7.2 获取合并文件的md5十六进制值
                    String mergeMd5Hex = DigestUtils.md5Hex(mergeFileStream);
                    // 7.3 校验
                    if (!fileMd5.equals(mergeMd5Hex)) {
                        return Result.error(HttpStatus.HTTP_BAD_REQUEST.getCode(), "合并文件校验不通过");
                    }
                } catch (IOException e) {
                    return Result.error(HttpStatus.HTTP_INTERNAL_ERROR.getCode(), "合并文件校验出错");
                }

                // 8.得到 mimetype
                String mimeType = FileTypeUtils.getMimeType(tempMergeFile);

                // 9.拿到合并文件在minio的存储路径
                String mergeFilePath = getFilePathByMd5(fileMd5, extension);
                // 10.将合并后的文件上传到文件系统
                minioClientUtils.uploadFile(tempMergeFile.getAbsolutePath(), bucket, mergeFilePath);

                // 11.设置需要入库的文件信息
                MediaFile mediaFile = new MediaFile();
                // 11.1 文件名
                mediaFile.setFileName(WaveStrUtils.removeBlank(fileName));
                // 11.2 文件类型
                mediaFile.setFileType(FileTypeUtils.getSimpleType(mimeType));
                // 11.3 文件格式
                mediaFile.setFileFormat(mimeType);
                // 11.4 文件标签
                mediaFile.setTag(WaveStrUtils.removeBlank(tag));
                // 11.5 存储桶
                mediaFile.setBucket(bucket);
                // 11.6 存储路径
                mediaFile.setFilePath(mergeFilePath);
                // 11.7 设置md5值
                mediaFile.setFileMd5(fileMd5);
                // 11.8 设置合并文件大小(单位:字节)
                mediaFile.setFileByteSize(tempMergeFile.length());
                // 11.9 设置文件格式大小
                mediaFile.setFileFormatSize(FileFormatUtils.formatFileSize(mediaFile.getFileByteSize()));
                // 11.10 设置上传者
                mediaFile.setUserId(UserHolderUtils.getUserId());
                // 12.保存至数据库
                this.save(mediaFile);

                // 15.返回成功
                return Result.ok(encodeFileInfo(mediaFile));
            } finally {
                // 13.删除临时分块文件
                for (File chunkFile : chunkFiles) {
                    if (chunkFile.exists()) {
                        chunkFile.delete();
                    }
                }
                // 14.删除合并的临时文件
                if (tempMergeFile != null) {
                    tempMergeFile.delete();
                }

            }
        } catch (Exception e) {
            return Result.error(HttpStatus.HTTP_INTERNAL_ERROR.getCode(), e.getMessage());
        }
    }


    /**
     * 得到合并文件的路径
     *
     * @param fileMd5 文件的md5十六进制值
     * @param fileExt 文件的扩展名
     * @return 合并文件路径
     */
    private String getFilePathByMd5(String fileMd5, String fileExt) {
        return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + fileMd5 + fileExt;
    }

7.补充-面试题

7.1 MinIO是什么?

MinIO一个轻量级的分布式文件系统,由多个个MinIO节点连接组成,可根据文件规模进行扩展,适用于海量文件的存储与访问。

7.2 为什么用MinIO

  1. MinIO开源,使用简单,功能强大。
  2. MinIO使用纠删码算法,只要不超过一半的节点坏掉整个文件系统就可以使用。
  3. 如果将坏的节点重新启动,自动恢复没有上传成功的文件。

7.3 怎么样构建这个独立文件服务?

  1. 我们项目中有很多要上传文件的地方,比如上传图片、上传文档、上传视频等,所以我们要构建一 个独立的文件服务负责上传、下载等功能,负责对文件进行统一管理。

  2. 创建单独的文件服务,提供以下接口:

  • 上传接口
  • 下载接口
  • 我的图库接口
  • 我的文件库接口
  • 删除文件接口
  1. 文件的存储和下载使用MinIO实现。

MinIO是一个分布式的文件系统,性能高,扩展强。

  1. 使用Nginx+MinIO组成一个文件服务器。通过访问Nginx,由nginx代理将请求转发到MinIO去浏览、下载文件。

7.4 断点续传是怎么做的?

我们是基于分块上传的模式实现断点续传的需求,当文件上传一部分断网后前边已经上传过的不再上传。

  1. 前端对文件分块。
  2. 前端使用多线程一块-块上传,上传前给服务端发一 个消息校验该分块是否上传,如果已上传则不再上传。
  3. 等所有分块上传完毕,服务端合并所有分块,校验文件的完整性。因为分块全部上传到了服务器,服务器将所在分块按顺序进行合并,就是写每个分块文件内容按顺序依次写入一个文件中。(使用字节流去读写文件)
  4. 前端给服务传了一个md5值,服务端合并文件后计算合并后文件的md5是否和前端传的一样,如果一样则说文件完整,如果不一样说明可能由于网络丢包导致文件不完整,这时上传失败需要重新上传。

7.5 分块文件清理问题

上传一个文件进行分块上传,上传一半不传了, 之前上传到minio的分块文件要清理吗?怎么做的?

  1. 在数据库中有一张文件表记录minio中存储的文件信息。
  2. 文件开始上传时会写入文件表,状态为,上传中,上传完成会更新状态为上传完成。
  3. 当一个文件传了一半不再上传了说明该文件没有上传完成,会有定时任务去查询文件表中的记录,如果文件未上传完成则删除minio中没有上传成功的文件目录。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/342135.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

网络协议(六):网络层

网络协议系列文章 网络协议(一)&#xff1a;基本概念、计算机之间的连接方式 网络协议(二)&#xff1a;MAC地址、IP地址、子网掩码、子网和超网 网络协议(三)&#xff1a;路由器原理及数据包传输过程 网络协议(四)&#xff1a;网络分类、ISP、上网方式、公网私网、NAT 网络…

【Spark分布式内存计算框架——Spark Core】10. Spark 内核调度(中)

8.3 Spark Shuffle 首先回顾MapReduce框架中Shuffle过程&#xff0c;整体流程图如下 Spark在DAG调度阶段会将一个Job划分为多个Stage&#xff0c;上游Stage做map工作&#xff0c;下游Stage做reduce工作&#xff0c;其本质上还是MapReduce计算框架。Shuffle是连接map和reduce之…

全国空气质量排行,云贵川和西藏新疆等地空气质量更好

哈喽&#xff0c;大家好&#xff0c;春节刚刚过去&#xff0c;不知道大家是不是都开始进入工作状态了呢&#xff1f;春节期间&#xff0c;允许燃放烟花爆竹的地区的朋友们不知道都去欣赏烟花表演没有&#xff1f;其他地区的朋友们相比烟花表演可能更关心燃放烟花爆竹造成的环境…

浅谈前端设计模式:策略模式和状态模式的异同点

一、策略模式 策略模式是定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。 而且策略模式是重构小能力&#xff0c;特别适合拆分“胖逻辑”。 这个定义乍一看会有点懵&#xff0c;不过通过下面的例子就能慢慢理解它的意思。 先来看一个真实场景 某次活动要做…

测试2年还拿实习生的薪资打发我,你后悔去吧····

20年7月大学毕业&#xff0c;学的计算机科学专业。因为考研之后&#xff0c;秋招结束了。没什么更多的岗位选择&#xff0c;就想找个工作先干着&#xff0c;然后亲戚在一家大厂公司上班说要招测试&#xff0c;所以就来做测试了。 虽然都是属于计算机大类&#xff0c;但自己专业…

记一次 .NET 某游戏网站 CPU爆高分析

一&#xff1a;背景 1. 讲故事 这段时间经常有朋友微信上问我这个真实案例分析连载怎么不往下续了&#xff0c;关注我的朋友应该知道&#xff0c;我近二个月在研究 SQLSERVER&#xff0c;也写了十多篇文章&#xff0c;为什么要研究这东西呢&#xff1f; 是因为在 dump 中发现…

零入门kubernetes网络实战-13->同一宿主机上的两个网络命名空间通信方案

《零入门kubernetes网络实战》视频专栏地址 https://www.ixigua.com/7193641905282875942 本篇文章视频地址(稍后上传) 本篇文章主要是想模拟一下&#xff0c;在同一个宿主机上&#xff0c;多个网络命名空间之间如何通信&#xff1f; 有哪些可以采取的方案。 可能存在的方案…

【GD32F427开发板试用】6. 定时器运用之精确定时1s

本篇文章来自极术社区与兆易创新组织的GD32F427开发板评测活动&#xff0c;更多开发板试用活动请关注极术社区网站。作者&#xff1a;hehung 之前发帖 【GD32F427开发板试用】1. 串口实现scanf输入控制LED 【GD32F427开发板试用】2. RT-Thread标准版移植 【GD32F427开发板试用…

MySQL:连explain的type类型都没搞清楚,怎敢说精通SQL优化?

我们在使用SQL语句查询表数据时&#xff0c;提前用explain进行语句分析是一个非常好的习惯。通过explain输出sql的详细执行信息&#xff0c;就可以针对性的进行sql优化。 今天我们来分析一下&#xff0c;在explain中11种不同type代表的含义以及其应用场景。 1&#xff0c;sys…

如何在dom节点上使用fixed定位?实现基于父节点而不是浏览器的滚动定位?一眼看懂,简单明了。

文章目录一、想要实现的功能场景二、父相子绝方式实现dom节点内元素滚动定位2.1、父相子绝代码2.2、实现效果三、iframe中使用fixed方式实现3.1、fixed代码3.2、实现效果四、总结一、想要实现的功能场景 想在一个node节点中实现滚动&#xff0c;但是我的文本要基于这个元素在滚…

云计算关键技术

在云计算数据中心的构建过程中&#xff0c;离不开一些关键的技术&#xff0c;比如&#xff0c;虚拟化技术&#xff0c;分布式数据存储技术&#xff0c;云计算平台管理软件以及其它诸如HBase&#xff0c;Hadoop等相关技术。目录 虚拟化技术 计算虚拟化技术-KVM 分布式数据存储技…

基于 YAML 接口自动化测试框架设计

在设计自动化测试框架的时候&#xff0c;我们会经常将测试数据保存在外部的文件&#xff08;如Excel、YAML、CSV&#xff09;&#xff0c;或者数据库中&#xff0c;实现脚本与数据解耦&#xff0c;方便后期维护。目前非常多的自动化测试框架采用通过Excel或者YAML文件直接编写测…

Allegro如何设置自动保存和自动保存的时间操作指导

Allegro如何设置自动保存和自动保存的时间操作指导 做PCB设计的时候,自动保存软件是一个必要的功能,Allegro同样支持设置自动保存,而且可以设置自动保存的时间。 如下图 具体操作如下 点击Setup点击User Preferences

都说高速信号过孔尽量少,高速先生却说有时候多点反而好?

作者&#xff1a;一博科技高速先生成员 黄刚过孔在高速领域可谓让硬件工程师&#xff0c;PCB设计工程师甚至仿真工程师都闻风丧胆&#xff0c;首先是因为它的阻抗没法像传输线一样&#xff0c;通过一些阻抗计算软件来得到&#xff0c;一般来说只能通过3D仿真来确定&#xff0c;…

二叉树的性质与推导及常见习题整理

目录 一、性质推导 二、常见的二叉树性质习题 1. 某二叉树共有 399 个结点&#xff0c;其中有 199 个度为 2 的结点&#xff0c;则该二叉树中的叶子结点数为&#xff08;&#xff09;。 2.在具有 2n 个结点的完全二叉树中&#xff0c;叶子结点个数为&#xff08;&#xff…

字母板上的路径[提取公共代码,提高复用率]

提取公共代码前言一、字母版上的路径二、贪心1、idea2、go3、代码不断拆分复用的过程总结参考文献前言 写代码&#xff0c;在提高效率的同时&#xff0c;要方便人看&#xff0c;这个人包括自己。大函数要拆分成一些小函数&#xff0c;让每个函数的宏观目的和步骤都显得清晰&am…

分享微信点餐小程序搭建步骤_微信点餐功能怎么做

线下餐饮实体店都开始摸索发展网上订餐服务。最多人选择的是入驻外卖平台&#xff0c;但抽成高&#xff0c;推广还要另买流量等问题&#xff0c;也让不少商家入不敷出。在这种情况下&#xff0c;建立自己的微信订餐小程序&#xff0c;做自己的私域流量是另一种捷径。那么&#…

分类模型评估:混淆矩阵、准确率、召回率、ROC

1. 混淆矩阵 在二分类问题中&#xff0c;混淆矩阵被用来度量模型的准确率。因为在二分类问题中单一样本的预测结果只有Yes or No&#xff0c;即&#xff1a;真或者假两种结果&#xff0c;所以全体样本的经二分类模型处理后&#xff0c;处理结果不外乎四种情况&#xff0c;每种…

反应-扩散方程(Reaction-diffusion system)

文章目录单组分反应-扩散方程双组分反应-扩散方程三组分和更多组分的反应-扩散方程Fishers equationKPP方程Belousov–Zhabotinsky reaction历史化学机理变体Noise-induced order数学背景Briggs–Rauscher reactionZFK equation行波解渐近解外部区域内部区域KPP-ZFK 转变Clavin…

13-PHP使用过的函数 121-130

121、bindColumn 将字段绑定到变量上 // while,foreach,list()进行结果数组的解构&#xff0c;解构到变量中; //!要在预处理对象上调用bindColumn函数 $stmt->bindColumn(id,$id); $stmt->bindColumn(name,$name); $stmt->bindColumn(sex,$sex); $stmt->bindColumn…