4.3 媒资管理模块 - Minio系统上传图片与视频

发布时间:2023年12月25日

一、上传图片

此模块涉及到修改/添加课程,之前已经写过接口了,在下面这篇文章中

3.2 内容管理模块 - 课程分类、新增课程、修改课程-CSDN博客

1.1 需求分析

我们的文件要存储在minio文件系统中

在新增课程界面上传课程图片,也可以修改课程图片

这一步上传图片是请求的媒资服务接口,并不是内容管理服务接口

image-20231222215414543

在课程基本信息中有一个“pic”字段,要存储图片的路径

image-20231222215700731

pic字段值类似“/mediafiles/2022/09/18/a16da7a132559daf9e1193166b3e7f52.jpg”,其中“mediafiles”就是桶,后面就是文件具体的对象名

在我们前端功能.env环境变量配置文件定义了图片服务器地址

如果想要展示图片,那就要将“http://192.168.101.65:9000”拼接上pic字段值

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

为什么不将“http://192.168.101.65:9000”直接写在数据库里

因为如果上线之后,地址就不好修改了


为什么要单独设置一个媒资管理服务

①管理文件的信息,我们使用media_files表记录文件信息

不管是图片、视频、文档,全会记录在这个表中

image-20231222223702229

②把文件本身放在分布式文件系统(minio)中管理起来

方便管理,媒资服务就是一个统一的文件管理功能


详细流程

image-20231222223956542

1、前端进入上传图片界面

2、上传图片,请求媒资管理服务。

3、媒资管理服务将图片文件存储在MinIO。

4、媒资管理记录文件信息到数据库。

5、前端请求内容管理服务保存课程信息,在内容管理数据库保存图片地址。

1.2 数据模型

我们怎么区分文件是否已经上传

可以通过文件的MD5值,同一个文件的MD5值相同

前端上传的时候就会把文件MD5值传给我们后台,后台拿着MD5值去数据库查询,如果有这个MD5值了,说明文件已经上传成功了,不需要再上传了

1.2.1 media_files 媒资信息表

image-20231222224302847

file_path存储路径,相当于java代码中的对象名

url:可以通过http访问文件的路径

url = /bucket/+file_path

但是有例外,如果是上传了一个avi视频的话,那file_path里面存的就是avi结尾的视频地址,但是avi的视频最终是不能播放的,我们需要把这个视频进行转码生成MP4格式的视频,此时url就是存储的MP4的视频地址、

如果是图片的话url = /bucket/+file_path这个等式是一个样子的

/**
 * 媒资信息
 */
@Data
@TableName("media_files")
public class MediaFiles implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.ASSIGN_ID)
    private String id;

    /**
     * 机构ID
     */
    private Long companyId;

    /**
     * 机构名称
     */
    private String companyName;

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

    /**
     * 文件类型(文档,音频,视频)
     */
    private String fileType;

    /**
     * 标签
     */
    private String tags;

    /**
     * 存储目录
     */
    private String bucket;

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


    /**
     * 文件标识
     */
    private String fileId;

    /**
     * 媒资文件访问地址
     */
    private String url;


    /**
     * 上传人
     */
    private String username;

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

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

    /**
     * 状态,1:未处理,视频处理完成更新为2
     */
    private String status;

    /**
     * 备注
     */
    private String remark;

    /**
     * 审核状态
     */
    private String auditStatus;

    /**
     * 审核意见
     */
    private String auditMind;

    /**
     * 文件大小
     */
    private Long fileSize;

}

除此之外还会涉及到课程基本信息表course_base,表中pic字段存储课程图片的路径

image-20231222215700731

1.3 准备Minio环境

1.3.1 桶环境

  • mediafiles桶

    视频以外的文件都存储在这个桶内

image-20231222230701829

  • video 桶

    存放视频

    image-20231222230711368

并且两个桶的Access Policy权限都要改成public

image-20231222230808165

1.3.2 连接Minio参数

在media-service工程中配置minio的相关信息,要配置在nacos中

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.101.65:3306/xcplus_media?serverTimezone=UTC&userUnicode=true&useSSL=false&
    username: root
    password: mysql
  cloud:
   config:
    #配置本地优先
    override-none: true  

minio:
  #地址
  endpoint: http://192.168.101.65:9000 
  #账号
  accessKey: minioadmin 
  #密码
  secretKey: minioadmin 
  #两个桶
  bucket:
    files: mediafiles 
    videofiles: video

本地bootstrap.yaml文件

spring:
  application:
    name: media-service
  cloud:
    nacos:
      server-addr: 192.168.101.65:8848
      discovery:
        namespace: ${spring.profiles.active}
        group: xuecheng-plus-project
      config:
        namespace: ${spring.profiles.active}
        group: xuecheng-plus-project
        file-extension: yaml
        refresh-enabled: true
        shared-configs:
          - data-id: logging-${spring.profiles.active}.yaml
            group: xuecheng-plus-common
            refresh: true

#profiles默认为dev
  profiles:
    active: dev

1.3.3 Minio配置类

在media-service工程中进行配置

@Configuration
public class MinioConfig {
 
    @Value("${minio.endpoint}")
    private String endpoint;
    @Value("${minio.accessKey}")
    private String accessKey;
    @Value("${minio.secretKey}")
    private String secretKey;

    @Bean
    public MinioClient minioClient() {

        MinioClient minioClient =
                MinioClient.builder()
                        .endpoint(endpoint)
                        .credentials(accessKey, secretKey)
                        .build();
        return minioClient;
    }
}

1.4 接口定义

下面的内容其实是完成标红的地方

image-20231222235649629

做这一部分的时候可能发现course_base课程基本信息表和media_files表没有关联,那怎么将图片的url存放到course_base课程基本信息表中的pic字段呢?

当上传完图片之后,会点击保存,这个时候就会对course_base表中的各种信息进行操作了

image-20231223214409515

但是我感觉没有很好的方法让course_base表

1.4.1 上传图片接口请求参数

和media_file相关的文件上传的基本信息

/**
 * @description 上传普通文件请求参数
 */
@Data
public class UploadFileParamsDto {

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


    /**
     * 文件类型(文档,音频,视频)
     */
    private String fileType;
    /**
     * 文件大小
     */
    private Long fileSize;

    /**
     * 标签
     */
    private String tags;

    /**
     * 上传人
     */
    private String username;

    /**
     * 备注
     */
    private String remark;

}

1.4.2 上传图片接口返回值

这些信息其实是文件表中的信息

{
  "id": "a16da7a132559daf9e1193166b3e7f52",
  "companyId": 1232141425,
  "companyName": null,
  "filename": "1.jpg",
  "fileType": "001001",
  "tags": "",
  "bucket": "/testbucket/2022/09/12/a16da7a132559daf9e1193166b3e7f52.jpg",
  "fileId": "a16da7a132559daf9e1193166b3e7f52",
  "url": "/testbucket/2022/09/12/a16da7a132559daf9e1193166b3e7f52.jpg",
  "timelength": null,
  "username": null,
  "createDate": "2022-09-12T21:57:18",
  "changeDate": null,
  "status": "1",
  "remark": "",
  "auditStatus": null,
  "auditMind": null,
  "fileSize": 248329
}

根据上面返回信息封装一个Dto

但是我们不会直接使用media_files的实体类,假如前端多需要几个参数的话,我们还需要修改

在media-model工程中添加如下实体类

@Data
public class UploadFileResultDto extends MediaFiles {

}

1.4.3 接口代码

    /**
     * 对于请求内容:Content-Type: multipart/form-data;
     * 前端向后端传输一个文件,那后端程序就属于一个消费者,我们指定一下类型
     * form-data; name="filedata"; filename="具体的文件名称"
     * <p>
     * 我们可以使用@RequestPart指定一下前端向后端传输文件的名称
     * 用MultipartFile类型接收前端向后端传输的文件
     */
    @ApiOperation("上传图片")
    @PostMapping(value = "/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public UploadFileResultDto upload(@RequestPart("filedata") MultipartFile filedata) throws IOException {
        //此时已经接收到文件了,目前作为临时文件存储在内存中
        //1.创建一个临时文件,前缀是"minio",后缀是“.temp”
        File tempFile = File.createTempFile("minio", ".temp");
        //2.将上传后的文件传输到临时文件中
        filedata.transferTo(tempFile);
        //3.取出临时文件的绝对路径
        String localFilePath = tempFile.getAbsolutePath();

        Long companyId = 1232141425L; //先写死,写认证授权系统时再进行
        //4.准备上传文件的信息
        UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
        //filedata.getOriginalFilename()获取原始文件名称
        uploadFileParamsDto.setFilename(filedata.getOriginalFilename());
        //文件大小
        uploadFileParamsDto.setFileSize(filedata.getSize());
        //文件类型 001001在数据字典中代表图片
        uploadFileParamsDto.setFileType("001001");
        //调用service上传图片
        return mediaFileService.uploadFile(companyId, uploadFileParamsDto, localFilePath);
    }

1.5 MediaFilesMapper

我们需要保存文件信息

/**
 * 媒资信息 Mapper 接口
 */
public interface MediaFilesMapper extends BaseMapper<MediaFiles> {

}

1.6 MediaFileServiceImpl

媒资管理业务类 - 下面的代码其实就做了上传图片与文件信息入库

    @Autowired
    private MinioClient minioClient;

    //除了视频文件以外的桶
    @Value("${minio.bucket.files}")
    private String bucket_medialFiles;

    //视频文件桶
    @Value("${minio.bucket.videofiles}")
    private String bucket_video;

 /**
     * 上传文件
     *
     * @param companyId           机构id
     * @param uploadFileParamsDto 上传文件信息
     * @param localFilePath       文件磁盘路径
     * @return 文件信息
     */
    @Transactional
    @Override
    public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath) {
        //TODO 1.将文件上传到Minio

        //TODO 1.1 获取文件扩展名
        String filename = uploadFileParamsDto.getFilename();
        String extension = filename.substring(filename.lastIndexOf("."));

        //TODO 1.2 根据文件扩展名获取mimeType
        String mimeType = this.getMimeType(extension);

        //TODO 1.3 bucket,从nacos中读取
        //TODO 1.4 ObjectName约定在MinIo系统中存储的目录是年/月/日/图片文件
        //得到文件的路径defaultFolderPath
        String defaultFolderPath = this.getDefaultFolderPath();
        //最终存储的文件名是MD5值
        String fileMd5 = this.getFileMd5(new File(localFilePath));
        String ObjectName = defaultFolderPath + fileMd5 + extension;
        //TODO 1.5 上传文件到Minio
        boolean result = this.addMediaFilesToMinIO(localFilePath, bucket_medialFiles, ObjectName, mimeType);

        if (!result) {
            XueChengPlusException.cast("上传文件失败");
        }
        //TODO 2.将文件信息保存到数据库
        MediaFiles mediaFiles = this.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_medialFiles, ObjectName);
        if (mediaFiles == null){
            XueChengPlusException.cast("文件上传后保存信息失败");
        }
        UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
        BeanUtils.copyProperties(mediaFiles, uploadFileResultDto);
        return uploadFileResultDto;
    }

    /**
     * @param companyId           机构id
     * @param fileMd5             文件md5值
     * @param uploadFileParamsDto 上传文件的信息
     * @param bucket              桶
     * @param objectName          对象名称
     * @return com.xuecheng.media.model.po.MediaFiles
     * @description 将文件信息添加到文件表
     */

    public MediaFiles addMediaFilesToDb(Long companyId, String fileMd5, UploadFileParamsDto uploadFileParamsDto, String bucket, String objectName) {
        //根据文件MD5值向数据库查找文件信息
        MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
        if (mediaFiles == null) {
            mediaFiles = new MediaFiles();
            BeanUtils.copyProperties(uploadFileParamsDto, mediaFiles);
            mediaFiles.setId(fileMd5);//文件信息的主键是文件的MD5值
            mediaFiles.setCompanyId(companyId);//机构ID
            mediaFiles.setBucket(bucket);//桶
            mediaFiles.setFilePath(objectName);//对象名
            mediaFiles.setFileId(fileMd5);//file_id字段
            mediaFiles.setUrl("/" + bucket + "/" + objectName);//url
            mediaFiles.setCreateDate(LocalDateTime.now());//上传时间
            mediaFiles.setStatus("1");//状态 1正常 0不展示
            mediaFiles.setAuditStatus("002003");//审核状态 002003审核通过
            int insert = mediaFilesMapper.insert(mediaFiles);
            if (insert <= 0) {
                log.debug("向数据库保存文件失败bucket:{},objectName:{}", bucket, objectName);
            }
        }
        return mediaFiles;
    }


    /**
     * 将文件上传到MinIo
     *
     * @param bucket        桶
     * @param localFilePath 文件在本地的路径
     * @param objectName    上传到MinIo系统中时的文件名称
     * @param mimeType      上传的文件类型
     */
    private boolean addMediaFilesToMinIO(String localFilePath, String bucket, String objectName, String mimeType) {
        try {
            UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
                    //桶,也就是目录
                    .bucket(bucket)
                    //指定本地文件的路径
                    .filename(localFilePath)
                    //上传到minio中的对象名,上传的文件存储到哪个对象中
                    .object(objectName)
                    .contentType(mimeType)
                    //构建
                    .build();
            minioClient.uploadObject(uploadObjectArgs);
            log.debug("上传文件到minio成功,bucket:{},objectName:{}", bucket, objectName);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            log.info("上传文件出错,bucket:{},objectName:{},错误信息:{}", bucket, objectName, e.getMessage());
            return false;
        }
    }


    /**
     * 根据扩展名获取mimeType
     *
     * @param extension 扩展名
     */
    private String getMimeType(String extension) {
        if (extension == null) {
            //目的是防止空指针异常
            extension = "";
        }
        ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
        //通用mimeType,字节流
        String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
        if (extensionMatch != null) {
            mimeType = extensionMatch.getMimeType();
        }
        return mimeType;
    }

    /**
     * @return 获取文件默认存储目录路径 年/月/日
     */
    private String getDefaultFolderPath() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
        return sdf.format(new Date()).replace("-", "/") + "/";
    }


    /**
     * 获取文件的md5
     *
     * @param file 文件
     * @return MD5值
     */
    private String getFileMd5(File file) {
        try (FileInputStream fileInputStream = new FileInputStream(file)) {
            return DigestUtils.md5Hex(fileInputStream);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

1.7 事物优化

在下面这篇文章中也有事物的方法,感兴趣的话可以查看一下1.6

Redis - 优惠券秒杀、库存超卖、分布式锁、Redisson

我在uploadFile上传文件的方法中加了@Transactional方法

也就是说在uploadFile方法执行之前就会开启事物

但是addMediaFilesToMinIO方法会通过网络访问Minio分布式文件系统上传文件,但是通过网络访问的话这个时间可长可短,假如说网络访问时间长的话,事物的时间就会比较长,占用数据库资源的时间就会长,极端情况下会导致数据库的连接不够用

所在当方法中有网络访问的话,千万不要使用数据库的@Transactional事物进行控制

所以此时我们在addMediaFilesToDb方法上添加@Transactional控制事物的方法就好了


public MediaFiles addMediaFilesToDb(Long companyId, String fileMd5, UploadFileParamsDto uploadFileParamsDto, String bucket, String objectName) {
....
}

但是现在也会出现一个问题,我们在uploadFile方法中调用addMediaFilesToDb方法的时候并不会触发addMediaFilesToDb方法上的@Transactional,这种情况也是事物失效的场景之一

分析一下这种情况出现的原因

在uploadFile方法上添加@Transactional注解,代理对象执行此方法前会开启事务

image-20231223224851440

如果在uploadFile方法上没有@Transactional注解,代理对象执行此方法前不进行事务控制

断该方法是否可以事务控制必须保证是通过代理对象调用此方法,且此方法上添加了@Transactional注解

image-20231223224941165

在addMediaFilesToDb方法上添加@Transactional注解,也不会进行事务控制是因为并不是通过代理对象执行的addMediaFilesToDb方法

如下图所示,在addMediaFilesToDb方法中的this指代的并不是代理对象,uploadFile方法中this指代的也不是代理对象

我们在Controller使用@AutoWired的注解引入的MediaFileService对象是代理对象,怎么在调用的时候就不是了呢?

在MediaFileServiceImpl类中this指代的就是当前类,不是代理对象

代理对象其实是在原始对象上包了一层,然后调用原始对象上的方法而已

image-20231223225251724

为了解决这个问题,我们可以在uploadFile方法中使用代理的方式调用addMediaFilesToDb方法,使其能触发@Transactional注解

不同担心循环依赖的问题,spring有三级缓存

@Autowired
MediaFileService currentProxy;

将addMediaFilesToDb方法提成接口

/**
 * @description 将文件信息添加到文件表
 * @param companyId  机构id
 * @param fileMd5  文件md5值
 * @param uploadFileParamsDto  上传文件的信息
 * @param bucket  桶
 * @param objectName 对象名称
 * @return com.xuecheng.media.model.po.MediaFiles
 */

public MediaFiles addMediaFilesToDb(Long companyId, String fileMd5, UploadFileParamsDto uploadFileParamsDto, String bucket, String objectName);

调用addMediaFilesToDb方法的代码处改为如下

//TODO 2.将文件信息保存到数据库
MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_medialFiles, ObjectName);
if (mediaFiles == null){
    XueChengPlusException.cast("文件上传后保存信息失败");
}

此时就是通过CGLB生成的代理对象,挺完美的

image-20231223231025237

1.8 测试

测试上传文件

image-20231223210201133

image-20231223210009088

二、上传视频

上传视频的位置如下所示

image-20231223231511151

image-20231223232117836

2.0 响应结果类

/**
 * 通用结果类
 */
@Data
@ToString
public class RestResponse<T> {
    /**
     * 响应编码,0为正常,-1错误
     */
    private int code;

    /**
     * 响应提示信息
     */
    private String msg;

    /**
     * 响应内容
     */
    private T result;


    public RestResponse() {
        this(0, "success");
    }

    public RestResponse(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    /**
     * 错误信息的封装
     *
     * @param msg
     * @param <T>
     * @return
     */
    public static <T> RestResponse<T> validfail(String msg) {
        RestResponse<T> response = new RestResponse<T>();
        response.setCode(-1);
        response.setMsg(msg);
        return response;
    }

    public static <T> RestResponse<T> validfail(T result, String msg) {
        RestResponse<T> response = new RestResponse<T>();
        response.setCode(-1);
        response.setResult(result);
        response.setMsg(msg);
        return response;
    }


    /**
     * 添加正常响应数据(包含响应内容)
     *
     * @return RestResponse Rest服务封装相应数据
     */
    public static <T> RestResponse<T> success(T result) {
        RestResponse<T> response = new RestResponse<T>();
        response.setResult(result);
        return response;
    }

    public static <T> RestResponse<T> success(T result, String msg) {
        RestResponse<T> response = new RestResponse<T>();
        response.setResult(result);
        response.setMsg(msg);
        return response;
    }

    /**
     * 添加正常响应数据(不包含响应内容)
     *
     * @return RestResponse Rest服务封装相应数据
     */
    public static <T> RestResponse<T> success() {
        return new RestResponse<T>();
    }


    public Boolean isSuccessful() {
        return this.code == 0;
    }
}

2.1 断点续传

假如说用户上传了一个大文件,但是用户的网不是很好,上传了一部分网断了,这个时候就需要一个断点续传的需求了

通常视频文件都比较大,所以对于媒资系统上传文件的需求要满足大文件的上传要求。

http协议本身对上传文件大小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了没有上传完成,需要客户重新上传,用户体验非常差,所以对于大文件上传的要求最基本的是断点续传

断点续传:在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性

image-20231224012021229

流程如下

1、前端对文件进行分块

2、前端上传分块文件前请求媒资服务检查文件是否存在,如果已经存在则不再上传

3、如果分块文件不存在则前端开始上传

4、前端请求媒资服务上传分块

5、媒资服务将分块上传至MinIO

6、前端将分块上传完毕请求媒资服务合并分块

7、媒资服务判断分块上传完成则请求MinIO合并文件

8、合并完成校验合并后的文件是否完整,如果不完整则删除文件

2.2 测试Demo

2.2.1 分块测试

/**
 * 测试分块
 */
@Test
public void testChunk() throws Exception {
    //TODO 1.获取源文件
    File sourceFile = new File("E:\\歌.mp4");

    //TODO 2.定义基本参数
    //2.1 分块文件存储路径
    String chunkFilePath = "E:\\chunk\\";
    //2.2 分块文件的大小 1024*1024*1 代表1M,5M的话乘5即可(也就是最小单位是字节byte 1024个byte是1k)
    int chunkSize = 1024 * 1024 * 1;
    //2.3 分块文件大小
    //Math.ceil表示向上取整
    //sourceFile.length()是获取文件的大小是多少byte字节
    int chunkNum = (int) Math.ceil((sourceFile.length() * 1.0) / chunkSize);

    //TODO 3.从源文件中读数据,向分块文件中写数据
    //RandomAccessFile流既可以读又可以写
    //参数一:File类型  参数二:是读(“r”)还是写"rw"
    RandomAccessFile raf_r = new RandomAccessFile(sourceFile, "r");
    //缓存区,1k
    byte[] bytes = new byte[1024];
    //TODO 3.1 创建分块文件
    for (int i = 0; i < chunkNum; i++) {
        File chunkFile = new File(chunkFilePath + i);
        //分块文件写入流
        RandomAccessFile raf_rw = new RandomAccessFile(chunkFile, "rw");
        int len = -1;
        //将数据读取到缓冲区中raf_r.read(bytes)
        while ((len = raf_r.read(bytes)) != -1) {
            //向临时文件中进行写入
            raf_rw.write(bytes, 0, len);
            //如果分块文件chunkFile的大小大于等于我们规定的分块文件的chunkSize大小,就不要再继续了
            if (chunkFile.length() >= chunkSize) {
                break;
            }
        }
        raf_rw.close();
    }
    raf_r.close();
}

image-20231224003333071

2.2.2 合并测试

/**
 * 测试合并
 */
@Test
public void testMerge() throws Exception {
    //TODO 1.基本参数
    //分块文件目录
    File chunkFolder = new File("E:\\chunk\\");
    //源文件
    File sourceFile = new File("E:\\歌.mp4");
    //合并后的文件在哪里
    File mergeFile = new File("E:\\chunk\\歌Copy.mp4");

    //TODO 2.取出所有分块文件,此时的顺序可能是无序的
    File[] files = chunkFolder.listFiles();
    //将数组转换成List
    List<File> fileList = Arrays.asList(files);
    //利用Comparator进行排序
    Collections.sort(fileList, new Comparator<File>() {
        @Override
        public int compare(File o1, File o2) {
            //升序
            return Integer.parseInt(o1.getName()) - Integer.parseInt(o2.getName());
        }
    });

    //TODO 3.合并分块文件
    //缓存区,1k
    byte[] bytes = new byte[1024];

    //向合并分块的流
    RandomAccessFile raf_rw = new RandomAccessFile(mergeFile, "rw");
    for (File file : fileList) {
        //向读取分块文件
        RandomAccessFile raf_r = new RandomAccessFile(file, "r");
        int len = -1;
        while ((len = raf_r.read(bytes)) != -1) {
            raf_rw.write(bytes, 0, len);
        }
        raf_r.close();
    }
    raf_rw.close();

    //TODO 校验是否合并成功
    //合并文件完成后比对合并的文件MD5值域源文件MD5值
    FileInputStream fileInputStream = new FileInputStream(sourceFile);
    FileInputStream mergeFileStream = new FileInputStream(mergeFile);
    //取出原始文件的md5
    String originalMd5 = DigestUtils.md5Hex(fileInputStream);
    //取出合并文件的md5进行比较
    String mergeFileMd5 = DigestUtils.md5Hex(mergeFileStream);

    if (originalMd5.equals(mergeFileMd5)) {
        System.out.println("合并文件成功");
    } else {
        System.out.println("合并文件失败");
    }
}

image-20231224010242972

image-20231224010056328

2.3 测试MinIo

image-20231224012021229

MinioClient minioClient =
        MinioClient.builder()
                //这个地方是运行minio后展示的地址
                .endpoint("http://192.168.101.65:9000")
                //账号和密码
                .credentials("minioadmin", "minioadmin")
                .build();

2.3.1 上传分块

将分块文件上传至MinIo

/**
 * 将分块文件上传到minio
 */
@Test
public void uploadChunk() throws Exception {
    //获取所有的分块文件
    File file = new File("E:\\chunk\\");
    File[] files = file.listFiles();
    for (File chunkFile : files) {
        minioClient.uploadObject(
                UploadObjectArgs.builder()
                        //桶,也就是目录
                        .bucket("testbucket")
                        //指定本地文件的路径
                        .filename(chunkFile.getAbsolutePath())
                        //对象名
                        .object("chunk/"+chunkFile.getName())
                        //构建
                        .build());
        System.out.println("上传分块"+chunkFile.getName()+"成功");
    }
}

image-20231224015333814

2.3.2 合并分块

分块已经在Minio了,我们调用Minio提供的SDK即可合并分块

/**
 * 调用minio接口合并分块
 */
@Test
public void merge() throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
    //分块文件集合
    List<ComposeSource> sources = new ArrayList<>();
    sources.add(ComposeSource.builder()
            //分块文件所在桶
            .bucket("testbucket")
            //分块文件名称
            .object("chunk/0")
            .build());
    sources.add(ComposeSource.builder()
            //分块文件所在桶
            .bucket("testbucket")
            //分块文件名称
            .object("chunk/1")
            .build());

    ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()
            //指定合并后文件在哪个桶里
            .bucket("testbucket")
            //最终合并后的文件路径及名称
            .object("merge/merge01.mp4")
            //指定分块源文件
            .sources(sources)
            .build();
    //合并分块
    minioClient.composeObject(composeObjectArgs);
}

出现这个文件说明我们分块的文件太小了,要大于5242880byte,也就是5M,我们上传的文件才1048576byte,也就是1M

Minio默认的分块文件大小是5M

image-20231224021335475

2.4 上传视频

  • 前端上传视频之前,需要先检查文件是否已经上传过,我们只需要拿着文件的MD5值去数据库查询即可,如果查到了就说明之前已经上传完成了

  • 假如说文件没有上传过,前端开始对视频进行分块然后将分块传输给后端,但是后端不会立即将分块上传到Minio系统,而是先检查此分块是否从前上传到在Minio系统

我们知道文件的MD5值及分块的序号,就可以知道这个分块在不在Minio系统

因为我们是根据文件的MD5值及分块的序号在Minio系统中存储的

  • 等所有的分块文件都上传完毕后,前端就可以请求后端接口进行合并分块

我们某个文件的分块都是在一个目录下的,也就是说一个分块是在一个目录,不用担心分块文件会重复

2.4.1 文件上传前校验

分为“文件上传前检查文件”与“分块文件上传前的检测”两个接口

2.4.1.1 BigFilesController
/**
 * 1.检查文件数据库有没有
 * 2.如果数据库有了再检查minio系统当用有没有(可能存在脏数据,数据库中有但是minio没有那也要传输)
 */
@ApiOperation(value = "文件上传前检查文件")
@PostMapping("/upload/checkfile")
public RestResponse<Boolean> checkfile(@RequestParam("fileMd5") String fileMd5) {
    return mediaFileService.checkFile(fileMd5);
}

/**
 * 分块在数据库中是不存储的,但是可以向minio中查询分块是否存在
 * minio中有了就不再传了,若没有的话再传
 */
@ApiOperation(value = "分块文件上传前的检测")
@PostMapping("/upload/checkchunk")
public RestResponse<Boolean> checkChunk(@RequestParam("fileMd5") String fileMd5,
                                        @RequestParam("chunk") int chunk) throws Exception {
    return mediaFileService.checkChunk(fileMd5,chunk);
}
2.4.1.2 MediaFileServiceImpl
/**
 * 1.首先查询数据库,如果文件不在数据库中表明文件不在
 * 2.如果文件在数据库中再查询minio系统,
 *
 * @param fileMd5 文件的md5
 * @return com.xuecheng.base.model.RestResponse<java.lang.Boolean> false不存在,true存在
 * @description 检查文件是否存在
 */
@Override
public RestResponse<Boolean> checkFile(String fileMd5) {
    //TODO 1.查询数据库
    MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
    //TODO 2.如果数据库存在再查询minio
    if (mediaFiles != null) {
        GetObjectArgs getObjectArgs = GetObjectArgs.builder()
                //mediaFiles会有记录
                .bucket(mediaFiles.getBucket())
                .object(mediaFiles.getFilePath())
                .build();
        try {
            FilterInputStream inputStream = minioClient.getObject(getObjectArgs);
            if (inputStream != null) {
                //文件已经存在
                return RestResponse.success(true);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    //文件不存在
    return RestResponse.success(false);
}

private String getChunkFileFolderPath(String fileMd5) {
    return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/chunk/";
}

检查分块是否存在

对于Service的checkChunk分块文件校验方法我觉得还有另外一个见解思路

课程中对分块文件的校验思路只是检查一下minio系统中有没有对应的minio文件,如果没有的话就上传分块

但是我觉得这个地方可以改一改

有化成根据fileMd5去数据库查一下是否存在这个文件,然后得到这个文件的getFilePath,查看一下是否有完整的视频文件,如果有的话直接不用检查Minio分块文件是否存在了,假如说Minio没有完整的文件,那再上传对应的分块文件

可能有的人会问,我们明明已经在checkFile中校验了,为什么还要校验?

因为我在学习这个课程的时候我发现一个bug,就是我无法重复上传同一个文件,显然是不合理的

我说的重复上传的意思是毫无征兆的上传不上,前端也不会给什么提示信息,一直卡在百分之九十多的位置不动,为了优化这个地方我才采用了我这个思路

而且我不明白课程中checkFile明明已经校验为true文件存在了,还要进行checkChunk检查分块、合并分块的逻辑

如果是我做前端的话,我就当checkFile返回true时也不进行checkchunk检查分块,更不会合并分块

image-20231224223747158

image-20231224223730483

下面的TODO 1便是我新添加上的,只需要加载Todo2之前即可

2.4.3 合并分块文件模块也要加

//TODO 1.判断fileMd5对应的文件已经合并成一个完整的文件了,如果有了的话,那也不需要检查分块了
//判断Minio系统中是否有已经有合并的文件了,如果有的话没有分块所在路径也无所谓
RestResponse<Boolean> booleanRestResponse = this.checkFile(fileMd5);
if (booleanRestResponse.getResult()){
    //文件已经存在
    return RestResponse.success(true);
}
/**
 * 分块不会存在于数据库,直接查询minio系统即可
 *
 * @param fileMd5    文件的md5
 * @param chunkIndex 分块序号
 * @return com.xuecheng.base.model.RestResponse<java.lang.Boolean> false不存在,true存在
 * @description 检查分块是否存在
 */
@Override
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex) {
    //TODO 2.根据MD5得到分块文件的目录路径
    //分块存储路径:md5前两位为两个目录,MD5值也是一层目录,chunk目录存储分块文件
    String chunkFileFolderPath = this.getChunkFileFolderPath(fileMd5);

    GetObjectArgs getObjectArgs = GetObjectArgs.builder()
            //视频的桶
            .bucket(bucket_video)
            //文件名是目录路径+分块序号
            .object(chunkFileFolderPath+chunkIndex)
            .build();
    try {
        FilterInputStream inputStream = minioClient.getObject(getObjectArgs);
        if (inputStream != null) {
            //文件已经存在
            return RestResponse.success(true);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    return RestResponse.success(false);
}

2.4.2 上传分块文件

2.4.2.1 BigFilesController
@ApiOperation(value = "上传分块文件")
@PostMapping("/upload/uploadchunk")
public RestResponse uploadChunk(@RequestParam("file") MultipartFile file,
                                @RequestParam("fileMd5") String fileMd5,
                                @RequestParam("chunk") int chunk) throws Exception {
    //1.创建一个临时文件,前缀是"minio",后缀是“.temp”
    File tempFile = File.createTempFile("minio", ".temp");
    file.transferTo(tempFile);
    return mediaFileService.uploadChunk(fileMd5,chunk,tempFile.getAbsolutePath());
}
2.4.2.2 MediaFileServiceImpl
/**
 * @param fileMd5            文件md5
 * @param chunk              分块序号
 * @param localChunkFilePath 本地文件路径
 * @return com.xuecheng.base.model.RestResponse
 * @description 上传分块
 */
@Override
public RestResponse uploadChunk(String fileMd5, int chunk, String localChunkFilePath) {
    //TODO 将分块文件上传到minio
    //传空默认返回类型MediaType.APPLICATION_OCTET_STREAM_VALUE application/octet-stream未知流类型
    String mimeType = getMimeType(null);
    //获取分块文件的目录
    String chunkFileFolderPath = this.getChunkFileFolderPath(fileMd5);
    boolean b = this.addMediaFilesToMinIO(localChunkFilePath, bucket_video, chunkFileFolderPath + chunk, mimeType);
    if (!b) {
        //false
        return RestResponse.validfail(false, "上传分块文件{" + fileMd5 + "/" + chunk + "}失败");
    }
    //上传分块文件成功
    return RestResponse.success(true);
}

    /**
     * 将文件上传到MinIo
     *
     * @param bucket        桶
     * @param localFilePath 文件在本地的路径
     * @param objectName    上传到MinIo系统中时的文件名称
     * @param mimeType      上传的文件类型
     */
    private boolean addMediaFilesToMinIO(String localFilePath, String bucket, String objectName, String mimeType) {
        try {
            UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
                    //桶,也就是目录
                    .bucket(bucket)
                    //指定本地文件的路径
                    .filename(localFilePath)
                    //上传到minio中的对象名,上传的文件存储到哪个对象中
                    .object(objectName)
                    .contentType(mimeType)
                    //构建
                    .build();
            minioClient.uploadObject(uploadObjectArgs);
            log.debug("上传文件到minio成功,bucket:{},objectName:{}", bucket, objectName);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            log.info("上传文件出错,bucket:{},objectName:{},错误信息:{}", bucket, objectName, e.getMessage());
            return false;
        }
    }

2.4.3 合并分块文件

  • 找到分块文件

  • 调用Minio的SDK,将分块文件进行合并

  • 校验合并后的文件与原文件是否一致

    合并后的文件来源于分块文件,如果上传分块的过程中有问题导致数据丢失,那合并后的文件肯定是不完整的

    合并后的文件与原文件一致,才可以说视频是上传成功的

  • 将文件信息入库

  • 最后清理Minio的分块文件

2.4.3.1 BigFilesController
/**
 * @param fileMd5    文件md5值
 * @param fileName   合并分块之后要入库,fileName原始文件名要写在数据库
 * @param chunkTotal 总共分块数
 */
@ApiOperation(value = "合并文件")
@PostMapping("/upload/mergechunks")
public RestResponse mergeChunks(@RequestParam("fileMd5") String fileMd5,
                                @RequestParam("fileName") String fileName,
                                @RequestParam("chunkTotal") int chunkTotal) throws Exception {
    Long companyId = 1232141425L;
    //文件信息对象
    UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
    uploadFileParamsDto.setFilename(fileName);
    uploadFileParamsDto.setTags("视图文件");
    uploadFileParamsDto.setFileType("001002");//数据字典代码 - 001002代表视频
    return mediaFileService.mergeChunks(companyId, fileMd5, chunkTotal, uploadFileParamsDto);
}
2.4.3.2 MediaFileServiceImpl

其实和2.4.1模块同理

可以在mergeChunks放大TODO1之前下面添加这一段代码

假如说合并后的文件已经存在了,就不需要进行合并了

//TODO 0.如果已经有了合并分块后对应的文件的话,就不用再合并了
//判断Minio系统中是否有已经有合并的文件了,如果有的话没有分块所在路径也无所谓
RestResponse<Boolean> booleanRestResponse = this.checkFile(fileMd5);
if (booleanRestResponse.getResult()){
    //文件已经存在
    return RestResponse.success(true);
}
/**
 * 为什么又companyId 机构ID?
 * 分布式文件系统空间不是随便使用的,比如某个机构传输的课程很多很多,那我们就可以收费了(比如超过1Tb便开始收费)
 * 知道了companyId我们就知道是谁传的,也知道这些机构用了多少GB
 *
 * @param companyId           机构id
 * @param fileMd5             文件md5
 * @param chunkTotal          分块总和
 * @param uploadFileParamsDto 文件信息(要入库)
 * @return com.xuecheng.base.model.RestResponse
 * @description 合并分块
 */
@Override
public RestResponse mergeChunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {
    //TODO 1.获取所有分块文件
    List<ComposeSource> sources = new ArrayList<>();
    //1.1 分块文件路径
    String chunkFileFolderPath = this.getChunkFileFolderPath(fileMd5);
    for (int i = 0; i < chunkTotal; i++) {
        sources.add(ComposeSource.builder()
                //分块文件所在桶
                .bucket(bucket_video)
                //分块文件名称
                .object(chunkFileFolderPath + i).build());
    }
    //1.2 指定合并后文件存储在哪里
    String filename = uploadFileParamsDto.getFilename();
    String fileExt = filename.substring(filename.lastIndexOf("."));
    //1.3 获取对象存储名
    String filePathByMD5 = this.getFilePathByMD5(fileMd5, fileExt);
    ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()
            //指定合并后文件在哪个桶里
            .bucket(bucket_video)
            //最终合并后的文件路径及名称
            .object(filePathByMD5)
            //指定分块源文件
            .sources(sources).build();
    //TODO 2.合并分块
    try {
        minioClient.composeObject(composeObjectArgs);
    } catch (Exception e) {
        e.printStackTrace();
        log.error("合并文件出错:bucket:{},objectName:{},错误信息:{}", bucket_video, filePathByMD5, e.getMessage());
        return RestResponse.validfail(false, "合并文件出错");
    }

    //TODO 3.校验合并后的文件与原文件是否一致
    //3.1校验时先要把文件下载下来
    File tempFile = this.downloadFileFromMinIO(bucket_video, filePathByMD5);
    //3.2 比较原文件与临时文件的MD5值
    //将FileInputStream放在括号里,当try..catch执行结束后会自动关闭流,不用加finally了
    try (FileInputStream fis = new FileInputStream(tempFile)) {
        String mergeFile_md5 = DigestUtils.md5Hex(fis);
        if (!fileMd5.equals(mergeFile_md5)) {
            log.error("校验合并文件md5值不一致,原始文件{},合并文件{}", fileMd5, mergeFile_md5);
            return RestResponse.validfail(false, "文件校验失败");
        }

        //保存一下文件信息 - 文件大小
        uploadFileParamsDto.setFileSize(tempFile.length());

    } catch (IOException e) {
        e.printStackTrace();
        return RestResponse.validfail(false, "文件校验失败");
    }

    //TODO 4.文件信息
    MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_video, filePathByMD5);
    if (mediaFiles == null) {
        return RestResponse.validfail(false, "文件入库失败");
    }

    //TODO 5.清理分块文件
    //5.1获取分块文件路径
    //this.getChunkFileFolderPath(fileMd5);
    this.clearChunkFiles(chunkFileFolderPath, chunkTotal);

    return RestResponse.success(true);
}
       /**
     * 清除分块文件
     *
     * @param chunkFileFolderPath 分块文件路径
     * @param chunkTotal          分块文件总数
     */
    private void clearChunkFiles(String chunkFileFolderPath, int chunkTotal) {
        //需要参数removeObjectsArgs

        //Iterable<DeleteObject> objects =
        List<DeleteObject> objects = Stream.iterate(0, i -> ++i)
                .limit(chunkTotal)
                //String.concat函数用于拼接字符串
                .map(i -> new DeleteObject(chunkFileFolderPath.concat(Integer.toString(i))))
                .collect(Collectors.toList());

        RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder()
                //指定要清理的分块文件的桶
                .bucket(bucket_video)
                //需要一个Iterable<DeleteObject>迭代器
                .objects(objects)
                .build();
        //执行了这段方法并没有真正的删除,还需要遍历一下
        Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);
        results.forEach(f->{
            try {
                //get方法执行之后才是真正的删除了
                DeleteError deleteError = f.get();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
//        或者是下面这种遍历方式,都是可以的
//        for (Result<DeleteError> deleteError:results){
//            DeleteError error = deleteError.get();
//        }

    }

最终合并后的路径是在getFilePathByMD5中

/**
 * @param fileMd5 文件md5值
 * @param fileExt 文件扩展名
 */
private String getFilePathByMD5(String fileMd5, String fileExt) {
    return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + fileMd5 + fileExt;
}

2.4.4 测试

  • 首先修改Springboot-web默认上传文件大小

前端对文件分块的大小为5MB,SpringBoot web默认上传文件的大小限制为1MB,这里需要在media-api工程修改配置如下:

spring:
  servlet:
    multipart:
      max-file-size: 50MB
      max-request-size: 50MB
  • max-file-size

    指定单个文件上传的最大大小限制,单个文件的大小如果超过了这个配置的值,将会导致文件上传失败

  • max-request-size

    指定整个 HTTP 请求的最大大小限制,包括所有上传文件和其他请求数据,请求的总大小超过了这个配置的值,将会导致整个请求失败

两个配置项的值可以使用标准的大小单位,比如 KB(千字节)、MB(兆字节)等。在你的例子中,50MB 表示最大文件大小和最大请求大小都被限制为 50 兆字节

  • 点击“上传视频”按钮

image-20231224165736643

  • 点击“添加文件”按钮

image-20231224165809867

  • 上传文件

image-20231224172907840

  • 查看Minio系统

    分块上传挺成功的

image-20231224172946156

  • 合并分块之后

    所有的分块都被清理了,然后合并成一个文件

  • 经过我的2.4.3与2.4.1优化之后,上传挺正常了

image-20231224230839650

文章来源:https://blog.csdn.net/weixin_51351637/article/details/135188811
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。