学成在线--媒资管理模块

媒资管理模块

模块需求分析

模块介绍

  • 媒资管理系统是每个在线教育平台所必须具备的,百度百科对其定义如下

媒体资源管理(Media Asset Management,MAM)系统是建立在多媒体、网络、数据库和数字存储等先进技术基础上的一个对各种媒体及内容(如视/音频资料、文本文件、图表等)进行数字化存储、管理以及应用的总体解决方案,包括数字媒体的采集、编目、管理、传输和编码转换等所有环节。其主要是满足媒体资源拥有者收集、保存、查找、编辑、发布各种信息的要求,为媒体资源的使用者提供访问内容的便捷方法,实现对媒体资源的高效管理,大幅度提高媒体资源的价值。

  • 每个教学机构都可以在媒资管理系统管理自己的教学资源,包括:视频、教案等文件
  • 目前媒资管理的主要管理对象是视频、图片、文档等,包括:媒资文件的查询、文件上传、视频处理等
  • 主要的几个功能如下:
    • 媒资查询:教学机构查询自己所拥有的的媒资信息
    • 文件上传:包括上传图片、上传文档、上传视频
    • 视频处理:视频上传成功,系统自动对视频进行编码处理
    • 文件删除:教学机构删除自己上传的媒资文件
  • 下图是课程编辑与发布的全流程,可以通过下图看到媒资在整体流程的位置

业务流程

上传图片

  • 教学机构人员在课程信息编辑页面上传课程图片,课程图片统一记录在媒资管理系统

上传视频

  1. 教学机构人员进入媒资管理列表查询自己上传的媒资文件
  2. 教育机构用户在媒资管理页面中点击上传视频按钮
  3. 选择要上传的文件,自动执行文件上传
  4. 视频上传成功会自动处理,处理完成后可以预览视频

处理视频

  • 对需要转码处理的视频,系统会自动对齐处理,处理后生成视频的URL

审核媒资

  1. 运营用户登入运营平台,并进入媒资管理界面,查找待审核媒资
  2. 点击列表中媒资名称连接,可以预览该媒资,若是视频,则播放视频
  3. 点击列表中某媒资后的审核按钮,即完成媒资的审批过程

绑定媒资

  • 课程计划创建好后需要绑定媒资文件,比如:如果课程计划绑定了视频文件,进入课程在线学习界面后,点课程计划名称则在线播放视频
  • 如何将课程计划绑定媒资呢?
    1. 教育机构用户进入课程管理页面编辑某一课程,在课程大纲编辑页的某一小节后,可以添加媒资信息
    2. 点击添加视频,会弹出对话框,可通过输入视频关键字搜索已审核通过的视频媒资
    3. 选择视频媒资,点击提交安努,完成课程计划绑定媒资流程

数据模型

  • 本模块妹子恩建相关数据表如下
    1. media_files:存储文件信息,包括图片、视频、文档等
    2. media_process:待处理视频表
    3. media_process_history:视频处理历史表,记录已经处理成功的视频信息

搭建模块环境

架构分析

  • 当前要开发的是媒资管理服务,目前为止共三个微服务:内容管理、系统管理、媒资管理
  • 后期还会添加更多的微服务,目前这种由前端直接请求微服务的方式存在弊端
  • 如果在前端对每个请求地址都配置绝对路径,非常不利于系统维护,例如下面这种
// 列表
export async function dictionaryAll(params: any = undefined, body: any = undefined): Promise<ISystemDictionary[]> {
	//const { data } = await createAPI('/system/dictionary/all', 'get', params, body)
	const { data } = await createAPI('http://localhost:53110/system/dictionary/all', 'get', params, body)
	return data
}
  • 当系统上限后,需要将localhost改为公网域名,如果这种地址非常多,那么修改会很麻烦
  • 基于这个问题,可以采用网关来解决
  • 这样在前端代码中只需要指定每个接口的相对路径
// 列表
export async function dictionaryAll(params: any = undefined, body: any = undefined): Promise<ISystemDictionary[]> {
	//const { data } = await createAPI('/system/dictionary/all', 'get', params, body)
	const { data } = await createAPI('/system/dictionary/all', 'get', params, body)
	return data
}
  • 在前端代码的一个固定的地方在接口地址前统一添加网关地址,每个请求统一到网关,由网关将请求转发到具体的微服务
  • 有了网关就可以对请求进行路由,例如:可以根据请求路径路由、根据host地址路由等。当微服务有多个实例时,还可以通过负载均衡算法进行路由
  • 此外,网关还可以实现权限控制、限流等功能
  • 本项目采用SpringCloudGateway作为网关,网关在请求路由时,需要知道每个微服务实例的地址。
  • 项目使用Nacos作为服务发现中心和配置中心,整体架构如下
  • 流程如下
    1. 微服务启动,将自己注册到Nacos,Nacos记录了个微服务实例的地址
    2. 网关从Nacos读取服务列表,包括服务名称、服务地址等
    3. 请求到达网关,网关将请求路由到具体的微服务
  • 要使用网关首先搭建Nacos、Nacos有两个作用
    1. 服务发现中心
      • 微服务将自身注册到Nacos,网关从Nacos获取微服务列表
    2. 配置中心
      • 微服务众多,它们的配置信息也非常复杂,为了提供系统的可维护性,微服务的配置信息统一在Nacos配置

搭建Nacos

  • 在此之前我们先下载安装并启动
    • 拉取镜像
    docker pull nacos/nacos-server:1.4.1
    
    • 创建并运行一个容器
    docker run --name nacos -e MODE=standalone -p 8849:8848 -d nacos/nacos-server:1.4.1
    
    • 访问虚拟机ip:8848/nacos登录,默认的账号密码均为nacos

服务发现中心

  • 搭建Nacos服务发现中心之前,需要搞清楚两个概念
    1. namespace:用于区分环境,例如:开发环境dev、测试环境test、生产环境prod
    2. group:用于区分项目,例如xuecheng-plus、reggie项目
  • 首先,在Nacos配置namespace
    • 新增开发环境命名空间
      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D6hBzVdb-1679735939066)(https://s1.ax1x.com/2023/02/13/pSIg82j.png)]
    • 用同样的方法新增生产环境和测试环境的命名空间
  • 随后将各微服务注册到Nacos
    1. 在xuecheng-plus-parent中添加依赖管理
    <properties>
        <spring-cloud-alibaba.version>2.2.6.RELEASE</spring-cloud-alibaba.version>
    </properties>
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-alibaba-dependencies</artifactId>
        <version>${spring-cloud-alibaba.version}</version>
        <type>pom</type>
        <scope>import</scope>
    </dependency>
    
    1. 在xuecheng-content-api、xuecheng-plus-system中添加依赖
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-alibaba-nacos-discovery</artifactId>
    </dependency>
    
    1. 配置nacos地址
      • 在content-service的application.yml中配置如下信息
      spring:
        application:
          name: content-service
        cloud:
          nacos:
            server-addr: 127.0.0.1:8848
            discovery:
              namespace: dev
              group: xuecheng-plus-project
      
      • 在system-service的application.yml中配置如下信息
      spring:
        application:
          name: system-service
        cloud:
          nacos:
            server-addr: 127.0.0.1:8848
            discovery:
              namespace: dev
              group: xuecheng-plus-project
      
    2. 重启这两个服务,进入Nacos中查看服务列表

配置中心

配置三要素
  • 搭建完Nacos服务发现中心,现在我们来搭建Nacos配置中心,其目的就是通过Nacos去管理项目中的所有配置
  • 那么先将项目中的配置文件进行分类
    1. 每个项目特有的配置
      • 是指该配置只有在某些项目中需要配置,或者该配置在每个项目中配置的值不同
      • 例如spring.application.name
    2. 项目所公用的配置
      • 是指一些在若干项目中配置内容相同的配置,例如redis的配置,很多项目用的同一套redis服务,所以配置也一样
  • 另外,还需要知道Nacos如何去定位一个具体的配置文件,即配置三要素
    • namespace
    • group
    • dataid
  • 通过namespace、group找到具体的环境和具体的项目
  • 通过dataid找到具体的配置文件,dataid由三部分组成
    • 例如content-service-dev.yml,由content-servicedevyml三部分组成
      • content-service:它是在application.yml中配置的应用名,即spring.application.name的值
      • dev:它是环境名,由spring.profile.active指定
      • yml:它是配置文件的后缀
  • 所以,如果我们要配置content-service工程的配置文件
    • 在开发环境中配置content-service-dev.yml
    • 在生产环境中配置content-service-prod.yml
    • 在测试环境中配置content-service-test.yml
配置content-service
  • 下面以配置content-service为例,在开发环境下添加content-service-dev.yaml配置
  • 为什么不在nacos中配置如下内容?
spring:
  application:
    name: content-service
  • 因为Nacos是先根据spring.cloud.nacos.server-addr获取Nacos地址,再根据${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}作为文件id,来读取配置。
  • 而读取配置文件的顺序如下
    1. bootstrap.yml
    2. nacos中的配置文件
    3. 本地application.yml
  • 所以我们要先在bootstrap.yml中配置文件id,然后nacos才知道怎么读取配置文件。
  • 那么我们现在在content-service下创建bootstrap.yml
spring:
  application:
    name: content-service
  cloud:
    nacos:
      server-addr: 127.0.0.1:8848
      config:
        namespace: ${spring.profiles.active}
        group: xuecheng-plus-project
        file-extension: yaml
        refresh-enabled: true
  profiles:
    active: dev
  • 删除原本的application.yml,在content-service工程中添加依赖
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
    </dependency>
  • 随后运行测试方法,看看能否正产读取数据库的配置信息,并读取数据
    @Test
    void contextQueryCourseTest() {
        PageResult<CourseBase> result = courseBaseInfoService.queryCourseBaseList(new PageParams(1L, 10L), new QueryCourseParamDto());
        log.info("查询到数据:{}", result);
    }
配置content-api
  • 以相同的方法配置content-api,在nacos的开发环境下新建配置content-api-dev.yaml
    {% tabs 交给nacos的content-api配置 %}
  • 我们先来看看哪些配置可以交给Nacos管理
    • 原本content-api的配置如下,除了服务名,都可以交给content-api-dev.yaml管理
    server:
      servlet:
        context-path: /content
      port: 53040
    
    spring:
      application:
        name: content-api
      
    # 日志文件配置路径
    logging:
      config: classpath:log4j2-dev.xml
    
    # swagger 文档配置
    swagger:
      title: "学成在线内容管理系统"
      description: "内容系统管理系统对课程相关信息进行业务管理数据"
      base-package: com.xuecheng.content
      enabled: true
      version: 1.0.0
    
  • 经过我们的分析,content-api-dev.yaml的内容如下
server:
  servlet:
    context-path: /content
  port: 53040
  
# 日志文件配置路径
logging:
  config: classpath:log4j2-dev.xml

# swagger 文档配置
swagger:
  title: "学成在线内容管理系统"
  description: "内容系统管理系统对课程相关信息进行业务管理数据"
  base-package: com.xuecheng.content
  enabled: true
  version: 1.0.0

{% endtabs %}

  • 那么我们现在就需要配置content-apibootstrap.yml
#微服务配置
spring:
  application:
    name: content-api
  cloud:
    nacos:
      server-addr: 127.0.0.1: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
        extension-configs:
          - data-id: content-service-${spring.profiles.active}.yaml
            group: xuecheng-plus-project
            refresh: true
  profiles:
    active: dev

{% note warning no-icon %}

  • 注意:因为content-api接口工程依赖了content-service工程的jar,而content-service的配置也交由nacos管理了(主要是数据库配置),所以我们现在需要extension-configs扩展配置文件的方式引用service工程的配置文件
  • 如果需要添加多个扩展文件,继续在下面添加即可
extension-configs:
    - data-id: content-service-${spring.profiles.active}.yaml
    group: xuecheng-plus-project
    refresh: true
    - data-id: 填写文件 dataid
    group: xuecheng-plus-project
    refresh: true           

{% endnote %}

公用配置

  • nacos还提供了shared-configs,可以引用公用配置
  • 我们之前在content-api中配置了swagger,并且所有接口工程都需要配置swagger
  • 那么我们这里就可以将swagger的配置定义为一个公用配置,哪个项目需要,哪个项目就引用
  • 接下来,我们就着手创建xuecheng-plus的公用配置,进入nacos的开发环境,添加swagger-dev.yaml公用配置,这里的group可以设置为xuecheng-plus-common,该组下的内容都作为xuecheng-plus的公用配置
  • 删除content-api-dev.yaml中的swagger配置,在content-apibootstrap.yml中使用shared-config添加公用配置
    {% tabs 删除配置 %}
  server:
    servlet:
      context-path: /content
    port: 53040
    
  # 日志文件配置路径
  logging:
    config: classpath:log4j2-dev.xml
  # swagger 文档配置
- swagger:
-   title: "学成在线内容管理系统"
-   description: "内容系统管理系统对课程相关信息进行业务管理数据"
-   base-package: com.xuecheng.content
-   enabled: true
-   version: 1.0.0
#微服务配置
spring:
  application:
    name: content-api
  cloud:
    nacos:
      server-addr: 127.0.0.1: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
        extension-configs:
          - data-id: content-service-${spring.profiles.active}.yaml
            group: xuecheng-plus-project
            refresh: true
+       shared-configs: 
+         - data-id: swagger-${spring.profiles.active}.yaml
+           group: xuecheng-plus-common
+           refresh: true

  profiles:
    active: dev

{% endtabs %}

  • 再以相同的方法配置日志的公用配置
  • 删除content-api-dev.yaml中的logging配置,在content-apibootstrap.yml中使用shared-config添加公用配置
    {% tabs 删置 %}
  server:
    servlet:
      context-path: /content
    port: 53040
    
- # 日志文件配置路径
- logging:
-   config: classpath:log4j2-dev.xml
    shared-configs:
        - data-id: swagger-${spring.profiles.active}.yaml
        group: xuecheng-plus-common
        refresh: true
+       - data-id: logging-${spring.profiles.active}.yaml
+       group: xuecheng-plus-common
+       refresh: true

{% endtabs %}

  • 那么到此为止,配置完成,重启content-api,访问swagger页面,查看swagger接口文档是否可以正常显示,查看控制台log4j2日志是否正常输出

系统管理配置

  • 按照上面的方法,将系统管理服务的配置信息在nacos上进行配置
  • 在开发环境下添加system-service-dev.yaml配置
  • 在system-service下创建bootstrap.yml配置文件
spring:
  application:
    name: system-service
  cloud:
    nacos:
      server-addr: 127.0.0.1:8848
      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:
    active: dev
  • 在开发环境下添加system-api-dev.yaml配置
  • 在system-api下创建bootstrap.yml配置文件
#微服务配置
spring:
  application:
    name: system-api
  cloud:
    nacos:
      server-addr: 127.0.0.1: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
        extension-configs:
          - data-id: system-service-${spring.profiles.active}.yaml
            group: xuecheng-plus-project
            refresh: true
        shared-configs:
          - data-id: swagger-${spring.profiles.active}.yaml
            group: xuecheng-plus-common
            refresh: true
          - data-id: logging-${spring.profiles.active}.yaml
            group: xuecheng-plus-common
            refresh: true
  profiles:
    active: dev

配置优先级

  • 到目前位置,已经将所有微服务的配置统一在nacos进行配置,用到的配置文件有本地的bootstrap.yaml和nacos上的配置文件
  • 引入配置文件的形式有
    1. 通过dataid方式引入
    2. 以扩展配置文件方式引入
    3. 以公用配置文件方式引入
  • 各配置文件的优先级:项目应用名配置文件(content-api-dev.yaml) > 扩展配置文件(content-service-dev.html) > 共享配置文件(swagger-dev.yaml) > 本地配置文件(application.yml)
  • 有时候我们在测试程序时,直接在本地加一个配置进行测试,这时候我们想让本地配置优先级最高,那么可以在nacos配置文件中配置如下内容
spring:
  cloud:
    config: 
      override-none: true

搭建Gateway

  • 本项目使用SpringCloudGateway作为网关,下面创建网关工程
  • 指定父工程,添加依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <!--指定父工程为xuecheng-plus-parent-->
    <parent>
        <groupId>com.xuecheng</groupId>
        <artifactId>xuecheng-plus-parent</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <relativePath>../xuecheng-plus-parent</relativePath>
    </parent>

    <artifactId>xuecheng-plus-gateway</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>xuecheng-plus-gateway</name>
    <description>xuecheng-plus-gateway</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <!--网关-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <!--服务发现中心-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </dependency>

        <!-- 排除 Spring Boot 依赖的日志包冲突 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-logging</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!-- Spring Boot 集成 log4j2 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-log4j2</artifactId>
        </dependency>
    </dependencies>

</project>
  • 配置网关的bootstrap.yml配置文件
#微服务配置
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: 127.0.0.1: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:
    active: dev
  • 在nacos的开发环境下创建gateway-dev.yaml配置
server:
  port: 53010 # 网关端口
spring:
  cloud:
    gateway:
      routes: 
        - id: content-api # 路由id,自定义,只要唯一即可
          uri: lb://content-api # 路由的目标地址 lb就是负载均衡,后面跟服务名称
          predicates: # 路由断言,也就是判断请求是否符合路由规则的条件
            - Path=/content/** # 这个是按照路径匹配,只要以/content/开头就符合要求
        - id: system-api
          uri: lb://system-api
          predicates:
            - Path=/system/**
  • 在http-client.env.json中配置网关的地址
{
  "dev": {
    "host": "localhost:53010",
    "content_host": "localhost:53040",
    "system_host": "localhost:53110",
    "media_host": "localhost:53050",
    "cache_host": "localhost:53035",
+   "gateway_host": "localhost:53010"
  }
}
  • 使用HTTP Client测试课程查询接口
### 课程查询列表
POST {{gateway_host}}/content/course/list?pageNo=1&pageSize=2
Content-Type: application/json

{
  "auditStatus": "",
  "courseName": "",
  "publishStatus": ""
}
  • 运行,得到正常请求数据
POST http://localhost:53010/content/course/list?pageNo=1&pageSize=2

HTTP/1.1 200 OK
transfer-encoding: chunked
Content-Type: application/json
Date: Tue, 14 Feb 2023 10:25:35 GMT

{
  "items": [
    {
      "id": 1,
      "companyId": 22,
      "companyName": null,
      "name": "JAVA8/9/10新特性讲解啊",

···

}

搭建媒资工程

  • 从课程资料中获取媒资工程xuecheng-plus-media,拷贝到项目工程根目录,修改其bootstrap中的nacos连接信息
  • 创建媒资数据库xc_media,并导入数据库脚本
  • 在nacos的开发环境下创建media-api-dev.yamlmedia-service-dev.yaml
    {% tabs 媒资工程的两个配置 %}
server:
  servlet:
    context-path: /media
  port: 53050
spring:
   datasource:
    druid:
      stat-view-servlet:
        enabled: true
        loginUsername: admin
        loginPassword: 123456
    dynamic:
      primary: content #设置默认的数据源或者数据源组,默认值即为master
      strict: true #设置严格模式,默认false不启动. 启动后在未匹配到指定数据源时候回抛出异常,不启动会使用默认数据源.
      druid:
        initial-size: 3
        max-active: 5
        min-idle: 5
        max-wait: 60000
        pool-prepared-statements: true
        max-pool-prepared-statement-per-connection-size: 20
        time-between-eviction-runs-millis: 60000
        min-evictable-idle-time-millis: 300000
        validation-query: SELECT 1 FROM DUAL
        test-while-idle: true
        test-on-borrow: false
        test-on-return: false
        stat-view-servlet:
          enabled: true
          url-pattern: /druid/*
          #login-username: admin
          #login-password: admin
        filter:
          stat:
            log-slow-sql: true
            slow-sql-millis: 1000
            merge-sql: true
          wall:
            config:
              multi-statement-allow: true
      datasource:
        content:
          url: jdbc:mysql://localhost:3306/xc_content?serverTimezone=UTC&allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8
          username: root
          password: mysql
          driver-class-name: com.mysql.cj.jdbc.Driver
        media:
          url: jdbc:mysql://localhost:3306/xc_media?serverTimezone=UTC&allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8
          username: root
          password: mysql
          driver-class-name: com.mysql.cj.jdbc.Driver

{% endtabs %}

分布式文件系统

什么是分布式文件系统

  • 我们先来看看文件系统的定义

文件系统是操作系统用于明确存储设备(常见的是磁盘,也有基于NAND Flash的固态硬盘)或分区上的文件的方法和数据结构;即在存储设备上组织文件的方法。操作系统中负责管理和存储文件信息的软件机构称为文件管理系统,简称文件系统。文件系统由三部分组成:文件系统的接口,对对象操纵和管理的软件集合,对象及属性。从系统角度来看,文件系统是对文件存储设备的空间进行组织和分配,负责文件存储并对存入的文件进行保护和检索的系统。具体地说,它负责为用户建立文件,存入、读出、修改、转储文件,控制文件的存取,当用户不再使用时撤销文件等。

  • 文件系统是负责管理和存储文件和系统软件,操作系统通过文件系统提供的借口去存取文件,用户通过操作系统访问磁盘上的文件
  • 常见的文件系统:FAT16/FAT32、NTFS、HFS、UFS、APFS、XFS、Ext4等
  • 现在有个问题,一些短视频平台拥有大量的视频、图片,这些视频文件、图片文件该如何存储呢?如何存储可以满足互联网上海量用户的浏览
    • 这里说的分布式文件系统就是海量用户查阅海量文件的方案
  • 我们再来看看分布式文件系统的定义

分布式文件系统(Distributed File System,DFS)是指文件系统管理的物理存储资源不一定直接连接在本地节点上,而是通过计算机网络与节点(可简单的理解为一台计算机)相连;或是若干不同的逻辑磁盘分区或卷标组合在一起而形成的完整的有层次的文件系统。DFS为分布在网络上任意位置的资源提供一个逻辑上的树形文件系统结构,从而使用户访问分布在网络上的共享文件更加简便。单独的 DFS共享文件夹的作用是相对于通过网络上的其他共享文件夹的访问点

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

  • 这样做的好处

    1. 一台计算机的文件系统处理能力扩充到多台计算机同时处理
    2. 一台计算机挂了, 还有另外副本计算机提供数据
    3. 每台计算机可以放在不同的地域,这样用户就可以就近访问,提高访问速度
  • 市面上有哪些分布式文件系统的产品呢?

    1. NFS(Linux里讲过)

      NFS是基于UDP/IP协议的应用,其实现主要是采用远程过程调用RPC机制,RPC提供了一组与机器、操作系统以及低层传送协议无关的存取远程文件的操作。RPC采用了XDR的支持。XDR是一种与机器无关的数据描述编码的协议,他以独立与任意机器体系结构的格式对网上传送的数据进行编码和解码,支持在异构系统之间数据的传送。

      • 特点
        • 在客户端上映射NFS服务器的驱动器
        • 客户端通过万国访问NFS服务器的硬盘完全透明
    2. GFS

      GFS是一个可扩展的分布式文件系统,用于大型的、分布式的、对大量数据进行访问的应用。它运行于廉价的普通硬件上,并提供容错功能。它可以给大量的用户提供总体性能较高的服务。

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

      Hadoop分布式文件系统(HDFS)是指被设计成适合运行在通用硬件(commodity hardware)上的分布式文件系统(Distributed File System)。它和现有的分布式文件系统有很多共同点。但同时,它和其他的分布式文件系统的区别也是很明显的。HDFS是一个高度容错性的系统,适合部署在廉价的机器上。HDFS能提供高吞吐量的数据访问,非常适合大规模数据集上的应用。HDFS放宽了一部分POSIX约束,来实现流式读取文件系统数据的目的。HDFS在最开始是作为Apache Nutch搜索引擎项目的基础架构而开发的。HDFS是Apache Hadoop Core项目的一部分。
      HDFS有着高容错性(fault-tolerant)的特点,并且设计用来部署在低廉的(low-cost)硬件上。而且它提供高吞吐量(high throughput)来访问应用程序的数据,适合那些有着超大数据集(large data set)的应用程序。HDFS放宽了(relax)POSIX的要求(requirements)这样可以实现流的形式访问(streaming access)文件系统中的数据。
      [外链图片转存中…(img-X4y2doSR-1679735939077)]

      • HDFS采用主从结构,一个HDFS集群由一个名称节点和若干数据节点组成
      • 名称节点存储数据的元信息,一个完整的数据文件分成若干块存储在数据节点
      • 客户端从名称节点获取数据的元信息及数据分块的信息,得到信息客户端即可从数据块来存储数据
    4. 云计算厂家
      • 阿里云对象存储服务(Object Storage Service,简称 OSS),是阿里云提供的海量、安全、低成本、高可靠的云存储服务。其数据设计持久性不低于 99.9999999999%(12 个 9),服务设计可用性(或业务连续性)不低于 99.995%。
        • 官方网站:https://www.aliyun.com/product/oss
      • 百度对象存储BOS提供稳定、安全、高效、高可扩展的云存储服务。您可以将任意数量和形式的非结构化数据存入BOS,并对数据进行管理和处理。BOS支持标准、低频、冷和归档存储等多种存储类型,满足多场景的存储需求。
        • 官方网站:https://cloud.baidu.com/product/bos.html

MinIO

介绍

  • 本项目采用MinIO构建分布式文件系统,MinIO是一个非常轻量的服务,可以很简单的和其他应用结合使用。它兼容亚马逊S3云存储服务接口,非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等
  • 它的一大特点就是轻量,使用简单、功能强大,支持各种平台,单个文件最大5TB,兼容提供了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块硬盘上
  • 使用纠删码的好处是即便丢失一半数量(N/2)的硬盘,仍可以恢复数据。例如上面集合中有4个以内的硬盘损害,仍可保证数据恢复,不影响上传和下载;但如果多余一半的硬盘损坏,则无法恢复。
  • MinIO下载地址:https://dl.min.io/server/minio/release/
  • 安装完毕后,CMD进入minio.exe所在目录,执行下面的命令,会在D盘创建4个目录,模拟4个硬盘
minio.exe server D:\develop\minio_data\data1  D:\develop\minio_data\data2  D:\develop\minio_data\data3  D:\develop\minio_data\data4
  • 启动结果如下
  • 默认账号密码均为minioadmin,访问localhost:9000进行登录
  • 不过我们这里由于条件有限,所以先不做分布式,修改启动命令
minio.exe server d:\minio_data
  • 之后创建两个buckets
    • mediafiles:普通文件
    • video:视频文件
      [外链图片转存中…(img-mV7p2zzs-1679735939078)]

SDK

上传文件
  • MinIO听歌多个语言版本SDK的支持,Java版本的文档地址:https://docs.min.io/docs/java-client-quickstart-guide.html
  • 最低需求Java 1.8或更高版本
  • 在media-service工程中添加依赖
<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服务
ParametersDescription
EndpointURL to S3 service.
Access KeyAccess key (aka user ID) of an account in the S3 service.
Secret KeySecret key (aka password) of an account in the S3 service.
  • 官方文档给出的示例代码如下
import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.UploadObjectArgs;
import io.minio.errors.MinioException;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

public class FileUploader {
  public static void main(String[] args)
      throws IOException, NoSuchAlgorithmException, InvalidKeyException {
    try {
      // Create a minioClient with the MinIO server playground, its access key and secret key.
      MinioClient minioClient =
          MinioClient.builder()
              .endpoint("https://play.min.io")
              .credentials("Q3AM3UQ867SPQQA43P2F", "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG")
              .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.");
      }

      // 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")
              .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());
    }
  }
}
  • 那么我们在其基础上进行修改,完成基本的上传、下载和删除功能
    {% tabs asssdasd %}
  • 我们先来分析一下示例代码
public class FileUploader {
  public static void main(String[] args)
      throws IOException, NoSuchAlgorithmException, InvalidKeyException {
    try {
      // Create a minioClient with the MinIO server playground, its access key and secret key.
      // 创建MinIO客户端,连接参数就是上述表格中的三个参数,127.0.0.1:9000、minioadmin、minioadmin
      MinioClient minioClient =
          MinioClient.builder()
              .endpoint("https://play.min.io")
              .credentials("Q3AM3UQ867SPQQA43P2F", "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG")
              .build();

      // Make 'asiatrip' bucket if not exist.
      // 由于backet我们已经手动创建了,所以这段代码可以删掉
      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.");
      }

      // Upload '/home/user/Photos/asiaphotos.zip' as object name 'asiaphotos-2015.zip' to bucket
      // 'asiatrip'.
      // 将 '/home/user/Photos/asiaphotos.zip' 文件命名为 'asiaphotos-2015.zip'
      // 并上传到 'asiatrip' 里(示例代码创建的bucket)
      minioClient.uploadObject(
          UploadObjectArgs.builder()
              .bucket("asiatrip")
              .object("asiaphotos-2015.zip")
              .filename("/home/user/Photos/asiaphotos.zip")
              .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());
    }
  }
}
  • 在此之前,我们先创建一个名为testbucket的桶,然后将其权限修改为public
  • 那我们现在把不要的代码删掉,再修改一下连接参数、bucket名、和要上传的文件路径,就可以进行测试了
@SpringBootTest
public class MinIOTest {
    // 创建MinioClient对象
    static MinioClient minioClient =
            MinioClient.builder()
                    .endpoint("http://127.0.0.1:9000")
                    .credentials("minioadmin", "minioadmin")
                    .build();

    /**
     * 上传测试方法
     */
    @Test
    public void uploadTest() {
        try {
            minioClient.uploadObject(
                    UploadObjectArgs.builder()
                            .bucket("testbucket")
                            .object("pic01.png")    // 同一个桶内对象名不能重复
                            .filename("D:\\Picture\\background\\01.png")
                            .build()
            );
            System.out.println("上传成功");
        } catch (Exception e) {
            System.out.println("上传失败");
        }
    }
}

{% endtabs %}

  • 由于我们已经将桶的权限修改为了public,所以我们直接访问http://127.0.0.1:9000/testbucket/pic01.png, 也是可以直接看到上传的图片的
删除文件
  • 编写测试方法
    @Test
    public void deleteTest() {
        try {
            minioClient.removeObject(RemoveObjectArgs
                    .builder()
                    .bucket("testbucket")
                    .object("pic01.png")
                    .build());
            System.out.println("删除成功");
        } catch (Exception e) {
            System.out.println("删除失败");
        }
    }
查询文件
  • 编写测试方法
    @Test
    public void getFileTest() {
        try {
            InputStream inputStream = minioClient.getObject(GetObjectArgs.builder()
                    .bucket("testbucket")
                    .object("pic01.png")
                    .build());
            FileOutputStream fileOutputStream = new FileOutputStream("C:\\Users\\15863\\Desktop\\tmp.png");
            byte[] buffer = new byte[1024];
            int len;
            while ((len = inputStream.read(buffer)) != -1) {
                fileOutputStream.write(buffer,0,len);
            }
            inputStream.close();
            fileOutputStream.close();
            System.out.println("下载成功");
        } catch (Exception e) {
            System.out.println("下载失败");
        }
    }
  • 用IOUtils简化代码
    @Test
    public void getFileTest() {
        try {
            InputStream inputStream = minioClient.getObject(GetObjectArgs.builder()
                    .bucket("testbucket")
                    .object("pic01.png")
                    .build());
            FileOutputStream fileOutputStream = new FileOutputStream("C:\\Users\\15863\\Desktop\\tmp.png");
            IOUtils.copy(inputStream,fileOutputStream);
            System.out.println("下载成功");
        } catch (Exception e) {
            System.out.println("下载失败");
        }
    }

上传图片

需求分析

业务流程

  • 我们在新增课程的时候,需要上传课程图片
  • 课程图片上传至分布式文件系统,在课程信息中保存课程图片路径,流程如下
  1. 前端进入上传图片界面
  2. 上传图片,请求媒资管理服务
  3. 媒资管理服务将图片文件存储在MinIO
  4. 媒资管理记录文件信息到数据库
  5. 保存课程信息,在内容管理数据库保存图片地址

  • 媒资管理服务由接口层和业务层共同完成,具体分工如下
    • 用户上传图片请求至媒资管理的接口层,接口层解析文件信息,通过业务层将文件保存至minio和数据库

数据模型

  • 涉及到的数据表主要是媒资信息

环境准备

  • 在minio中配置bucket,创建一个名为mediafiles的bucket,并将其权限设置为public
  • 在nacos中配置minio的相关信息,在nacos的开发环境下新增配置media-service-dev.yaml
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/xc_media?serverTimezone=UTC&userUnicode=true&useSSL=false
    username: root
    password: A10ne,tillde@th.
  cloud:
   config:
    override-none: true

minio:
  endpoint: http://127.0.0.1:9000
  accessKey: minioadmin
  secretKey: minioadmin
  bucket:
    files: mediafiles
    videofiles: video
  • 在media-service工程下配置bootstrap.yml
spring:
  application:
    name: media-service
  cloud:
    nacos:
      server-addr: 127.0.0.1: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
  • 在media-service工程下编写minio的配置类
    • 该配置类中药根据yaml中的minio配置信息,创建一个MinioClient对象,并声明为bean
@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() {
        return MinioClient.builder().
                endpoint(endpoint).
                credentials(accessKey, secretKey).
                build();
    }
}

接口定义

  • 根据需求分析,下面进行接口定义。
  • 此接口定义为一个通用的上传文件的接口,可以上传图片或其他文件
  • 首先分析接口
    • 请求地址:/media/upload/coursefile
    • 请求参数:Content-Type: multipart/form-data;boundary=… FormData: filedata=??, folder=?, objectName=?
    • 响应参数:文件信息,如下,其内容与media_files表中的字段完全一致
    {
    "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
    }
    
  • 定义模型类,虽然响应结果与MediaFiles表中的字段完全一致,但最好不要直接用MediaFiles类。因为该类属于PO类,如果后期我们要对响应结果进行修改,那么模型类也需要进行修改,但是MediaFiles是PO类,我们不能动。所以可以直接用一个类继承MediaFiles,里面什么属性都不用加
@Data
public class UploadFileResultDto extends MediaFiles {
}
  • 定义接口,其中folder和objectName这两个参数不一定传,所以将其required设为false
    @ApiOperation("上传文件")
    @RequestMapping(value = "/upload/coursefile",consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public UploadFileResultDto upload(@RequestPart("filedata") MultipartFile upload,
                                      @RequestParam(value = "folder", required = false) String folder,
                                      @RequestParam(value = "objectName", required = false) String objectName) {
        return null;
    }

接口开发

DAO开发

  • 根据需求分析,DAO层实现向media_file表中插入一条记录,使用media_files表生成的mapper即可

Service开发

  • Service方法需要听歌一个更加通用的保存文件的方法
  • 定义请求参数类,上传文件,我们需要文件名称、文件的content-type、文件类型(文档、视频、图片等,对应数据字典表中的类型)、文件大小、标签、上传人、备注
 @Data
 @ToString
public class UploadFileParamsDto {

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

 /**
  * 文件content-type
 */
 private String contentType;

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

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

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

 /**
  * 备注
  */
 private String remark;
}
  • 定义service方法,MultipartFile是SpringMVC提供简化上传操作的工具类,不使用框架之前,都是使用原生的HttpServletRequest来接收上传的数据,文件是以二进制流传递到后端的。为了使接口更通用,我们可以用字节数组代替MultpartFile类型
    /**
     * @description 上传文件的通用接口
     * @param companyId           机构id
     * @param uploadFileParamsDto 文件信息
     * @param bytes               文件字节数组
     * @param folder              桶下边的子目录
     * @param objectName          对象名称
     * @return com.xuecheng.media.model.dto.UploadFileResultDto
     */
    UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, byte[] bytes, String folder, String objectName);
  • 实现方法如下,主要分为两部分
    1. 将文件上传到minio
    2. 将文件信息写入media_file表中

{% tabs asdasd %}

  • 将文件上传到minio
  • 前面我们上传文件是用的uploadObject方法,是从本地磁盘上传文件,为了使方法更通用,这里使用putObject
    @Autowired
    MinioClient minioClient;

    // 从配置文件获取bucket
    @Value("${minio.bucket.files}")
    private String bucket_files;

    /**
     *
     * @param companyId           机构id
     * @param uploadFileParamsDto 文件信息
     * @param bytes               文件字节数组
     * @param folder              桶下边的子目录
     * @param objectName          对象名称
     * @return
     */
    @Override
    public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, byte[] bytes, String folder, String objectName) {

        if (StringUtils.isEmpty(folder)) {
            // 如果目录不存在,则自动生成一个目录
            folder = getFileFolder(true, true, true);
        } else if (!folder.endsWith("/")) {
            // 如果目录末尾没有 / ,替他加一个
            folder = folder + "/";
        }
        if (StringUtils.isEmpty(objectName)) {
            // 如果文件名为空,则设置其默认文件名为文件的md5码 + 文件后缀名
            String filename = uploadFileParamsDto.getFilename();
            objectName = DigestUtils.md5DigestAsHex(bytes) + filename.substring(filename.lastIndexOf("."));
        }
        objectName = folder + objectName;
        try {
            ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
            minioClient.putObject(PutObjectArgs.builder()
                    .bucket(bucket_files)
                    .object(objectName)
                    .stream(byteArrayInputStream, byteArrayInputStream.available(), -1)
                    .contentType(uploadFileParamsDto.getContentType())
                    .build());
        } catch (Exception e) {

        }
        return null;
    }

    /**
     * 自动生成目录
     * @param year  是否包含年
     * @param month 是否包含月
     * @param day   是否包含日
     * @return
     */
    private String getFileFolder(boolean year, boolean month, boolean day) {
        StringBuffer stringBuffer = new StringBuffer();
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        String dateString = dateFormat.format(new Date());
        String[] split = dateString.split("-");
        if (year) {
            stringBuffer.append(split[0]).append("/");
        }
        if (month) {
            stringBuffer.append(split[1]).append("/");
        }
        if (day) {
            stringBuffer.append(split[2]).append("/");
        }
        return stringBuffer.toString();
    }
  • 将文件信息写入media_file表中
    // 保存到数据库
    MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMD5);
    if (mediaFiles == null) {
        mediaFiles = new MediaFiles();
        BeanUtils.copyProperties(uploadFileParamsDto, mediaFiles);
        mediaFiles.setId(fileMD5);
        mediaFiles.setFileId(fileMD5);
        mediaFiles.setCompanyId(companyId);
        mediaFiles.setBucket(bucket_files);
        mediaFiles.setCreateDate(LocalDateTime.now());
        mediaFiles.setStatus("1");
        mediaFiles.setFilePath(objectName);
        mediaFiles.setUrl("/" + bucket_files + "/" + objectName);
        // 查阅数据字典,002003表示审核通过
        mediaFiles.setAuditStatus("002003");
    }
    int insert = mediaFilesMapper.insert(mediaFiles);
    if (insert <= 0) {
        XueChengPlusException.cast("保存文件信息失败");
    }
    UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
    BeanUtils.copyProperties(mediaFiles, uploadFileResultDto);
            return uploadFileResultDto;
    @Autowired
    MediaFilesMapper mediaFilesMapper;

    @Autowired
    MinioClient minioClient;

    @Value("${minio.bucket.files}")
    private String bucket_files;

    /**
     * @param companyId           机构id
     * @param uploadFileParamsDto 文件信息
     * @param bytes               文件字节数组
     * @param folder              桶下边的子目录
     * @param objectName          对象名称
     * @return
     */
    @Override
    public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, byte[] bytes, String folder, String objectName) {
        String fileMD5 = DigestUtils.md5DigestAsHex(bytes);
        if (StringUtils.isEmpty(folder)) {
            // 如果目录不存在,则自动生成一个目录
            folder = getFileFolder(true, true, true);
        } else if (!folder.endsWith("/")) {
            // 如果目录末尾没有 / ,替他加一个
            folder = folder + "/";
        }
        if (StringUtils.isEmpty(objectName)) {
            // 如果文件名为空,则设置其默认文件名为文件的md5码 + 文件后缀名
            String filename = uploadFileParamsDto.getFilename();
            objectName = fileMD5 + filename.substring(filename.lastIndexOf("."));
        }
        objectName = folder + objectName;
        try {
            ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
            // 上传到minio
            minioClient.putObject(PutObjectArgs.builder()
                    .bucket(bucket_files)
                    .object(objectName)
                    .stream(byteArrayInputStream, byteArrayInputStream.available(), -1)
                    .contentType(uploadFileParamsDto.getContentType())
                    .build());
            // 保存到数据库
            MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMD5);
            if (mediaFiles == null) {
                mediaFiles = new MediaFiles();
                BeanUtils.copyProperties(uploadFileParamsDto, mediaFiles);
                mediaFiles.setId(fileMD5);
                mediaFiles.setFileId(fileMD5);
                mediaFiles.setCompanyId(companyId);
                mediaFiles.setBucket(bucket_files);
                mediaFiles.setCreateDate(LocalDateTime.now());
                mediaFiles.setStatus("1");
                mediaFiles.setFilePath(objectName);
                mediaFiles.setUrl("/" + bucket_files + "/" + objectName);
                // 查阅数据字典,002003表示审核通过
                mediaFiles.setAuditStatus("002003");
            }
            int insert = mediaFilesMapper.insert(mediaFiles);
            if (insert <= 0) {
                XueChengPlusException.cast("保存文件信息失败");
            }
            UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
            BeanUtils.copyProperties(mediaFiles, uploadFileResultDto);
            return uploadFileResultDto;
        } catch (Exception e) {
            XueChengPlusException.cast("上传过程中出错");
        }
        return null;
    }

    /**
     * 自动生成目录
     * @param year  是否包含年
     * @param month 是否包含月
     * @param day   是否包含日
     * @return
     */
    private String getFileFolder(boolean year, boolean month, boolean day) {
        StringBuffer stringBuffer = new StringBuffer();
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        String dateString = dateFormat.format(new Date());
        String[] split = dateString.split("-");
        if (year) {
            stringBuffer.append(split[0]).append("/");
        }
        if (month) {
            stringBuffer.append(split[1]).append("/");
        }
        if (day) {
            stringBuffer.append(split[2]).append("/");
        }
        return stringBuffer.toString();
    }
    private String getFileFolder(boolean year, boolean month, boolean day) {
        StringBuffer stringBuffer = new StringBuffer();
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        String dateString = dateFormat.format(new Date());
        String[] split = dateString.split("-");
        if (year) {
            stringBuffer.append(split[0]).append("/");
        }
        if (month) {
            stringBuffer.append(split[1]).append("/");
        }
        if (day) {
            stringBuffer.append(split[2]).append("/");
        }
        return stringBuffer.toString();
    }

{% endtabs %}

完善Controller

  • 完善接口层代码
    @ApiOperation("上传文件")
    @RequestMapping(value = "/upload/coursefile",consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public UploadFileResultDto upload(@RequestPart("filedata") MultipartFile upload,
                                      @RequestParam(value = "folder", required = false) String folder,
                                      @RequestParam(value = "objectName", required = false) String objectName) {
        UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
        uploadFileParamsDto.setFileSize(upload.getSize());
        String contentType = upload.getContentType();
        if (contentType.contains("image")) {
            // 图片
            uploadFileParamsDto.setFileType("001001");
        } else {
            // 其他
            uploadFileParamsDto.setFileType("001003");
        }
        uploadFileParamsDto.setFilename(upload.getOriginalFilename());
        uploadFileParamsDto.setContentType(contentType);
        Long companyId = 1232141425L;
        try {
            UploadFileResultDto uploadFileResultDto = mediaFileService.uploadFile(companyId, uploadFileParamsDto, upload.getBytes(), folder, objectName);
            return uploadFileResultDto;
        } catch (IOException e) {
            XueChengPlusException.cast("上传文件过程出错");
        }
        return null;
    }
  • 使用HTTP Client测试
### 上传文件
POST {{media_host}}/media/upload/coursefile
Content-Type: multipart/form-data; boundary=WebAppBoundary

--WebAppBoundary
Content-Disposition: form-data; name="filedata"; filename="test01.jpg"
Content-Type: application/octet-stream

< C:\Users\kyle\Desktop\Picture\photo\bg01.jpg

# 响应结果如下
POST http://localhost:53050/media/upload/coursefile

HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Thu, 16 Feb 2023 09:57:48 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "id": "632fb34166d91865da576032b9330ced",
  "companyId": 1232141425,
  "companyName": null,
  "filename": "test01.jpg",
  "fileType": "001003",
  "tags": null,
  "bucket": "mediafiles",
  "filePath": "2023/57/16/632fb34166d91865da576032b9330ced.jpg",
  "fileId": "632fb34166d91865da576032b9330ced",
  "url": "/mediafiles/2023/57/16/632fb34166d91865da576032b9330ced.jpg",
  "username": null,
  "createDate": "2023-02-16 17:57:48",
  "changeDate": null,
  "status": "1",
  "remark": "",
  "auditStatus": "002003",
  "auditMind": null,
  "fileSize": 22543
}
响应文件已保存。
> 2023-02-16T175748.200.json
  • 在对应的bucket中也可以查看到上传的图片

Service代码优化

  • 在上传文件的方法中包括两部分
    1. 向MinIO存储文件
    2. 向数据库存储文件信息
  • 下面将这两部分抽取出来,后期可供其他Service方法调用
  • 为了跟方便的获取content-type,我们可以添加simplemagic依赖,它提供的方法可以根据文件扩展名,得到资源的content-type
  • 在base工程中添加依赖
<dependency>
    <groupId>com.j256.simplemagic</groupId>
    <artifactId>simplemagic</artifactId>
    <version>1.17</version>
</dependency>
  • 可通过如下代码得到资源的content-type
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(扩展名);
String contentType = extensionMatch.getMimeType();
  • IDEA中使用{% kbd Ctrl %} + {% kbd Alt %} + {% kbd M %} 可以快速重构
    /**
     * @param companyId           机构id
     * @param uploadFileParamsDto 文件信息
     * @param bytes               文件字节数组
     * @param folder              桶下边的子目录
     * @param objectName          对象名称
     * @return
     */
    @Override
    public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, byte[] bytes, String folder, String objectName) {
        String fileMD5 = DigestUtils.md5DigestAsHex(bytes);
        if (StringUtils.isEmpty(folder)) {
            // 如果目录不存在,则自动生成一个目录
            folder = getFileFolder(true, true, true);
        } else if (!folder.endsWith("/")) {
            // 如果目录末尾没有 / ,替他加一个
            folder = folder + "/";
        }
        if (StringUtils.isEmpty(objectName)) {
            // 如果文件名为空,则设置其默认文件名为文件的md5码 + 文件后缀名
            String filename = uploadFileParamsDto.getFilename();
            objectName = fileMD5 + filename.substring(filename.lastIndexOf("."));
        }
        objectName = folder + objectName;
        try {
            addMediaFilesToMinIO(bytes, bucket_files, objectName);
            MediaFiles mediaFiles = addMediaFilesToDB(companyId, uploadFileParamsDto, objectName, fileMD5, bucket_files);
            UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
            BeanUtils.copyProperties(mediaFiles, uploadFileResultDto);
            return uploadFileResultDto;
        } catch (Exception e) {
            XueChengPlusException.cast("上传过程中出错");
        }
        return null;
    }

    /**
     * 将文件信息添加到文件表
     * @param companyId             机构id
     * @param uploadFileParamsDto   上传文件的信息
     * @param objectName            对象名称
     * @param fileMD5               文件的md5码
     * @param bucket                桶
     * @return
     */
    private MediaFiles addMediaFilesToDB(Long companyId, UploadFileParamsDto uploadFileParamsDto, String objectName, String fileMD5, String bucket) {
        // 保存到数据库
        MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMD5);
        if (mediaFiles == null) {
            mediaFiles = new MediaFiles();
            BeanUtils.copyProperties(uploadFileParamsDto, mediaFiles);
            mediaFiles.setId(fileMD5);
            mediaFiles.setFileId(fileMD5);
            mediaFiles.setCompanyId(companyId);
            mediaFiles.setBucket(bucket);
            mediaFiles.setCreateDate(LocalDateTime.now());
            mediaFiles.setStatus("1");
            mediaFiles.setFilePath(objectName);
            mediaFiles.setUrl("/" + bucket + "/" + objectName);
            // 查阅数据字典,002003表示审核通过
            mediaFiles.setAuditStatus("002003");
        }
        int insert = mediaFilesMapper.insert(mediaFiles);
        if (insert <= 0) {
            XueChengPlusException.cast("保存文件信息失败");
        }
        return mediaFiles;
    }

    /**
     * @param bytes      文件字节数组
     * @param bucket     桶
     * @param objectName 对象名称 23/02/15/porn.mp4
     * @throws ErrorResponseException
     * @throws InsufficientDataException
     * @throws InternalException
     * @throws InvalidKeyException
     * @throws InvalidResponseException
     * @throws IOException
     * @throws NoSuchAlgorithmException
     * @throws ServerException
     * @throws XmlParserException
     */
    private void addMediaFilesToMinIO(byte[] bytes, String bucket, String objectName) {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
        String contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE; // 默认content-type为未知二进制流
        if (objectName.indexOf(".") >= 0) { // 判断对象名是否包含 .
            // 有 .  则划分出扩展名
            String extension = objectName.substring(objectName.lastIndexOf("."));
            // 根据扩展名得到content-type,如果为未知扩展名,例如 .abc之类的东西,则会返回null
            ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
            // 如果得到了正常的content-type,则重新赋值,覆盖默认类型
            if (extensionMatch != null) {
                contentType = extensionMatch.getMimeType();
            }
        }
        try {
            minioClient.putObject(PutObjectArgs.builder()
                    .bucket(bucket)
                    .object(objectName)
                    .stream(byteArrayInputStream, byteArrayInputStream.available(), -1)
                    .contentType(contentType)
                    .build());
        } catch (Exception e) {
            log.debug("上传到文件系统出错:{}", e.getMessage());
            throw new XueChengPlusException("上传到文件系统出错");
        }
    }
  • 优化后使用HTTP Client进行测试
  • 同时,根据文件扩展名获取content-type的方法可以进一步抽取,可以在base工程中创建一个工具类,供其他微服务使用
    private static String getContentType(String objectName) {
        String contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE; // 默认content-type为未知二进制流
        if (objectName.indexOf(".") >= 0) { // 判断对象名是否包含 .
            // 有 .  则划分出扩展名
            String extension = objectName.substring(objectName.lastIndexOf("."));
            // 根据扩展名得到content-type,如果为未知扩展名,例如 .abc之类的东西,则会返回null
            ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
            // 如果得到了正常的content-type,则重新赋值,覆盖默认类型
            if (extensionMatch != null) {
                contentType = extensionMatch.getMimeType();
            }
        }
        return contentType;
    }

Service事务优化

  • 我们现在思考一下,updateFile方法是否应该开启事务
  • 目前如果在updateFile方法上添加@Transactional,当调用updateFile方法前会开启数据库事务,如果上传文件过程时间较长(例如用户在上传超大视频文件),那么数据库的食物持续时间也会变长(因为在updateFile方法中,我们即要将文件上传到minio,又要将文件信息写入数据库),这样数据库连接释放就慢,最终导致数据库链接不够用
  • 那么解决办法也显而易见,那就是只在addMediaFilesToDB方法上添加事务控制即可,同时将uploadFile方法上的@Transactional注解去掉
  • 但事情并不是那么简单,首先我们来看一下Spring的事务控制
  • 判断方法能否被事务控制
    1. 是不是通过代理对象调用的方法
    2. 该方法上是否添加了@Transactional注解
  • 现在只满足了添加事务注解,那么如何判断是不是通过代理对象调用的方法呢?
    • 我们可以打个断点看一下
  • 当我们在一个不能被事务控制的方法里(uploadFile),调用一个被事务控制的方法(addMediaFilesToDB),那么该方法(addMediaFilesToDB)也不会被事务控制
  • 那么如何解决呢?
    • 我们需要通过代理对象去调用addMediaFilesToDB方法
  • 在MediaFileService的实现类中注入MediaFileService的代理对象
    @Autowired
    MediaFileService currentProxy;
  • 将addMediaFilesToDB方法提取成接口
   /**
     * 将文件信息添加到文件表
     *
     * @param companyId           机构id
     * @param uploadFileParamsDto 上传文件的信息
     * @param objectName          对象名称
     * @param fileMD5             文件md5码
     * @param bucket              桶
     * @return
     */
    MediaFiles addMediaFilesToDB(Long companyId, UploadFileParamsDto uploadFileParamsDto, String objectName, String fileMD5, String bucket);
  • 通过代理对象调用addMediaFilesToDB
MediaFiles mediaFiles = currentProxy.addMediaFilesToDB(companyId, uploadFileParamsDto, objectName, fileMD5, bucket_files);
  • 再次测试事务是否可以正常控制
    • 打断点看到这次是代理对象调用的方法

前后端联调

  • 修改前段的图片服务器地址为自己的minio地址
# 图片服务器地址
VUE_APP_SERVER_PICSERVER_URL=http://127.0.0.1:9000
  • 在新增课程、编辑课程界面上传图片,保存课程信息后再次进入编辑课程界面,查看是否可以正常保存图片信息
  • 上传图片完成后,进入媒资管理,查看文件列表中是否有刚刚上传的图片信息

bug修复

  • 在媒资列表可以查看到刚刚上传的图片信息,但是通过条件查询不起作用
  • 原因:没有使用查询条件
  • 解决:修改MediaFileServiceImpl中的queryMediaFiles方法
    @Override
    public PageResult<MediaFiles> queryMediaFiles(Long companyId, PageParams pageParams, QueryMediaParamsDto queryMediaParamsDto) {

        //构建查询条件对象
        LambdaQueryWrapper<MediaFiles> queryWrapper = new LambdaQueryWrapper<>();
+       queryWrapper.like(!StringUtils.isEmpty(queryMediaParamsDto.getFilename()), MediaFiles::getFilename, queryMediaParamsDto.getFilename());
+       queryWrapper.eq(!StringUtils.isEmpty(queryMediaParamsDto.getFileType()), MediaFiles::getFileType, queryMediaParamsDto.getFileType());
        //分页对象
        Page<MediaFiles> page = new Page<>(pageParams.getPageNo(), pageParams.getPageSize());
        // 查询数据内容获得结果
        Page<MediaFiles> pageResult = mediaFilesMapper.selectPage(page, queryWrapper);
        // 获取数据列表
        List<MediaFiles> list = pageResult.getRecords();
        // 获取数据总数
        long total = pageResult.getTotal();
        // 构建结果集
        PageResult<MediaFiles> mediaListResult = new PageResult<>(list, total, pageParams.getPageNo(), pageParams.getPageSize());
        return mediaListResult;

    }
  • 重启服务,测试是否能正常查询

上传视频

需求分析

  1. 教学机构人员进入媒资管理列表查询自己上传的媒资文件
  2. 教育机构人员在媒资管理页面中点击上传视频按钮,打开上传界面
  3. 选择要上传的文件,自动执行文件上传
  4. 视频上传成功会自动处理,处理完成后可以预览视频

断点续传

什么是断点续传

  • 通常视频文件都比较大,所以对于媒资系统上传文件的需求要满足大文件的上传需求。HTTP协议本身对上传文件大小没有限制,但是客户的网络环境之类、电脑硬件环境等参差不齐,如果一个大文件快上传完了,但是突然断网了,没有上传完成,需要客户重新上传,那么用户体验就非常差。所以对于大文件上传的最基本要求就是断点续传
  • 流程如下
    1. 前端上传前先把文件分成块
    2. 一块一块的上传,上传中断后重新上传。已上传的分块则不用再上传
    3. 各分块上传完成后,在服务端合并文件

分块与合并测试

  • 为了更好的理解文件分块上传的原理,下面用Java代码测试文件的分块与合并
  • 文件分块的流程如下
    1. 获取源文件长度
    2. 根据设定的分块文件大小,计算出块数(向上取整,例如33.4M的文件,块大小为1M,则需要34块)
    3. 从源文件读取数据,并依次向每一个块文件写数据
  • 文件分块测试代码如下
    @Test
    public void testChunk() throws IOException {
        // 源文件
        File sourceFile = new File("D:\\BaiduNetdiskDownload\\星际牛仔1998\\星际牛仔1.mp4");
        // 块文件路径
        String chunkPath = "D:\\BaiduNetdiskDownload\\星际牛仔1998\\chunk\\";
        File chunkFolder = new File(chunkPath);
        if (!chunkFolder.exists()) {
            chunkFolder.mkdirs();
        }
        // 分块大小 1M
        long chunkSize = 1024 * 1024 * 1;
        // 计算块数,向上取整
        long chunkNum = (long) Math.ceil(sourceFile.length() * 1.0 / chunkSize);
        // 缓冲区大小
        byte[] buffer = new byte[1024];
        // 使用RandomAccessFile访问文件
        RandomAccessFile raf_read = new RandomAccessFile(sourceFile, "r");
        // 遍历分块,依次向每一个分块写入数据
        for (int i = 0; i < chunkNum; i++) {
            // 创建分块文件,默认文件名 path + i,例如chunk\1  chunk\2
            File file = new File(chunkPath + i);
            if (file.exists()){
                file.delete();
            }
            boolean newFile = file.createNewFile();
            if (newFile) {
                int len;
                RandomAccessFile raf_write = new RandomAccessFile(file, "rw");
                // 向分块文件写入数据
                while ((len = raf_read.read(buffer)) != -1) {
                    raf_write.write(buffer, 0, len);
                    // 写满就停
                    if (file.length() >= chunkSize)
                        break;
                }
                raf_write.close();
            }
        }
        raf_read.close();
        System.out.println("写入分块完毕");
    }
  • 文件合并流程
    1. 找到要合并的文件并按文件分块的先后顺序排序
    2. 创建合并文件
    3. 依次从合并的文件中读取数据冰箱合并文件写入数据
  • 文件合并的测试代码
    @Test
    public void testMerge() throws IOException {
        // 块文件目录
        File chunkFolder = new File("D:\\BaiduNetdiskDownload\\星际牛仔1998\\chunk\\");
        // 源文件
        File sourceFile = new File("D:\\BaiduNetdiskDownload\\星际牛仔1998\\星际牛仔1.mp4");
        // 合并文件
        File mergeFile = new File("D:\\BaiduNetdiskDownload\\星际牛仔1998\\星际牛仔1-1.mp4");
        mergeFile.createNewFile();
        // 用于写文件
        RandomAccessFile raf_write = new RandomAccessFile(mergeFile, "rw");
        // 缓冲区
        byte[] buffer = new byte[1024];
        // 文件名升序排序
        File[] files = chunkFolder.listFiles();
        List<File> fileList = Arrays.asList(files);
        Collections.sort(fileList, Comparator.comparingInt(o -> Integer.parseInt(o.getName())));
        // 合并文件
        for (File chunkFile : fileList) {
            RandomAccessFile raf_read = new RandomAccessFile(chunkFile, "r");
            int len;
            while ((len = raf_read.read(buffer)) != -1) {
                raf_write.write(buffer, 0, len);
            }
            raf_read.close();
        }
        raf_write.close();
        // 判断合并后的文件是否与源文件相同
        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("合并文件失败");
        }
    }

上传视频流程

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

接口定义

  • 根据上传视频流程,定义接口
  • 与前端的约定是
    • 操作成功返回
    {
        "code": 0
    }
    
    • 操作失败返回
    {
        "code": 1
    }
    
  • 在base工程的model包下新建RestResponse类
@Data
public class RestResponse<T> {
    /**
     * 相应编码 0为正常 -1为错误
     */
    private int code;
    /**
     * 响应提示信息
     */
    private String msg;
    /**
     * 响应内容
     */
    private T result;

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

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

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

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

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

    /**
     * 正常信息的封装
     */
    public static <T> RestResponse<T> success() {
        return new RestResponse<>();
    }

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

    public static <T> RestResponse<T> success(String msg, T result) {
        RestResponse<T> response = new RestResponse<>();
        response.setMsg(msg);
        response.setResult(result);
        return response;
    }
}
  • 定义接口如下
@Api(value = "大文件上传接口", tags = "大文件上传接口")
@RestController
public class BigFilesController {
    @ApiOperation(value = "文件上传前检查文件")
    @PostMapping("/upload/checkfile")
    public RestResponse<Boolean> checkFile(@RequestParam("fileMd5") String fileMd5) {
        return null;
    }

    @ApiOperation(value = "分块文件上传前检查分块")
    @PostMapping("/upload/checkchunk")
    public RestResponse<Boolean> checkChunk(@RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") int chunk) {
        return null;
    }

    @ApiOperation(value = "上传分块文件")
    @PostMapping("/upload/uploadchunk")
    public RestResponse uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") int chunk) {
        return null;
    }

    @ApiOperation(value = "合并分块文件")
    @PostMapping("/upload/mergechunks")
    public RestResponse mergeChunks(@RequestParam("fileMd5") String fileMd5, @RequestParam("fileName") String fileName, @RequestParam("chunkTotal") int chunkTotal) {
        return null;
    }
}

接口开发

DAO开发

  • 向媒资数据库的文件表插入记录,使用自动生成的Mapper接口即可满足要求

Service开发

检查文件和分块
  • 首先实现检查文件方法和检查分块方法
  • 定义Service接口
    /**
     * 检查文件是否存在
     *
     * @param fileMd5 文件的md5
     * @return
     */
    boolean checkFile(String fileMd5);

    /**
     * 检查分块是否存在
     * @param fileMd5       文件的MD5
     * @param chunkIndex    分块序号
     * @return
     */
    boolean checkChunk(String fileMd5, int chunkIndex);
  • 判断文件是否存在
    • 首先判断数据库中是否存在该文件
    • 其次判断minio的bucket中是否存在该文件
    @Override
    public RestResponse<Boolean> checkFile(String fileMd5) {
        MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
        // 数据库中不存在,则直接返回false 表示不存在
        if (mediaFiles == null) {
            return RestResponse.success(false);
        }
        // 若数据库中存在,根据数据库中的文件信息,则继续判断bucket中是否存在
        try {
            InputStream inputStream = minioClient.getObject(GetObjectArgs
                    .builder()
                    .bucket(mediaFiles.getBucket())
                    .object(mediaFiles.getFilePath())
                    .build());
            if (inputStream == null) {
                return RestResponse.success(false);
            }
        } catch (Exception e) {
            return RestResponse.success(false);
        }
        return RestResponse.success(true);
    }
  • 判断分块是否存在
    • 分块是否存在,只需要判断minio对应的目录下是否存在分块文件
    @Override
    public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex) {
        // 获取分块目录
        String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
        String chunkFilePath = chunkFileFolderPath + chunkIndex;
        try {
            // 判断分块是否存在
            InputStream inputStream = minioClient.getObject(GetObjectArgs
                    .builder()
                    .bucket(video_files)
                    .object(chunkFilePath)
                    .build());
            // 不存在返回false
            if (inputStream == null) {
                return RestResponse.success(false);
            }
        } catch (Exception e) {
            // 出异常也返回false
            return RestResponse.success(false);
        }
        // 否则返回true
        return RestResponse.success();
    }

    private String getChunkFileFolderPath(String fileMd5) {
        return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
    }
上传分块
  • 定义Service接口
    /**
     * 上传分块
     * @param fileMd5   文件MD5
     * @param chunk     分块序号
     * @param bytes     文件字节
     * @return
     */
    RestResponse uploadChunk(String fileMd5,int chunk,byte[] bytes);
  • 接口实现
    @Override
    public RestResponse uploadChunk(String fileMd5, int chunk, byte[] bytes) {
        // 分块文件路径
        String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;
        try {
            addMediaFilesToMinIO(bytes, video_files, chunkFilePath);
            return RestResponse.success(true);
        } catch (Exception e) {
            log.debug("上传分块文件:{}失败:{}", chunkFilePath, e.getMessage());
        }
        return RestResponse.validfail("上传文件失败", false);
    }
上传分块测试
  • 完善Controller
    @ApiOperation(value = "文件上传前检查文件")
    @PostMapping("/upload/checkfile")
    public RestResponse<Boolean> checkFile(@RequestParam("fileMd5") String fileMd5) {
        return mediaFileService.checkFile(fileMd5);
    }

    @ApiOperation(value = "分块文件上传前检查分块")
    @PostMapping("/upload/checkchunk")
    public RestResponse<Boolean> checkChunk(@RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") int chunk) {
        return mediaFileService.checkChunk(fileMd5, chunk);
    }

    @ApiOperation(value = "上传分块文件")
    @PostMapping("/upload/uploadchunk")
    public RestResponse uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") int chunk) throws Exception {
        return mediaFileService.uploadChunk(fileMd5, chunk, file.getBytes());
    }
合并前下载分块
  • 在合并分块前,我们需要先下载分块,在ServiceImpl中定义下载分块方法
    /**
     * 下载分块文件
     * @param fileMd5       文件的MD5
     * @param chunkTotal    总块数
     * @return 分块文件数组
     */
    private File[] checkChunkStatus(String fileMd5, int chunkTotal) {
        // 作为结果返回
        File[] files = new File[chunkTotal];
        // 获取分块文件目录
        String chunkFileFolder = getChunkFileFolderPath(fileMd5);
        for (int i = 0; i < chunkTotal; i++) {
            // 获取分块文件路径
            String chunkFilePath = chunkFileFolder + i;
            File chunkFile = null;
            try {
                // 创建临时的分块文件
                chunkFile = File.createTempFile("chunk" + i, null);
            } catch (Exception e) {
                XueChengPlusException.cast("创建临时分块文件出错:" + e.getMessage());
            }
            // 下载分块文件
            chunkFile = downloadFileFromMinio(chunkFile, video_files, chunkFilePath);
            // 组成结果
            files[i] = chunkFile;
        }
        return files;
    }

    /**
     * 从Minio中下载文件
     * @param file          目标文件
     * @param bucket        桶
     * @param objectName    桶内文件路径
     * @return
     */
    private File downloadFileFromMinio(File file, String bucket, String objectName) {
        try (FileOutputStream fileOutputStream = new FileOutputStream(file);
             InputStream inputStream = minioClient.getObject(GetObjectArgs
                     .builder()
                     .bucket(bucket)
                     .object(objectName)
                     .build())) {
            IOUtils.copy(inputStream, fileOutputStream);
            return file;
        } catch (Exception e) {
            XueChengPlusException.cast("查询文件分块出错");
        }
        return null;
    }
合并分块
  • 合并分块的实现代码如下
    @Override
    public RestResponse mergeChunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) throws IOException {
        // 下载分块文件
        File[] chunkFiles = checkChunkStatus(fileMd5, chunkTotal);
        // 获取源文件名
        String fileName = uploadFileParamsDto.getFilename();
        // 获取源文件扩展名
        String extension = fileName.substring(fileName.lastIndexOf("."));
        // 创建出临时文件,准备合并
        File mergeFile = File.createTempFile(fileName, extension);
        // 缓冲区
        byte[] buffer = new byte[1024];
        // 写入流,向临时文件写入
        RandomAccessFile raf_write = new RandomAccessFile(mergeFile, "rw");
        // 遍历分块文件数组
        for (File chunkFile : chunkFiles) {
            // 读取流,读分块文件
            RandomAccessFile raf_read = new RandomAccessFile(chunkFile, "r");
            int len;
            while ((len = raf_read.read(buffer)) != -1) {
                raf_write.write(buffer, 0, len);
            }
        }
        uploadFileParamsDto.setFileSize(mergeFile.length());
        // 对文件进行校验,通过MD5值比较
        FileInputStream mergeInputStream = new FileInputStream(mergeFile);
        String mergeMd5 = DigestUtils.md5DigestAsHex(mergeInputStream);
        if (!fileMd5.equals(mergeMd5)) {
            XueChengPlusException.cast("合并文件校验失败");
        }
        // 拼接合并文件路径
        String mergeFilePath = getFilePathByMd5(fileMd5, extension);
        // 将本地合并好的文件,上传到minio中,这里重载了一个方法
        addMediaFilesToMinIO(mergeFile.getAbsolutePath(), video_files, mergeFilePath);
        // 将文件信息写入数据库
        MediaFiles mediaFiles = addMediaFilesToDB(companyId, uploadFileParamsDto, mergeFilePath, mergeMd5, video_files);
        if (mediaFiles == null) {
            XueChengPlusException.cast("媒资文件入库出错");
        }
        return RestResponse.success();
    }

    /**
     * 将本地文件上传到minio
     * @param filePath      本地文件路径
     * @param bucket        桶
     * @param objectName    对象名称
     */
    private void addMediaFilesToMinIO(String filePath, String bucket, String objectName) {
        String contentType = getContentType(objectName);
        try {
            minioClient.uploadObject(UploadObjectArgs
                    .builder()
                    .bucket(bucket)
                    .object(objectName)
                    .filename(filePath)
                    .contentType(contentType)
                    .build());
        } catch (Exception e) {
            XueChengPlusException.cast("上传到文件系统出错");
        }
    }

    /**
     * 根据MD5和文件扩展名,生成文件路径,例 /2/f/2f6451sdg/2f6451sdg.mp4
     * @param fileMd5       文件MD5
     * @param extension     文件扩展名
     * @return
     */
    private String getFilePathByMd5(String fileMd5, String extension) {
        return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + fileMd5 + extension;
    }
  • 基本的业务逻辑就是这些,但是现在还少了点东西,我们没有做异常处理,简单的throw出去而已,并且创建的临时文件,也需要删除,完善后的代码如下
    @Override
    public RestResponse mergeChunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {
        // 下载分块文件
        File[] chunkFiles = checkChunkStatus(fileMd5, chunkTotal);
        // 获取源文件名
        String fileName = uploadFileParamsDto.getFilename();
        // 获取源文件扩展名
        String extension = fileName.substring(fileName.lastIndexOf("."));
        // 创建出临时文件,准备合并
        File mergeFile = null;
        try {
            mergeFile = File.createTempFile(fileName, extension);
        } catch (IOException e) {
            XueChengPlusException.cast("创建合并临时文件出错");
        }
        try {
            // 缓冲区
            byte[] buffer = new byte[1024];
            // 写入流,向临时文件写入
            try (RandomAccessFile raf_write = new RandomAccessFile(mergeFile, "rw")) {
                // 遍历分块文件数组
                for (File chunkFile : chunkFiles) {
                    // 读取流,读分块文件
                    try (RandomAccessFile raf_read = new RandomAccessFile(chunkFile, "r")) {
                        int len;
                        while ((len = raf_read.read(buffer)) != -1) {
                            raf_write.write(buffer, 0, len);
                        }
                    }
                }
            } catch (Exception e) {
                XueChengPlusException.cast("合并文件过程中出错");
            }
            uploadFileParamsDto.setFileSize(mergeFile.length());
            // 对文件进行校验,通过MD5值比较
            try (FileInputStream mergeInputStream = new FileInputStream(mergeFile)) {
                String mergeMd5 = org.apache.commons.codec.digest.DigestUtils.md5Hex(mergeInputStream);
                if (!fileMd5.equals(mergeMd5)) {
                    XueChengPlusException.cast("合并文件校验失败");
                }
                log.debug("合并文件校验通过:{}", mergeFile.getAbsolutePath());
            } catch (Exception e) {
                XueChengPlusException.cast("合并文件校验异常");
            }
            String mergeFilePath = getFilePathByMd5(fileMd5, extension);
            // 将本地合并好的文件,上传到minio中,这里重载了一个方法
            addMediaFilesToMinIO(mergeFile.getAbsolutePath(), video_files, mergeFilePath);
            log.debug("合并文件上传至MinIO完成{}", mergeFile.getAbsolutePath());
            // 将文件信息写入数据库
            MediaFiles mediaFiles = addMediaFilesToDB(companyId, uploadFileParamsDto, mergeFilePath, fileMd5, video_files);
            if (mediaFiles == null) {
                XueChengPlusException.cast("媒资文件入库出错");
            }
            log.debug("媒资文件入库完成");
            return RestResponse.success();
        } finally {
            for (File chunkFile : chunkFiles) {
                try {
                    chunkFile.delete();
                } catch (Exception e) {
                    log.debug("临时分块文件删除错误:{}", e.getMessage());
                }
            }
            try {
                mergeFile.delete();
            } catch (Exception e) {
                log.debug("临时合并文件删除错误:{}", e.getMessage());
            }
        }
    }

接口层完善

  • 下面完善接口
@Api(value = "大文件上传接口", tags = "大文件上传接口")
@RestController
public class BigFilesController {
    @Autowired
    private MediaFileService mediaFileService;

    @ApiOperation(value = "文件上传前检查文件")
    @PostMapping("/upload/checkfile")
    public RestResponse<Boolean> checkFile(@RequestParam("fileMd5") String fileMd5) {
        return mediaFileService.checkFile(fileMd5);
    }

    @ApiOperation(value = "分块文件上传前检查分块")
    @PostMapping("/upload/checkchunk")
    public RestResponse<Boolean> checkChunk(@RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") int chunk) {
        return mediaFileService.checkChunk(fileMd5, chunk);
    }

    @ApiOperation(value = "上传分块文件")
    @PostMapping("/upload/uploadchunk")
    public RestResponse uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("fileMd5") String fileMd5, @RequestParam("chunk") int chunk) throws Exception {
        return mediaFileService.uploadChunk(fileMd5, chunk, file.getBytes());
    }

    @ApiOperation(value = "合并分块文件")
    @PostMapping("/upload/mergechunks")
    public RestResponse mergeChunks(@RequestParam("fileMd5") String fileMd5, @RequestParam("fileName") String fileName, @RequestParam("chunkTotal") int chunkTotal) throws IOException {
        Long companyId = 1232141425L;
        UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
        uploadFileParamsDto.setFileType("001002");
        uploadFileParamsDto.setTags("课程视频");
        uploadFileParamsDto.setRemark("");
        uploadFileParamsDto.setFilename(fileName);
        return mediaFileService.mergeChunks(companyId, fileMd5, chunkTotal, uploadFileParamsDto);
    }
}

接口测试

  • 前后端联调,上传视频进行测试
  • 数据库和MinIO中均能看到对应的数据
    [外链图片转存中…(img-82oVFI2P-1679735939082)]

文件预览

需求分析

  • 图片上传成功、视频上传成功后,可以通过预览按钮查看文件内容
  • 预览的方式是通过浏览器直接打开文件,对于图片和浏览器支持的视频格式可以直接浏览
  • 说明
    1. 前端请求接口层预览文件
    2. 接口层将文件id传递给服务层
    3. 服务层使用文件id查询媒资数据库文件表,获取文件的URL
    4. 接口层将文件url返回给前端,通过浏览器打开URL

接口定义

  • 根据需求分析,定义的接口如下
    @ApiOperation(value = "预览文件")
    @GetMapping("/preview/{mediaId}")
    public RestResponse<String> getPlayUrlByMediaId(@PathVariable String mediaId) {
        return null;
    }

接口开发

设置URL

  • 有一些浏览器不支持的视频格式,不能在浏览器中直接浏览,所以我们要修改保存媒资信息到数据库的方法
    • 当文件是图片时,设置URL字段
    • 当视频是MP4格式时,设置URL字段
    • 其他情况暂不设置URL,需要文件处理后再设置URL字段
  • 修改保存媒资信息的方法
    /**
     * 将文件信息添加到文件表
     *
     * @param companyId           机构id
     * @param uploadFileParamsDto 上传文件的信息
     * @param objectName          对象名称
     * @param fileMD5             文件的md5码
     * @param bucket              桶
     */
    @Transactional
    public MediaFiles addMediaFilesToDB(Long companyId, UploadFileParamsDto uploadFileParamsDto, String objectName, String fileMD5, String bucket) {
        // 保存到数据库
        MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMD5);
        if (mediaFiles == null) {
            mediaFiles = new MediaFiles();
            BeanUtils.copyProperties(uploadFileParamsDto, mediaFiles);
            mediaFiles.setId(fileMD5);
            mediaFiles.setFileId(fileMD5);
            mediaFiles.setCompanyId(companyId);
            mediaFiles.setBucket(bucket);
            mediaFiles.setCreateDate(LocalDateTime.now());
            mediaFiles.setStatus("1");
            mediaFiles.setFilePath(objectName);
+           // 获取源文件名的contentType
+           String contentType = getContentType(objectName);
+           // 如果是图片格式或者mp4格式,则设置URL属性,否则不设置
+           if (contentType.contains("image") || contentType.contains("mp4")) {
+               mediaFiles.setUrl("/" + bucket + "/" + objectName);
+           }
            // 查阅数据字典,002003表示审核通过
            mediaFiles.setAuditStatus("002003");
        }
        int insert = mediaFilesMapper.insert(mediaFiles);
        if (insert <= 0) {
            XueChengPlusException.cast("保存文件信息失败");
        }
        return mediaFiles;
    }

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值