1 模块需求分析
1.1 模块介绍
媒资管理系统是每个在线教育平台所必须具备的,查阅百度百科对它的定义如下:
媒体资源管理(Media Asset Management,MAM)系统是建立在多媒体、网络、数据库和数字存储等先进技术基础上的一个对各种媒体及内容(如视/音频资料、文本文件、图表等)进行数字化存储、管理以及应用的总体解决方案,包括数字媒体的采集、编目、管理、传输和编码转换等所有环节。其主要是满足媒体资源拥有者收集、保存、查找、编辑、发布各种信息的要求,为媒体资源的使用者提供访问内容的便捷方法,实现对媒体资源的高效管理,大幅度提高媒体资源的价值。
每个教学机构都可以在媒资系统管理自己的教学资源,包括:视频、教案等文件。
目前媒资管理的主要管理对象是视频、图片、文档等,包括:媒资文件的查询、文件上传、视频处理等。
- 媒资查询:教学机构查询自己所拥有的媒资信息。
- 文件上传:包括上传图片、上传文档、上传视频。
- 视频处理:视频上传成功,系统自动对视频进行编码处理。
- 文件删除:教学机构删除自己上传的媒资文件。
下图是课程编辑与发布的整体流程,通过下图可以看到媒资管理在整体流程的位置:
1.2 业务流程
1.2.1 上传图片
教学机构人员在课程信息编辑页面上传课程图片,课程图片统一记录在媒资管理系统。
下图是上传图片的界面:
1.2.2 上传视频
1、教学机构人员进入媒资管理列表查询自己上传的媒资文件。
点击“媒资管理”
进入媒资管理列表页面查询本机构上传的媒资文件。
2、教育机构用户在"媒资管理"页面中点击 “上传视频” 按钮。
点击“上传视频”打开上传页面
3、选择要上传的文件,自动执行文件上传,视频上传成功会自动处理。
1.2.3 处理视频
对需要转码处理的视频系统会自动对其处理,处理后生成视频的URL。
处理视频没有用户界面,完全是后台自动执行。
1.2.4 审核媒资
审核媒资包括程序自动审核和人工审核,程序可以通过鉴黄接口(https://www.aliyun.com/product/lvwang?spm=5176.19720258.J_3207526240.51.e93976f4rSq796
)审核视频,对有异议的视频由人工进行审核。
1.运营用户登入运营平台并进入媒资管理页面,查找待审核媒资
2.点击列表中媒资名称链接,可预览该媒资,若是视频,则播放视频。
3.点击列表中某媒资后的"审核" 按钮,既完成媒资的审批过程。
点击“审核”,选择审核结果,输入审核意见。
1.2.5 绑定媒资
课程计划创建好后需要绑定媒资文件,比如:如果课程计划绑定了视频文件,进入课程在线学习界面后点课程计划名称则在线播放视频。如下图:
如何将课程计划绑定媒资呢?
1.教育机构用户进入课程管理页面并编辑某一个课程,在"课程大纲"标签页的某一小节后可点击”添加视频“。
2.弹出添加视频对话框,可通过视频关键字搜索已审核通过的视频媒资。
3.选择视频媒资,点击提交按钮,完成课程计划绑定媒资流程。
课程计划关联视频后如下图:
1.3 数据模型
本模块媒资文件相关的数据表如下:
-
媒资文件表:存储文件信息,包括图片、视频、文档等。
-
media_process: 待处理视频表。
-
media_process_history: 视频处理历史表,记录已经处理成功的视频信息。
媒资文件与课程计划绑定关系表如下:
2 搭建模块环境
2.1 架构的问题分析
当前要开发的是媒资管理服务,目前为止共三个微服务:内容管理、系统管理、媒资管理,如下图:
后期还会添加更多的微服务,当前这种由前端直接请求微服务的方式存在弊端:
如果在前端对每个请求地址都配置绝对路径,非常不利于系统维护,比如下边代码中请求系统管理服务的地址使用的是localhost
当系统上线后这里需要改成公网的域名,如果这种地址非常多则非常麻烦。
基于这个问题可以采用网关来解决,如下图:
这样在前端的代码中只需要指定每个接口的相对路径,如下所示:
在前端代码的一个固定的地方在接口地址前统一加网关的地址,每个请求统一到网关,由网关将请求转发到具体的微服务。
为什么所有的请求先到网关呢?
有了网关就可以对请求进行路由,路由到具体的微服务,减少外界对接微服务的成本,比如:400电话,路由的试可以根据请求路径进行路由、根据host地址进行路由等, 当微服务有多个实例时可以通过负载均衡算法进行路由,如下:
另外,网关还可以实现权限控制、限流等功能。
项目采用Spring Cloud Gateway作为网关,网关在请求路由时需要知道每个微服务实例的地址,项目使用Nacos作用服务发现中心和配置中心,整体的架构图如下:
流程如下:
- 微服务启动,将自己注册到Nacos,Nacos记录了各微服务实例的地址。
- 网关从Nacos读取服务列表,包括服务名称、服务地址等。
- 请求到达网关,网关将请求路由到具体的微服务。
要使用网关首先搭建Nacos,Nacos有两个作用:
- 服务发现中心。
微服务将自身注册至Nacos,网关从Nacos获取微服务列表。 - 配置中心。
微服务众多,它们的配置信息也非常复杂,为了提供系统的可维护性,微服务的配置信息统一在Nacos配置。
2.2 搭建Nacos
2.2.1 服务发现中心
- Spring Cloud :一套规范
- Spring Cloud alibaba: nacos服务注册中心,配置中心
- 根据上节讲解的网关的架构图,要使用网关首先搭建Nacos。
- 首先搭建Nacos服务发现中心。
- 在搭建Nacos服务发现中心之前需要搞清楚两个概念:namespace和group
- namespace:用于区分环境、比如:开发环境、测试环境、生产环境。
- group:用于区分项目,比如:xuecheng-plus项目、xuecheng2.0项目
- 首先在nacos配置namespace:
- 登录Centos,启动Naocs,使用sh /data/soft/restart.sh将自动启动Nacos。
- 访问:
http://192.168.101.65:8848/nacos/
- 账号密码:nacos/nacos
- 登录成功,点击左侧菜单“命名空间”进入命名空间管理界面,
点击“新建命名空间”,填写命名空间的相关信息。如下图:
使用相同的方法再创建“测试环境”、"生产环境"的命名空间。
创建成功,如下图:
在教学中可以创建具体班级的命名空间,假如创建1010班级的命名空间,如下:
注意:如果使用dev1010命名空间,在下边的配置中对namespace配置为dev1010。
首先完成各服务注册到Naocs,下边将内容管理服务注册到nacos中。
- 在xuecheng-plus-parent中添加依赖管理
<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>
2)在内容管理模块的接口工程中添加如下依赖
- 如果要上报你的服务信息到nacos就要添加这个依赖,内容管理下面有3个工程,那么那么那个服务需要上报呢???
- 那个启动服务就上报那个------api服务
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
3)配置nacos的地址
在内容管理的接口工程的配置文件中配置如下信息:
#微服务配置
spring:
application:
name: content-api #微服务名称
cloud:
nacos:
server-addr: 192.168.101.65:8848 #nacos服务地址
discovery:
namespace: dev402 #上报到那个命名空间
group: xuecheng-plus-project #上报到那个分组---项目名
4)重启内容管理服务
待微服务启动成功,进入Nacos服务查看服务列表
在 “开发环境” 命名空间下有这个服务这说明内容管理微服务在Nacos注册成功。
点击其它一个微服务的“详情”
通过上图可以查看微服务实例的地址。
2.2.2 配置中心
2.2.2.1 配置三要素
搭建完成Nacos服务发现中心,下边搭建Nacos为配置中心,其目的就是通过Nacos去管理项目的所有配置。
先将项目中的配置文件分分类:
1、每个项目特有的配置
是指该配置只在有些项目中需要配置,或者该配置在每个项目中配置的值不同。
比如:spring.application.name每个项目都需要配置但值不一样,以及有些项目需要连接数据库而有些项目不需要,有些项目需要配置消息队列而有些项目不需要。
2、项目所公用的配置
是指在若干项目中配置内容相同的配置。比如:redis的配置,很多项目用的同一套redis服务所以配置也一样。
另外还需要知道nacos如何去定位一个具体的配置文件,即:namespace、group、dataid.
- 通过namespace、group找到具体的环境和具体的项目。
- 通过dataid找到具体的配置文件,dataid有三部分组成
比如:content-service-dev.yaml配置文件 由(content-service)-(dev). (yaml)三部分组成
-
content-service:第一部分,它是在application.yaml中配置的应用名,即spring.application.name的值。
-
dev:第二部分,它是环境名,通过spring.profiles.active指定,
-
Yaml: 第三部分,它是配置文件 的后缀,目前nacos支持properties、yaml等格式类型,本项目选择yaml格式类型。
所以,如果我们要配置content-service工程的配置文件:
- 在开发环境中配置content-service-dev.yaml
- 在测试环境中配置content-service-test.yaml
- 在生产环境中配置content-service-prod.yaml
我们启动项目中传入spring.profiles.active的参数决定引用哪个环境的配置文件,例如:传入spring.profiles.active=dev表示使用dev环境的配置文件即content-service-dev.yaml。
2.2.2.2 配置content-api
在内容管理模块的接口工程配置依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
下边以开发环境为例对content-api工程的配置文件进行配置,进入nacos,进入开发环境。
点击加号,添加一个配置
输入data id、group以及配置文件内容。
server:
servlet: #接口的根路径
context-path: /content
port: 63040
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.101.65:3306/xc402_content?serverTimezone=UTC&userUnicode=true&useSSL=false&
username: root
password: mysql
为什么没在nacos中配置以下这些微服务的配置,而是放在bootstrap.yaml文件中?
spring:
application:
name: content-api
...
因为刚才说了dataid第一部分就是spring.application.name,nacos 客户端要根据此值确定配置文件 名称,所以spring.application.name不在nacos中配置,而是要在工程的本地进行配置。
因此:与nacos地址和配置文件有关的所有信息都应该放到bootstrap.yaml文件中。
在content-api工程 的本地配置bootstrap.yaml,内容如下:
#server: 改为在nacos中配置
# servlet:
# context-path: /content #接口的根路径
# port: 63040
#微服务配置
spring:
application:
name: content-api #微服务名称
profiles:
active: dev #环境名:开发环境,这里是dev
cloud:
nacos:
server-addr: 192.168.101.65:8848 #nacos服务地址
discovery: #服务注册相关配置
namespace: dev402 #上报到哪个命名空间
group: xuecheng-plus-project #上报到那个分组---项目名
config: #配置文件相关配置
namespace: dev402 #命名空间
group: xuecheng-plus-project #组名
file-extension: yaml #后缀
refresh-enabled: true
# datasource: 原本在本地配置---》改为在nacos中配置-----》接口不需要配置数据库改为引用servce模块配置
# driver-class-name: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://192.168.101.65:3306/xc402_content?serverTimezone=UTC&userUnicode=true&useSSL=false&
# username: root
# password: mysql
# 日志文件配置路径
logging:
config: classpath:log4j2-dev.xml
swagger:
title: "学成在线内容管理系统"
description: "内容系统管理系统对课程相关信息进行管理"
base-package: com.xuecheng.content
enabled: true
version: 1.0.0
重启content-api服务,并使用Httpclient测试课程查询接口是否可以正常查询。
swagger地址:http://localhost:63040/
2.2.2.3 配置content-service
当前配置存在的问题:
-
service工程主程序里是没有配置文件的,它的配置文件在test里面,其实这种情况是不用配置的,那为什么这里还要配置呢???
-
content-api接口层本身是不需要连接数据库的,但是我们确实在nacos配置文件中配置了数据库信息。
-
因为接口层依赖了service层代码,当一打包启动的时候他就把service层的mapper、service代码全部依赖到了content-api模块,service模块需要数据库连接所以在api模块配置了数据库连接。但实际上接口层本身就是一个控制层方法不需要配置数据库连接的。
正确的配置方式:
- service该在nacos中该配置的文件仍然配置,然后api工程去引用service的配置,这样在api工程就不需要配置数据库连接了。
- 因为nacos中的配置是项目特有的配置,接口特有的配置是不需要数据库连接的。
步骤:
第一步:添加依赖(service模块不需要注册到注册中心,所以不需要添加注册依赖和注册的配置)
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
第二步:在nacos中编辑配置文件
#微服务配置
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.101.65:3306/xc402_content?serverTimezone=UTC&userUnicode=true&useSSL=false&
username: root
password: mysql
第三步:编辑bootstrap.yaml文件
#微服务配置
spring:
application:
name: content-service #微服务名称
profiles:
active: dev #环境名:开发环境,这里是dev
cloud:
nacos:
server-addr: 192.168.101.65:8848 #nacos服务地址
config: #配置文件相关配置
namespace: dev402 #命名空间
group: xuecheng-plus-project #组名
file-extension: yaml #后缀
refresh-enabled: true
# datasource: 改为在nacos配置了
# driver-class-name: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://192.168.101.65:3306/xc402_content?serverTimezone=UTC&userUnicode=true&useSSL=false&
# username: root
# password: mysql
# 日志文件配置路径
logging:
config: classpath:log4j2-dev.xml
第四步:api接口工程引用service的配置文件
先注释掉api接口的数据库连接信息
注意:因为api接口工程依赖了service工程 的jar,所以这里使用extension-configs扩展配置文件 的方式引用service工程所用到的配置文件。
extension-configs: #引入扩展信息nacos中的配置
- data-id: content-service-${spring.profiles.active}.yaml
group: xuecheng-plus-project
refresh: true
如果添加多个扩展文件,继续在下添加即可,如下:
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
重启content-api服务,并使用Httpclient测试课程查询接口是否可以正常查询。
swagger地址:http://localhost:63040/
2.2.3 公用配置
还有一个优化的点是如何在nacos中配置项目的公用配置呢?
nacos提供了shared-configs可以引入公用配置。
在content-api中配置了swagger,所有的接口工程 都需要配置swagger,这里就可以将swagger的配置定义为一个公用配置,哪个项目用引入即可。
单独在xuecheng-plus-common
分组下创建xuecheng-plus的公用配置,进入nacos的开发环境,添加swagger-dev.yaml
公用配置
#注意是通用的,所以把数据改一下
swagger:
title: "学成在线项目接口文档"
description: "学成在线项目接口文档"
base-package: com.xuecheng
enabled: true
version: 1.0.0
删除接口工程中对swagger的配置。
项目使用shared-configs可以引入公用配置。在接口工程的本地配置文件 中引入公用配置,如下:
#server: 抽取到nacos-api配置中
# servlet:
# context-path: /content #接口的根路径
# port: 63040
#微服务配置
spring:
application:
name: content-api #微服务名称
profiles:
active: dev #环境名:开发环境,这里是dev
cloud:
nacos:
server-addr: 192.168.101.65:8848 #nacos服务地址
discovery: #服务注册相关配置
namespace: dev402 #上报到哪个命名空间
group: xuecheng-plus-project #上报到那个分组---项目名
config: #配置文件相关配置
namespace: dev402 #命名空间
group: xuecheng-plus-project #组名
file-extension: yaml #后缀
refresh-enabled: true
extension-configs: #引入扩展信息nacos中的配置
- data-id: content-service-${spring.profiles.active}.yaml
group: xuecheng-plus-project
refresh: true
shared-configs: #引入nacos公有文件中的配置
- 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
# datasource: 原本在本地配置---》改为在nacos中配置-----》接口不需要配置数据库改为引用servce模块配置
# driver-class-name: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://192.168.101.65:3306/xc402_content?serverTimezone=UTC&userUnicode=true&useSSL=false&
# username: root
# password: mysql
# 日志文件配置路径 抽取到nacos公共的配置loggin-dev.yaml中
#logging:
# config: classpath:log4j2-dev.xml
#swagger: 抽取到nacos公共的配置swagger-dev.yaml中
# title: "学成在线内容管理系统"
# description: "内容系统管理系统对课程相关信息进行管理"
# base-package: com.xuecheng.content
# enabled: true
# version: 1.0.0
再以相同 的方法配置日志的公用配置。
注释掉原来的日志配置
在接口工程和业务工程,引入logging-dev.yaml公用配置文件
配置完成,重启content-api接口工程,访问http://localhost:63040/content/swagger-ui.html 查看swagger接口文档是否可以正常访问,查看控制台log4j2日志输出是否正常。
2.2.4 配置优先级
到目前为止已将所有微服务的配置统一在nacos进行配置,用到的配置文件有本地的配置文件 bootstrap.yaml和nacos上的配置文件,SpringBoot读取配置文件 的顺序如下:
引入配置文件的形式有:以api模块为例
1、以项目应用名方式引入:api
2、以扩展配置文件方式引入:api 引用 service(数据库配置)
3、以共享配置文件 方式引入:swagger,log
4、本地配置文件:api的本地bootstrap.yml文件
各配置文件 的优先级:项目应用名配置文件 > 扩展配置文件 > 共享配置文件 > 本地配置文件。
有时候我们在测试程序时直接在本地加一个配置进行测试,比如下边的例子:
我们想启动两个内容管理微服务,此时需要在本地指定不同的端口,通过VM Options参数,在IDEA配置启动参数
调出VM Options参数选项:
通过-D指定参数名和参数值,参数名即在bootstrap.yml中配置的server.port。
启动ContentApplication2,发现端口仍然是63040,这说明本地的配置没有生效。
这时我们想让本地最优先,可以在nacos配置文件 中配置如下即可实现:
#配置本地优先
spring:
cloud:
config:
override-none: true
再次启动ContentApplication2,端口为63041。
2.2.5 导入配置文件
课程资料中提供了系统用的所有配置文件nacos_config_export.zip,下边将nacos_config_export.zip导入nacos。 (不用解压)
进入具体的命名空间,点击“导入配置”
打开导入窗口
如果导入的配置和现在nacos里面的配置有重复:
- 选择跳过,因为nacos里面的配置信息之前调试已经通过了,所以选择跳过。
点击“上传文件”选择资料中的nacos_config_export.zip开始导入。
2.2.6 作业:配置系统管理服务
按照上边的方法 自行将系统管理服务的配置信息在nacos上进行配置。
1、添加对应的依赖
系统管理模块的api接口工程中添加nacos注册依赖,配置中心依赖
<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>
系统管理模块的service工程中添加n配置中心依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
2、编辑api模块的配置文件、编辑service模块的配置文件
#server:
# servlet:
# context-path: /system
# port: 63110
#微服务配置
spring:
application:
name: system-api
profiles:
active: dev #环境名:开发环境,这里是dev
cloud:
nacos:
server-addr: 192.168.101.65:8848 #nacos服务地址
discovery: #服务注册相关配置
namespace: dev402 #上报到哪个命名空间
group: xuecheng-plus-project #上报到那个分组---项目名
config: #配置文件相关配置
namespace: dev402 #命名空间
group: xuecheng-plus-project #组名
file-extension: yaml #后缀
refresh-enabled: true
extension-configs: #引入扩展信息nacos中的配置
- data-id: system-service-${spring.profiles.active}.yaml
group: xuecheng-plus-project
refresh: true
shared-configs: #引入nacos公有文件中的配置
- 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
# datasource:
# driver-class-name: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://192.168.101.65:3306/xcplus_system?serverTimezone=UTC&userUnicode=true&useSSL=false&
# username: root
# password: mysql
# 日志文件配置路径
#logging:
# config: classpath:log4j2-dev.xml
# swagger 文档配置
#swagger:
# title: "学成在线系统管理"
# description: "系统管理接口"
# base-package: com.xuecheng.system
# enabled: true
# version: 1.0.0
#微服务配置
spring:
application:
name: system-service
profiles:
active: dev #环境名:开发环境,这里是dev
cloud:
nacos:
server-addr: 192.168.101.65:8848 #nacos服务地址
config: #配置文件相关配置
namespace: dev402 #命名空间
group: xuecheng-plus-project #组名
file-extension: yaml #后缀
refresh-enabled: true
shared-configs: #引入nacos公有日志文件中的配置
- data-id: logging-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true
# datasource:
# driver-class-name: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://192.168.101.65:3306/xc402_system?serverTimezone=UTC&userUnicode=true&useSSL=false&
# username: root
# password: mysql
# 日志文件配置路径
#logging:
# config: classpath:log4j2-dev.xml
3、nacos配置中心的配置文件:在2.2.5中已经导入。
2.3 搭建Gateway
本项目使用Spring Cloud Gateway作为网关,下边创建网关工程。
新建一个网关工程:xuecheng-plus-gateway
工程结构
添加依赖:
<?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.yaml配置文件
#微服务配置
spring:
application:
name: gateway # 服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: 192.168.101.65:8848
discovery:
namespace: dev402
group: xuecheng-plus-project
config:
namespace: dev402
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
在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
启动网关工程,通过网关工程访问微服务进行测试。
网关服务在nacos注册成功:
在http-client-env.json中配置网关的地址
使用httpclient测试课程查询 接口,如下:
### 课程查询列表
POST {{gateway_host}}/content/course/list?pageNo=2&pageSize=1
Content-Type: application/json
{
"auditStatus": "202002",
"courseName": ""
}
运行,观察是否可以正常访问接口 ,如下所示可以正常请求接口。
http://localhost:63010/content/course/list?pageNo=2&pageSize=1
HTTP/1.1 200 OK
transfer-encoding: chunked
Content-Type: application/json
Date: Sun, 11 Sep 2022 09:54:32 GMT
{
"items": [
{
"id": 26,
"companyId": 1232141425,
"companyName": null,
"name": "spring cloud实战",
"users": "所有人",
"tags": null,
"mt": "1-3",
"mtName": null,
"st": "1-3-2",
"stName": null,
"grade": "200003",
"teachmode": "201001",
"description": "本课程主要从四个章节进行讲解: 1.微服务架构入门 2.spring cloud 基础入门 3.实战Spring Boot 4.注册中心eureka。",
"pic": "https://cdn.educba.com/academy/wp-content/uploads/2018/08/Spring-BOOT-Interview-questions.jpg",
"createDate": "2019-09-04 09:56:19",
"changeDate": "2021-12-26 22:10:38",
"createPeople": null,
"changePeople": null,
"auditStatus": "202002",
"status": "203001",
"coursePubId": null,
"coursePubDate": null
}
],
"counts": 29,
"page": 2,
"pageSize": 1
}
2.4 搭建媒资工程
至此Nacos、网关已经搭建完成,下边将媒资工程导入项目(和内容管理模块的结构相同,所以不在手动搭建了)。
从课程资料中获取媒资工程 xuecheng-plus-media,拷贝到项目工程根目录。
右键pom.xml转为maven工程。
下边做如下配置:
-
创建媒资数据库xc402_media,并导入资料目录中的xcplus_media.sql
-
修改nacos上的media-service-dev.yaml配置文件中的数据库链接信息:xc402_media
-
修改本地配置文件:
重启media-api工程只要能正常启动成功即可,稍后根据需求写接口。
测试:导入的文件有一个接口
访问swagger:http://localhost:63050/media/swagger-ui.html
查询成功,说明搭建成功。
3 分布式文件系统
3.1 什么是分布式文件系统
要理解分布式文件系统首先了解什么是文件系统。
查阅百度百科:
文件系统是负责管理和存储文件的系统软件,操作系统通过文件系统提供的接口去存取文件,用户通过操作系统访问磁盘上的文件。
下图指示了文件系统所处的位置:
常见的文件系统:FAT16/FAT32、NTFS、HFS、UFS、APFS、XFS、Ext4等 。
现在有个问题,一此短视频平台拥有大量的视频、图片,这些视频文件、图片文件该如何存储呢?如何存储可以满足互联网上海量用户的浏览。
今天讲的分布式文件系统就是海量用户查阅海量文件的方案。
我们阅读百度百科去理解分布式文件系统的定义:
通过概念可以简单理解为:一个计算机无法存储海量的文件,通过网络将若干计算机组织起来共同去存储海量的文件,去接收海量用户的请求,这些组织起来的计算机通过网络进行通信,如下图:
好处:
- 一台计算机的文件系统处理能力扩充到多台计算机同时处理。
- 一台计算机挂了还有另外副本计算机提供数据。
- 每台计算机可以放在不同的地域,这样用户就可以就近访问,提高访问速度。
市面上有哪些分布式文件系统的产品呢?
1、NFS
阅读百度百科:
特点:
- 在客户端上映射NFS服务器的驱动器。
- 客户端通过网络访问NFS服务器的硬盘完全透明。
2、GFS
- GFS采用主从结构,一个GFS集群由一个master和大量的chunkserver组成。
- master存储了数据文件的元数据,一个文件被分成了若干块存储在多个chunkserver中。
- 用户从master中获取数据元信息,向chunkserver存储数据。
3、HDFS
HDFS,是Hadoop Distributed File System的简称,是Hadoop抽象文件系统的一种实现。HDFS是一个高度容错性的系统,适合部署在廉价的机器上。HDFS能提供高吞吐量的数据访问,非常适合大规模数据集上的应用。 HDFS的文件分布在集群机器上,同时提供副本进行容错及可靠性保证。例如客户端写入读取文件的直接操作都是分布在集群各个机器上的,没有单点性能压力。
下图是HDFS的架构图:
- 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
3.2 MinIO
3.2.1 介绍
本项目采用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块硬盘上。
使用纠删码的好处是即便丢失一半数量(N/2)的硬盘,仍然可以恢复数据。 比如上边集合中有4个以内的硬盘损害仍可保证数据恢复,不影响上传和下载,如果多于一半的硬盘坏了则无法恢复。
3.2.2 数据恢复演示
下边在本机演示MinIO恢复数据的过程,在本地创建4个目录表示4个硬盘。
正常情况下应该启动4台虚拟机,但是太耗费性能所以用一个目录代表一个节点。
下载minio,下载地址在https://dl.min.io/server/minio/release/
,可从课程资料找到MinIO的安装文件minio.zip解压即可使用,CMD进入有minio.exe的目录,运行下边的命令:
minio.exe server E:\MinIO\data\data1 E:\MinIO\data\data2 E:\MinIO\data\data3 E:\MinIO\data\data4
启动结果如下:
说明如下:
WARNING: MINIO_ACCESS_KEY and MINIO_SECRET_KEY are deprecated.
Please use MINIO_ROOT_USER and MINIO_ROOT_PASSWORD
Formatting 1st pool, 1 set(s), 4 drives per set.
WARNING: Host local has more than 2 drives of set. A host failure will result in data becoming unavailable.
WARNING: Detected default credentials 'minioadmin:minioadmin', we recommend that you change these values with 'MINIO_ROOT_USER' and 'MINIO_ROOT_PASSWORD' environment variables
- 老版本使用的MINIO_ACCESS_KEY 和 MINIO_SECRET_KEY不推荐使用,推荐使用MINIO_ROOT_USER 和MINIO_ROOT_PASSWORD设置账号和密码。
- pool即minio节点组成的池子,当前有一个pool和4个硬盘组成的set集合
- 因为集合是4个硬盘,大于2的硬盘损坏数据将无法恢复。
- 账号和密码默认为minioadmin、minioadmin,可以在环境变量中设置通过’MINIO_ROOT_USER’ and ‘MINIO_ROOT_PASSWORD’ 进行设置。
下边输入http://localhost:9000进行登录,账号和密码为:minioadmin/minioadmin
登录成功:
下一步创建bucket,桶,它相当于存储文件的目录,可以创建若干的桶。
输入bucket的名称,点击“CreateBucket”,创建成功
点击“upload”上传文件。
下边上传几个文件
下边去四个目录观察文件的存储情况
我们发现上传的1.mp4文件存储在了四个目录,即四个硬盘上。
下边测试minio的数据恢复过程:
- 首先删除一个目录。
- 删除目录后仍然可以在web控制台上传文件和下载文件。
- 稍等片刻删除的目录自动恢复。
- 删除两个目录。
- 删除两个目录也会自动恢复。
- 删除三个目录 。
- 由于 集合中共有4块硬盘,有大于一半的硬盘损坏数据无法恢复。
- 此时报错:We encountered an internal error, please try again. (Read failed. Insufficient number of drives online)在线驱动器数量不足。
3.2.3 测试Docker环境
开发阶段和生产阶段统一使用Docker下的MINIO。
在下发的虚拟机中已安装了MinIO的镜像和容器,执行sh /data/soft /restart.sh启动Docker下的MinIO
启动完成登录MinIO查看是否正常。
访问http://192.168.101.65:9000
账号和密码为:minioadmin/minioadmin
本项目创建两个buckets:
- mediafiles: 普通文件
- video:视频文件
3.2.4 SDK
3.2.4.1上传文件
说明:上面是在浏览器访问页面,之后上传图片的,开发中一定是使用java代码的方式来操作。
MinIO提供多个语言版本SDK的支持,下边找到java版本的文档:
地址:https://docs.min.io/docs/java-client-quickstart-guide.html
最低需求Java 1.8或更高版本:
在media-service工程中添加依赖,maven依赖如下:
<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服务。
参数 | 说明 |
---|---|
Endpoint | 对象存储服务的URL |
Access Key | Access key就像用户ID,可以唯一标识你的账户。 |
Secret Key | Secret key是你账户的密码。 |
官方的示例代码如下:
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());
}
}
}
参考示例在media-service工程中 测试上传文件功能,
首先创建一个用于测试的bucket:testbucket
点击“Manage”修改bucket的访问权限
选择public权限:如果不改,接口中是拿不到下载里面的文件的。
在xuecheng-plus-media-service工程 的test下编写测试代码如下:
package com.xuecheng.media;
import io.minio.MinioClient;
import io.minio.UploadObjectArgs;
import org.junit.jupiter.api.Test;
/**
* @description 测试MinIO
* @author Mr.M
* @date 2022/9/11 21:24
* @version 1.0
*/
public class MinioTest {
/**
* endpoint:MinIO的url
* credentials:账户、密码
*/
static MinioClient minioClient =
MinioClient.builder()
.endpoint("http://192.168.101.65:9000")
.credentials("minioadmin", "minioadmin")
.build();
//上传文件
@Test
public void upload() {
try {
//上传文件的参数信息
UploadObjectArgs testbucket = UploadObjectArgs.builder()
.bucket("testbucket") //确定桶
.filename("D:\\图片\\a1.jpeg") //指定本地文件路径(本地磁盘的那一个文件进行上传)
.object("a1.jpeg") //文件上传到Minio中起的文件名
//.object("001/test001.mp4")//添加子目录
//.contentType("video/mp4")//默认根据扩展名确定文件内容类型,也可以指定
.build();
//上传文件
minioClient.uploadObject(testbucket);
System.out.println("上传成功");
} catch (Exception e) {
e.printStackTrace();
System.out.println("上传失败");
}
}
}
上传用到的图片:
执行upload方法,分别测试向桶的根目录上传文件以及子目录上传文件。
上传成功,通过web控制台查看文件,并预览文件。
说明:最好通过一个第三方的工具类指定文件扩展名,虽然默认可以识别扩展名,但前提是你上传的文件有扩展名,如果上传的文件没有扩展名就无法识别了。
-
设置contentType可以通过com.j256.simplemagic.ContentType枚举类查看常用的mimeType(媒体类型)ctrl+shift+n
-
需要引入第三方的依赖
通过扩展名得到mimeType,代码如下:
//根据扩展名取出mimeType
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(".mp4");
String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//通用mimeType,字节流
完善上边的代码 如下:
/**
* endpoint:MinIO的url
* credentials:账户、密码
*/
static MinioClient minioClient =
MinioClient.builder()
.endpoint("http://192.168.101.65:9000")
.credentials("minioadmin", "minioadmin")
.build();
//上传文件
@Test
public void upload() {
//通过扩展名得到媒体资源类型 mimeType
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(".mp4");//输入扩展名
String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//通用mimeType,字节流(没有扩展名时,默认是一个未知的类型)
if(extensionMatch!=null){
//如果有扩展名则获取它的媒体类型,这样使用的就不是默认的了
mimeType = extensionMatch.getMimeType();
}
try {
//上传文件的参数信息
UploadObjectArgs testbucket = UploadObjectArgs.builder()
.bucket("testbucket") //确定桶
.filename("D:\\图片\\a1.jpeg") //指定本地文件路径(本地磁盘的那一个文件进行上传)
.object("a1.jpeg") //文件上传到Minio中起的文件名
//.object("001/test001.mp4")//添加子目录
.contentType(mimeType)//默认根据扩展名确定文件内容类型,也可以指定
.build();
//上传文件
minioClient.uploadObject(testbucket);
System.out.println("上传成功");
} catch (Exception e) {
e.printStackTrace();
System.out.println("上传失败");
}
}
3.2.4.2 删除文件
下边测试删除文件
参考:https://docs.min.io/docs/java-client-api-reference#removeObject
//删除
@Test
public void delete(){
try {
//构建删除文件参数信息
RemoveObjectArgs removeObjectArgs= RemoveObjectArgs.builder()
.bucket("testbucket") //删除哪个桶
.object("a1.jpeg") //删除Minio中的那个文件名(对象名)
//.object("001/test001.mp4") 删除带子目录的文件
.build();
//删除文件:不加s是删除一个文件
minioClient.removeObject(removeObjectArgs);
System.out.println("删除成功");
} catch (Exception e) {
e.printStackTrace();
System.out.println("删除失败");
}
}
3.2.4.3 查询文件
通过查询文件查看文件是否存在minio中。
参考:https://docs.min.io/docs/java-client-api-reference#getObject
//查询文件 从minio中下载
@Test
public void test_getFile() throws Exception{
//1.构建参数
GetObjectArgs getObjectArgs = GetObjectArgs.builder()
.bucket("testbucket") //桶
.object("a1.jpeg") //minio中下载的文件名
.build();
//2.下载文件
//输入流获取文件
FilterInputStream inputStream = minioClient.getObject(getObjectArgs);
//指定输出流:下载的位置 注意:这是查询远程服务获取的一个流对象,远程流计算md5可能会不稳定有问题,所以需要用本地流
FileOutputStream outputStream = new FileOutputStream(new File("D:\\图片\\a12222.jpeg"));
//流拷贝:把输入流的内容拷贝到输出流中
IOUtils.copy(inputStream,outputStream);
//3.校验文件的完整性:如何知道文件下载完成后是完整的,没有丢失呢??? (这个地方讲的不太好,参考一下即可)
//思路:将原始文件的md5加密后的值和下载后的文件md5值进行比较即可 远程流网络有问题不稳定---改为本地流
//String source_md5 = DigestUtils.md5Hex(inputStream); //原始文件:minio中文件的md5
//最终:通过本地上传之前的文件和下载到本地后的文件进行比较
FileInputStream fileInputStream1 = new FileInputStream(new File("D:\\图片\\a1.jpeg"));
String source_md5 = DigestUtils.md5Hex(fileInputStream1);
String local_md5 = DigestUtils.md5Hex(new FileInputStream(new File("D:\\图片\\a12222.jpeg")));//本地文件md5
if(source_md5.equals(local_md5)){
System.out.println("下载成功");
}
}
4 上传图片
4.1 需求分析
4.1.1 业务流程
课程图片是宣传课程非常重要的信息,在新增课程界面上传课程图片,也可以修改课程图片。
如下图:
上传课程图片总体上包括两部分:
- 上传课程图片前端请求媒资管理服务将文件上传至分布式文件系统,并且在媒资管理数据库保存文件信息。
- 上传图片成功保存图片地址到课程基本信息表中。
详细流程如下:
- 前端进入上传图片界面
- 上传图片,请求媒资管理服务。
- 媒资管理服务将图片文件存储在MinIO。
- 媒资管理记录文件信息到数据库。
- 为什么存储文件信息:类似于云盘,第一次上传4个G的视频第二次再次上传一个相同的4个G的视频,他不会在重新传一遍了而是立即上传上去了,因为它已经校验这个视频在云盘上已经存在了,所以我们要对文件的信息进行存储,用来校验你重复上传时这个文件是否已经上传过,如果存在就不用上传了。
- 前端请求内容管理服务保存课程信息,在内容管理数据库保存图片地址。
- 保存在前面内容管理模块已经做过了
4.1.2 数据模型
涉及到的数据表有:课程信息表中的图片字段、媒资数据库的文件表,下边主要看媒资数据库的文件表。
各字段描述如下:
- id:前端上传的时候会传递md5值作为id,根据这个md5值来区分文件是否上传。
- 同一个文件的md5值是一样的,前端在上传的时候会把这个文件的md5值传到后台,后台如果有这个md5值说明文件上传成功。
- bucket:桶
- file_path:存储路径
- 文件访问地址就是bucket+file_path
- url:最终访问的文件地址,bucket+file_path不就是访问地址吗,为什么还要设计一个url呢???
- 如果是图片的话file_path和url是相等的,但是如果是视频的话比如在file_path存的是一个avi的视频地址,但是avi的视频是不能播放的,需要把avi视频转码为MP4视频,而这个url存的就是MP4的视频地址。
4.2 准备环境
首先在minio配置bucket,bucket名称为:mediafiles,并设置bucket的权限为公开。
- mediafiles:这个桶存放非视频文件
- video:存放视频文件的桶
在nacos配置中minio的相关信息,进入media-service-dev.yaml:
- 之前测试代码是写死的,正式工程连接信息是放在配置文件中的。
配置信息如下:
minio:
endpoint: http://192.168.101.65:9000 #minio地址
accessKey: minioadmin #账号
secretKey: minioadmin #密码
bucket:
files: mediafiles #桶
videofiles: video #桶
在media-service工程编写minio的配置类:
package com.xuecheng.media.config;
import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @description minio配置
* @author Mr.M
* @date 2022/9/12 19:32
* @version 1.0
*/
@Configuration
public class MinioConfig {
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.accessKey}")
private String accessKey;
@Value("${minio.secretKey}")
private String secretKey;
@Bean
public MinioClient minioClient() {
MinioClient minioClient =
MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
return minioClient;
}
}
4.3 接口定义
根据需求分析,下边进行接口定义,此接口定义为一个通用的上传文件接口,可以上传图片或其它文件。
首先分析接口:
-
请求地址:/media/upload/coursefile
-
请求内容:Content-Type: multipart/form-data;
-
form-data; name=“filedata”; filename=“具体的文件名称”
-
响应参数:文件信息,如下
{
"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
}
定义上传响应模型类:
package com.xuecheng.media.model.dto;
import com.xuecheng.media.model.po.MediaFiles;
import lombok.Data;
/**
* ClassName: UploadFileResultDto 类似于vo
* Package: com.xuecheng.media.model.dto
* Description: 上传普通文件成功响应结果 将来前端想要上传写别的东西直接修改这个类即可,不能修改数据库对应的po
*
* @Author xxx
* @Create 2024/6/7 20:09
* @Version 1.0
*/
@Data
public class UploadFileResultDto extends MediaFiles {
}
定义接口如下:
/**
* MediaType.MULTIPART_FORM_DATA_VALUE:multipart/form-data,指定文件上传以二进制方式上传
* @RequestPart("filedata"):指定前端传过来的文件名称 form-data; name=“filedata”; filename=“具体的文件名称”
* MultipartFile:上传的文件封装到 MultipartFile对象中,通过此对象可以获取文件相关信息
* 返回值:根据接口可知需要返回文件信息表的信息,但是不能直接使用这个文件表模型model--po--MediaFiles,因为将来如果前端让你多传些东西你肯定不能
* 直接修改数据库对应的这个po类,所以建议单独的在建造一个模型类Dto。
*/
@ApiOperation("上传文件")
@RequestMapping(value = "/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadFileResultDto upload(@RequestPart("filedata") MultipartFile filedata) throws IOException {
//调用service上传图片
return null;
}
接口定义好后可以用httpclient工具测试一下
使用httpclient测试
### 上传文件
POST {{media_host}}/media/upload/coursefile
Content-Type: multipart/form-data; boundary=WebAppBoundary
--WebAppBoundary
Content-Disposition: form-data; name="filedata"; filename="1.jpg"
Content-Type: application/octet-stream
< d:/develop/upload/1.jpg
4.4 接口开发
4.4.1 DAO开发
根据需求分析DAO层实现向media_files表插入一条记录,使用media_files表生成的mapper即可。
4.4.2 Service开发
Service方法需要提供一个更加通用的保存文件的方法。
定义请求参数类:
package com.xuecheng.media.model.dto;
import lombok.Data;
/**
* @description 上传普通文件请求参数,因为上次的文件信息要保存到数据库 Dto
* @author Mr.M
* @date 2022/9/12 18:49
* @version 1.0
*/
@Data
public class UploadFileParamsDto {
/**
* 文件名称
*/
private String filename;
/**
* 文件类型(文档,音频,视频)
*/
private String fileType;
/**
* 文件大小
*/
private Long fileSize;
/**
* 标签
*/
private String tags;
/**
* 上传人
*/
private String username;
/**
* 备注
*/
private String remark;
}
定义service方法:
/**
* 上传文件
* @param companyId 机构id:同样带告诉属于哪一个机构
* @param uploadFileParamsDto 上传文件信息:用来保存信息到数据库
* @param localFilePath 文件磁盘路径:用来上传文件到Minoo
* @return 文件信息
*/
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath);
实现方法如下:
@Autowired
MediaFilesMapper mediaFilesMapper;
@Autowired
MinioClient minioClient;
//存储普通文件:获取nacos中配置的桶
@Value("${minio.bucket.files}")
private String bucket_mediafiles;
//存储视频
@Value("${minio.bucket.videofiles}")
private String bucket_video;
/**
* 抽取1:根据扩展名来获取mimeType文件内容类型
* @param extension:扩展名
* @return
*/
private String getMimeType(String extension){
if(extension==null){//如果扩展名为空下面这个findExtensionMatch()方法会报空指针异常,所以为空时设置一个空字符串。
extension="";
}
//根据扩展名取出mimeType
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//通用mimeType,字节流
if(extensionMatch != null){
mimeType = extensionMatch.getMimeType();
}
return mimeType;
}
/**
* 抽取2:构建上传文件到minio中:只要是上传都调用这个方法
* @param localFilePath 文件本地路径
* @param mimeType 媒体类型
* @param bucket 桶
* @param objectName 对象名:上传到minio中起的文件名(有子目录的需要写子目录)
* @return
*/
public boolean addMediaFilesToMinIO(String localFilePath,String mimeType,String bucket,String objectName) {
try {
UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
.bucket(bucket) //桶
.filename(localFilePath) //指定本地文件路径
.object(objectName) //对象名 放在子目录下
.contentType(mimeType) //设置媒体文件类型
.build();//对象名
//上传文件
minioClient.uploadObject(uploadObjectArgs);
log.debug("上传文件到minio成功,bucket:{},objectName;{},错误信息:{}",bucket,objectName);
return true;
} catch (Exception e) {
e.printStackTrace();
log.error("上传文件出错,bucket:{},objectName:{},错误信息:{}",bucket,objectName,e.getMessage());
return false;
}
}
/**
* 抽取3:获取文件默认存储目录路径 年/月/日
* 即:对象名/上传到minio中的文件名是放在子目录中,没有直接放在bucket的根目录下。
* @return
*/
private String getDefaultFolderPath() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
//2022-02-17---->2022/01/17/
String folder = sdf.format(new Date()).replace("-", "/")+"/";
return folder;
}
//抽取4:获取文件的md5
private String getFileMd5(File file) {
try (FileInputStream fileInputStream = new FileInputStream(file)) {
String fileMd5 = DigestUtils.md5Hex(fileInputStream);
return fileMd5;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* 上传文件
* @param companyId 机构id:同样带告诉属于哪一个机构
* @param uploadFileParamsDto 上传文件信息:用来保存信息到数据库
* @param localFilePath 文件磁盘路径:用来上传文件到Minoo
* @return 文件信息
*/
@Transactional
@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath) {
//文件名
String filename = uploadFileParamsDto.getFilename();
//先得到扩展名
String extension = filename.substring(filename.lastIndexOf("."));
//得到上传到minio的bucket的文件子目录
String defaultFolderPath = getDefaultFolderPath();
//得到上传到minio的bucket的文件子目录下的文件名----文件的md5值
String fileMd5 = getFileMd5(new File(localFilePath));
//得到对象名:上传到minio中起的文件名(有子目录的需要写子目录:子目录+文件名)
String objectName = defaultFolderPath+fileMd5+extension;
//1.将文件上传到minio
//1.1调用获取mimeType文件内容类型的方法:扩展名通过分隔上传的文件名后缀获取
String minType = getMimeType(extension);
//1.2调用文件上传到minio的方法:本地文件路径、媒体类型、桶、对象名:上传到minio中起的文件名(有子目录的需要写子目录)
boolean result = addMediaFilesToMinIO(localFilePath,minType,bucket_mediafiles,objectName);
if(!result) {
XueChengPlusException.cast("上传文件失败");
}
//2.将文件信息写入media_file表中:机构id 文件md5值 上传文件的信息 桶 对象名称
MediaFiles mediaFiles = addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_mediafiles, objectName);
//准备返回的对象:来前端想要上传写别的东西直接修改这个类即可,不能修改数据库对应的po
UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
BeanUtils.copyProperties(mediaFiles,uploadFileResultDto);
return uploadFileResultDto;
}
/**
* @description 抽取5:将文件信息添加到文件表
* @param companyId 机构id
* @param fileMd5 文件md5值
* @param uploadFileParamsDto 上传文件的信息
* @param bucket 桶
* @param objectName 对象名称
* @return com.xuecheng.media.model.po.MediaFiles
* @author Mr.M
* @date 2022/10/12 21:22
*/
public MediaFiles addMediaFilesToDb(Long companyId,String fileMd5,UploadFileParamsDto uploadFileParamsDto,String bucket,String objectName){
//根据主键的值/文件的md5 查询文件如果在数据库中存在了就不在保存了。
MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
if (mediaFiles == null) {
mediaFiles = new MediaFiles();
//拷贝基本信息:把前端传递过来的参数拷贝到数据库里面
BeanUtils.copyProperties(uploadFileParamsDto, mediaFiles);
//构造余下的参数信息
mediaFiles.setId(fileMd5);//文件id
mediaFiles.setCompanyId(companyId);//机构id
mediaFiles.setBucket(bucket);//桶
mediaFiles.setFilePath(objectName);//file_path 对象名
mediaFiles.setFileId(fileMd5);//file_id 文件id
mediaFiles.setUrl("/"+bucket+"/"+objectName);//url:文件上传地址
mediaFiles.setCreateDate(LocalDateTime.now());//上传时间
mediaFiles.setStatus("1");//状态 1:正常,0:不展示
mediaFiles.setAuditStatus("002003");//审核状态:202001审核未通过、202002未提交、202003已提交、202004审核通过
//保存文件信息到文件表
int insert = mediaFilesMapper.insert(mediaFiles);
if (insert < 0) {
log.debug("向数据库保存文件失败,bucket:{},objectName",bucket,objectName);
XueChengPlusException.cast("保存文件信息失败");
}
log.debug("保存文件信息到数据库成功,{}",mediaFiles.toString());
}
return mediaFiles;
}
4.4.3 完善接口层
完善接口层代码 :
/**
* MediaType.MULTIPART_FORM_DATA_VALUE:multipart/form-data,指定文件上传以二进制方式上传
* @RequestPart("filedata"):指定前端传过来的文件名称 form-data; name=“filedata”; filename=“具体的文件名称”
* MultipartFile:上传的文件封装到 MultipartFile对象中,通过此对象可以获取文件相关信息
* 返回值:根据接口可知需要返回文件信息表的信息,但是不能直接使用这个文件表模型model--po--MediaFiles,因为将来如果前端让你多传些东西你肯定不能
* 直接修改数据库对应的这个po类,所以建议单独的在建造一个模型类Dto。
*/
@ApiOperation("上传文件")
@RequestMapping(value = "/upload/coursefile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadFileResultDto upload(@RequestPart("filedata") MultipartFile filedata) throws IOException {
//1.构建机构id:暂时写死
Long companyId = 1232141425L;
//2.上传文件信息
UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
//文件大小
uploadFileParamsDto.setFileSize(filedata.getSize());
//图片:写死了
uploadFileParamsDto.setFileType("001001");
//原始文件名称
uploadFileParamsDto.setFilename(filedata.getOriginalFilename());
//文件大小
long fileSize = filedata.getSize();
uploadFileParamsDto.setFileSize(fileSize);
//3.文件磁盘路径
//创建临时文件:前缀 后缀 随便写
File tempFile = File.createTempFile("minio", "temp");
//上传的文件拷贝到临时文件
filedata.transferTo(tempFile);
//文件路径
String absolutePath = tempFile.getAbsolutePath();
//4.调用service上传文件:机构id 上传文件信息 文件磁盘路径
UploadFileResultDto uploadFileResultDto = mediaFileService.uploadFile(companyId, uploadFileParamsDto, absolutePath);
return uploadFileResultDto;
}
4.4.4 接口测试
1、首先使用httpclient测试
### 上传文件 media_host
POST localhost:63050/media/upload/coursefile
Content-Type: multipart/form-data; boundary=WebAppBoundary
--WebAppBoundary
Content-Disposition: form-data; name="filedata"; filename="c1.jpeg"
Content-Type: application/octet-stream
< D:/图片/c1.jpeg
文件上传到minio成功:
文件信息成功保存到数据库
2、再进行前后端联调测试
网关工程搭建完成即可将前端工程中的接口地址改为网关的地址
启动:前端,后端(网关,内容管理,系统服务,媒资)
在新增课程、编辑课程界面上传图,保存课程信息后再次进入编辑课程界面,查看是否可以正常保存课程图片信息。
前端页面访问空白的:如果system启动的端口号是8080而不是63110.
把system模块下的target目录删除后,重新启动项目生成即可。
上图图片完成后,进入媒资管理,查看文件列表中是否有刚刚上传的图片信息。
查看minio、查看数据库
4.4.5 Service事务优化
上边的service方法优化后并测试通过,现在思考关于uploadFile方法的是否应该开启事务。
目前是在uploadFile方法上添加@Transactional,当调用uploadFile方法前会开启数据库事务,如果上传文件过程时间较长那么数据库的事务持续时间就会变长,这样数据库链接释放就慢,最终导致数据库链接不够用。
我们只将addMediaFilesToDb方法添加事务控制即可,uploadFile方法上的@Transactional注解去掉。
优化后如下:
/**
* @description 抽取5:将文件信息添加到文件表
* @param companyId 机构id
* @param fileMd5 文件md5值
* @param uploadFileParamsDto 上传文件的信息
* @param bucket 桶
* @param objectName 对象名称
* @return com.xuecheng.media.model.po.MediaFiles
* @author Mr.M
* @date 2022/10/12 21:22
*/
@Transactional //将来有可能向其他表写入数据,所以要加入事务
public MediaFiles addMediaFilesToDb(Long companyId,String fileMd5,UploadFileParamsDto uploadFileParamsDto,String bucket,String objectName){
//根据主键的值/文件的md5 查询文件如果在数据库中存在了就不在保存了。
MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
if (mediaFiles == null) {
mediaFiles = new MediaFiles();
//拷贝基本信息:把前端传递过来的参数拷贝到数据库里面
BeanUtils.copyProperties(uploadFileParamsDto, mediaFiles);
//构造余下的参数信息
mediaFiles.setId(fileMd5);//文件id
mediaFiles.setCompanyId(companyId);//机构id
mediaFiles.setBucket(bucket);//桶
mediaFiles.setFilePath(objectName);//file_path 对象名
mediaFiles.setFileId(fileMd5);//file_id 文件id
mediaFiles.setUrl("/"+bucket+"/"+objectName);//url:文件上传地址
mediaFiles.setCreateDate(LocalDateTime.now());//上传时间
mediaFiles.setStatus("1");//状态 1:正常,0:不展示
mediaFiles.setAuditStatus("002003");//审核状态:202001审核未通过、202002未提交、202003已提交、202004审核通过
//保存文件信息到文件表
int insert = mediaFilesMapper.insert(mediaFiles);
if (insert < 0) {
log.debug("向数据库保存文件失败,bucket:{},objectName",bucket,objectName);
XueChengPlusException.cast("保存文件信息失败");
}
log.debug("保存文件信息到数据库成功,{}",mediaFiles.toString());
}
return mediaFiles;
}
我们人为在int insert = mediaFilesMapper.insert(mediaFiles);下边添加一个异常代码int a=1/0;
测试是否事务控制。很遗憾,事务控制失败。
抛出异常应该回滚,但是数据却入库成功了。
方法上已经添加了@Transactional注解为什么该方法不能被事务控制呢?
如果是在uploadFile方法上添加@Transactional注解就可以控制事务,去掉则不行。
现在的问题其实是一个非事务方法调同类一个事务方法,事务无法控制,这是为什么?
下边分析原因:
如果在uploadFile方法上添加@Transactional注解,代理对象执行此方法前会开启事务,如下图:
如果在uploadFile方法上没有@Transactional注解,代理对象执行此方法前不进行事务控制,如下图:
所以判断该方法是否可以事务控制必须保证是通过代理对象调用此方法,且此方法上添加了@Transactional注解。
现在在addMediaFilesToDb方法上添加@Transactional注解,也不会进行事务控制是因为并不是通过代理对象执行的addMediaFilesToDb方法。为了判断在uploadFile方法中去调用addMediaFilesToDb方法是否是通过代理对象去调用,我们可以打断点跟踪。
我们发现在uploadFile方法中去调用addMediaFilesToDb方法不是通过代理对象去调用。
如何解决呢?通过代理对象去调用addMediaFilesToDb方法即可解决。
在MediaFileService的实现类中注入MediaFileService的代理对象,如下:
@Autowired
MediaFileService currentProxy;
将addMediaFilesToDb方法提成接口:因为这个方法没有暴漏成接口你没办法调用
/** 上传图片事务优化:对外暴漏保存信息的接口
* @description 将文件信息添加到文件表
* @param companyId 机构id
* @param fileMd5 文件md5值
* @param uploadFileParamsDto 上传文件的信息
* @param bucket 桶
* @param objectName 对象名称
* @return com.xuecheng.media.model.po.MediaFiles
* @author Mr.M
* @date 2022/10/12 21:22
*/
public MediaFiles addMediaFilesToDb(Long companyId,String fileMd5,UploadFileParamsDto uploadFileParamsDto,String bucket,String objectName);
调用addMediaFilesToDb方法的代码处改为如下:
.....
//写入文件表
MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_files, objectName);
....
再次测试事务是否可以正常控制。
文件信息表之前是43行现在还是43行,说明事务回滚没有保存成功。
5 上传视频
5.1 需求分析
1、教学机构人员进入媒资管理列表查询自己上传的媒资文件。
点击“媒资管理”
进入媒资管理列表页面查询本机构上传的媒资文件。
2、教育机构用户在"媒资管理"页面中点击 “上传视频” 按钮。
点击“上传视频”打开上传页面
3、选择要上传的文件,自动执行文件上传。
4、视频上传成功会自动处理,处理完成可以预览视频。
5.2 断点续传技术
5.2.1 什么是断点续传
通常视频文件都比较大,所以对于媒资系统上传文件的需求要满足大文件的上传要求。http协议本身对上传文件大小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了没有上传完成,需要客户重新上传,用户体验非常差,所以对于大文件上传的要求最基本的是断点续传。
什么是断点续传:
引用百度百科:断点续传指的是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性。
断点续传流程如下图:
流程如下:
1、前端上传前先把文件分成块
2、一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传
3、各分块上传完成最后在服务端合并文件
5.2.2 分块与合并测试
为了更好的理解文件分块上传的原理,下边用java代码测试文件的分块与合并。
文件分块的流程如下:
1、获取源文件长度
2、根据设定的分块文件的大小计算出块数
3、从源文件读数据依次向每一个块文件写数据。
测试代码如下:
//分块测试
@Test
public void testChunk() throws IOException {
//1.源文件
File sourceFile = new File("D:\\视频\\a1.mp4");
//2.分块
//分块文件存储路径
String chunkFilePath="D:\\视频\\fenkuai\\";
//分块文件大小 5M 最小为5M 最后一块可以小于5M
int chunkSize = 1024 * 1024 * 5;
//分块文件的个数:有可能是小数,比如 四块半,那就是5个分块
int chunkNum = (int)Math.ceil(sourceFile.length()*1.0 / chunkSize);//向上取整
//3.使用流从源文件读数据,向分块文件中写数据
// 随机存取文件流RandomAccessFile:既可以读也可以写
// 参数1:文件名
// 参数2: r:以只读方式打开 写r只能读,不能写
RandomAccessFile raf_r = new RandomAccessFile(sourceFile, "r");
//缓存区
byte[] bytes = new byte[1024];
for(int i=0;i<chunkNum;i++){ //遍历获取分块文件的个数
//确定分块文件的存储路径:分块文件存储路径+序号i
File chunkFile = new File(chunkFilePath + i);
//分块文件写入流 :rw,读取和写入
RandomAccessFile raf_rw = new RandomAccessFile(chunkFile, "rw");
int len = -1;
while((len=raf_r.read(bytes))!=-1){//当文件读完了,len的长度=-1,也就是说len的长度!=-1时文件还没有读完让它继续读。
//从缓冲区里把数据往分块文件中写
raf_rw.write(bytes,0,len);
//一个分块文件的大小是有限的:当这个分块文件的大小>=当时设置的文件大小时,就不让它在写了
if(chunkFile.length()>=chunkSize){
break;
}
}
raf_rw.close();
}
raf_r.close();
}
分块成功:
文件合并流程:
1、找到要合并的文件并按文件合并的先后进行排序。
2、创建合并文件
3、依次从合并的文件中读取数据向合并文件写入数
文件合并的测试代码 :
//合并测试
@Test
public void testMerge() throws IOException {
//块文件目录
File chunkFolder=new File("D:\\视频\\fenkuai\\");
//源文件
File sourceFile = new File("D:\\视频\\a1.mp4");
//合并后的文件:还放在同一个目录下,只不过修改下文件名即可。
File mergeFile = new File("D:\\视频\\a2.mp4");
//取出所有的分块文件
File[] files = chunkFolder.listFiles();
//将数组转成list
List<File> filesList = Arrays.asList(files);
//对分块文件排序:一定是0号 1号 2号 3号...这样挨着合并的,不能是1号直接和3号这样合并无序的合并。
Collections.sort(filesList, new Comparator<File>() {
@Override
public int compare(File o1, File o2) {
//子目录都是:0 1 2 3 4...这样的,所以按照升序排序只需要获取子目录名称转化为数字:
// 0-1=
// 1-2=
// ...
//返回负数从小到大
return Integer.parseInt(o1.getName())-Integer.parseInt(o2.getName());
}
});
//向合并文件写的流
RandomAccessFile raf_rw = new RandomAccessFile(mergeFile, "rw");
//缓存区
byte[] bytes = new byte[1024];
//遍历分块文件,向合并的文件写
for(File file: filesList){
//读分块的流
RandomAccessFile raf_r = new RandomAccessFile(file, "r");
int len = -1;
while((len=raf_r.read(bytes))!=-1){
raf_rw.write(bytes,0,len);
}
raf_r.close();
}
raf_rw.close();
//合并文件完成后对合并的文件md5值校验
FileInputStream fileInputStream_merge = new FileInputStream(mergeFile);
FileInputStream fileInputStream_source = new FileInputStream(sourceFile);
String md5_merge = DigestUtils.md5Hex(fileInputStream_merge); //源文件
String md5_source = DigestUtils.md5Hex(fileInputStream_source);//合并后的文件
if(md5_merge.equals(md5_source)){
System.out.println("文件合并成功");
}
}
文件合并成功:
5.2.3 视频上传流程
下图是上传视频的整体流程:
1、前端对文件进行分块。
2、前端上传分块文件前请求媒资服务检查文件是否存在,如果已经存在则不再上传。
3、如果分块文件不存在则前端开始上传
4、前端请求媒资服务上传分块。
5、媒资服务将分块上传至MinIO。
6、前端将分块上传完毕请求媒资服务合并分块。
7、媒资服务判断分块上传完成则请求MinIO合并文件。
8、合并完成校验合并后的文件是否完整,如果不完整则删除文件。
总结:
- 接口1:查询分块
- 接口2:上传分块
- 接口3:合并分块
5.2.4 minio合并文件测试
说明:查询分块、上传分块都测试过(3.2.4中),现在测试minio合并分块
1、将分块文件上传至minio
//将分块文件上传至minio
@Test
public void uploadChunk(){
//分块文件存储路径
String chunkFolderPath = "D:\\视频\\fenkuai\\";
File chunkFolder = new File(chunkFolderPath);
//分块文件
File[] files = chunkFolder.listFiles();
//将分块文件上传至minio
for (int i = 0; i < files.length; i++) {
try {
//构造上传文件参数:
//注意:分块文件没有扩展名
UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
.bucket("testbucket") //桶
.object("chunk/" + i) //对象名:文件上传到Minio中起的文件名,有子目录
.filename(chunkFolderPath+i)//本地文件路径(包括文件名)
.build();
//上传文件
minioClient.uploadObject(uploadObjectArgs);
System.out.println("上传分块成功"+i);
} catch (Exception e) {
e.printStackTrace();
}
}
}
2、通过minio的合并文件
//调用minio接口合并分块
@Test
public void testMerge() throws Exception{
//说明:list集合有序,按顺序存进去的,所以这里不用考虑排序
//方式一:
/*List<ComposeSource> sources = null;
for(int i=0;i<27;i++) {
//指定分块文件的信息
ComposeSource composeSource = ComposeSource.builder().bucket("testbucket").object("chunk/" + i).build();
sources.add(composeSource);
}*/
//方式二:
List<ComposeSource> sources = Stream.iterate(0,i->++i)
.limit(27)
.map(i->ComposeSource.builder()
.bucket("testbucket")
.object("chunk/"+i)
.build())
.collect(Collectors.toList());
//指定合并后的objectName等信息
ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()
.bucket("testbucket") //桶
.object("merge01.mp4") //对象名:文件上传到Minio中起的文件名
.sources(sources)//指定源文件
.build();
//合并文件
//报错size 1048576 must be greater than 5242880 minio 默认的分块文件大小最小为5M 最后一块可以小于5M
minioClient.composeObject(composeObjectArgs);
}
//清除分块文件:分块是上传过程中需要的东西,上传完成后清理掉
@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();
}
});
}
使用minio合并文件报错:java.lang.IllegalArgumentException: source testbucket/chunk/0: size 1048576 must be greater than 5242880
minio合并文件默认分块最小5M,我们将分块改为5M再次测试。
5.3 接口定义
根据上传视频流程,定义接口,与前端的约定是
- 操作成功返回
{
code: 0
}
- 否则返回
{
code: -1
}
从课程资料中拷贝RestResponse.java类到base工程下的model包下。
在base工程的model包下新建RestResponse类:统一返回结果
package com.xuecheng.base.model;
import lombok.Data;
@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;
}
}
定义接口如下:
package com.xuecheng.media.api;
import com.j256.simplemagic.ContentInfo;
import com.j256.simplemagic.ContentInfoUtil;
import com.xuecheng.base.model.RestResponse;
import com.xuecheng.media.model.dto.UploadFileParamsDto;
import com.xuecheng.media.service.MediaFileService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/**
* @author Mr.M
* @version 1.0
* @description 大文件上传接口
* @date 2022/9/6 11:29
*/
@Api(value = "大文件上传接口", tags = "大文件上传接口")
@RestController
public class BigFilesController {
@ApiOperation(value = "文件上传前检查文件")
@PostMapping("/upload/checkfile")
public RestResponse<Boolean> checkfile(
@RequestParam("fileMd5") String fileMd5
) throws Exception {
return null;
}
@ApiOperation(value = "分块文件上传前的检测")
@PostMapping("/upload/checkchunk")
public RestResponse<Boolean> checkchunk(@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") int chunk) throws Exception {
return null;
}
@ApiOperation(value = "上传分块文件")
@PostMapping("/upload/uploadchunk")
public RestResponse uploadchunk(@RequestParam("file") MultipartFile file,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") int chunk) throws Exception {
return null;
}
/**
* 合并视频分块文件
* @param fileMd5 通过md5值拿到分块文件的地址
* @param fileName 文件名:上传文件到minio时有一个原始文件名,在文件合并时需要把这个文件名放到数据库
* @param chunkTotal 分块的个数
* @return
* @throws Exception
*/
@ApiOperation(value = "合并文件")
@PostMapping("/upload/mergechunks")
public RestResponse mergechunks(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName,
@RequestParam("chunkTotal") int chunkTotal) throws Exception {
return null;
}
}
5.4 上传分块开发
5.4.1 DAO开发
向媒资数据库的文件表插入记录,使用自动生成的Mapper接口即可满足要求。
5.4.2 Service开发
5.4.2.1 检查文件和分块
接口完成进行接口实现,首先实现检查文件方法和检查分块方法。
在MediaFileService中定义service接口如下:
/**
* @description 检查文件是否存在
* @param fileMd5 文件的md5
* @return com.xuecheng.base.model.RestResponse<java.lang.Boolean> false不存在,true存在
* @author Mr.M
* @date 2022/9/13 15:38
*/
public RestResponse<Boolean> checkFile(String fileMd5);
/**
* @description 检查分块是否存在
* @param fileMd5 文件的md5
* @param chunkIndex 分块序号
* @return com.xuecheng.base.model.RestResponse<java.lang.Boolean> false不存在,true存在
* @author Mr.M
* @date 2022/9/13 15:39
*/
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex);
service接口实现方法:
/**
* 检查文件是否存在:
* 思路:
* 1.先查询数据库,如果数据库不存在文件信息,就表示文件不存在
* 2.如果数据库存在在查询minio,
*
* @param fileMd5 文件的md5
* @return true(文件存在) false(文件不存在),这是前端约定好的返回值
*/
@Override
public RestResponse<Boolean> checkFile(String fileMd5) {
//1.先查询数据库:文件信息表
MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
//2.如果数据库存在再查询minio:文件信息表已经存了文件的相关信息,所以可以取出来
if (mediaFiles != null) {
//桶
String bucket = mediaFiles.getBucket();
//objectname:minio中下载的文件名
String filePath = mediaFiles.getFilePath();
//如果数据库存在再查询minio
GetObjectArgs getObjectArgs = GetObjectArgs.builder()
.bucket(bucket)
.object(filePath)
.build();
//查询远程服务获取到一个流对象
try {
FilterInputStream inputStream = minioClient.getObject(getObjectArgs);
if (inputStream != null) {
//文件已存在
return RestResponse.success(true);
}
} catch (Exception e) {
e.printStackTrace();
}
}
//文件不存在
return RestResponse.success(false);
}
/**
* 检查分块是否存在:
* 思路:分块信息在数据库中就不会存在,所以只需要查询分块信息是否在minio即可
*
* @param fileMd5 文件的md5
* @param chunkIndex 分块序号:分块文件名
* @return
*/
@Override
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex) {
//分块存储路径是:md5前2位为2个目录+MD5值目录+chunk目录+分块文件名
//根据md5得到分块文件所在目录的路径
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
//如果数据库存在再查询minio
GetObjectArgs getObjectArgs = GetObjectArgs.builder()
.bucket(bucket_video) //配置文件注入的
.object(chunkFileFolderPath + chunkIndex) //对象名:子目录+minio中的文件名
.build();
//查询远程服务获取到一个流对象
try {
FilterInputStream inputStream = minioClient.getObject(getObjectArgs);
if (inputStream != null) {
//分块文件已存在
return RestResponse.success(true);
}
} catch (Exception e) {
e.printStackTrace();
}
//分块文件不存在
return RestResponse.success(false);
}
//得到分块文件的目录
private String getChunkFileFolderPath(String fileMd5) {
//截取md5的前两位,拼接位子目录
return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
}
5.4.2.2 上传分块
定义service接口
/**
* 上传分块:
* @param fileMd5 文件的md5
* @param chunk 分块序号
* @param localChunkFilePath 分块文件本地路径
* @return
*/
public RestResponse uploadChunk(String fileMd5,int chunk,String localChunkFilePath);
接口实现:
/**
* 上传分块:
*
* @param fileMd5 文件的md5
* @param chunk 分块序号
* @param localChunkFilePath 分块文件本地路径
* @return
*/
@Override
public RestResponse uploadChunk(String fileMd5, int chunk, String localChunkFilePath) {
//分块文件的路径:子目录加+分块文件名
String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;
//获取mimeType
String mimeType = getMimeType(null);
//将文件存储至minIO:调用之前上传图片时抽取出来的方法addMediaFilesToMinIO(传文件到minio中)
// 前端已经将上传的视频分块过了,所以后端不用再做分块直接调用之前的上传文件到minio方法即可
// 参数1:文件本地路径 前端传过来的
// 参数2:媒体类型 调用之前上传图片时抽取的方法getMimeType()-根据扩展名获取媒体类型,
// 由于分块文件没有扩展名,所以传递为null 得到一个空串,返回一个未知的媒体类型APPLICATION_OCTET_STREAM_VALUE
// 参数3:桶 配置文件传来的
// 参数4:对象名:上传到minio中起的文件名(有子目录的需要写子目录)
boolean b = addMediaFilesToMinIO(localChunkFilePath, mimeType, bucket_video, chunkFilePath);
if (!b) {
return RestResponse.validfail("上传分块文件失败", false);
}
//上传成功
return RestResponse.success(true);
}
5.4.2.3 完善接口层
/**
* 上传视频
*/
@Api(value = "大文件上传接口", tags = "大文件上传接口")
@RestController
public class BigFilesController {
@Autowired
MediaFileService mediaFileService;
@ApiOperation(value = "文件上传前检查文件")
@PostMapping("/upload/checkfile")
public RestResponse<Boolean> checkfile(
@RequestParam("fileMd5") String fileMd5) throws Exception {
return mediaFileService.checkFile(fileMd5);
}
@ApiOperation(value = "分块文件上传前的检测")
@PostMapping("/upload/checkchunk")
public RestResponse<Boolean> checkchunk(@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") int chunk) throws Exception {
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 {
//文件磁盘路径
//创建临时文件:前缀 后缀 随便写
File tempFile = File.createTempFile("minio", "temp");
//上传的文件拷贝到临时文件
file.transferTo(tempFile);
//文件路径
String localFilePath = tempFile.getAbsolutePath();
//文件的md5 分块序号 分块文件本地路径
RestResponse restResponse = mediaFileService.uploadChunk(fileMd5, chunk, localFilePath);
return restResponse;
}
}
5.4.2.4 上传分块测试
启动前端工程,进入上传视频界面进行前后端联调测试。
前端对文件分块的大小为5MB,SpringBoot web默认上传文件的大小限制为1MB,这里需要在media-api工程修改配置如下:
spring:
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
-
max-file-size:单个文件的大小限制
-
Max-request-size: 单次请求的大小限制
5.5 合并分块开发
5.5.1 service开发
定义service接口:
/**
* @description 合并分块
* @param companyId 机构id:需要知道那个机构传的
* @param fileMd5 文件md5:通过md5值拿到分块文件的地址
* @param chunkTotal 分块的个数
* @param uploadFileParamsDto 文件信息:用来入库
* @return com.xuecheng.base.model.RestResponse
* @author Mr.M
* @date 2022/9/13 15:56
*/
public RestResponse mergechunks(Long companyId,String fileMd5,int chunkTotal,UploadFileParamsDto uploadFileParamsDto);
service实现:
/**
* 合并分块:
* @param companyId 机构id:需要知道那个机构传的
* @param fileMd5 文件md5:通过md5值拿到分块文件的地址
* @param chunkTotal 分块的个数
* @param uploadFileParamsDto 文件信息:用来入库
* @return
*/
@Override
public RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {
//1.找到分块文件调用minio的sdk进行文件合并
//1.1构建sources参数:
//分块文件所在目录:调用上面测试分块和文件是否存在时抽取出来的 得到分块文件的目录的方法
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
//找到所有的分块文件调用minio的sdk进行文件合并
List<ComposeSource> sources = Stream.iterate(0, i->++i)
.limit(chunkTotal) //分块个数
.map(i->ComposeSource.builder()
.bucket(bucket_video) //桶
.object(chunkFileFolderPath+i)//对象名:目录+文件名
.build())
.collect(Collectors.toList());
//1.2构建objectName参数:合并后的文件的子目录+文件名
//源文件名称
String filename = uploadFileParamsDto.getFilename();
//扩展名
String extension = filename.substring(filename.lastIndexOf("."));
//合并后文件的objectName:抽取出一个得到合并后文件的对象名的方法
String objectName = getFilePathByMd5(fileMd5, extension);
//1.3指定合并后的objectName等信息:
ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()
.bucket(bucket_video) //桶
.object(objectName) //对象名:最终合并后的文件的objectName(子目录+文件名)
.sources(sources)//指定源文件
.build();
//1.4合并文件
//报错size 1048576 must be greater than 5242880 minio 默认的分块文件大小为5M
try {
minioClient.composeObject(composeObjectArgs);
} catch (Exception e) {
e.printStackTrace();
log.error("合并文件出错,bucket:{},objectName:{},错误信息:{}",bucket_video,objectName,e.getMessage());
return RestResponse.validfail("合并文件异常",false);
}
//2.校验合并后的和源文件是否一致,视频上传才成功
// 思路:比较原始文件的md5和下载到本地后文件的md5值是否一致
File file = downloadFileFromMinIO(bucket_video, objectName);
try(FileInputStream fileInputStream = new FileInputStream(file)){//流放到()里用完自动关,不用再加finally
//计算合并后文件的md5
String mergeFile_md5 = DigestUtils.md5Hex(fileInputStream);
//比较原始的md5和合并后的md5
if(!fileMd5.equals(mergeFile_md5)){
log.error("校验合并文件md5值不一致,原始文件:{},合并文件:{}",fileMd5,mergeFile_md5);
return RestResponse.validfail("文件校验失败",false);
}
//文件大小
uploadFileParamsDto.setFileSize(file.length());
} catch (Exception e) {
return RestResponse.validfail("文件校验失败",false);
}
//3.将文件信息入库:机构id 文件md5值 上传文件的信息 桶 对象名称
//非事务方法调用事务方法会导致事务失效:所以用代理对象调用事务方法
MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_video, objectName);
//4.清理分块文件:分块文件只是上传时候用到的临时文件,不用了就删除。
//分块文件路径 分块文件总数
clearChunkFiles(chunkFileFolderPath,chunkTotal);
return RestResponse.success(true);
}
合并分块所调用抽取出来的方法:
//得到分块文件的目录
private String getChunkFileFolderPath(String fileMd5) {
//截取md5的前两位,拼接位子目录
return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";
}
/**
* 得到合并后的文件的地址:对象名
* @param fileMd5 文件id即md5值
* @param fileExt 文件扩展名
* @return
*/
private String getFilePathByMd5(String fileMd5,String fileExt){
return fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" + fileMd5 + "/" +fileMd5 +fileExt;
}
/**
* 从minio下载文件:3.2.4.3 查询文件时讲过
* @param bucket 桶
* @param objectName 对象名称
* @return 下载后的文件
*/
public File downloadFileFromMinIO(String bucket,String objectName){
//临时文件
File minioFile = null;
FileOutputStream outputStream = null;
try{
//输入流获取文件
InputStream stream = minioClient.getObject(GetObjectArgs.builder()
.bucket(bucket) //桶
.object(objectName) //minio中下载的文件名
.build());
//创建临时文件:返回值就是文件路径
minioFile=File.createTempFile("minio", ".merge");
//指定输出流:下载的位置 注意:这是查询远程服务获取的一个流对象,远程流计算md5可能会不稳定有问题,所以需要用本地流
outputStream = new FileOutputStream(minioFile);
//流拷贝:把输入流的内容拷贝到输出流中
IOUtils.copy(stream,outputStream);
return minioFile;
} catch (Exception e) {
e.printStackTrace();
}finally {
if(outputStream!=null){
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
/**
* 清除minio中的分块文件
* @param chunkFileFolderPath 分块文件路径
* @param chunkTotal 分块文件总数
*/
private void clearChunkFiles(String chunkFileFolderPath,int chunkTotal) {
Iterable<DeleteObject> objects = Stream.iterate(0, i -> ++i)
.limit(chunkTotal) //分块个数
.map(i -> new DeleteObject(chunkFileFolderPath.concat(Integer.toString(i))))
.collect(Collectors.toList());
//构建参数对象
RemoveObjectsArgs removeObjectArgs = RemoveObjectsArgs.builder()
.bucket(bucket_video)
.objects(objects) //需要清理的分块文件地址
.build();
//清理文件:
Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectArgs);
//真正删除加下列语句
results.forEach(f -> {
try {
DeleteError deleteError = f.get();
} catch (Exception e) {
e.printStackTrace();
}
});
}
5.5.2 接口层完善
下边完善接口层
/**
* 合并视频分块文件
* @param fileMd5 通过md5值拿到分块文件的地址
* @param fileName 文件名:上传文件到minio时有一个原始文件名,在文件合并时需要把这个文件名放到数据库
* @param chunkTotal 分块的个数
* @return
* @throws Exception
*/
@ApiOperation(value = "合并文件")
@PostMapping("/upload/mergechunks")
public RestResponse mergechunks(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName,
@RequestParam("chunkTotal") int chunkTotal) throws Exception {
//机构id
Long companyId = 1232141425L;
UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
uploadFileParamsDto.setFileType("001002"); //文件类型(文档,音频,视频001002) 查看字典表
uploadFileParamsDto.setTags("课程视频"); //标签
uploadFileParamsDto.setRemark(""); //备注
uploadFileParamsDto.setFilename(fileName);//文件的名称
//文件大小:写在MediaFileServiceImpl业务层,合并分块后验证合并后的和源文件是否一致,视频上传才成功的代码中。
//机构id 文件md5 分块的个数 文件信息:用来入库
return mediaFileService.mergechunks(companyId,fileMd5,chunkTotal,uploadFileParamsDto);
}
5.5.3 合并分块测试
下边进行前后端联调:
1、上传一个视频测试合并分块的执行逻辑
进入service方法逐行跟踪。
2、断点续传测试
上传一部分后,停止刷新浏览器再重新上传,通过浏览器日志发现已经上传过的分块不再重新上传
上传一部分后,停止刷新浏览器
重新上传:先调用校验接口后面才调用上传接口,说明已经上传的部分不在上传。
5.6 其它问题(自己做)
5.6.1 临时文件没有删除
5.6.2 分块文件清理问题
上传一个文件进行分块上传,上传一半不传了,之前上传到minio的分块文件要清理吗?怎么做的?
1、在数据库中有一张文件表记录minio中存储的文件信息。
2、文件开始上传时会写入文件表,状态为上传中,上传完成会更新状态为上传完成。
3、当一个文件传了一半不再上传了说明该文件没有上传完成,会有定时任务去查询文件表中的记录,如果文件未上传完成则删除minio中没有上传成功的文件目录。
5.7 面试
5.7.1 什么情况事务失效
- 在方法中捕获异常没有抛出去
- 非事务方法调用事务方法
- 事务方法内部调用事务方法
- @Transactional标记的方法不是public
- 抛出的异常与rollbackFor指定的异常不匹配,默认rollbackFor指定的异常为RuntimeException
- 数据库表不支持事务,比如MySQL的MyISAM
- Spring的传播行为导致事务失效,比如:
PROPAGATION_NEVER、 PROPAGATION_ NOT_SUPPORTED
- PROPAGATION_ _REQUIRED --支持当前事务,如果当前没有事务,就新建一个事务。 这是最常见的选择。
- PROPAGATION_ SUPPORTS --支持当前事务,如果当前没有事务,就以非事务方式执行。
- PROPAGATION_ MANDATORY --支持当前事务,如果当前没有事务,就抛出异常。
- PROPAGATION_ REQUIRES_ NEW --新建事务,如果当前存在事务,把当前事务挂起。
- PROPAGATION_ NOT SUPPORTED --以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
- PROPAGATION_ _NEVER --以非事务方式执行,如果当前存在事务,则抛出异常。
- PROPAGATION_ NESTED --如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则与
- PROPAGATION_ REQUIRED类似的操作。
5.7.2 断点续传怎么实现
我们是基于分块上传的模式实现断点续传的需求,当文件上传一部分断网后前边已经 上传过的不再上传。
- 前端对文件分块。
- 前端使用多线程一块一块上传,上传前给服务 端发一个消息校验该分块是否上传,如果已上传则不再上传。
- 等所有分块上传完毕,服务端合并所有分块,校验文件的完整性。
- 因为分块全部上传到了服务器,服务器将所有分块按顺序进行合并,就是写每个分块文件内容按顺序依次写入一个文件中。使用字节流去读写文件。
- 前端给服务传了一个md5值,服务端合并文件后计算合并后文件的md5是否和前端传的一样,如果一样则说文件完整,如果不一样说明可能由于网络丢包导致文件不完整,这时上传失败需要重新上传。
6 文件预览(待定)
6.1 需求分析
- 图片上传成功、视频上传成功后,可以通过预览按钮查看文件内容
- 预览的方式是通过浏览器直接打开文件,对于图片和浏览器支持的视频格式可以直接浏览
说明
- 前端请求接口层预览文件
- 接口层将文件id传递给服务层
- 服务层使用文件id查询媒资数据库文件表,获取文件的URL
- 接口层将文件url返回给前端,通过浏览器打开URL
6.2 接口定义
根据需求分析,定义的接口如下
@ApiOperation(value = "预览文件")
@GetMapping("/preview/{mediaId}")
public RestResponse<String> getPlayUrlByMediaId(@PathVariable String mediaId) {
return null;
}
6.3 接口开发
6.3.1 设置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;
}
6.3.2 DAO开发
使用自动生成的Mapper接口即可
6.3.3 Service开发
定义根据id查询媒资文件接口
MediaFiles getFileById(String mediaId);
方法实现
@Override
public MediaFiles getFileById(String id) {
MediaFiles mediaFiles = mediaFilesMapper.selectById(id);
if (mediaFiles == null || StringUtils.isEmpty(mediaFiles.getUrl())) {
XueChengPlusException.cast("视频还没有转码处理");
}
return mediaFiles;
}
6.3.4 完善Controller
完善接口层代码
@ApiOperation(value = "预览文件")
@GetMapping("/preview/{mediaId}")
public RestResponse<String> getPlayUrlByMediaId(@PathVariable String mediaId) {
MediaFiles mediaFile = mediaFileService.getFileById(mediaId);
return RestResponse.success(mediaFile.getUrl());
}
6.3.5 接口测试
前后端联调
- 上传MP4视频文件,并预览
- 上传图片文件,并预览
- 上传.avi格式的视频文件,尝试预览,观察错误提示信息,稍后通过视频处理对视频转码
7 视频处理
7.1 需求
7.1.1 总体需求
视频上传成功需要对视频的格式进行转码处理,比如:avi转成mp4。如何用Java程序对视频进行处理呢?当视频比较多的时候我们如何可以高效处理。
在一些云平台上对象存储产品就具有文件处理的功能,如下图:
所以一般做文件存储的服务都需要对文件进行处理,例如对视频进行转码处理,可能由于文件量较大需要使用多线程等技术进行高效处理。
7.7.2 什么是视频编码
视频上传成功后需要对视频进行转码处理。
什么是视频编码?查阅百度百科如下:
详情参考 :
https://baike.baidu.com/item/%E8%A7%86%E9%A2%91%E7%BC%96%E7%A0%81/839038
首先我们要分清文件格式和编码格式:
-
文件格式:是指.mp4、.avi、.rmvb等 这些不同扩展名的视频文件的文件格式 ,视频文件的内容主要包括视频和音频,其文件格式是按照一 定的编码格式去编码,并且按照该文件所规定的封装格式将视频、音频、字幕等信息封装在一起,播放器会根据它们的封装格式去提取出编码,然后由播放器解码,最终播放音视频。
-
音视频编码格式:通过音视频的压缩技术,将视频格式转换成另一种视频格式,通过视频编码实现流媒体的传输。比如:一个.avi的视频文件原来的编码是a,通过编码后编码格式变为b,音频原来为c,通过编码后变为d。
音视频编码格式各类繁多,主要有几下几类:
- MPEG系列
- (由ISO[国际标准组织机构]下属的MPEG[运动图象专家组]开发 )视频编码方面主要是Mpeg1(vcd用的就是它)、Mpeg2(DVD使用)、Mpeg4(的DVDRIP使用的都是它的变种,如:divx,xvid等)、Mpeg4 AVC(正热门);音频编码方面主要是MPEG Audio Layer 1/2、MPEG Audio Layer 3(大名鼎鼎的mp3)、MPEG-2 AAC 、MPEG-4 AAC等等。注意:DVD音频没有采用Mpeg的。
- H.26X系列
- (由ITU[国际电传视讯联盟]主导,侧重网络传输,注意:只是视频编码)
包括H.261、H.262、H.263、H.263+、H.263++、H.264(就是MPEG4 AVC-合作的结晶)
- (由ITU[国际电传视讯联盟]主导,侧重网络传输,注意:只是视频编码)
为什么要有不同的编码格式?
- 因为视频实在不同的媒体上播放的,
- 视频=视频编码+音频编码
目前最常用的编码标准是视频H.264,音频AAC。
提问:
- H.264是编码格式还是文件格式?
- mp4是编码格式还是文件格式?
项目需求:视频转为H.264,音频转为AAC,文件格式统一转为mp4.
7.7.3 FFmpeg 的基本使用
说明:
- 流媒体程序员专门来做视频的编码转换
- 我们java程序员做媒资处理直接调用流媒体程序员提供的工具来做视频转码。
我们将视频录制完成后,使用视频编码软件对视频进行编码,本项目 使用FFmpeg对视频进行编码 。
FFmpeg被许多开源项目采用,QQ影音、暴风影音、VLC等。
下载:FFmpeg https://www.ffmpeg.org/download.html#build-windows
请从课程资料目录解压ffmpeg.zip,并将解压得到的exe文件加入环境变量。
测试是否正常:cmd运行 ffmpeg.exe -version
安装成功,作下简单测试
将一个.avi文件转成mp4、mp3、gif等。
比如我们将nacos.avi文件转成mp4,可以将ffmpeg.exe配置到环境变量path中,进入视频目录直接运行:
-
转成mp4:
ffmpeg.exe -i 1.avi 1.mp4
-
转成mp3:
ffmpeg -i nacos.avi nacos.mp3
-
转成gif:
ffmpeg -i nacos.avi nacos.gif
官方文档(英文):http://ffmpeg.org/ffmpeg.html
7.7.4 视频处理工具类
将课程资料目录中的util.zip解压,将解压出的工具类拷贝至base工程。
其中Mp4VideoUtil类是用于将视频转为mp4格式,是我们项目要使用的工具类。
下边看下这个类的代码,并进行测试。
我们要通过ffmpeg对视频转码,Java程序调用ffmpeg,使用java.lang.ProcessBuilder去完成,具体在Mp4VideoUtil类的63行,下边进行简单的测试,下边的代码运行本机安装的QQ软件。
ProcessBuilder builder = new ProcessBuilder();
builder.command("C:\\Program Files (x86)\\Tencent\\QQ\\Bin\\QQScLauncher.exe");
//将标准输入流和错误输入流合并,通过标准输入流程读取信息
builder.redirectErrorStream(true);
Process p = builder.start();
对Mp4VideoUtil类需要学习使用方法,下边代码将一个avi视频转为mp4视频,如下:
public static void main(String[] args) throws IOException {
//ffmpeg的路径
String ffmpeg_path = "D:\\soft\\ffmpeg\\ffmpeg.exe";//ffmpeg的安装位置
//源avi视频的路径
String video_path = "D:\\develop\\bigfile_test\\nacos01.avi";
//转换后mp4文件的名称
String mp4_name = "nacos01.mp4";
//转换后mp4文件的路径
String mp4_path = "D:\\develop\\bigfile_test\\nacos01.mp4";
//创建工具类对象
Mp4VideoUtil videoUtil = new Mp4VideoUtil(ffmpeg_path,video_path,mp4_name,mp4_path);
//开始视频转换,成功将返回success
String s = videoUtil.generateMp4();
System.out.println(s);
}
执行main方法,最终在控制台输出 success 表示执行成功。
7.2 分布式任务处理
7.2.0 什么是分布式任务调度
对一个视频的转码可以理解为一个任务的执行,如果视频的数量比较多,如何去高效处理一批任务呢?
1、多线程
- 多线程是充分利用单机的资源。
2、分布式加多线程
-
充分利用多台计算机,每台计算机使用多线程处理。
-
方案2可扩展性更强。
-
方案2是一种分布式任务调度的处理方案。
什么是任务调度?
按照某个固定的时间间隔去执行任务。
什么是分布式任务调度?
- 一台电脑的多线程cpu能力有限,多台电脑的cpu能力就提高了。
分布式让多台电脑同时去进行任务调度。
我们可以先思考一下下面业务场景的解决方案:
- 每隔24小时执行数据备份任务。
- 12306网站会根据车次不同,设置几个时间点分批次放票。
- 某财务系统需要在每天上午10点前结算前一天的账单数据,统计汇总。
- 商品成功发货后,需要向客户发送短信提醒。
类似的场景还有很多,我们该如何实现?
多线程方式实现:
学过多线程的同学,可能会想到,我们可以开启一个线程,每sleep一段时间,就去检查是否已到预期执行时间。
以下代码简单实现了任务调度的功能:
public static void main(String[] args) {
//任务执行间隔时间
final long timeInterval = 1000;
Runnable runnable = new Runnable() {
public void run() {
while (true) {
//TODO:something
try {
Thread.sleep(timeInterval);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread thread = new Thread(runnable);
thread.start();
}
上面的代码实现了按一定的间隔时间执行任务调度的功能。
Jdk也为我们提供了相关支持,如Timer、ScheduledExecutor,下边我们了解下。
Timer方式实现:定时器
public static void main(String[] args){
Timer timer = new Timer();
timer.schedule(new TimerTask(){
@Override
public void run() {
//TODO:something
}
}, 1000, 2000); //1秒后开始调度,每2秒执行一次
}
Timer 的优点在于简单易用,每个Timer对应一个线程,因此可以同时启动多个Timer并行执行多个任务,同一个Timer中的任务是串行执行。
ScheduledExecutor方式实现:线程池
public static void main(String [] agrs){
ScheduledExecutorService service = Executors.newScheduledThreadPool(10);
service.scheduleAtFixedRate(
new Runnable() {
@Override
public void run() {
//TODO:something
System.out.println("todo something");
}
}, 1,
2, TimeUnit.SECONDS);
}
Java 5 推出了基于线程池设计的 ScheduledExecutor,其设计思想是,每一个被调度的任务都会由线程池中一个线程去执行,因此任务是并发执行的,相互之间不会受到干扰。
Timer 和 ScheduledExecutor 都仅能提供基于开始时间与重复间隔的任务调度,不能胜任更加复杂的调度需求。比如,设置每月第一天凌晨1点执行任务、复杂调度任务的管理、任务间传递数据等等。
第三方Quartz方式实现,项目地址:https://github.com/quartz-scheduler/quartz
Quartz 是一个功能强大的任务调度框架,它可以满足更多更复杂的调度需求,Quartz 设计的核心类包括 Scheduler, Job 以及 Trigger。其中,Job 负责定义需要执行的任务,Trigger 负责设置调度策略,Scheduler 将二者组装在一起,并触发任务开始执行。Quartz支持简单的按时间间隔调度、还支持按日历调度方式,通过设置CronTrigger表达式(包括:秒、分、时、日、月、周、年)进行任务调度。
下边是一个例子代码:
public static void main(String [] agrs) throws SchedulerException {
//创建一个Scheduler
SchedulerFactory schedulerFactory = new StdSchedulerFactory();
Scheduler scheduler = schedulerFactory.getScheduler();
//创建JobDetail
JobBuilder jobDetailBuilder = JobBuilder.newJob(MyJob.class);
jobDetailBuilder.withIdentity("jobName","jobGroupName");
JobDetail jobDetail = jobDetailBuilder.build();
//创建触发的CronTrigger 支持按日历调度
CronTrigger trigger = TriggerBuilder.newTrigger()
.withIdentity("triggerName", "triggerGroupName")
.startNow()
.withSchedule(CronScheduleBuilder.cronSchedule("0/2 * * * * ?"))
.build();
scheduler.scheduleJob(jobDetail,trigger);
scheduler.start();
}
public class MyJob implements Job {
@Override
public void execute(JobExecutionContext jobExecutionContext){
System.out.println("todo something");
}
}
通过以上内容我们学习了什么是任务调度,任务调度所解决的问题,以及任务调度的多种实现方式。
任务调度顾名思义,就是对任务的调度,它是指系统为了完成特定业务,基于给定时间点,给定时间间隔或者给定执行次数自动执行任务。
什么是分布式任务调度?
通常任务调度的程序是集成在应用中的,比如:优惠卷服务中包括了定时发放优惠卷的的调度程序,结算服务中包括了定期生成报表的任务调度程序,由于采用分布式架构,一个服务往往会部署多个冗余实例来运行我们的业务,在这种分布式系统环境下运行任务调度,我们称之为分布式任务调度,如下图:
分布式调度要实现的目标:
不管是任务调度程序集成在应用程序中,还是单独构建的任务调度系统,如果采用分布式调度任务的方式就相当于将任务调度程序分布式构建,这样就可以具有分布式系统的特点,并且提高任务的调度处理能力:
1、并行任务调度
- 并行任务调度实现靠多线程,如果有大量任务需要调度,此时光靠多线程就会有瓶颈了,因为一台计算机CPU的处理能力是有限的。
- 如果将任务调度程序分布式部署,每个结点还可以部署为集群,这样就可以让多台计算机共同去完成任务调度,我们可以将任务分割为若干个分片,由不同的实例并行执行,来提高任务调度的处理效率。
2、高可用
- 若某一个实例宕机,不影响其他实例来执行任务。
3、弹性扩容
- 当集群中增加实例就可以提高并执行任务的处理效率。
4、任务管理与监测
- 对系统中存在的所有定时任务进行统一的管理及监测。让开发人员及运维人员能够时刻了解任务执行情况,从而做出快速的应急处理响应。
5、避免任务重复执行
- 当任务调度以集群方式部署,同一个任务调度可能会执行多次,比如在上面提到的电商系统中到点发优惠券的例子,就会发放多次优惠券,对公司造成很多损失,所以我们需要控制相同的任务在多个运行实例上只执行一次。
7.2.1 并发和并行
并发和并行的区别为:意思不同、侧重不同、处理不同。
一、意思不同
1、并发:并发是指两个或多个事件在同一时间间隔发生。
2、并行:并行是指两个或者多个事件在同一时刻发生。
二、侧重不同
1、并发:并发侧重于在同一实体上。
2、并行:并行侧重于在不同实体上。
三、处理不同
1、并发:并发在一台处理器上“同时”处理多个任务。
2、并行:并行在多台处理器上同时处理多个任务。
并发
- 并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
并行
- 并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。
7.2.2 XXL-JOB介绍
XXL-JOB是一个轻量级分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。现已开放源代码并接入多家公司线上产品线,开箱即用。
官网:https://www.xuxueli.com/xxl-job/
文档:https://www.xuxueli.com/xxl-job/#%E3%80%8A%E5%88%86%E5%B8%83%E5%BC%8F%E4%BB%BB%E5%8A%A1%E8%B0%83%E5%BA%A6%E5%B9%B3%E5%8F%B0XXL-JOB%E3%80%8B
XXL-JOB主要有调度中心、执行器、任务:
调度中心:
- 负责管理调度信息,按照调度配置发出调度请求,自身不承担业务代码;
- 主要职责为执行器管理、任务管理、监控运维、日志管理等
任务执行器:
负责接收调度请求并执行任务逻辑
;- 只要职责是注册服务、任务执行服务(接收到任务后会放入线程池中的任务队列)、执行结果上报、日志服务等
任务:
- 负责执行具体的业务处理。
调度中心与执行器之间的工作流程如下:
执行流程:
- 任务执行器根据配置的调度中心的地址,自动注册到调度中心
- 达到任务触发条件,调度中心下发任务
- 执行器基于线程池执行任务,并把执行结果放入内存队列中、把执行日志写入日志文件中
- 执行器消费内存队列中的执行结果,主动上报给调度中心
- 当用户在调度中心查看任务日志,调度中心请求任务执行器,任务执行器读取任务日志文件并返回日志详情
7.2.3 搭建XXL-JOB
7.2.3.1 调度中心
首先下载XXL-JOB
GitHub:https://github.com/xuxueli/xxl-job
码云:https://gitee.com/xuxueli0323/xxl-job
项目使用2.3.1版本: https://github.com/xuxueli/xxl-job/releases/tag/2.3.1
也可从课程资料目录获取,解压xxl-job-2.3.1.zip
使用IDEA打开解压后的目录:使用一个新窗口打开
xxl-job-admin:调度中心
xxl-job-core:公共依赖
xxl-job-executor-samples:执行器Sample示例(选择合适的版本执行器,可直接使用)
- xxl-job-executor-sample-springboot:Springboot版本,通过Springboot管理执行器,推荐这种方式;
- xxl-job-executor-sample-frameless:无框架版本;
doc :文档资料,包含数据库脚本
在下发的虚拟机的MySQL中已经创建了xxl_job_2.3.1数据库
如下图:
执行sh /data/soft/restart.sh自动启动xxl-job调度中心(虚拟机中已经安装并启动过了)
访问:http://192.168.101.65:8088/xxl-job-admin/
账号和密码:admin/123456
如果无法使用虚拟机运行xxl-job可以在本机idea运行xxl-job调度中心。
7.2.3.2 执行器
说明:
- 上面看到的画面是调度中心,在虚拟机部署
- 执行干活的人就是你的java程序,媒资管理服务中干活的是service模块,所以在service中创建执行器
- 创建执行器首先要在调度中心注册一下,也就是告诉调度中心有个人准备来做执行器了。
下边配置执行器,执行器负责与调度中心通信接收调度中心发起的任务调度请求。
1、下边进入调度中心添加执行器
点击新增,填写执行器信息,appname是前边在nacos中配置xxl信息时指定的执行器的应用名。
添加成功:
2、首先在媒资管理模块的service工程添加依赖,在项目的父工程已约定了版本2.3.1
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
</dependency>
3、在nacos下的media-service-dev.yaml下配置xxl-job
xxl:
job:
admin:
addresses: http://192.168.101.65:8088/xxl-job-admin #调度中心的地址
executor:
appname: testHandler #在调度中心配置的执行器名称
address:
ip:
port: 9999 #调度中心给执行器下发任务,所以执行器肯定要启动一个服务端口
logpath: /data/applogs/xxl-job/jobhandler #日志路径
logretentiondays: 30
accessToken: default_token
注意配置中的appname这是执行器的应用名,port是执行器启动的端口,如果本地启动多个执行器注意端口不能重复。
4、配置xxl-job的执行器
将xxl-job示例工程下配置类拷贝到媒资管理的service工程下
拷贝至:
到此完成媒资管理模块service工程配置xxl-job执行器,在xxl-job调度中心添加执行器,下边准备测试执行器与调度中心是否正常通信,因为接口工程依赖了service工程,所以启动媒资管理模块的接口工程。
启动后观察日志,出现下边的日志表示执行器在调度中心注册成功
同时观察调度中心中的执行器界面
在线机器地址处已显示1个执行器。
7.2.3.3 执行任务
下边编写任务,参考示例工程中任务类的编写方法,如下图:
在媒资服务service包下新建jobhandler存放任务类,下边参考示例工程编写一个任务类
package com.xuecheng.media.service.jobhandler;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* @description 测试执行器
* @author Mr.M
* @date
* @version 1.0
*/
@Component
@Slf4j
public class SampleJob {
/**
* 1、简单任务示例(Bean模式)
*/
@XxlJob("testJob") //任务名称
public void testJob() throws Exception {
//编写任务的执行逻辑
log.info("开始执行.....");
}
}
下边在调度中心添加任务,进入任务管理
点击新增,填写任务信息
基础配置:
- 执行器:哪个执行器执行任务
- 任务描述:随便写
- 负责人:随便写
- 报警邮件:任务执行有问题会根据这个地址发送邮件
调度配置:
- 调度类型:
- 固定速度:指按固定的间隔定时调度。
- Cron:通过Cron表达式实现更丰富的定时调度策略。
Cron表达式是一个字符串,通过它可以定义调度策略,格式如下:
-
{秒数} {分钟} {小时} {日期} {月份} {星期} {年份(可为空)}
-
xxl-job提供图形界面去配置:
一些例子如下:
30 10 1 * * ?
每天1点10分30秒触发0/30 * * * * ?
每30秒触发一次* 0/10 * * * ?
每10分钟触发一次
任务配置:
-
运行模式:有BEAN和GLUE,bean模式较常用就是在项目工程中编写执行器的任务代码,GLUE是将任务代码编写在调度中心。
-
JobHandler:即任务方法名,填写任务方法上边@XxlJob注解中的名称。
-
任务参数:暂时不填
高级配置:
-
路由策略:当执行器集群部署时,调度中心向哪个执行器下发任务,这里选择第一个表示只向第一个执行器下发任务,路由策略的其它选项稍后在分片广播章节详细解释。
- 高级配置的其它配置项稍后在分片广播章节详细解释。
添加成功,启动任务
通过调度日志查看任务执行情况
下边启动媒资管理的service工程,启动执行器。
观察执行器方法的执行:每隔5秒执行一次任务调度方法
如果要停止任务需要在调度中心操作
任务跑一段时间注意清理日志
7.2.4 分片广播
掌握了xxl-job的基本使用,下边思考如何进行分布式任务处理呢?如下图,我们会启动多个执行器组成一个集群,去执行任务。
执行器在集群部署下调度中心有哪些调度策略呢?
查看xxl-job官方文档,阅读高级配置相关的内容:
高级配置:
-
路由策略:当执行器集群部署时,提供丰富的路由策略,包括:
- FIRST(第一个):固定选择第一个机器;
- LAST(最后一个):固定选择最后一个机器;
- ROUND(轮询):挨个执行;
- RANDOM(随机):随机选择在线的机器;
- CONSISTENT_HASH(一致性HASH):每个任务按照Hash算法固定选择某一台机器,且所有任务均匀散列在不同机器上。
- LEAST_FREQUENTLY_USED(最不经常使用):使用频率最低的机器优先被选举;
- LEAST_RECENTLY_USED(最近最久未使用):最久未使用的机器优先被选举;
- FAILOVER(故障转移):按照顺序依次进行心跳检测,第一个心跳检测成功的机器选定为目标执行器并发起调度;
- BUSYOVER(忙碌转移):按照顺序依次进行空闲检测,第一个空闲检测成功的机器选定为目标执行器并发起调度;
SHARDING_BROADCAST(分片广播)
:广播触发对应集群中所有机器执行一次任务
,同时系统自动传递分片参数,可根据分片参数开发分片任务;- 说明:上面都是一次只能执行一个任务,而分片广播一次可以执行多个任务。
-
子任务:每个任务都拥有一个唯一的任务ID(任务ID可以从任务列表获取),当本任务执行结束并且执行成功时,将会触发子任务ID所对应的任务的一次主动调度,通过子任务可以实现一个任务执行完成去执行另一个任务。
-
调度过期策略:
- 忽略:调度过期后,忽略过期的任务,从当前时间开始重新计算下次触发时间;
- 立即执行一次:调度过期后,立即执行一次,并从当前时间开始重新计算下次触发时间;
-
阻塞处理策略:调度过于密集执行器来不及处理时的处理策略;
- 单机串行(默认):调度请求进入单机执行器后,调度请求进入FIFO队列并以串行方式运行;
- 丢弃后续调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,本次请求将会被丢弃并标记为失败;
- 覆盖之前调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,将会终止运行中的调度任务并清空队列,然后运行本地调度任务;
-
任务超时时间:支持自定义任务超时时间,任务运行超时将会主动中断任务;
-
失败重试次数:支持自定义任务失败重试次数,当任务失败时将会按照预设的失败重试次数主动进行重试;
下边要重点说的是分片广播策略,分片是指是调度中心以执行器为维度进行分片,将集群中的执行器标上序号:0,1,2,3…,广播是指每次调度会向集群中的所有执行器发送任务调度,请求中携带分片参数。
如下图:
每个执行器收到调度请求同时接收分片参数。
xxl-job支持动态扩容执行器集群从而动态增加分片数量,当有任务量增加可以部署更多的执行器到集群中,调度中心会动态修改分片的数量。
作业分片适用哪些场景呢?
- 分片任务场景:10个执行器的集群来处理10w条数据,每台机器只需要处理1w条数据,耗时降低10倍;
- 广播任务场景:广播执行器同时运行shell脚本、广播集群节点进行缓存更新等。
所以,广播分片方式不仅可以充分发挥每个执行器的能力,并且根据分片参数可以控制任务是否执行,最终灵活控制了执行器集群分布式处理任务。
使用说明:
“分片广播” 和普通任务开发流程一致,不同之处在于可以获取分片参数进行分片业务处理。
Java语言任务获取分片参数方式:
BEAN、GLUE模式(Java),可参考Sample示例执行器中的示例任务"ShardingJobHandler":
/**
* 2、分片广播任务
*/
@XxlJob("shardingJobHandler")
public void shardingJobHandler() throws Exception {
// 分片序号,从0开始
int shardIndex = XxlJobHelper.getShardIndex();
// 分片总数
int shardTotal = XxlJobHelper.getShardTotal();
....
下边测试作业分片:
1、定义作业分片的任务方法
/**
* 2、分片广播任务
*/
@XxlJob("shardingJobHandler")
public void shardingJobHandler() throws Exception {
// 分片参数
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
log.info("分片参数:当前分片序号 = {}, 总分片数 = {}", shardIndex, shardTotal);
log.info("开始执行第"+shardIndex+"批任务");
}
2、在调度中心添加任务
添加成功:
启动任务,观察日志
下边启动两个执行器实例,观察每个实例的执行情况
首先在nacos中配置media-service的本地优先配置:
#配置本地优先
spring:
cloud:
config:
override-none: true
将media-service启动两个实例
两个实例的在启动时注意端口不能冲突:
实例1 :server.port=63050 、xxl.job.executor.port=9999
实例2 在VM options处添加:-Dserver.port=63051 -Dxxl.job.executor.port=9998
例如:
启动两个实例
观察任务调度中心,稍等片刻执行器有两个
观察两个执行实例的日志:
另一实例的日志如下:
从日志可以看每个实例的分片序号不同。
如果其中一个执行器挂掉,只剩下一个执行器在工作,稍等片刻调用中心发现少了一个执行器将动态调整总分片数为1。
总结:为什么使用分片广播?
- 因为它可以最大限度地提高我们分布式任务调度的执行能力。
- 重点在于分片参数。
到此作业分片任务调试完成,此时我们可以思考:
当一次分片广播到来,各执行器如何根据分片参数去分布式执行任务,保证执行器之间执行的任务不重复呢?
7.3 技术方案
7.3.1 作业分片方案
掌握了xxl-job的分片广播调度方式,下边思考如何分布式去执行学成在线平台中的视频处理任务。
任务添加成功后,对于要处理的任务会添加到待处理任务表中,现在启动多个执行器实例去查询这些待处理任务,此时如何保证多个执行器不会查询到重复的任务呢?
XXL-JOB并不直接提供数据处理的功能,它只会给执行器分配好分片序号,在向执行器任务调度的同时下发分片总数以及分片序号等参数,执行器收到这些参数根据自己的业务需求去利用这些参数。
下图表示了多个执行器获取视频处理任务的结构:
每个执行器收到广播任务有两个参数:分片总数、分片序号。每个执行从数据表取任务时可以让任务id 模上 分片总数
,如果等于分片序号则执行此任务。
上边两个执行器实例那么分片总数为2,序号为0、1,从任务1开始,如下:
- 1 % 2 = 1 执行器2执行
- 2 % 2 = 0 执行器1执行
- 3 % 2 = 1 执行器2执行
以此类推.
7.3.2 保证任务不重复执行(幂等性)
说明:
- 通过作业分片方案保证了执行器之间查询到不重复的任务
- 但是如果一个执行器在处理一个视频还没有完成,此时调度中心又一次请求调度,为了不重复处理同一个视频该怎么办?
首先配置调度过期策略:
查看文档如下:
- 调度过期策略:调度中心错过调度时间的补偿处理策略,包括:忽略、立即补偿触发一次等;
- 忽略:调度过期后,忽略过期的任务,从当前时间开始重新计算下次触发时间;
- 立即执行一次:调度过期后,立即执行一次,并从当前时间开始重新计算下次触发时间;
这里我们选择忽略
,如果立即执行一次就可能重复执行相同的任务。
其次,再看阻塞处理策略:阻塞处理策略就是当前执行器正在执行任务还没有结束时调度中心进行任务调度,此时该如何处理。
查看文档如下:
- 阻塞处理策略:调度过于密集执行器来不及处理时的处理策略;
- 单机串行(默认):调度请求进入单机执行器后,调度请求进入FIFO队列并以串行方式运行;
- 丢弃后续调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,本次请求将会被丢弃并标记为失败;
- 覆盖之前调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,将会终止运行中的调度任务并清空队列,然后运行本地调度任务;
这里如果选择覆盖之前调度则可能重复执行任务,这里选择丢弃后续调度或单机串行方式
来避免任务重复执行。
只做这些配置可以保证任务不会重复执行吗?
做不到,还需要保证任务处理的幂等性,什么是任务的幂等性?任务的幂等性是指:对于数据的操作不论多少次,操作的结果始终是一致的
。在本项目中要实现的是不论多少次任务调度同一个视频只执行一次成功的转码。
什么是幂等性?
它描述了一次和多次请求某一个资源对于资源本身应该具有同样的结果。
幂等性是为了解决重复提交问题,比如:恶意刷单,重复支付等。
解决幂等性常用的方案:
-
数据库约束,比如:唯一索引,主键。
-
乐观锁,常用于数据库,更新数据时根据乐观锁状态去更新。
-
唯一序列号,操作传递一个唯一序列号,操作时判断与该序列号相等则执行。
基于以上分析,在执行器接收调度请求去执行视频处理任务时要实现视频处理的幂等性,要有办法去判断该视频是否处理完成,如果正在处理中或处理完则不再处理。这里我们在数据库视频处理表中添加处理状态字段,视频处理完成更新状态为完成,执行视频处理前判断状态是否完成,如果完成则不再处理。
7.3.3 视频处理方案(面试会问)
确定了分片方案,下边梳理整个视频上传及处理的业务流程。
上传视频成功向视频处理待处理表添加记录。
视频处理的详细流程如下:
- 任务调度中心广播作业分片。
- 执行器收到广播作业分片,从数据库读取待处理任务,读取未处理及处理失败的任务。
- 执行器更新任务为处理中,根据任务内容从MinIO下载要处理的文件。
- 执行器启动多线程去处理任务。
- 任务处理完成,上传处理后的视频到MinIO。
- 将更新任务处理结果,如果视频处理完成除了更新任务处理结果以外还要将文件的访问地址更新至任务处理表及文件表中,最后将任务完成记录写入历史表。
7.4 查询待处理任务
7.4.1 需求分析
查询待处理任务
、只处理未提交
及处理失败
的任务,任务处理失败后进行重试,最多重试3次。
任务处理成功将待处理记录移动到历史任务表(即:任务处理成功后任务表记录删除,历史任务表新增
)。
- 任务处理成功后,为什么不直接修改任务表的字段为
处理成功
,而是要把记录移动到历史任务表呢??? - 答:待处理任务表数量越来越大,每次都要在里面查询领取任务,对性能有影响。所以任务已经处理完成了,没用了就可以把它移动到历史任务表。
下图是待处理任务表:
历史任务表与待处理任务表的结构相同。
7.4.2 添加待处理任务
上传视频成功向视频处理待处理表添加记录,暂时只添加对avi视频的处理记录。
根据MIME Type去判断是否是avi视频,下边列出部分MIME Type
avi视频的MIME Type是video/x-msvideo
修改文件信息入库方法,如下:
说明:
- 什么时候想记录这个待处理任务???
- 肯定是视频上传之后合并分块成功,此时才会记录。
分析:具体代码写在哪个位置
- 代码直接写在合并分块视频成功后,真的合适吗???
- 我们还要确保:视频一旦上传合并成功,那么这个视频的待处理任务就要添加成功。反之这个视频没有上传合并成功,这个待处理任务不能添加上去。
- 解决:事务控制,找到合并视频分块哪里进行数据库事务操作了,并且这个方法已经被事务控制了。
- 具体写的位置:写在将文件信息添加到文件表的方法中,具体是方法中的
向文件信息表插入文件信息成功后的代码
写向任务表添加未处理任务记录信息
,这样操作文件表和操作任务表的操作都在同一个事务方法中,要么都成功要么都失败。
- 解决:事务控制,找到合并视频分块哪里进行数据库事务操作了,并且这个方法已经被事务控制了。
@Autowired
MediaProcessMapper mediaProcessMapper;//待处理任务mapper
/**
* @param companyId 机构id
* @param fileMd5 文件md5值
* @param uploadFileParamsDto 上传文件的信息
* @param bucket 桶
* @param objectName 对象名称
* @return com.xuecheng.media.model.po.MediaFiles
* @description 抽取5:将文件信息添加到文件表
* @author Mr.M
* @date 2022/10/12 21:22
*/
@Transactional //将来有可能向其他表写入数据,所以要加入事务
public MediaFiles addMediaFilesToDb(Long companyId, String fileMd5, UploadFileParamsDto uploadFileParamsDto, String bucket, String objectName) {
//根据主键的值/文件的md5 查询文件如果在数据库中存在了就不在保存了。
MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);
if (mediaFiles == null) {
mediaFiles = new MediaFiles();
//拷贝基本信息:把前端传递过来的参数拷贝到数据库里面
BeanUtils.copyProperties(uploadFileParamsDto, mediaFiles);
//构造余下的参数信息
mediaFiles.setId(fileMd5);//文件id
mediaFiles.setCompanyId(companyId);//机构id
mediaFiles.setBucket(bucket);//桶
mediaFiles.setFilePath(objectName);//file_path 对象名
mediaFiles.setFileId(fileMd5);//file_id 文件id
mediaFiles.setUrl("/" + bucket + "/" + objectName);//url:文件上传地址
mediaFiles.setCreateDate(LocalDateTime.now());//上传时间
mediaFiles.setStatus("1");//状态 1:正常,0:不展示
mediaFiles.setAuditStatus("002003");//审核状态:202001审核未通过、202002未提交、202003已提交、202004审核通过
//保存文件信息到文件表
int insert = mediaFilesMapper.insert(mediaFiles);
//int a=1/0; //测试事务优化
if (insert < 0) {
log.debug("向数据库保存文件失败,bucket:{},objectName", bucket, objectName);
XueChengPlusException.cast("保存文件信息失败");
}
log.debug("保存文件信息到数据库成功,{}", mediaFiles.toString());
//记录待处理任务:到这一步说明上传视频成功(向文件信息表插入数据),之后写待处理任务(向待处理任务表插入数据),
// 此时上传视频成功的操作和向待处理任务表中插入数据的操作,处于同一个事务方法中。
addWaitingTask(mediaFiles);
}
return mediaFiles;
}
/**
* 抽取的添加待处理任务
* @param mediaFiles 媒资文件信息
*/
private void addWaitingTask(MediaFiles mediaFiles){
//1.获取文件的mimeType:
//文件名称
String filename = mediaFiles.getFilename();
//截取出文件扩展名
String exension = filename.substring(filename.lastIndexOf("."));
//根据扩展名获取文件mimeType:此方法之前上传图片文件时已经抽取
String mimeType = getMimeType(exension);
//2.根据MIMEType媒体类型判断如果是avi视频才写入待处理任务:如果有一天处理文档或其他类型的视频了,该如何写呢,难道要加一堆if判断吗??
// 可以把这段代码提取成一个私有方法,然后用一个专门的配置文件注入文件的类型即可。
if(mimeType.equals("video/x-msvideo")){//如果是avi视频写入待处理任务
MediaProcess mediaProcess = new MediaProcess();//待处理任务表
//将通用的数据从文件信息表拷贝到任务表,之后设置剩下的属性。
BeanUtils.copyProperties(mediaFiles,mediaProcess);
mediaProcess.setStatus("1");//未处理
mediaProcess.setFailCount(0);//失败次数默认为0
mediaProcessMapper.insert(mediaProcess);//插入数据
}
}
/**
* 抽取1:根据扩展名来获取mimeType文件内容类型
*
* @param extension:扩展名
* @return
*/
private String getMimeType(String extension) {
if (extension == null) {//如果扩展名为空下面这个findExtensionMatch()方法会报空指针异常,所以为空时设置一个空字符串。
extension = "";
}
//根据扩展名取出mimeType
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//通用mimeType,字节流
if (extensionMatch != null) {
mimeType = extensionMatch.getMimeType();
}
return mimeType;
}
进行前后端测试,上传4个avi视频,观察待处理任务表是否存在记录,记录是否完成。
- 准备数据:
- 修改url字段:因为这个上传的这几个视频最终都是要进行处理的,处理完成后才设置url,这个url是互联网最终要播放的地址。而我们这里还没有处理,直接将还没有转码前的地址设置了进去,所以没转码前的地址直接设置为null即可。
7.4.3 查询待处理任务
如何保证查询到的待处理视频记录不重复?
sql解释:
SELECT * FROM media_process t WHERE
t.id % 2 = 0 # 保证每个执行器查询到的任务不重复:任务id % 任务器总数= 执行器的序号
AND (t.status = '1' OR t.status = '3') #限制状态:1 未处理 3 处理失败
AND t.fail_count < 3 #任务一直重复失败,之前规定失败重置的次数最大为3,所以我们只需要查询小于3的失败次数。
LIMIT 0,2 #查出来100个任务都要查询吗,不是,每次查询固定的任务数量
编写根据分片参数获取待处理任务的DAO方法,定义DAO接口如下:
public interface MediaProcessMapper extends BaseMapper<MediaProcess> {
/**
* @description 根据分片参数获取待处理任务
* @param shardTotal 分片总数
* @param shardIndex 分片序号
* @param count 任务数
* @return java.util.List<com.xuecheng.media.model.po.MediaProcess>
* @author Mr.M
* @date 2022/9/14 8:54
*/
@Select("select * from media_process t where t.id % #{shardTotal} = #{shardIndex} " +
"and (t.status = '1' or t.status = '3') and t.fail_count < 3 limit #{count}")
List<MediaProcess> selectListByShardIndex(@Param("shardTotal") int shardTotal,
@Param("shardIndex") int shardIndex,
@Param("count") int count);
}
定义Service接口,查询待处理
package com.xuecheng.media.service;
import com.xuecheng.media.model.po.MediaProcess;
import java.util.List;
/**
* @author Mr.M
* @version 1.0
* @description 媒资文件处理业务方法
* @date
*/
public interface MediaFileProcessService {
/**
* @description 获取待处理任务
* @param shardIndex 分片序号
* @param shardTotal 分片总数
* @param count 获取记录数
* @return java.util.List<com.xuecheng.media.model.po.MediaProcess>
* @author Mr.M
* @date
*/
public List<MediaProcess> getMediaProcessList(int shardIndex, int shardTotal, int count);
}
service接口实现
/**
* @description TODO
* @author Mr.M
* @date
* @version 1.0
*/
@Slf4j
@Service
public class MediaFileProcessServiceImpl implements MediaFileProcessService {
@Autowired
MediaFilesMapper mediaFilesMapper;
@Autowired
MediaProcessMapper mediaProcessMapper;
@Override
public List<MediaProcess> getMediaProcessList(int shardIndex, int shardTotal, int count) {
List<MediaProcess> mediaProcesses = mediaProcessMapper.selectListByShardIndex(shardTotal, shardIndex, count);
return mediaProcesses;
}
}
7.5 开始执行任务
7.5.1 分布式锁
前边分析了保证任务不重复执行的方案,理论上每个执行器分到的任务是不重复的,但是当在执行器弹性扩容时无法绝对避免任务不重复执行,比如:原来有四个执行器正在执行任务,由于网络问题原有的0、1号执行器无法与调度中心通信,调度中心就会对执行器重新编号,原来的3、4执行器可能就会执行和0、1号执行器相同的任务。
为了避免多线程去争抢同一个任务可以使用synchronized同步锁去解决,如下代码:
synchronized(锁对象){
执行任务...
}
synchronized只能保证同一个虚拟机中多个线程去争抢锁。
如果是多个执行器分布式部署,并不能保证同一个视频只有一个执行器去处理。
现在要实现分布式环境下所有虚拟机中的线程去同步执行就需要让多个虚拟机去共用一个锁,虚拟机可以分布式部署,锁也可以分布式部署,如下图:
虚拟机都去抢占同一个锁,锁是一个单独的程序提供加锁、解锁服务。
该锁已不属于某个虚拟机,而是分布式部署,由多个虚拟机所共享,这种锁叫分布式锁。
实现分布式锁的方案有很多,常用的如下:
1、基于数据库实现分布锁
- 利用数据库主键唯一性的特点,或利用数据库唯一索引、行级锁的特点,多个线程同时去更新相同的记录,谁更新成功谁就抢到锁。
2、基于redis实现锁
- redis提供了分布式锁的实现方案,比如:SETNX、set nx、redisson等。
- 拿SETNX举例说明,SETNX命令的工作过程是去set一个不存在的key,多个线程去设置同一个key只会有一个线程设置成功,设置成功的的线程拿到锁。
3、使用zookeeper实现
- zookeeper是一个分布式协调服务,主要解决分布式程序之间的同步的问题。
- zookeeper的结构类似的文件目录,多线程向zookeeper创建一个子目录(节点)只会有 一个创建成功,利用此特点可以实现分布式锁,谁创建该结点成功谁就获得锁。
7.5.2 开启任务
下边基于数据库方式实现分布锁,开始执行任务将任务执行状态更新为4表示任务执行中。
下边的sql语句可以实现更新操作:
update media_process m set m.status='4' where m.id=?
如果是多个线程去执行该sql都将会执行成功,但需求是只能有一个线程抢到锁,所以此sql无法满足需求。
下边使用乐观锁的方式实现更新操作:
UPDATE media_process m SET m.status='4' #谁把状态更新成 4处理中,谁就拿到锁
WHERE (m.status='1' OR m.status='3') #1(未处理) 2(处理失败),3个人来更新如果第一个人更新成功,状态变为4(更新中),后面的人就更新不成功了(状态发生了变化)。
AND m.fail_count < 3 #任务一直重复失败,之前规定失败重置的次数最大为3,所以我们只需要处理小于3的失败次数。
AND m.id=6
多个线程同时执行上边的sql只会有一个线程执行成功。
什么是乐观锁、悲观锁?
-
synchronized是一种悲观锁,在执行被synchronized包裹的代码时需要首先获取锁,没有拿到锁则无法执行,是总悲观的认为别的线程会去抢,所以要悲观锁。
-
乐观锁的思想是它不认为会有线程去争抢,尽管去执行,如果没有执行成功就再去重试。
数据库的乐观锁实现方式
是在表中增加一个version字段,更新时判断是否等于某个版本,等于则更新否则更新失败,如下方式。
# 如果第一个人更新成功版本号+1,第二个人来时由于版本号发生了变化所以sql执行不成功。
update t1 set t1.data1 = '',t1.version='2' where t1.version='1'
实现如下:
1、定义mapper
public interface MediaProcessMapper extends BaseMapper<MediaProcess> {
/**
* 开启一个任务
* @param id 任务id
* @return 更新记录数
*/
@Update("update media_process m set m.status='4' where (m.status='1' or m.status='3') and m.fail_count<3 and m.id=#{id}")
int startTask(@Param("id") long id);
}
2、在MediaFileProcessService中定义接口
/**
* 开启一个任务
* @param id 任务id
* @return true开启任务成功,false开启任务失败
*/
public boolean startTask(long id);
//实现如下
public boolean startTask(long id) {
int result = mediaProcessMapper.startTask(id);
return result<=0?false:true;
}
7.6 更新任务状态
任务处理完成需要更新任务处理结果,任务执行成功更新视频的URL、及任务处理结果,将待处理任务记录删除,同时向历史任务表添加记录。
在MediaFileProcessService接口添加方法
/**
* @description 保存任务结果
* @param taskId 任务id
* @param status 任务状态
* @param fileId 文件id
* @param url url
* @param errorMsg 错误信息
* @return void
* @author Mr.M
* @date 2022/10/15 11:29
*/
void saveProcessFinishStatus(Long taskId,String status,String fileId,String url,String errorMsg);
service接口方法实现如下:
@Autowired
MediaProcessHistoryMapper mediaProcessHistoryMapper;
/**
* @description 保存任务结果
* @param taskId 任务id
* @param status 任务状态
* @param fileId 文件id
* @param url url
* @param errorMsg 错误信息
*/
@Transactional
@Override
public void saveProcessFinishStatus(Long taskId, String status, String fileId, String url, String errorMsg) {
//1.更新之前先查询:查出任务,如果不存在则直接返回,不需要更新了
MediaProcess mediaProcess = mediaProcessMapper.selectById(taskId);
if(mediaProcess == null){
return ;
}
//2.如果任务执行失败
if(status.equals("3")){
//更新MediaProcess表的状态
mediaProcess.setStatus("3");
mediaProcess.setFailCount(mediaProcess.getFailCount()+1);//失败次数+1
mediaProcess.setErrormsg(errorMsg);
mediaProcessMapper.updateById(mediaProcess);
return;
}
//3.如果任务执行成功
//文件表记录
MediaFiles mediaFiles = mediaFilesMapper.selectById(fileId);
if(mediaFiles!=null){
//更新meida_file媒资文件表中的访问url
mediaFiles.setUrl(url);
mediaFilesMapper.updateById(mediaFiles);
}
//更新MediaProcess任务待处理表的url和状态
mediaProcess.setUrl(url); //更新任务表的url
mediaProcess.setStatus("2");//处理成功
mediaProcess.setFinishDate(LocalDateTime.now());//时间
mediaProcessMapper.updateById(mediaProcess);
//4.将MediaProcess表记录插入到MediaProcessHistory表
MediaProcessHistory mediaProcessHistory = new MediaProcessHistory();
BeanUtils.copyProperties(mediaProcess, mediaProcessHistory);
mediaProcessHistoryMapper.insert(mediaProcessHistory);
//5.从MediaProcess删除当前任务
mediaProcessMapper.deleteById(taskId);
}
7.7 视频处理
视频采用并发处理,每个视频使用一个线程去处理,每次处理的视频数量不要超过cpu核心数。
所有视频处理完成结束本次执行,为防止代码异常出现无限期等待则添加超时设置,到达超时时间还没有处理完成仍结束任务。
定义任务类VideoTask 如下:
package com.xuecheng.media.service.jobhander;
import com.xuecheng.base.utils.Mp4VideoUtil;
import com.xuecheng.media.model.po.MediaProcess;
import com.xuecheng.media.service.MediaFileProcessService;
import com.xuecheng.media.service.MediaFileService;
import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.*;
/**
* @author Mr.M
* @version 1.0
* @description TODO
* @date 2022/10/15 11:58
*/
@Slf4j
@Component
public class VideoTask {
@Autowired
MediaFileService mediaFileService;
@Autowired
MediaFileProcessService mediaFileProcessService;
@Value("${videoprocess.ffmpegpath}")
String ffmpegpath;
@XxlJob("videoJobHandler")
public void videoJobHandler() throws Exception {
// 分片参数
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
List<MediaProcess> mediaProcessList = null;
int size = 0;
try {
//取出cpu核心数作为一次处理数据的条数
int processors = Runtime.getRuntime().availableProcessors();
//一次处理视频数量不要超过cpu核心数
mediaProcessList = mediaFileProcessService.getMediaProcessList(shardIndex, shardTotal, processors);
size = mediaProcessList.size();
log.debug("取出待处理视频任务{}条", size);
if (size < 0) {
return;
}
} catch (Exception e) {
e.printStackTrace();
return;
}
//启动size个线程的线程池
ExecutorService threadPool = Executors.newFixedThreadPool(size);
//计数器
CountDownLatch countDownLatch = new CountDownLatch(size);
//将处理任务加入线程池
mediaProcessList.forEach(mediaProcess -> {
threadPool.execute(() -> {
try {
//任务id
Long taskId = mediaProcess.getId();
//抢占任务
boolean b = mediaFileProcessService.startTask(taskId);
if (!b) {
return;
}
log.debug("开始执行任务:{}", mediaProcess);
//下边是处理逻辑
//桶
String bucket = mediaProcess.getBucket();
//存储路径
String filePath = mediaProcess.getFilePath();
//原始视频的md5值
String fileId = mediaProcess.getFileId();
//原始文件名称
String filename = mediaProcess.getFilename();
//将要处理的文件下载到服务器上
File originalFile = mediaFileService.downloadFileFromMinIO(mediaProcess.getBucket(), mediaProcess.getFilePath());
if (originalFile == null) {
log.debug("下载待处理文件失败,originalFile:{}", mediaProcess.getBucket().concat(mediaProcess.getFilePath()));
mediaFileProcessService.saveProcessFinishStatus(mediaProcess.getId(), "3", fileId, null, "下载待处理文件失败");
return;
}
//处理结束的视频文件
File mp4File = null;
try {
mp4File = File.createTempFile("mp4", ".mp4");
} catch (IOException e) {
log.error("创建mp4临时文件失败");
mediaFileProcessService.saveProcessFinishStatus(mediaProcess.getId(), "3", fileId, null, "创建mp4临时文件失败");
return;
}
//视频处理结果
String result = "";
try {
//开始处理视频
Mp4VideoUtil videoUtil = new Mp4VideoUtil(ffmpegpath, originalFile.getAbsolutePath(), mp4File.getName(), mp4File.getAbsolutePath());
//开始视频转换,成功将返回success
result = videoUtil.generateMp4();
} catch (Exception e) {
e.printStackTrace();
log.error("处理视频文件:{},出错:{}", mediaProcess.getFilePath(), e.getMessage());
}
if (!result.equals("success")) {
//记录错误信息
log.error("处理视频失败,视频地址:{},错误信息:{}", bucket + filePath, result);
mediaFileProcessService.saveProcessFinishStatus(mediaProcess.getId(), "3", fileId, null, result);
return;
}
//将mp4上传至minio
//mp4在minio的存储路径
String objectName = getFilePath(fileId, ".mp4");
//访问url
String url = "/" + bucket + "/" + objectName;
try {
mediaFileService.addMediaFilesToMinIO(mp4File.getAbsolutePath(), "video/mp4", bucket, objectName);
//将url存储至数据,并更新状态为成功,并将待处理视频记录删除存入历史
mediaFileProcessService.saveProcessFinishStatus(mediaProcess.getId(), "2", fileId, url, null);
} catch (Exception e) {
log.error("上传视频失败或入库失败,视频地址:{},错误信息:{}", bucket + objectName, e.getMessage());
//最终还是失败了
mediaFileProcessService.saveProcessFinishStatus(mediaProcess.getId(), "3", fileId, null, "处理后视频上传或入库失败");
}
}finally {
countDownLatch.countDown();
}
});
});
//等待,给一个充裕的超时时间,防止无限等待,到达超时时间还没有处理完成则结束任务
countDownLatch.await(30, TimeUnit.MINUTES);
}
private String getFilePath(String fileMd5,String fileExt){
return fileMd5.substring(0,1) + "/" + fileMd5.substring(1,2) + "/" + fileMd5 + "/" +fileMd5 +fileExt;
}
}
修改nacos中的ffmpeg安装位置
7.8 测试
7.8.1 基本测试
进入xxl-job调度中心添加执行器和视频处理任务,在nacos中配置执行器
- 进入xxl-job调度中心添加执行器
- 进入xxl-job调度中心添加视频处理任务
- 配置阻塞处理策略为:丢弃后续调度。
- 配置视频处理调度时间间隔不用根据视频处理时间去确定,可以配置的小一些,如:5分钟,即使到达调度时间如果视频没有处理完会丢弃调度请求。
- 在nacos中配置执行器
配置完成开始测试视频处理:
-
首先上传至少4个视频,非mp4格式。
-
在xxl-job启动视频处理任务
-
观察媒资管理服务后台日志
-
查看minio
-
查看数据库:待处理任务表记录移动到历史记录表
7.8.2 失败测试
1、先停止调度中心的视频处理任务。
2、上传视频,手动修改待处理任务表中file_path字段为一个不存在的文件地址(把原先上传过的b4删除掉:数据库,minio中都删除)
3、启动任务
- 观察任务处理失败后是否会重试,并记录失败次数。
7.8.3 抢占任务测试
1、修改调度中心中视频处理任务的阻塞处理策略为“覆盖之间的调度”
2、在抢占任务代码处打断点并选择支持多线程方式(3个都设置)
3、在抢占任务代码处的下边两行代码分别打上断点,避免观察时代码继续执行。
4、启动任务
此时多个线程执行都停留在断点处
依次放行,观察同一个任务只会被一个线程抢占成功。
7.9 其它问题
7.9.1 任务补偿机制
如果有线程抢占了某个视频的处理任务,如果线程处理过程中挂掉了,该视频的状态将会一直是处理中,其它线程将无法处理,这个问题需要用补偿机制。
单独启动一个任务找到待处理
任务表中超过执行期限但仍在处理中的任务,将任务的状态改为执行失败。
任务执行期限是处理一个视频的最大时间,比如定为30分钟,通过任务的启动时间去判断任务是否超过执行期限。
大家思考这个sql该如何实现?
大家尝试自己实现此任务补偿机制。
7.9.2 达到最大失败次数
当任务达到最大失败次数时一般就说明程序处理此视频存在问题,这种情况就需要人工处理,在页面上会提示失败的信息,人工可手动执行该视频进行处理,或通过其它转码工具进行视频转码,转码后直接上传mp4视频。
7.9.3 分块文件清理问题
上传一个文件进行分块上传,上传一半不传了,之前上传到minio的分块文件要清理吗?怎么做的?
1、在数据库中有一张文件表记录minio中存储的文件信息。
2、文件开始上传时会写入文件表,状态为上传中,上传完成会更新状态为上传完成。
3、当一个文件传了一半不再上传了说明该文件没有上传完成,会有定时任务去查询文件表中的记录,如果文件未上传完成则删除minio中没有上传成功的文件目录。
7.10 面试
7.10.1 xxl-job的工作原理是什么? xxl-job是什么怎么工作?
XXL-JOB分布式任务调度服务由调用中心和执行器组成,调用中心负责按任务调度策略向执行器下发任务,执行器负责接收任务执行任务。
1)首先部署并启动xxl-job调度中心。(一个java工程)
2)首先在微服务添加xxl-job依赖。在微服务中配置执行器
3)启动微服务,执行器向调度中心上报自己。
4)在微服务中写一个任务方法并用xxl-job的注解去标记执行任务的方法名称。
5)在调度中心配置任务调度策略,调度策略就是每隔多长时间执行还是在每天或每月的固定时间去执行,比如每天0点执行,或每隔1小时执行一次等。
6)在调度中心启动任务。
7)调度中心根据任务调度策略,到达时间就开始下发任务给执行器。
8)执行器收到任务就开始执行任务。
7.10.2 如何保证任务不重复执行?
1)调度中心按分片广播的方式去下发任务
2)执行器收到作业分片广播的参数;分片总数和分片序号,计算任务id 除以分片总数得到一个余数,如果余数等于分片序号这时就去执行这个任务,这里保证了不同的执行器执行不同的任务。
3)配置调度过期策略为"忽略”,避免同一个执行器多次重复执行同一个任务
4)配置任务阻塞处理策略为"丢弃后续调度”,注意:丢弃也没事下一次调度就又可以执行了
5)另外还要保证任务处理的幂等性,执行过的任务可以打一个状态标记已完成,下次再调度执行该任务判断该任务已完成就不再执行
7.10.3 任务幂等性如何保证?
它描述了一次和多次请求某一个资源对于资源本身应该具有同样的结果。幂等性是为了解决重复提交问题,比如:恶意刷单,重复支付等。
解决幂等性常用的方案:
1)数据库约束,比如:唯一索引,主键。同一个主键不可能两次都插入成功。
2)乐观锁,常用于数据库,更新数据时根据乐观锁状态去更新。
3)唯一序列号,请求前生成唯一的序列号,携带序列号去请求,执行时在redis记录该序列号表示以该序列号的请求执行过了,如果相同的序列号再次来执行说明是重复执行。
这里我们在数据库视频处理表中添加处理状态字段,视频处理完成更新状态为完成,执行视频处理前判断状态是否完成,如果完成则不再处理。
8 绑定媒资
目的:把视频和课程计划绑定起来,将来我们在课程页面点击这个课程计划就能找到关联的视频,去播放了。
8.1 需求分析
8.1.1 业务流程
到目前为止,媒资管理已完成文件上传、视频处理、我的媒资功能等基本功能,其它模块可以使用媒资文件,本节要讲解课程计划绑定媒资文件。
如何将课程计划绑定媒资呢?
首先进入课程计划界面,然后选择要绑定的视频进行绑定即可。
具体的业务流程如下:
-
教育机构用户进入课程管理页面并编辑某一个课程,在"课程大纲"标签页的某一小节后可点击”添加视频“。
-
弹出添加视频对话框,可通过视频关键字搜索已审核通过的视频媒资。
-
选择视频媒资,点击提交按钮,完成课程计划绑定媒资流程。
课程计划关联视频后如下图:
点击已经绑定的视频名称即可解除绑定。
8.1.2 数据模型
课程计划绑定媒资文件后存储至课程计划绑定媒资表
,即teachplan_media表中(课程计划和媒资中间关系表)
8.2 接口定义
根据业务流程,用户进入课程计划列表,首先确定向哪个课程计划添加视频,点击”添加视频“后用户选择视频,选择视频,点击提交,前端以json格式请求以下参数:
提交媒资文件id、文件名称、教学计划id
示例如下:
{
"mediaId": "70a98b4a2fffc89e50b101f959cc33ca",
"fileName": "22-Hmily实现TCC事务-开发bank2的confirm方法.avi",
"teachplanId": 257
}
此接口在内容管理模块提供。
在内容管理模块定义请求参数模型类型:
package com.xuecheng.content.model.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* ClassName: BindTeachplanMediaDto
* Package: com.xuecheng.content.model.dto
* Description: 绑定媒资和课程计划的模型类
*
* @Author xxx
* @Create 2024/6/16 10:47
* @Version 1.0
*/
@Data
@ApiModel(value="BindTeachplanMediaDto", description="教学计划-媒资绑定提交数据")
public class BindTeachplanMediaDto {
@ApiModelProperty(value = "媒资文件id", required = true)
private String mediaId;
@ApiModelProperty(value = "媒资文件名称", required = true)
private String fileName;
@ApiModelProperty(value = "课程计划标识", required = true)
private Long teachplanId;
}
在TeachplanController类中定义接口如下:
@ApiOperation(value = "课程计划和媒资信息绑定")
@PostMapping("/teachplan/association/media")
public void associationMedia(@RequestBody BindTeachplanMediaDto bindTeachplanMediaDto){
}
8.3 接口开发
8.3.1 DAO开发
对teachplanMedia表自动生成Mapper。
8.3.2 Service开发
根据需求定义service接口
/**
* @description 教学计划绑定媒资
* @param bindTeachplanMediaDto
* @return
* @author Mr.M
* @date
*/
public void associationMedia(BindTeachplanMediaDto bindTeachplanMediaDto);
定义接口实现
@Transactional
@Override
public void associationMedia(BindTeachplanMediaDto bindTeachplanMediaDto) {
//课程计划id
Long teachplanId = bindTeachplanMediaDto.getTeachplanId();
Teachplan teachplan = teachplanMapper.selectById(teachplanId);
if(teachplan==null){
XueChengPlusException.cast("课程计划不存在");
}
// 获取教学计划的层级,只有第二层级允许绑定媒资信息(第二层级为小节,第一层级为章节)
Integer grade = teachplan.getGrade();
if (grade != 2) {
XueChengPlusException.cast("只有小节允许绑定媒资信息");
}
//思路分析:你点击添加的时候可能已经绑定过视频了,所以我们先把原先的删除掉在进行新的绑定。
//1.先删除原有记录,根据课程计划id删除它所绑定的媒资
int delete = teachplanMediaMapper.delete(new LambdaQueryWrapper<TeachplanMedia>().eq(TeachplanMedia::getTeachplanId, bindTeachplanMediaDto.getTeachplanId()));
//2.再添加新记录
TeachplanMedia teachplanMedia = new TeachplanMedia();
teachplanMedia.setMediaId(bindTeachplanMediaDto.getMediaId());//媒资文件id
teachplanMedia.setTeachplanId(teachplanId);//课程计划标识
teachplanMedia.setCourseId(teachplan.getCourseId()); //课程标识id
teachplanMedia.setMediaFilename(bindTeachplanMediaDto.getFileName());//媒资文件名称
teachplanMedia.setCreateDate(LocalDateTime.now());//创建时间
teachplanMediaMapper.insert(teachplanMedia);
}
8.3.3 接口层完善
完善接口层调用Service层的代码
@ApiOperation(value = "课程计划和媒资信息绑定")
@PostMapping("/teachplan/association/media")
void associationMedia(@RequestBody BindTeachplanMediaDto bindTeachplanMediaDto){
teachplanService.associationMedia(bindTeachplanMediaDto);
}
8.3.4 接口测试
1、使用httpclient测试
### 课程计划绑定视频
POST {{media_host}}/media/teachplan/association/media
Content-Type: application/json
{
"mediaId": "",
"fileName": "",
"teachplanId": ""
}
2、前后端联调
-
此功能较为简单推荐直接前后端联调
-
向指定课程计划添加视频
-
再次添加,先删除在添加新的
8.4 实战
8.4.1 需求分析
根据接口定义实现解除绑定功能。
点击已经绑定的视频名称即可解除绑定。
8.4.2 接口定义
接口定义如下:
delete /teachplan/association/media/{teachPlanId}/{mediaId}
返回200状态码表示成功。
8.4.3 接口开发
- 在TeachplanController中定义接口
@ApiOperation("课程计划解除媒资信息绑定")
@DeleteMapping("/teachplan/association/media/{teachPlanId}/{mediaId}")
public void unassociationMedia(@PathVariable Long teachPlanId, @PathVariable Long mediaId) {
}
- 根据需求定义Service接口
/** 解绑教学计划与媒资信息
* @param teachPlanId 教学计划id
* @param mediaId 媒资信息id
*/
void unassociationMedia(Long teachPlanId, Long mediaId);
- 定义接口实现
@Override
public void unassociationMedia(Long teachPlanId, Long mediaId) {
LambdaQueryWrapper<TeachplanMedia> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(TeachplanMedia::getTeachplanId, teachPlanId)
.eq(TeachplanMedia::getMediaId, mediaId);
teachplanMediaMapper.delete(queryWrapper);
}
- 完善接口层,调用service层的代码
@ApiOperation("课程计划解除媒资信息绑定")
@DeleteMapping("/teachplan/association/media/{teachPlanId}/{mediaId}")
public void unassociationMedia(@PathVariable Long teachPlanId, @PathVariable String mediaId) {
teachplanService.unassociationMedia(teachPlanId, mediaId);
}
8.4.4 测试
开发完成使用httpclient测试、前后端联调
### 课程计划接触视频绑定
DELETE {{media_host}}/media/teachplan/association/media/{teachPlanId}/{mediaId}