您现在的位置是:首页 >学无止境 >【微服务 | 学成在线】项目易错重难点分析(媒资管理模块篇·上)网站首页学无止境

【微服务 | 学成在线】项目易错重难点分析(媒资管理模块篇·上)

十八岁讨厌编程 2023-07-07 20:00:03
简介【微服务 | 学成在线】项目易错重难点分析(媒资管理模块篇·上)

模块整体认识

媒资管理模块主要负责以下几件事情:

  • 上传图片
  • 上传视频
  • 处理视频
    • 对需要转码处理的视频系统会自动对其处理,处理后生成视频的URL
    • 处理视频没有用户界面,完全是后台自动执行
  • 审核媒资
    • 分为自动审核和人工审核
    • 可调用第三方鉴别接口协助审核
  • 绑定媒资
    • 课程计划创建好后需要绑定媒资文件,比如:如果课程计划绑定了视频文件,进入课程在线学习界面后点课程计划名称则在线播放视频。

架构问题分析

当前要开发的是媒资管理服务,目前为止共三个微服务:内容管理、系统管理、媒资管理,如下图:
在这里插入图片描述

后期还会添加更多的微服务,当前这种由前端直接请求微服务的方式存在弊端,如果在前端对每个请求地址都配置绝对路径,非常不利于系统维护,比如下边代码中请求系统管理服务的地址使用的是localhost:

在这里插入图片描述

当系统上线后这里需要改成公网的域名,如果这种地址非常多则非常麻烦。
基于这个问题可以采用网关来解决,如下图:

在这里插入图片描述

这样在前端的代码中只需要指定每个接口的相对路径,如下所示:

在这里插入图片描述

在前端代码的一个固定的地方在接口地址前统一加网关的地址,每个请求统一到网关,由网关将请求转发到具体的微服务。

为什么所有的请求先到网关呢?

有了网关就可以对请求进行路由,路由到具体的微服务,减少外界对接微服务的成本,比如:400电话,路由的试可以根据请求路径进行路由、根据host地址进行路由等, 当微服务有多个实例时可以通过负载均衡算法进行路由,如下:

在这里插入图片描述

另外,网关还可以实现权限控制、限流等功能。
项目采用Spring Cloud Gateway作为网关,网关在请求路由时需要知道每个微服务实例的地址,项目使用Nacos作用服务发现中心和配置中心,整体的架构图如下:

在这里插入图片描述

流程如下:

  • 微服务启动,将自己注册到Nacos,Nacos记录了各微服务实例的地址。
  • 网关从Nacos读取服务列表,包括服务名称、服务地址等。
  • 请求到达网关,网关将请求路由到具体的微服务。

要使用网关首先搭建Nacos,Nacos有两个作用:

  • 服务发现中心。
    微服务将自身注册至Nacos,网关从Nacos获取微服务列表。
  • 配置中心。
    微服务众多,它们的配置信息也非常复杂,为了提供系统的可维护性,微服务的配置信息统一在Nacos配置。

nacos配置管理搭建

在搭建Nacos服务发现中心之前需要搞清楚两个概念:

  • namespace:用于区分环境、比如:开发环境、测试环境、生产环境。
  • group:用于区分项目,比如:xuecheng-plus项目、xuecheng2.0项目

配置过程省略,踩坑点如下:

spring:
  application:
    name: system-service
  cloud:
    nacos:
      server-addr: 192.168.101.65:8848
      discovery:
        namespace: dev
        group: xuecheng-plus-project

这里的namespace配置的是命名空间的id,而不是名字

同时我们的nacos还可以当作配置中心来使用。

我们项目中的配置文件可以分为两类:

  • 每个项目特有的配置:该配置只在有些项目中需要配置,或者该配置在每个项目中配置的值不同
  • 项目所公有的配置:若干项目中配置内容相同的配置

nacos定位一个具体的配置文件:

  • 通过namespace、group找到具体的环境和具体的项目
  • 通过dataid找到具体的配置文件,dataid有三部分组成
    • 第一部分,它是在application.yaml中配置的应用名,即spring.application.name的值
    • 第二部分,它是环境名,通过spring.profiles.active指定
    • 第三部分,它是配置文件 的后缀,目前nacos支持properties、yaml等格式类型,本项目选择yaml格式类型

接下来我们开始改造content-api的配置:

在本地:

server:
  servlet:
    context-path: /content
  port: 63040
#微服务配置
spring:
  application:
    name: content-api
  cloud:
    nacos:
      server-addr: 192.168.101.65:8848
      discovery:
        namespace: e353f415-0df4-483b-b516-2d47fa1148c5   #namespace的编号不是名字
        group: xuecheng-plus
      config:
        file-extension: yaml
        namespace: e353f415-0df4-483b-b516-2d47fa1148c5
        group: xuecheng-plus

  #  在nacos中进行管理
#  datasource:
#    driver-class-name: com.mysql.cj.jdbc.Driver
#    url: jdbc:mysql://192.168.101.65:3306/xcplus_content?serverTimezone=UTC&userUnicode=true&useSSL=false&
#    username: root
#    password: mysql
  profiles:
    active: dev
# 日志文件配置路径
logging:
  config: classpath:log4j2-dev.xml

swagger:
  title: "学成在线内容管理系统"
  description: "内容系统管理系统对课程相关信息进行管理"
  base-package: com.xuecheng
  enabled: true
  version: 1.0.0

注意:配置管理和服务发现的依赖和配置是两套东西,不会互通。也就是如果你两个都要使用使用,那么两个都要分别配置。

在nacos中:

在这里插入图片描述

这里记住一定要引入nacos的config依赖!!!

我们初步总结一下放在本地的配置,一般符合三个特点:

  • 不怎么会变动的配置
  • 用来将服务注册到nacos的配置
  • 用来将本服务的配置纳入nacos管理的配置

然后我们继续来配置content-service

我们可以看到在api的配置文件中我们配置了数据库相关的信息。但是我们知道api层是不需要数据库的支持的,真正要配置数据库的应该是content-service。我们的此时content-service中没有任何配置是因为我们在api中引入了其依赖然后又配置了数据库相关的信息。

最佳实践应该这样:service正常进行配置,在api工程中去引用我们service中的配置

有人问既然在service层已经配置了为什么还要在api层引入?
我们在api层只是引入了service层的依赖但是依赖中是不会带有配置文件的,我们平时在jar包种看到的都是一个个class文件。依赖在哪里引入我们就要在哪里进行配置。

在这里插入图片描述
这里的extension-configs接受的是一个列表,我们在这里可以添加多个扩展文件。

nacos公用配置

还有一个优化的点是如何在nacos中配置项目的公用配置呢?

nacos提供了shared-configs可以引入公用配置。

在content-api中配置了swagger,所有的接口工程都需要配置swagger,这里就可以将swagger的配置定义为一个公用配置,哪个项目用引入即可。

我们创建一个xuecheng-plus-common分组专门用来放xuecheng-plus的公用配置,进入nacos的开发环境,添加swagger-dev.yaml公用配置:

在这里插入图片描述

删除接口工程中对swagger的配置。

项目使用shared-configs可以引入公用配置。在接口工程的本地配置文件 中引入公用配置,如下:

在这里插入图片描述

再以相同 的方法配置日志的公用配置。

配置优先级

到目前为止已将所有微服务的配置统一在nacos进行配置,用到的配置文件有本地的配置文件 bootstrap.yaml和nacos上的配置文件,SpringBoot读取配置文件 的顺序如下:
在这里插入图片描述
引入配置文件的形式有:

  • 以项目应用名方式引入
  • 以扩展配置文件方式引入
  • 以共享配置文件方式引入
  • 本地配置文件

各配置文件的优先级:

项目应用名配置文件 > 扩展配置文件 > 共享配置文件 > 本地配置文件

有时候我们在测试程序时直接在本地加一个配置进行测试,比如下边的例子:
我们想启动两个内容管理微服务,此时需要在本地指定不同的端口,通过VM Options参数,在IDEA配置启动参数

在这里插入图片描述
通过-D指定参数名和参数值,参数名即在bootstrap.yml中配置的server.port。

在这里插入图片描述
启动ContentApplication2,发现端口仍然是63040,这说明本地的配置没有生效。

这时我们想让本地最优先,可以在nacos配置文件 中配置如下即可实现

#配置本地优先
spring:
 cloud:
  config:
    override-none: true

我们也可以通过VM Options参数灵活的切换各种环境下的配置文件:
在这里插入图片描述

网关搭建

创建模块引入依赖这里不多赘述,我们直接来看看nacos中管理的配置文件:

server:
  port: 63010 # 网关端口
spring:
  cloud:
    gateway:
#      filter:
#        strip-prefix:
#          enabled: true
      routes: # 网关路由配置
        - id: content-api # 路由id,自定义,只要唯一即可
          # uri: http://127.0.0.1:8081 # 路由的目标地址 http就是固定地址
          uri: lb://content-api # 路由的目标地址 lb就是负载均衡,后面跟服务名称
          predicates: # 路由断言,也就是判断请求是否符合路由规则的条件
            - Path=/content/** # 这个是按照路径匹配,只要以/content/开头就符合要求
#          filters:
#            - StripPrefix=1
        - id: system-api
          # uri: http://127.0.0.1:8081
          uri: lb://system-api
          predicates:
            - Path=/system/**
#          filters:
#            - StripPrefix=1
        - id: media-api
          # uri: http://127.0.0.1:8081
          uri: lb://media-api
          predicates:
            - Path=/media/**
#          filters:
#            - StripPrefix=1
        - id: search-service
          # uri: http://127.0.0.1:8081
          uri: lb://search
          predicates:
            - Path=/search/**
#          filters:
#            - StripPrefix=1
        - id: auth-service
          # uri: http://127.0.0.1:8081
          uri: lb://auth-service
          predicates:
            - Path=/auth/**
#          filters:
#            - StripPrefix=1
        - id: checkcode
          # uri: http://127.0.0.1:8081
          uri: lb://checkcode
          predicates:
            - Path=/checkcode/**
#          filters:
#            - StripPrefix=1
        - id: learning-api
          # uri: http://127.0.0.1:8081
          uri: lb://learning-api
          predicates:
            - Path=/learning/**
#          filters:
#            - StripPrefix=1
        - id: orders-api
          # uri: http://127.0.0.1:8081
          uri: lb://orders-api
          predicates:
            - Path=/orders/**
#          filters:
#            - StripPrefix=1

分布式文件系统

什么是分布式文件系统

要理解分布式文件系统首先了解什么是文件系统。
查阅百度百科:

在这里插入图片描述

文件系统是负责管理和存储文件的系统软件,操作系统通过文件系统提供的接口去存取文件,用户通过操作系统访问磁盘上的文件。

下图指示了文件系统所处的位置:
在这里插入图片描述

常见的文件系统:FAT16/FAT32、NTFS、HFS、UFS、APFS、XFS、Ext4等 。

现在有个问题,一此短视频平台拥有大量的视频、图片,这些视频文件、图片文件该如何存储呢?如何存储可以满足互联网上海量用户的浏览。

分布式文件系统就是海量用户查阅海量文件的方案

我们阅读百度百科去理解分布式文件系统的定义:

在这里插入图片描述

通过概念可以简单理解为:一个计算机无法存储海量的文件,通过网络将若干计算机组织起来共同去存储海量的文件,去接收海量用户的请求,这些组织起来的计算机通过网络进行通信,如下图:

在这里插入图片描述

好处:

  • 一台计算机的文件系统处理能力扩充到多台计算机同时处理。
  • 一台计算机挂了还有另外副本计算机提供数据。
  • 每台计算机可以放在不同的地域,这样用户就可以就近访问,提高访问速度。

市面上有哪些分布式文件系统的产品呢?

  • NFS
    在这里插入图片描述

    • 特点:
      1)在客户端上映射NFS服务器的驱动器。
      2)客户端通过网络访问NFS服务器的硬盘完全透明。
  • GFS
    在这里插入图片描述
    在这里插入图片描述

    • GFS采用主从结构,一个GFS集群由一个master和大量的chunkserver组成。
    • master存储了数据文件的元数据,一个文件被分成了若干块存储在多个chunkserver中。
    • 用户从master中获取数据元信息,向chunkserver存储数据。
  • HDFS

    • 是Hadoop Distributed File System的简称,是Hadoop抽象文件系统的一种实现。HDFS是一个高度容错性的系统,适合部署在廉价的机器上。HDFS能提供高吞吐量的数据访问,非常适合大规模数据集上的应用。 HDFS的文件分布在集群机器上,同时提供副本进行容错及可靠性保证。例如客户端写入读取文件的直接操作都是分布在集群各个机器上的,没有单点性能压力。
      在这里插入图片描述

    • HDFS采用主从结构,一个HDFS集群由一个名称结点和若干数据结点组成。

    • 名称结点存储数据的元信息,一个完整的数据文件分成若干块存储在数据结点。

    • 客户端从名称结点获取数据的元信息及数据分块的信息,得到信息客户端即可从数据块来存取数据。

  • 云计算厂家

    • 阿里云对象存储服务(Object Storage Service,简称 OSS),是阿里云提供的海量、安全、低成本、高可靠的云存储服务。其数据设计持久性不低于 99.9999999999%,服务设计可用性(或业务连续性)不低于 99.995%。

      官方网站:https://www.aliyun.com/product/oss

    • 百度对象存储BOS提供稳定、安全、高效、高可扩展的云存储服务。您可以将任意数量和形式的非结构化数据存入BOS,并对数据进行管理和处理。BOS支持标准、低频、冷和归档存储等多种存储类型,满足多场景的存储需求。

      官方网站:https://cloud.baidu.com/product/bos.html

MinIO

本项目采用MinIO构建分布式文件系统,MinIO 是一个非常轻量的服务,可以很简单的和其他应用的结合使用,它兼容亚马逊 S3 云存储服务接口,非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等。

它一大特点就是轻量,使用简单,功能强大,支持各种平台,单个文件最大5TB,兼容 Amazon S3接口,提供了 Java、Python、GO等多版本SDK支持。

官网:https://min.io
中文:https://www.minio.org.cn/,http://docs.minio.org.cn/docs/

MinIO集群采用去中心化共享架构,每个结点是对等关系,通过Nginx可对MinIO进行负载均衡访问。

去中心化有什么好处?

在大数据领域,通常的设计理念都是无中心和分布式。Minio分布式模式可以帮助你搭建一个高可用的对象存储服务,你可以使用这些存储设备,而不用考虑其真实物理位置。

它将分布在不同服务器上的多块硬盘组成一个对象存储服务。由于硬盘分布在不同的节点上,分布式Minio避免了单点故障。如下图:
在这里插入图片描述
Minio使用纠删码技术来保护数据,它是一种恢复丢失和损坏数据的数学算法,它将数据分块冗余的分散存储在各各节点的磁盘上,所有的可用磁盘组成一个集合,上图由8块硬盘组成一个集合,当上传一个文件时会通过纠删码算法计算对文件进行分块存储,除了将文件本身分成4个数据块(一样的数据块),还会生成4个校验块,数据块和校验块会分散的存储在这8块硬盘上

这就意味着如果是8块盘,一个对象会被分成4个数据块、4个奇偶校验块,你可以丢失任意4块盘(不管其是存放的数据块还是奇偶校验块) , 你们仍可以从剩下的盘中的数据进行恢复。

使用纠删码的好处是即便丢失一半数量(N/2)的硬盘(),仍然可以恢复数据。 比如上边集合中有4个以内的硬盘损害仍可保证数据恢复,不影响上传和下载,如果多于一半的硬盘坏了则无法恢复。

数据恢复测试

下边在本机演示MinIO恢复数据的过程,在本地创建4个目录表示4个硬盘。

在这里插入图片描述

CMD进入有minio.exe的目录,运行下边的命令:

minio.exe server D:developminio_datadata1  D:developminio_datadata2  D:developminio_datadata3  D:developminio_datadata4

下边输入http://localhost:9000进行登录,账号和密码默认为:minioadmin/minioadmin

在这里插入图片描述

下一步创建bucket,桶,它相当于存储文件的目录,可以创建若干的桶。

在这里插入图片描述
输入bucket的名称,点击“CreateBucket”,创建成功

在这里插入图片描述

点击“upload”上传文件。

随便上传几个文件:

在这里插入图片描述

下边去四个目录观察文件的存储情况:
在这里插入图片描述
我们发现四个文件夹中均有我们的上传的文件。

接下来我们进行破坏性测试:

  • 如果只删除一个目录:删除目录后仍然可以在web控制台上传文件和下载文件,稍等片刻删除的目录自动恢复
  • 删除两个目录:删除两个目录也会自动恢复
  • 删除三个目录:由于 集合中共有4块硬盘,有大于一半的硬盘损坏数据无法恢复。
    • 此时报错:We encountered an internal error, please try again. (Read failed. Insufficient number of drives online)在线驱动器数量不足。

在本项目中我们的minio部署在Docker上,我们创建两个buckets:

  • mediafiles: 普通文件
  • video:视频文件

SDK

java版本的文档:
地址:https://docs.min.io/docs/java-client-quickstart-guide.html

所需依赖

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.4.3</version>
</dependency>
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.8.1</version>
</dependency>

连接到minio服务

需要三个参数才能连接到minio服务:
在这里插入图片描述

代码如下:

MinioClient minioClient =
            MinioClient.builder()
                    .endpoint("http://192.168.101.65:9000")
                    .credentials("minioadmin", "minioadmin")
                    .build();

创建一个桶

	  // Make 'asiatrip' bucket if not exist.
      boolean found =
          minioClient.bucketExists(BucketExistsArgs.builder().bucket("asiatrip").build());
      if (!found) {
        // Make a new bucket called 'asiatrip'.
        minioClient.makeBucket(MakeBucketArgs.builder().bucket("asiatrip").build());
      } else {
        System.out.println("Bucket 'asiatrip' already exists.");
      }

或者你自己手动创建一个:

在这里插入图片描述

点击“Manage”修改bucket的访问权限:

在这里插入图片描述
选择public权限

在这里插入图片描述

上传文件

      // Upload '/home/user/Photos/asiaphotos.zip' as object name   'asiaphotos-2015.zip' to bucket
      // 'asiatrip'.
      minioClient.uploadObject(
          UploadObjectArgs.builder()
              .bucket("asiatrip")
              .object("asiaphotos-2015.zip")
              .filename("/home/user/Photos/asiaphotos.zip")
              //默认根据扩展名确定文件内容类型,也可以指定
              .contentType("video/mp4")
              .build());
      System.out.println(
          "'/home/user/Photos/asiaphotos.zip' is successfully uploaded as "
              + "object 'asiaphotos-2015.zip' to bucket 'asiatrip'.");
    } catch (MinioException e) {
      System.out.println("Error occurred: " + e);
      System.out.println("HTTP trace: " + e.httpTrace());
    }

文件拓展名的获取

contentType可以通过com.j256.simplemagic.ContentType枚举类,它可以通过扩展名得到mimeType,代码如下:

    //根据扩展名取出mimeType
    ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(".mp4");
    String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//通用mimeType,字节流,用作保底方案
    //如果通过拓展名获取到了媒体类型,我们就对mimeType进行重新赋值
    if(extensionMatch != null) {
    	mimeType = extensionMatch.getMimeType(); 
    }

删除文件

	try {
        minioClient.removeObject(
               RemoveObjectArgs.builder()
               .bucket("testbucket")
               .object("001/test001.mp4")  //在桶的001子目录下
               .build()
        );
        System.out.println("删除成功");
    } catch (Exception e) {
       e.printStackTrace();
        System.out.println("删除失败");
    }

查询文件

通过查询文件查看文件是否存在minio中。

    GetObjectArgs getObjectArgs = GetObjectArgs.builder().bucket("testbucket").object("test001.mp4").build();
    try(
        FilterInputStream inputStream = minioClient.getObject(getObjectArgs);
        FileOutputStream outputStream = new FileOutputStream(new File("D:\develop\upload\1_2.mp4"));
     ) {
        IOUtils.copy(inputStream,outputStream);
     } catch (Exception e) {
        e.printStackTrace();
     }

这里使用了JDK7之后的语法try-with-resources

try (resource declaration) {
 // 使用的资源
} catch (ExceptionType e1) {
 // 异常块
}

合并文件块

//合并文件,要求分块文件最小5M
@Test
public void test_merge() throws Exception {
    List<ComposeSource> sources = Stream.iterate(0, i -> ++i)
            .limit(6)
            .map(i -> ComposeSource.builder()
                    .bucket("testbucket")
                    .object("chunk/".concat(Integer.toString(i)))
                    .build())
            .collect(Collectors.toList());

    ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder().bucket("testbucket").object("merge01.mp4").sources(sources).build();
    minioClient.composeObject(composeObjectArgs);

}

使用minio合并文件报错:java.lang.IllegalArgumentException: source testbucket/chunk/0: size 1048576 must be greater than 5242880
因为minio合并文件默认分块最小5M
SpringBoot web默认上传文件的大小限制为1MB,修改配置如下:

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

max-file-size:单个文件的大小限制
max-request-size: 单次请求的大小限制

删除分块文件

@Test
public void test_removeObjects(){
    //合并分块完成将分块文件清除
    List<DeleteObject> deleteObjects = Stream.iterate(0, i -> ++i)
            .limit(6)
            .map(i -> new DeleteObject("chunk/".concat(Integer.toString(i))))
            .collect(Collectors.toList());

    RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder().bucket("testbucket").objects(deleteObjects).build();
    Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);
    results.forEach(r->{
        DeleteError deleteError = null;
        try {
            deleteError = r.get();
        } catch (Exception e) {
            e.printStackTrace();
        }
    });
}

校验文件的完整性,对文件计算出md5值,比较原始文件的md5和目标文件的md5,一致则说明完整

//校验文件的完整性对文件的内容进行md5
FileInputStream fileInputStream1 = new FileInputStream(new File("D:\develop\upload\1.mp4"));
String source_md5 = DigestUtils.md5Hex(fileInputStream1);
FileInputStream fileInputStream = new FileInputStream(new File("D:\develop\upload\1a.mp4"));
String local_md5 = DigestUtils.md5Hex(fileInputStream);
if(source_md5.equals(local_md5)){
    System.out.println("下载成功");
}

上传图片

在这里插入图片描述

  • 前端进入上传图片界面
  • 上传图片,请求媒资管理服务。
  • 媒资管理服务将图片文件存储在MinIO。
  • 媒资管理记录文件信息到数据库。
  • 前端请求内容管理服务保存课程信息,在内容管理数据库保存图片地址。

http请求头中的content-type

我们的项目代码中涉及这一部分的内容,所以我在这里提一下

要知道什么是Content-Type,首先要了解什么是Internet Media Type。Internet Media Type即互联网媒体类型,也叫做MIME类型,使用两部分标识符来确定一个类型。在HTTP协议消息头中,使用Content-Type来表示具体请求中的媒体类型信息,意思就是说,Content-Type是Internet Media Type在HTTP协议中的别称。

我在这里提一下比较容易混淆的两种:

  • application/x-www-form-urlencoded: <form encType=””>中默认的encType,form表单数据被编码为key/value格式发送到服务器(表单默认的提交数据的格式)
  • multipart/form-data : 需要在表单中进行文件上传时,就需要使用该格式。

Spring MVC中关于Content-Type类型信息的使用:

在Spring MVC中,主要就是使用@RequestMapping注解来处理请求,其中:

  • consumes:指定处理请求的提交内容类型(Content-Type),例如application/json、text/html等。

想了解更多可以看以下的文章:
http请求头中的content-type 属性

@RequestPart

我们可以在Controller参数中发现一个没有见过的注解 – @RequestPart

  • @RequestPart这个注解用在multipart/form-data表单提交请求的方法上。
  • 接收参数的类型为MultipartFile,属于Spring的MultipartResolver类。这个请求是通过http协议传输的

我们常见的@RequestParam支持’application/json’,也同样支持multipart/form-data请求

区别在于当请求为multipart/form-data时,@RequestParam只能接收String类型的name-value值,@RequestPart可以接收复杂的请求域(像json、xml);@RequestParam 依赖Converter or PropertyEditor进行数据解析, @RequestPart参考’Content-Type’ 请求标头字段,依赖HttpMessageConverters进行数据解析

接口分析

在这里插入图片描述

在mapper层我们只需要保存媒资文件信息即可,对应的模型类为MediaFiles,可以直接使用Mybatis帮我们完成,其中再来看看Service层。

在Service层我们大致要干两件事:

  • 将用户上传的文件存储到MinIO中
  • 保存媒资信息到数据库(调用Mapper层接口)

我们结合着Minio上传文件的代码,来看看Service层我们需要什么参数:

// Upload '/home/user/Photos/asiaphotos.zip' as object name   'asiaphotos-2015.zip' to bucket  'asiatrip'.
      minioClient.uploadObject(
          UploadObjectArgs.builder()
              .bucket("asiatrip")
              .object("asiaphotos-2015.zip")
              .filename("/home/user/Photos/asiaphotos.zip")
              //默认根据扩展名确定文件内容类型,也可以指定
              .contentType("video/mp4")
              .build());

我们可以看到上传文件到MinIO需要桶的名字、文件名字、文件的位置这几个参数。其中文件的名字我们可以在文件位置中获得后缀然后自己统一自定义即可。也就是说桶的名字、文件位置是必要的参数

而保存媒资文件到数据库的参数我们使用UploadFileParamsDto。

返回值我们根据Controller层来,也就是返回UploadFileResultDto(上传普通文件成功响应结果)。

Service层的优化

目前是在uploadFile方法上添加@Transactional,当调用uploadFile方法前会开启数据库事务,如果上传文件过程时间较长那么数据库的事务持续时间就会变长,这样数据库链接释放就慢,最终导致数据库链接不够用
所以我们缩小事务范围,只将addMediaFilesToDb方法添加事务控制即可,uploadFile方法上的@Transactional注解去掉。

但是这样修改之后我们会发现一个问题,我们的addMediaFilesToDb方法是在uploadFile中调用的,这样的话addMediaFilesToDb方法并不是代理对象调用的,也就是说这个时候Spring事务会失效。

其实是一个非事务方法调同类一个事务方法,事务无法控制,这是为什么?
下边分析原因:
如果在uploadFile方法上添加@Transactional注解,代理对象执行此方法前会开启事务,如下图:
在这里插入图片描述
如果在uploadFile方法上没有@Transactional注解,代理对象执行此方法前不进行事务控制,如下图:
在这里插入图片描述
所以判断该方法是否可以事务控制必须保证是通过代理对象调用此方法,且此方法上添加了@Transactional注解。

这里解决方法有很多种,例如:

  • 获取代理类,利用代理类去调用addMediaFilesToDb方法
  • 自己注入自己,用注入的实例去调用addMediaFilesToDb方法
    • 这个方法的原理就是spring的ioc容器中默认都是原生对象,只有通过AOP增强的对象才是代理对象。例如配置了AOP的类或者类中方法上有@Transactional注解的

上传视频

断点续传技术

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

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

断点续传流程如下图:
在这里插入图片描述

流程如下:

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

java代码模拟分块与合并

文件分块的流程如下:

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

代码如下(省去了一些校验步骤,显示的较为核心的代码):

    /**
     * 大文件的分块上传
     */
    @Test
    public void testChunk() throws IOException {
        //先得到视频文件的位置
        File videoFile = new File("E:\filetest\项目视频1.mp4");

        //获取输入流
        FileInputStream fileInputStream = new FileInputStream(videoFile);

        //设置缓冲区
        byte[] buffer = new byte[1024];

        //约定一个region的大小为1MB

        final int REGION_SIZE = 1024 * 1024 * 1;

        //计算region个数
        int regionNum = (int) Math.ceil(videoFile.length() * 1.0 / REGION_SIZE);

        //开始进行分区上传
        for (int i = 0; i < regionNum; i++) {
            //配置路径
            File fileRegion = new File("E:\filetest\upload\" + i);

            //配置输出流
            FileOutputStream fileOutputStream = new FileOutputStream(fileRegion);
            //开始上传
            int s;
            while ((s = fileInputStream.read(buffer)) != -1) {
                //这里两句话的顺序很重要
                fileOutputStream.write(buffer, 0, s);
                if (fileRegion.length() >= REGION_SIZE) break;
            }
            fileOutputStream.close();
        }

        //关闭输入流
        fileInputStream.close();
    }

这一段代码我最先开始写的时候将while中的两个句子顺序弄反了。也就是if在前,write在后。如果这样的话我们分块的文件会变得不完整。我们可以试想一下,因为我们每一个region是1MB,当我们在上传第1025个kb的时候,我们会进入if语句从而break,也就是说这1kb就没有写进去,但是这1kb又是必不可少的,这就造成了文件的缺损。

文件合并流程:

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

代码如下:

    /**
     * 将文件进行合并
     */
    @Test
    public void testMerge() throws IOException {
        //设置region读取位置
        File regionFiles = new File("E:\filetest\upload");

        //设置合并后的存储位置
        File mergeFile = new File("E:\filetest\合并后.mp4");

        //获取所有region
        File[] files = regionFiles.listFiles();

        List<File> regionList = Arrays.asList(files);

        //将获取的regions进行排序,保证还原的有序性
        Collections.sort(
                regionList,
                (file1, file2) -> Integer.parseInt(file1.getName()) - Integer.parseInt(file2.getName())
        );

        //获取输出流
        FileOutputStream fileOutputStream = new FileOutputStream(mergeFile);

        //设置缓冲区
        byte[] buffer = new byte[1024];

        //将一个个rigion取出来合并
        for (File file : regionList) {
            //获得输入流
            FileInputStream fileInputStream = new FileInputStream(file);

            int k;
            while ((k = fileInputStream.read(buffer)) != -1) {
                fileOutputStream.write(buffer, 0, k);
            }

            //关闭流
            fileInputStream.close();
        }

        //关闭输出流
        fileOutputStream.close();

        //使用md5校验文件
        try (

                FileInputStream fileInputStream = new FileInputStream(new File("E:\filetest\项目视频1.mp4"));
                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("合并文件失败");
            }

        }

    }
}

上传视频流程

在这里插入图片描述

  1. 前端对文件进行分块。
  2. 前端上传分块文件前请求媒资服务检查文件是否存在,如果已经存在则不再上传。
  3. 如果分块文件不存在则前端开始上传
  4. 前端请求媒资服务上传分块。
  5. 媒资服务将分块上传至MinIO。
  6. 前端将分块上传完毕请求媒资服务合并分块。
  7. 媒资服务判断分块上传完成则请求MinIO合并文件。
  8. 合并完成校验合并后的文件是否完整,如果不完整则删除文件。

接口分析

在这里插入图片描述

此接口用来在上传文件之前检查该文件是否已经存在,前端只需要提供文件的md5值即可。

在这里插入图片描述
此接口用来在断点续传的时候检查哪些分块已经上传过了。我们需要的参数是文件的md5值以及分块的编号。因为我们在MinIO中使用文件md5值的前2位作为目录,以此来减小io的开销,提升查询的速度:
在这里插入图片描述
在这里插入图片描述

此接口用来上传分块文件,使用MultipartFile来接收前端传过来的分块文件。md5参数用来确认该分块放在桶中的哪个目录中。chunk用来给该分块编号。

在这里插入图片描述
此接口用来合并文件。fileMd5用来确认文件块放在那里,fileName用来给合并之后的文件命名,chunkTotal用来决定合并时for循环的次数。

然后我们具体来看看合并分块的业务层逻辑:

  • 找到分块文件调用MinIO的SDK进行文件合并
  • 校验合并后和源文件是否一致
    • 这个地方源文件的md5值是前端给出的,而我们需要把合并后的文件下载下来,然后计算md5值,再进行比较(文档中是这么个思路,但我认为可以不需要下载,直接从MinIO中查询合并文件拿到流就可以计算md5值了,不需要下载到本地)。
  • 将文件信息入库
  • 清理分块文件(单独抽象出方法)
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。