小工具知识网

当前位置: 首页 > 行业知识 > 《学成在线》微服务实战项目实操笔记系列(P1~P83)【上】

行业知识

《学成在线》微服务实战项目实操笔记系列(P1~P83)【上】

2026-11-15 03:17:43 来源:互联网转载

史上���详细《学成在线》项目实操笔记系列【上】,跟视频的每一P对应,全系列18万字,涵盖详细步骤与问题的解决方案。如果你操作到某一步卡壳,参考这篇,相信会带给你极大启发。

一、前期准备

1.1 项目介绍 P2

To C面向个人,B2B2C(第1个B是指商品或服务的供应商,第2个B是指从事电子商务的企业,C是消费者。例子:腾讯课堂,第1个B是腾讯公司,第2个B是入驻授课的企业,C是用户学习课程)

本项目含有3个端:用户端;机构端;运营端

1.2 说自己项目 P3

从以下几个方面进行项目介绍:

1.项目的背景,包括:是自研还是外包,什么业务,服务的客户群是谁,谁去运营等问题。

2.项目的业务流程(核心的业务流程)

3.项目的功能模块(核心模块一定要说)

4.项目的技术架构

5.个人工作职责(说得详细一些)

6.个人负责模块的详细说明,包括模块设计,用到的技术,技术的实现方案等(找最熟悉的模块进行说明)。

项目基本介绍:是公司自研的专门针对成人职业技能教育的网络课堂系统,网站提供了成人职业技能培训的相关课程,如:软件开发培训。基于B2B2C的业务模式。培训机构可以在平台入驻、发布课程,我们公司作为运营方由专门的人员对发布的课程进行审核,审核通过后课程才可以发布成功,课程包括免费和收费两种形式,对于免费课程用户可以直接选课学习,对于收费课程要在选课后支付成功才可以继续学习。

本项目包括3个端:用户端、机构端、运营端。

核心模块:内容管理、媒资管理、课程搜索、订单支付、选课管理、认证授权等。

本项目采用前后端分离架构,后端采用SpringBoot、SpringCloud技术栈开发,数据库使用了Mysql,还使用了Redis、消息队列、分布式文件系统、Elasticsearch等中间件系统(要清楚这些中间件在系统中是如何使用的,在哪里使用的)。

划分的微服务包括:内容管理服务、媒资管理服务、搜索服务、订单支付服务、学习中心服务、系统管理服务、认证授权服务、网关服务、注册中心服务、配置中心服务等。

我在这个项目中负责了内容管理、媒资管理、订单支付模块的设计与开发。

个人负责模块的详细说明:内容管理模块,是对平台上的课程进行管理。设计了课程基本信息表、课程营销表、课程计划、课程师资表。培训机构要发布一门课程需要填写课程基本信息、课程营销信息、课程计划信息、课程师资信息,填写完毕后要提交审核,由运营人员进行课程信息的审核,整个审核过程是程序自动审核加入人工确认的方式,通常24小时完成审核。课程审核通过即可发布课程,课程的相关信息会聚合到课程发布表中,这里不仅要将课程信息写到课程发布表还要将课程信息写到索引库、分布式文件系统中,所以这里存在分布式事务的问题,项目使用本地消息表加任务调度的方式去解决这里的分布式事务,保证数据的最终一致性。

1.3 技术架构 P5

业务:解决了什么问题,为用户提供了什么样的服务。

 

技术栈:

1.4 环境配置 P6

配置环境版本如下:

IDEA基本配置如下:

Maven配置:

先把maven解压,然后把maven仓库解压。

在maven的setting.xml文件中进行配置:

 

   alimaven   aliyun maven   http://maven.aliyun.com/nexus/content/groups/public   central

在IDEA中进行配置:

虚拟机配置:

解压虚拟机

双击虚拟机

点击虚拟网络编辑器

把VMnet8的子网地址改为:192.168.101.0

虚拟机的用户名:root,密码:centos

虚拟机ip:192.168.101.65

启动docker

systemctl start docker

运行所有的软件: 

sh /data/soft/restart.sh

 查询docker容器:docker ps

docker ps

数据库配置:

我是先用Navicat连接上虚拟机中的mysql:

Git配置:

下载完Git然后配置到IDEA上:

搭建Gogs:

Gogs是一个轻量级的远程仓库,是在虚拟机里面的,本项目使用Gogs作为Git远程仓库。进入Gogs:

http://192.168.101.65:10880

账号:gogs,密码:gogs

关联远程仓库:

我这里项目已经创建好了,我想要关联远程仓库,就不按照视频的方法进行。

点击VCS-Create Git Repositoty,选择当前项目的文件夹作为仓库,全选当前项目中所有文件,输入文字,然后点击commit。就可以把项目上传到本地仓库。

  

然后点击向上的按钮,点击Define remote,会跳出一个弹窗,弹窗的地址填写下面网页中的地址:

我直接使用现成的仓库,复制下面的HTTP地址,粘贴到上面弹窗中,然后会有输入账号密码的弹窗填gogs的账号密码:

然后就会出现项目内容:

如果忘记gogs密码:

可以在用户设置处更改密码;也可以在管理面板,用户管理,编辑处让管理员指定密码:

 

如何还是提示密码错误,可以在IDEA中,把文件名改掉(改成不存在的,会提示输入密码),也可以选不保存忘记密码:

配置.gitignore文件

把下面文件夹中的内容复制到IDEA的.gitignore文件中。

选中.gitignore,先commit然后push到远程仓库。

关于分支:

老师每一天的授课内容都会创建一个分支。如果想看看第1天的代码,就可以切换到第1天的分支,然后该分支只会显示第1天的代码。

分支在Git中相当于一个独立的工作流,每个分支都可以有不同的提交历史和代码改动。

可以通过在Git界面右键分支,点击Checkout来切换分支。

 

1.5 创建工程 P7

父工程职责:把所有依赖的版本确定下来,模块的聚合作用。

基础工程:基础的代码。所有的微服务依赖于基础工程。

首先创建名为xuecheng-plus-project的工程,把src文件删掉,然后创建xuecheng-plus-parent的模块。

项目结构大概如下,然后把第1章里的pom.xml代码复制到父工程的pom.xml文件中:

 

首先用properties标签把所有依赖的版本确定,然后dependencyManagement标签来引入对应的依赖。

下面创建xuecheng-plus-base模块,这个模块和parent模块是并列关系。base模块里除了src和pom.xml文件外其它东西删掉,包括一些启动类和配置项都要删除,只留下基本结构。

 

所有模块都是直接或间接继承父模块,所以要把父工程的坐标(下面红框里)复制,然后粘贴到base模块的标签下

然后把下面文件中base模块pom.xml中的标签下的内容单独复制,粘贴替换base模块的pom.xml中的下的内容。

1.6 Git面试 P8

可以在Git面板看到所有commit提交到本地仓库的版本。小铅笔所在的就是当前的分支。

面试题1:Git代码冲突怎么处理?

冲突的原因:本地文件的版本浴目标分支中文件的版本不一致时,当存在同一行的内容不同时在进行合并时会出现冲突。

场景:多个分支向主分支合并时(A同事和B同事开发过程中对同一文件的同一行内容进行修改)。同一个分支下pull或push操作时。

在IDEA里commit是提交到本地仓库(是本地机上的一个目录)。push是把本地仓库提交到远程仓库。

可以通过图形界面修改

通过代码行修改方式如下:

然后要add将文件添加到暂存区,commit将文件提交,最后push提交到远程仓库。

面试题2:你是在哪个分支开发?

我们不是直接在主分支开发,由技术经理创建独立的开发分支,我们是在独立的开发分支中进行开发,最后由技术经理将开发分支合并到主分支(技术经理对代码进行审查最终合并到主分支)。

1.7 Maven面试 P9

面试题1:Maven指令的作用

mvn clean 清除target目录中的生成结果

mvn compile 编译源代码(生成target目录,然后会有classes文件)

mvn test 执行单元测试

mvn package 打包(打成的jar包会放在target目录)

mvn install 打包并把打好的包上传到本地仓库

mvn deploy 打包并把打好的包上传到远程仓库

面试题2:Maven依赖版本冲突怎么处理?

maven的依赖版本冲突一般是由于间接依赖导致一个jar包有多个不同的版本。比如:A依赖了B的1.0版本,C依赖了B的2.0版本,项目依赖A和C,从而间接依赖了B的1.0和2.0版本,此时B有两个版本引入到了项目中,可能会出现ClassNotFoundException和NoSuchMethodError等错误。

处理版本冲突可以使用以下方法:

1.使用exclusions排除依赖

比如:我们只依赖B的1.0版本,此时可以在依赖C时排除对B的依赖。

2.使用dependencyManagement锁定版本号

通常在父工程对依赖的版本同一管理

比如:我们只依赖B的1.0版本,此时可以在父工程中限定B的版本为1.0

1.8 数据库环境 P10

数据库用的是虚拟docker容器里的数据库。

数据库用户名:root,密码:mysql。

输入下面启动运行:

systemctl start dockersh /data/soft/restart.sh

我是在Navicat中对虚拟机中MySQL的连接下新建了一个数据库: 

右键表,然后点击运行sql文件,选择下面的xcplus_content.sql这个文件打开,最后可以看到表都加载到了数据库中:

 

1.9 存储引擎及区别 P11

1.InnoDB(InnoDB用于事务处理,具有ACID事务支持等特性,如果要执行大量insert和update操作,应该选择这个):支持事务。使用的锁颗粒度默认为行级锁,可以支持更高的并发,也可以支持表锁。支持外键约束,外键约束降低了表的查询速度,增加了表之间的耦合度。

2.MyISAM(管理非事务表,提供高速存储和检索以及全文搜索能力):不提供事务支持。只支持表级锁。不支持外键。

3.memory:数据存储在内存中。

1.10 MySQL建表注意 P12

注意选择存储引擎,如果要支持事务需要选择InnoDB。

日期类型如果要记录时分秒选择datetime,只记录年月日使用date;固定长度字符选择char,不固定长度字符varchar(varchar比char节省空间但速度没有char快);对于内容介绍类的长广文本字段使用text或longtext类型;如果存储图片等二进制数据使用blob或longblob类型;对金额字段使用DECIMAL。

如果要存储text、blob字段建议单独建一张表,使用外键关联。

尽量不要定义外键,保证表的独立性,可以存在外键意义的字段。

注意字段的约束,如:非空、唯一、主键等。

1.11 (内容管理)创建工程 P15

 这一节具体可以参考day01/资料中的第2章讲义。

在xuecheng-plus-content下面创建xuecheng-plus-content-api、xuecheng-plus-content-model和xuecheng-plus-content-service这三个模块。

新建的目录结构如下:

 模块之间依赖关系如下:

xuecheng-plus-content-model依赖xuecheng-plus-base。

xuecheng-plus-content-service依赖xuecheng-plus-model。

xuecheng-plus-content-api依赖xuecheng-plus-content-service。因为看图,api依赖service也依赖model,但因为service依赖model,所以api如果依赖了service变相也依赖了model。

xuecheng-plus-content-model的pom.xml文件:

    4.0.0            xuecheng-plus-content        com.xuecheng        0.0.1-SNAPSHOT        xuecheng-plus-content-model                        com.xuecheng            xuecheng-plus-base            0.0.1-SNAPSHOT            

xuecheng-plus-content-service的pom.xml文件:

    4.0.0            xuecheng-plus-content        com.xuecheng        0.0.1-SNAPSHOT        xuecheng-plus-content-service                            com.xuecheng            xuecheng-plus-content-model            0.0.1-SNAPSHOT             

xuecheng-plus-content-api的pom.xml文件:

    4.0.0            xuecheng-plus-content        com.xuecheng        0.0.1-SNAPSHOT        xuecheng-plus-content-api                        com.xuecheng            xuecheng-plus-content-service            0.0.1-SNAPSHOT            

二、课程模块开发

2.1 课程查询 需求分析 P16

操作流程(一步一步要怎么操作)就是业务流程,然后弄清在操作流程中需要哪些数据。

2.2 课程查询 生成PO类 P17

具体的搭建步骤可以看资料中下面这个文件里的内容:

course_base文件是课程基本信息文件。

表结构如下。

首先把xuecheng-plus-generator这个代码生成器解压出来,然后放到xuecheng-plus-project这个总工程下面。

此时该模块还是灰色的,右键pom.xml然后点击Add as Maven Project,可以使其变成maven工程。

修改generator下的ContentCodeGenerator类:

修改下面数据的连接配置,像我的表叫content,可以去掉前面xc402:

先把java下面的content删掉,然后运行ContentCodeGenerator的main方法:

main方法执行完后,可以看到生成了content包,model下面有po类。把po下面的所有po类复制粘贴到xuecheng-plus-content-model下的po包中:

发现有一些报错,主要的原因是缺乏了依赖。

可以加入如下的依赖到model模块的pom.xml文件中:

                    com.baomidou            mybatis-plus-annotation            ${mybatis-plus-boot-starter.version}                            com.baomidou            mybatis-plus-core            ${mybatis-plus-boot-starter.version}                            org.projectlombok            lombok

现在就不会报错了: 

2.3 课程查询 设计分析 P18

2.4 课程查询 接口定义 P19

该节内容可以参考下面的文档进行配置,所以我只罗列关键步骤。

第1步:定义分页查询模型类。在xuecheng-plus-base下面的src\main\java\com\xuecheng\base\model下面创建PageParams类,写入代码。

第2步:定义条件模型类。在xuecheng-plus-content/xuecheng-plus-content-model下面src/main/java/com/xuecheng/content/model下创建dto包,创建QueryCourseParamsDto类:

第3步:定义响应模型类。

PageResult分为2部分,1部分是数据,另1部分是分页信息。

第4步:把依赖放到xuecheng-plus-content-api的pom.xml中

详细请见文档:

我个人导入的时候cloud的基础环境包有点问题,然后我修改了里面的代码:

第5步:在xuecheng-plus-content-api的com/xuecheng下面创建content/api,然后创建一个CourseBaseInfoController类:

@RestControllerpublic class CourseBaseInfoController {    @RequestMapping("/course/list")    public PageResult list(PageParams pageParams,@RequestBody QueryCourseParamsDto queryCourseParamsDto){        return null;    }}

第6步:在xuecheng-plus-content-api的com/xuecheng下面创建ContentApplication启动类,注意这个类一定是要在xucheng下面(不要放到content下)。

@SpringBootApplicationpublic class ContentApplication {    public static void main(String[] args){        SpringApplication.run(ContentApplication.class,args);    }}

log4j2-dev.xml在下面的位置,粘贴到下面位置:

 

在xuecheng-plus-content的xuecheng-plus-content-api下面的resources下创建bootstrap.yml文件,然后写入如下代码,注意把url中端口后的数据库名称改成自己的:

点击启动类中下面按钮启动项目:

重点:请求localhost:63040/content/course/list出现的是下面的情况(政策现象):

请求的数据和接口不匹配,加上pageNo和pageSize仍旧不行。

localhost:63040/content/course/list?pageNo=1&pageSize=30

是因为第二个参数要接受json数据转化为Java对象,而现在没有这个json数据。

@RequestBody这个注解对参数的要求为true,因此不行。修改方式如下给@RequestBody加一个required=false。

现在再请求就没有任何问题了:

控制台也显示成功:

前端和controller层间用vo传递数据,controller和service层间用DTO传输数据,service和dao层间用po传输数据。

如果有多个前端,比如手机、PC,传入的参数个数不同,就需要有VO(避免让负责手机前端的工程师误认为有5个参数),比如VO1对应手机是3个参数,VO2对应PC是5个参数。如果没有多个前端,就只有1个前端,那就只需要用DTO即可,不需要VO。

 2.5 课程查询 swagger P20

swagger可以在线生成接口文档。

首先加入swagger依赖(之前已加完),然后要在配置文件bootstrap.yml中进行配置:

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

在启动类上加@EnableSwagger2Doc注解

然后重启项目

在浏览器中输入:localhost:63040/content/swagger-ui.html,然后可以看到如下的界面:

在类上加@Api注解,在方法上加@ApiOperation接口,把@RequestMapping改成@PostMapping:

@Api(value="课程信息管理接口",tags="课程信息管理接口")@RestControllerpublic class CourseBaseInfoController {    @ApiOperation("课程查询接口")    @PostMapping("/course/list")    public PageResult list(PageParams pageParams,@RequestBody(required=false) QueryCourseParamsDto queryCourseParamsDto){        return null;    }}

效果如下: 

@ApiModelProperty可以加在属性上,用来给属性备注名称。

swagger里还可以进行接口测试

但发现日期不太好看

可以直接从下面这个文件中取出LocalDateTimeConfig这个工具类:

然后放到如下的位置:

重启项目,重新测试:

2.6 SpringBoot常用注解 P21

@ResponseBody : 将数据以json的格式响应给前端(侧重返回,定义在类上)

@RequestBody :将json数据转化为java对象(侧重接收,定义在方法上)

@PathVariable : 接收请求路径中占位符的值

@Autowired:是基于类型的注入。

@Resource:基于名称注入。

2.7 项目开发流程 P22

1.产品人员设计产品原型。

2.讨论需求。

3.分模块设计接口。

4.出接口文档。

5.将接口文档给到前端人员,前后端分离开发。

6.开发完毕进行测试。

7.测试完毕发布项目,由运维人员进行部署安装。

2.8 课程查询 DAO接口 P23

前后端分离开发,先定义controller层的接口,生成接口文档,然后再前后端一起开发。

要从底层开始写,从持久层开始写。

在xuecheng-plus-content-service下面的com/xuecheng下面创建content包,在content下创建mapper包。

然后把xuecheng-plus-generator下的com/xuecheng/content/mapper下的所有文件拷贝到上面service的mapper包下。

然后要进行单元测试,首先把xuecheng-plus-content-service的依赖补全,把如下这些依赖复制粘贴到service:

在service的test下创建resources:

把api模块定义的2个配置文件,拷贝到service模块:

配置文件只需要像下面这样:

在service的test/java下创建com/xuecheng包,然后把api的启动类拷贝到com/xuecheng下,删掉生成接口文档的注解:

分页插件会自动加limit语句

分页插件的原理:分页参数会放到ThreadLocal中,mybatis plus有一个拦截器,可以拦截执行的sql,根据数据库类型添加对应的分页语句重写sql,在末尾拼加limit。

在xuecheng-plus-content-service的java/com/xuecheng/content下创建一个config包,然后创建MybatisPlusConfig类,把代码都写入进去:

在service模块的test/java/com/xuecheng/content下创建CourseBaseMapperTests,写入如下内容:

如果左侧为绿√,代表测试通过,也可以打个断点看看:

CourseBaseMapperTests代码如下: 

@SpringBootTestpublic class CourseBaseMapperTests {    @Autowired    CourseBaseMapper courseBaseMapper;    @Test    public void testCourseBaseMapper(){        CourseBase courseBase = courseBaseMapper.selectById(18);        Assertions.assertNotNull(courseBase);        //详细进行分页查询的单元测试        //查询条件        QueryCourseParamsDto courseParamsDto = new QueryCourseParamsDto();        courseParamsDto.setCourseName("java"); //课程名称查询条件        //拼装查询条件        LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper();        //根据名称模糊查询.在sql中拼接course_base.name like '%值%'        queryWrapper.like(StringUtils.isNotEmpty(courseParamsDto.getCourseName()),CourseBase::getName,courseParamsDto.getCourseName());        //根据课程审核状态查询 course_base.audit_status= ?        queryWrapper.eq(StringUtils.isNotEmpty(courseParamsDto.getAuditStatus()),CourseBase::getAuditStatus,courseParamsDto.getAuditStatus());        //分页参数对象        PageParams pageParams = new PageParams();        pageParams.setPageNo(1L);        pageParams.setPageSize(2L);        //创建page分页参数对象,参数:当前页码,每页记录数。        Page page = new Page(pageParams.getPageNo(), pageParams.getPageSize());        //开始进行分页查询        Page pageResult = courseBaseMapper.selectPage(page, queryWrapper);        //数据列表        List items = pageResult.getRecords();        //总记录数        long total = pageResult.getTotal();        //List items,long counts,long page,long pageSize        PageResult courseBasePageResult = new PageResult(items,total,pageParams.getPageNo(),pageParams.getPageSize());        System.out.println(courseBasePageResult);    }}

在末尾打上断点,进行断点调试: 

看一下是否有LIMIT语句: 

看一下结果数据是否完整:

如果觉得数据有问题可以把SQL语句复制到Navicat中,然后把参数逐一替换问号,进行执行、

2.9 数据字典表 P24

下拉框的文字来源于数据字典表,方便前端进行修改替换展示。

数据字典由code编码和文字组成,通过编码来指代文字。

在Navicat中创建一个新的数据库,xc_system,uff8mb4_general_ci。然后执行.sql文件,全称是xcplus_system.sql。

虚拟机里面的Mysql已经有了这个数据库,名字叫作xcplus-system,所以我们不用创建,只需要知道流程。

2.10 (课程查询)service P25

在xuecheng-plus-base的xuecheng-plus-content-service下的src/main/java/com/xuecheng/content下创建service包,在service包下创建CourseBaseInfoService接口,写入如下代码:

//课程信息管理接口public interface CourseBaseInfoService {    /**     * 课程分页查询     * @param pageParams 分页查询参数     * @param queryCourseParamsDto 查询条件     * @return 查询结果     */    public PageResult queryCourseBaseList(PageParams pageParams, QueryCourseParamsDto queryCourseParamsDto);}

再在service包下创建impl包,在impl包下创建实现类CourseBaseInfoServiceImpl,写入如下代码(注意一定要把return null改过了):

@Slf4j@Servicepublic class CourseBaseInfoServiceImpl implements CourseBaseInfoService {    @Autowired    CourseBaseMapper courseBaseMapper;    @Override    public PageResult queryCourseBaseList(PageParams pageParams, QueryCourseParamsDto courseParamsDto) {        //拼装查询条件        LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper();        //根据名称模糊查询.在sql中拼接course_base.name like '%值%'        queryWrapper.like(StringUtils.isNotEmpty(courseParamsDto.getCourseName()),CourseBase::getName,courseParamsDto.getCourseName());        //根据课程审核状态查询 course_base.audit_status= ?        queryWrapper.eq(StringUtils.isNotEmpty(courseParamsDto.getAuditStatus()),CourseBase::getAuditStatus,courseParamsDto.getAuditStatus());        //创建page分页参数对象,参数:当前页码,每页记录数。        Page page = new Page(pageParams.getPageNo(), pageParams.getPageSize());        //开始进行分页查询        Page pageResult = courseBaseMapper.selectPage(page, queryWrapper);        //数据列表        List items = pageResult.getRecords();        //总记录数        long total = pageResult.getTotal();        //List items,long counts,long page,long pageSize        PageResult courseBasePageResult = new PageResult(items,total,pageParams.getPageNo(),pageParams.getPageSize());        return courseBasePageResult;    }}

 然后要进行单元测试,复制service模块test下原有的CourseBaseMapperTests粘贴到自己的路径下,然后改名为CourseBaseInfoServiceTests,写入如下代码:

@SpringBootTestpublic class CourseBaseInfoServiceTests {    @Autowired    CourseBaseInfoService courseBaseInfoService;    @Test    public void testCourseBaseInfoService(){        //查询条件        QueryCourseParamsDto courseParamsDto = new QueryCourseParamsDto();        courseParamsDto.setCourseName("java"); //课程名称查询条件        courseParamsDto.setAuditStatus("202004");        //分页参数对象        PageParams pageParams = new PageParams();        pageParams.setPageNo(1L);        pageParams.setPageSize(2L);        PageResult courseBasePageResult = courseBaseInfoService.queryCourseBaseList(pageParams, courseParamsDto);        System.out.println(courseBasePageResult);    }}

看一下控制台输出没太大问题: 

2.11 (课程查询)接口测试 P26

完善xuecheng-plus-content-api下的CourseBaseInfoController的代码:

@Api(value="课程信息管理接口",tags="课程信息管理接口")@RestControllerpublic class CourseBaseInfoController {    @Autowired    CourseBaseInfoService courseBaseInfoService;    @ApiOperation("课程查询接口")    @PostMapping("/course/list")    public PageResult list(PageParams pageParams,@RequestBody(required=false) QueryCourseParamsDto queryCourseParamsDto){        PageResult courseBasePageResult = courseBaseInfoService.queryCourseBaseList(pageParams, queryCourseParamsDto);        return courseBasePageResult;    }}

用swagger来测试没有问题: 

 

点击请求这里的地球,然后点击如下,生成一个Http的请求。 

把swagger上面的参数转移到下面:

###POST http://localhost:63040/content/course/list?pageNo=1&pageSize=2Content-Type: application/json{  "auditStatus": "202004",  "courseName": "java",  "publishStatus": ""}

同样可以查询出结果: 

现在可以把这些查询的文件,统一放到一个包下方便管理。

在xuecheng-plus-project下面创建一个api-test包,在包下创建一个文件名为:xc-content-api.http用来存放请求测试的语句,然后再创建一个http-client.env.json文件,用来配置相应的环境变量。

 

效果如下:

2.12 部署前端和管理服务 P27

安装完node和npm看看版本(我用的是《苍穹外卖》配置的版本,经测试可以,表明项目可以向下兼容):

用IDEA打开project-xczx2-portal-vue-ts,右键package.json,点击show npm scripts:

右键serve,然后点击Edit serve Settings。然后配置好Node和nmp的版本。最后Run serve:

 4

成功后点击链接访问即可:

打开开发者工具,all报错,这是系统管理服务,请求的是63110端口。

打开第2天的资料,把xuecheng-plus-system放到project里,把pom.xml转化为maven工程,把api模块的配置文件中的数据库修改为我们自己的数据库。

运行Api模块下的SystemApplication启动类。

出现的是跨域问题:

2.13 跨域三种方案 P28

判断是否跨域请求是基于浏览器的同源策略,同源策略是浏览器的一种安全机制,从一个地址请求另一个地址,如果协议、主机、端口三者全部一致则不属于跨域,否则有一个不一致就是跨域。

从http://localhost:8601到http://localhost:8602,因为端口不同,所以是跨域

从http://192.168.101.10:8601到http://192.168.101.11:8601,由于主机不同,是跨域

从http://192.168.101.10:8601到https://192.168.101.10:8601,由于协议不用,是跨域

方法1:JSONP

方法2:添加响应头

Access-Control-Allow-Origin: http://localhost:8601

服务器收到请求判断这个Origin是否允许跨域,如果允许则在响应头中说明允许该来源的跨域请求。

Access-Control-Allow-Origin: *

方法3:通过nginx代理跨域

由于服务器之间没有跨域,浏览器可以通过nginx去访问。 

Nginx和浏览器之间不是跨域的(协议、服务器、端口都一样),通过Nginx代理去访问服务器。

2.14 定义cors过滤器 P29

在xuecheng-plus-system的xuecheng-plus-content-api下的config包下创建一个GlobalCorsConfig类,写入如下代码:

@Configuration public class GlobalCorsConfig {  @Bean  public CorsFilter corsFilter() {   CorsConfiguration config = new CorsConfiguration();   //允许白名单域名进行跨域调用   config.addAllowedOrigin("*");   //允许跨越发送cookie   config.setAllowCredentials(true);   //放行全部原始头信息   config.addAllowedHeader("*");   //允许所有请求方法跨域调用   config.addAllowedMethod("*");   UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();   source.registerCorsConfiguration("/**", config);   return new CorsFilter(source);  } }

现在解决了跨域问题,前端页面能正常显示。 

2.15 课程查询前后端联调 P30

前端.env是重要文件,修改了要重新启动项目。

当前内容管理模块打开:VUE_APP_SERVER_API_URL=http://localhost:63040

引入网关后会打开:VUE_APP_SERVER_API_URL=http://localhost:63010

前后端联调的过程。

测试:输入java,看是否出现课程。点击下拉框,看是否有文字。

2.16 (分类查询)接口定义 P31

下表是树形结构,是三级结构。

在xuecheng-plus-content的xuecheng-plus-content-model下的dto中,定义一个新类CourseCategoryTreeDto,写入如下代码:

@Datapublic class CourseCategoryTreeDto extends CourseCategory implements  java.io.Serializable{    List childrenTreeNodes;}

在xuecheng-plus-content的xuecheng-plus-content-api/src/main/java/com/xuecheng/content/api下新增一个类CourseCategoryController,写入如下代码:

@RestControllerpublic class CourseCategoryController {    @GetMapping("/course-category/tree-nodes")    public List queryTreeNodes(){        return null;    }}

2.17 (分类查询)树型查询 P32

方法1:表的自连接。

select one.id one_id,one.label one_label,two.id two_id,two.label two_labelfrom course_category one inner join course_category two on two.parentid = one.idwhere one.parentid = '1'and one.is_show = '1'and two.is_show = '1'order by one.orderby,two.orderby

方法2:递归

向下递归,由根节点找子节点。 

with recursive t1 as (select * from course_category where id='1'union all select t2.* from course_category t2 inner join t1 on t1.id=t2.parentid)select * from t1order by t1.id

向上递归,由子节点找根节点。

with recursive t1 as (select * from course_category where id='1-1-1'union all select t2.* from course_category t2 inner join t1 on t1.parentid = t2.id)select * from t1order by t1.id

2.18 (分类查询)开发测试 P33

在xuecheng-plus-content的xuecheng-plus-content-service下的mapper中增加CourseCategoryMapper中的代码:

public interface CourseCategoryMapper extends BaseMapper {    //使用递归查询分类    public List selectTreeNodes(String id);}

在xuecheng-plus-content的xuecheng-plus-content-service下的mapper中增加CourseCategoryMapper.xml中的代码:

    with recursive t1 as (        select * from course_category where id=#{id}        union all        select t2.* from course_category t2 inner join t1 on t1.id = t2.parentid    )    select * from t1    order by t1.id

在xuecheng-plus-content的xuecheng-plus-content-service的test下的java/com/xuecheng/content下,把CourseBaseMapperTests拷贝一份起名CourseCategoryMapperTests:

写入如下代码:

@SpringBootTestpublic class CourseCategoryMapperTests {    @Autowired    CourseCategoryMapper courseCategoryMapper;    @Test    public void testCourseBaseMapper(){        List courseCategoryTreeDtos = courseCategoryMapper.selectTreeNodes("1");        System.out.println(courseCategoryTreeDtos);    }}

在xuecheng-plus-content的xuecheng-plus-content-service下的service下创建CourseCategoryService接口,写入如下代码:

public interface CourseCategoryService {    public List queryTreeNodes(String id);}

在xuecheng-plus-content的xuecheng-plus-content-service下的service下创建CourseCategoryServiceImpl类,写入如下代码:

@Slf4j@Servicepublic class CourseCategoryServiceImpl implements CourseCategoryService {    @Autowired    CourseCategoryMapper courseCategoryMapper;    @Override    public List queryTreeNodes(String id) {        //调用mapper递归查询出分类信息        List courseCategoryTreeDtos = courseCategoryMapper.selectTreeNodes(id);        //找到每个节点的子节点,最终封装成List        //先将list转成map,key就是结点的id,value就是CourseCategoryTreeDto对象,目的是为了方便从map获取结点。filter(item->!id.equals(item.getId()))把根节点拍出        Map mapTemp = courseCategoryTreeDtos.stream().filter(item->!id.equals(item.getId())).collect(Collectors.toMap(key -> key.getId(), value -> value));        //定义一个list作为最终返回的list        List courseCategoryList = new ArrayList();        //从头遍历List,一边遍历一边找子节点放在父节点的childrenTreeNodes        courseCategoryTreeDtos.stream().filter(item->!id.equals(item.getId())).forEach(item->{            if(item.getParentid().equals(id)){                courseCategoryList.add(item);            }            //找到节点的父节点            CourseCategoryTreeDto courseCategoryTreeDto = mapTemp.get(item.getParentid());            if(courseCategoryTreeDto!=null) {                if (courseCategoryTreeDto.getChildrenTreeNodes() == null) {                    //如果该父节点的ChildrenTreeNodes属性为空要new一个集合,因为要向该集合中放它的子节点                    courseCategoryTreeDto.setChildrenTreeNodes(new ArrayList());                }                //到每个节点的子节点放在父节点的childrenTreeNodes属性中                courseCategoryTreeDto.getChildrenTreeNodes().add(item);            }        });        return courseCategoryList;    }}

在xuecheng-plus-content的xuecheng-plus-content-service下的test下创建CourseCategoryServiceTests测试类,写入如下代码:

@SpringBootTestpublic class CourseCategoryServiceTests {    @Autowired    CourseCategoryService courseCategoryService;    @Test    public void testCourseBaseInfoService(){        List courseCategoryTreeDtos = courseCategoryService.queryTreeNodes("1");        System.out.println(courseCategoryTreeDtos);    }}

单元测试的效果如下: 

在xuecheng-plus-content-api的CourseCategoryController下完善代码如下:

@RestControllerpublic class CourseCategoryController {    @Autowired    CourseCategoryService courseCategoryService;    @GetMapping("/course-category/tree-nodes")    public List queryTreeNodes(){        return courseCategoryService.queryTreeNodes("1");    }}

先把xuecheng-plus-content的xuecheng-plus-content-api下的ContentApplication启动。

然后在api-test包下的xc-content-api.http中添加如下代码:

### 查询课程分类GET {{content_host}}/content/course-category/tree-nodes

可以看到正常输出结果: 

在前后端联调的时候可以看到课程分类有了结果:

2.19 (新增课程)接口定义 P34

2.20 (新增课程)接口开发 P35

 把下面2个Dto类复制到xuecheng-plus-content的xuecheng-plus-content-api的dto下面:

在xuecheng-plus-content的xuecheng-plus-content-api下的CourseBaseInfoController中写入如下代码:

@ApiOperation("新增课程")    @PostMapping("/content/course")    public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto){        return null;    }

在xuecheng-plus-content的xuecheng-plus-content-service的service下的CourseBaseInfoService中添加接口:

public CourseBaseInfoDto createCourseBase(Long companyId,AddCourseDto addCourseDto);

 在xuecheng-plus-content的xuecheng-plus-content-service的service/impl下的CourseBaseInfoServiceImpl中添加代码:

@Autowired    private CourseMarketMapper courseMarketMapper;    @Autowired    private CourseCategoryMapper courseCategoryMapper;    @Transactional    @Override    public CourseBaseInfoDto createCourseBase(Long companyId, AddCourseDto dto) {        //参数的合法性校验        if (StringUtils.isBlank(dto.getName())) {            throw new RuntimeException("课程名称为空");        }        if (StringUtils.isBlank(dto.getMt())) {            throw new RuntimeException("课程分类为空");        }        if (StringUtils.isBlank(dto.getSt())) {            throw new RuntimeException("课程分类为空");        }        if (StringUtils.isBlank(dto.getGrade())) {            throw new RuntimeException("课程等级为空");        }        if (StringUtils.isBlank(dto.getTeachmode())) {            throw new RuntimeException("教育模式为空");        }        if (StringUtils.isBlank(dto.getUsers())) {            throw new RuntimeException("适应人群为空");        }        if (StringUtils.isBlank(dto.getCharge())) {            throw new RuntimeException("收费规则为空");        }        //向课程基本信息表course_base写入数据        CourseBase courseBaseNew = new CourseBase();        //将传入的页面的参数放到courseBase对象中        BeanUtils.copyProperties(dto,courseBaseNew); //只要属性名相同就可以拷贝        courseBaseNew.setCompanyId(companyId);        courseBaseNew.setCreateDate(LocalDateTime.now());        //审核状态默认为未提交        courseBaseNew.setAuditStatus("202002");        //发布状态为未发布        courseBaseNew.setStatus("203001");        //插入数据库        int insert = courseBaseMapper.insert(courseBaseNew);        if(insert{content_host}}/content/courseContent-Type: application/json{  "charge": "201001",  "description": "adsd",  "grade": "204001",  "mt": "1-1",  "name": "java网络编程高级",  "originalPrice": 100,  "phone": "13333333",  "pic": "fdsf",  "price": 10,  "qq": "22333",  "st": "1-1-1",  "tags": "sdsdwe",  "teachmode": "20002",  "users": "初级人员",  "validDays": 365,  "wechat": "223344"}{content_host}}/content/course/40Content-Type: application/json{content_host}}/content/course/40Content-Type: application/json                                {content_host}}/content/teachplan/117/tree-nodes
p控制台输出的效果如下,测试成功(前后端联调测试没问题): /p p img height="200" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225S33B.png" width="115" /img height="200" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225S3F9.png" width="367" //p h42.33 (新增修改计划)定义 P49/h4 p点击“添加章”新增第一级课程计划。/p p点击“添加小节”向某个第一级课程计划下添加小节。/p p点击“章”、“节”的名称,可以修改名称、选择是否免费。/p p同一个接口接收新增和修改两个业务请求,以是否传递课程计划id 来判断是新增还是修改(传递了课程计划id说明当前是要修改该课程计划,否则是新增一个课程计划)。/p p在xuecheng-plus-content的xuecheng-plus-content-model下的dto中新增一个类,写入如下代码:/p pre @Data@ToStringpublic class SaveTeachplanDto { private Long id; private String pname; private Long parentid; private Integer grade; private String mediaType; private Long courseId; private Long coursePubId; private String isPreview;}/pre p 在xuecheng-plus-conten的xuecheng-plus-content-api下的content/api下的TeachplanController中写入如下代码:/p pre @ApiOperation("课程计划创建或修改")@PostMapping("/teachplan")public void saveTeachplan(@RequestBody SaveTeachplanDto teachplan){}/pre h42.34 (新增修改计划)开发 P50/h4 p在xuecheng-plus-content的xuecheng-plus-content-api的api的TeachplanController完善接口:/p pre @ApiOperation("课程计划创建或修改")@PostMapping("/teachplan")public void saveTeachplan(@RequestBody SaveTeachplanDto teachplan){ teachplanService.saveTeachplan(teachplan);}/pre p在xuecheng-plus-content的xuecheng-plus-content-service的service的TeachplanService下新增接口:/p pre //新增/修改/保存课程计划public void saveTeachplan(SaveTeachplanDto saveTeachplanDto);/pre p 在xuecheng-plus-content的xuecheng-plus-content-service的service的TeachplanServiceImpl下编写如下代码:/p pre @Overridepublic void saveTeachplan(SaveTeachplanDto saveTeachplanDto) { Long teachplanId = saveTeachplanDto.getId(); if(teachplanId==null){ //新增 Teachplan teachplan = new Teachplan(); BeanUtils.copyProperties(saveTeachplanDto,teachplan); //确定排序字段,找到它的同级节点个数,排序字段就是个数+1 Long parentId = saveTeachplanDto.getParentid(); Long courseId = saveTeachplanDto.getCourseId(); teachplan.setOrderby(getTeachplanCount(courseId, parentId)); teachplanMapper.insert(teachplan); }else{ //修改 Teachplan teachplan = teachplanMapper.selectById(teachplanId); //将参数复制到teachplan BeanUtils.copyProperties(saveTeachplanDto,teachplan); teachplanMapper.updateById(teachplan); }}/pre p前后端联调测试没问题, 添加小节没问题:/p pimg height="150" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225S42B.png" width="277" //p p存在一个BUG,添加章的时候不显示。/p h42.35 项目实战说明 P51/h4 p本节主要讲解了,如何下载gogs的windows版本,然后配置环境,方便分工协作开发接口。/p p删除课程计划。上移、下移排序。/p p师资管理,对老师表进行增删改查。/p p删除课程要把课程相关的基本信息、营销信息、课程计划、课程教师信息也删掉。/p h2三、媒资管理模块/h2 h43.1 媒资管理需求分析 P52/h4 p媒资管理是媒体资源管理,媒体资源主要包括视频、文章、图片、音频等。/p p图片会存储到分布式文件系统中。/p p视频上传会通过断点续传的方式上传。/p p到当前已经有了3个微服务,内容管理(管理课程),系统管理(数据字典),媒资管理。/p h43.2 为什么用网关 P53/h4 p网关是用来路由的,转发请求;可以实现权限控制、限流等功能;避免前端直接请求微服务,编程前端请求网关,网关来请求微服务。/p p网关从服务注册中心来拿到微服务实例的地址。/p h43.3 (nacos)服务发现中心 P54/h4 pSpring Cloud是一系列微服务技术栈,是一套规范。/p pSpring Cloud alibaba:nacos服务注册中心、配置中心。/p pnamespace是命名空间,用于区分环境,比如:开发环境、测试环境、生产环境。/p pgroup:用于区分项目。/p p下面是我登录nacos遇到的问题:/p p起初输入如下的命令,没能登录上nacos,显示的是拒绝访问:/p pre http://192.168.101.65:8848/nacos/#/login/pre p此时需要到虚拟机中,输入docker ps看看哪些服务启动了: /p pimg height="165" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225S4534.png" width="1200" //p p一定要确保先启动mysql再启动docker:/p pre docker stop mysqldocker stop nacosdocker start mysqldocker start nacos/pre p 如下图能够正常访问:/p p img height="100" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225S5X8.png" width="184" /img height="100" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225S5392.png" width="288" //p pimg height="150" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225S6322.png" width="277" //p p具体配置:/p p1.先要在xuecheng-plus-parent中添加如下依赖:/p pre dependency groupIdcom.alibaba.cloud/groupId spring-cloud-alibaba-dependencies/artifactId version${spring-cloud-alibaba.version}/version typepom/type scopeimport/scope/dependency/pre p2.然后要在xuecheng-plus-content-api的pom.xml中写入如下代码:/p pre dependency groupIdcom.alibaba.cloud/groupId spring-cloud-starter-alibaba-nacos-discovery/artifactId/dependency/pre p3.在api的bootstrap.yml配置文件中写入如下代码,配置nacos的地址:/p pre spring: application: name: content-api #服务名 cloud: nacos: server-addr: 192.168.101.65:8848 discovery: #服务注册相关配置 namespace: dev #命名空间 group: xuecheng-plus-project/pre p 记得刷新maven,然后启动contentApplication启动类,然后到nacos网页可以看到有服务成功注册入/p pimg height="100" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225SAV.png" width="339" //p h43.4 (nacos)配置api P55/h4 p搭建Nacos配置中心,目的是通过Nacos去管理项目的所有配置。更加方便,可以不用重启就去更改配置。/p pnacos定位一个具体配置文件的方式是:namespace、group、dataid。/p pdataid由三部分组成:应用名-环境名.配置文件格式/p p通过spring.profiles.active来指定环境名(开发环境,测试环境,生产环境):/p pimg height="60" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225SI38.png" width="207" //p p配置文件的名称如下:content-api-dev.yaml/p pimg height="150" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225SX47.png" width="311" //p p配置如下:/p pimg height="150" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225SY34.png" width="332" //p p可以把api的配置文件中的datasource给注释掉,记得要添加config的配置,把配置文件配置上,代码如下:/p pre cloud: nacos: server-addr: 192.168.101.65:8848 discovery: #服务注册相关配置 namespace: dev #命名空间 group: xuecheng-plus-project profiles: active: dev #环境名 config: #配置文件的相关信息 namespace: dev #命名空间 group: xuecheng-plus-project file-extension: yaml refresh-enabled: true extension-configs: - data-id: content-service-${spring.profiles.active}.yml group: xuecheng-plus-project refresh: true/pre p记得要在api的pom.xml文件中填写如下依赖,会从nacos定时拉取配置:/p pre dependency groupIdcom.alibaba.cloud/groupId spring-cloud-starter-alibaba-nacos-config/artifactId/dependency/pre p然后刷新maven,然后重启ContentApplication启动类,会出现如下场景:/p pimg height="100" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225T0950.png" width="295" //p p然后在swagger里面进行简单测试,看数据库能否连上,选择查询的接口,输入参数然后发送请求。/p h43.5 (nacos)配置service P56/h4 pservice的配置文件在test里面。/p papi原本不需要数据库配置连接,但因为api依赖了service模块,所以启动后service的代码依赖都会到api中。/p p所以现在的思路是:只在service中配置数据库依赖,然后api引用service的配置。/p p第1步:在service中进行相关配置/p p在service的pom.xml中加依赖,只需要加config依赖,不需要加discovery依赖:/p pre dependency groupIdcom.alibaba.cloud/groupId spring-cloud-starter-alibaba-nacos-config/artifactId/dependency/pre p在service的bootstrap.yml中修改配置文件(这里要注意2点,profiles这里不要写成profile,还有profiles是和cloud和application同级的):/p pre #微服务配置spring: application: name: content-service cloud: nacos: server-addr: 192.168.101.65:8848 config: #配置文件的相关信息 namespace: dev #命名空间 group: xuecheng-plus-project file-extension: yaml refresh-enabled: true profiles: active: dev/pre p在nacos中点+号,新建service的配置,把数据库的配置粘贴上即可:/p pimg height="150" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225T1195.png" width="321" //p p现在启动service的test下的CourseBaseMapperTests单元测试文件:/p pimg height="150" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225T15Z.png" width="276" //p p第2步:让api引用service的配置/p p注意这里要把api中的数据库连接屏蔽掉,只在service的nacos配置中留有数据库连接:/p pimg height="150" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225T2548.png" width="299" //p papi的bootstrap.yml配置文件中的代码如下:/p pre #微服务配置spring: application: name: content-api #服务名 cloud: nacos: server-addr: 192.168.101.65:8848 discovery: #服务注册相关配置 namespace: dev #命名空间 group: xuecheng-plus-project config: #配置文件的相关信息 namespace: dev #命名空间 group: xuecheng-plus-project file-extension: yaml refresh-enabled: true extension-configs: - data-id: content-service-${spring.profiles.active}.yaml group: xuecheng-plus-project refresh: true profiles: active: dev #环境名/pre p这里有几点易错的点:1.冒号后面都要空1格。2.config是在nacos的下一级,与server-addr和 discovery同级。3.profiles是在spring的下一级,与application和cloud同级。4.文件的后缀是yaml。5.${spring.profiles.active}注意这个花括号里都是点号分隔/p pimg height="150" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225T3257.png" width="275" /img height="150" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225T3316.png" width="279" //p p能出数据就没啥问题:/p pimg height="150" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225T43K.png" width="289" //p p因为swagger在所有的模块中都需要配置,所以我们想如何在nacos中配置项目的公用配置。/p pimg height="150" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225T4330.png" width="211" //p p配置内容填写如下内容:/p pre swagger: title: "学成在线项目接口文档" description: "学成在线项目接口文档" base-package: com.xuecheng enabled: true version: 1.0.0/pre pimg height="100" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225T4122.png" width="151" //p p微服务的完整配置如下,shared-configs必须要在extension-configs的平级位置,在config的次一级位置。/p pre #微服务配置spring: application: name: content-api#服务名 cloud: nacos: server-addr: 192.168.101.65:8848 discovery: #服务注册相关配置 namespace: dev #命名空间 group: xuecheng-plus-project config: #配置文件的相关信息 namespace: dev #命名空间 group: xuecheng-plus-project file-extension: yaml refresh-enabled: true extension-configs: - data-id: content-service-${spring.profiles.active}.yml group: xuecheng-plus-project refresh: true shared-configs: - data-id: swagger-${spring-profiles.active}.yaml group: xuecheng-plus-common refresh: true - data-id: logging-${spring-profiles.active}.yaml group: xuecheng-plus-common refresh: true profiles: active: dev #环境名/pre p这里有几点易错的点:1.冒号后面都要空1格。2.config是在nacos的下一级,与server-addr和 discovery同级。3.profiles是在spring的下一级,与application和cloud同级。4.文件的后缀是yaml。5.${spring.profiles.active}注意这个花括号里都是点号分隔 /p p测试:如果名称改变,控制台有DEBUG语句输出就没问题!/p pimg height="100" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225T52T.png" width="251" /img height="100" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225T5338.png" width="326" //p h43.6 (nacos)配置优先级 P57/h4 pimg height="100" src="https://m.xiaogongju.com.cn/uploads/allimg/20250422/-250422225T63I.png" width="336" //p p引入配置文件的形式有:/p p1、以项目应用名方式引入/p p2、以扩展配置文件方式引入/p p3、以共享配置文件 方式引入/p p4、本地配置文件/p p各配置文件 的优先级:项目应用名配置文件 扩展配置文件  共享配置文件 > 本地配置文件。

然后我们还要在nacos中加上本地优先。

现在启动contentApplication就没啥问题了。

3.7 (nacos)配置代码导入 P58

把下面的文件解压。

然后点击上传文件把这些配置都上传上来。

3.8 搭建网关 P59

按照如下的方式创建一个网关模块:

先把依赖全部先导入到pom.xml文件中:

把xuecheng-plus-system的配置文件全部拷贝到xuecheng-plus-gateway中:

启动类修改如下:

@SpringBootApplicationpublic class GatewayApplication {    public static void main(String[] args) {        SpringApplication.run(GatewayApplication.class,args);    }}

现在启动启动类,可以看到gateway网关已经申报到nacos:

测试的方法是把content_host改为gateway_host,然后发送请求,看看是否正常返回结果。

3.9 搭建媒资服务工程 P60

媒资服务工程的文件在day05的资料中,将xuecheng-plus-media.zip文件解压到当前文件夹,

把模块直接导入xuecheng-plus-project下面

启动完xuecheng-plus-media的xuecheng-plus-media-api下的启动类MediaApplication之后,然后可以访问下面的地址看看能不能访问接口文档:

http://localhost:63050/media/swagger-ui.html

3.10 分布式文件系统 P61

文件系统:方便对磁盘上的文件进行管理的软件系统。

因为一台计算机无法存储海量的文件,所以通过网络将若干计算机组织起来共同存储海量文件。

3.11 MinIO文件系统 P62

MinIO是一个轻量级的开源文件系统,可以存储大量的非结构化数据。去中心化的共享架构。

采用冗余存储,一个节点2块磁盘,8块磁盘组成一个集合。使用纠删码技术来保护数据,可以恢复丢失和损坏的数据。当上传一个文件会通过纠删码算法计算对文件进行分块存储,分成4个数据块,4个校验块,数据块和校验块会分散的存储在这8块硬盘上。

只要挂掉的节点不超过一半,就可以继续使用。

访问下面的地址:

192.168.101.65:9001/login

 

在xuecheng-plus-media的xuecheng-plus-media-service下的pom.xml中写入如下代码:

    io.minio    minio    8.4.3    com.squareup.okhttp3    okhttp    4.8.1

创建一个testbucket,记得把访问方式改为公有

在xuecheng-plus-media的xuecheng-plus-media-servicesrc下创建test包,然后在test下创建com.xuecheng.media包,然后在media包下面创建MinioTest类,然后写入如下的代码:

//测试minio的sdkpublic class MinioTest {    MinioClient minioClient =            MinioClient.builder()                    .endpoint("http://192.168.101.65:9000")                    .credentials("minioadmin","minioadmin")                    .build();    @Test    public void test_upload() throws Exception{        //上传文件的参数信息        UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()                .bucket("testbucket") //桶                .filename("C:\xuechengzaixian\1.mp4")//指定本地文件路径                .object("1.mp4")                .build();//对象名        //上传文件        minioClient.uploadObject(uploadObjectArgs);    }}

点击运行之后,可以看到1.mp4文件已经成功上传。

可以通过设置多层目录,来把文件存放在目录中。 

.object("test/01/1.mp4")

删除文件的代码如下:

@Testpublic void test_delete() throws Exception{    RemoveObjectArgs removeObjectArgs = RemoveObjectArgs.builder()            .bucket("testbucket")            .object("1.mp4")            .build();    //上传文件    minioClient.removeObject(removeObjectArgs);}

下载文件,从minio中下载文件到本地的某一目录:

//查询文件,从minio中下载    @Test    public void test_getFile() throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {        GetObjectArgs getObjectArgs = GetObjectArgs.builder().bucket("testbucket").object("test/01/1.mp4").build();        FilterInputStream inputStream = minioClient.getObject(getObjectArgs);        //指定输出流        FileOutputStream outputStream = new FileOutputStream(new File("C:\xuechengzaixian\1.mp4"));        IOUtils.copy(inputStream,outputStream);        //校验文件的完整性对文件内容进行md5        String source_md5 = DigestUtils.md5Hex(inputStream); //minio中文件的md5        String local_md5 = DigestUtils.md5Hex(new FileInputStream(new File("C:\code\result.mp4")));        if(source_md5.equals(local_md5)){            System.out.println("下载成功");        }    }

可以用md5来校验文件下载是否完整,有无缺损或者被破坏。

现在需要比较的是本地流和输出流文件的区别。 

 3.12 (上传图片)需求分析 P63

流程图如下:

如何判断文件已经上传,是通过md5值比较,文件上传的主体是机构,

3.13 (上传图片)接口定义 P64

首先要把mediafiles和video的状态设置为公开。

然后要在media-service-dev.yaml中把配置的信息填上去。

minio:  endpoint: http://192.168.101.65:9000  accessKey: minioadmin  secretKey: minioadmin  bucket:    files: mediafiles    videofiles: video

在xuecheng-plus-media的xuecheng-plus-media-service的config包下新建一个叫MinioConfig的类

import io.minio.MinioClient;import org.springframework.beans.factory.annotation.Value;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;@Configurationpublic 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 build = MinioClient.builder()                .endpoint(endpoint)                .credentials(accessKey, secretKey)                .build();        return build;    }}

在xuecheng-plus-media-model下的dto的UploadFileResultDto中写入如下代码:

@Data@ToStringpublic class UploadFileResultDto extends MediaFiles{}

 定义接口在xuecheng-plus-media-api下的api的MediaFilesController中写入如下的代码:

@ApiOperation("上传图片")@RequestMapping(value = "/upload/coursefile",consumes= MediaType.MULTIPART_FORM_DATA_VALUE)public UploadFileResultDto upload(@RequestPart("filedata")MultipartFile filedata){ //调用service上传图片 return null;}

3.14 (上传图片)上传文件 P65

在xuecheng-plus-media-model的dto下面创建UploadFileParamsDto,写入如下代码:

在xuecheng-plus-media-service的service的MediaFileService类中,写入如下代码:

//本地文件的路径public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath) throws Exception;

 在xuecheng-plus-media-service的service/impl的MediaFileServiceImpl类中,写入的完整代码见下一节:

3.15 (上传图片)信息入库 P66

xuecheng-plus-media的xuecheng-plus-media-api的MediaFilesController的上传图片功能的完整代码:

@ApiOperation("上传图片")     @RequestMapping(value = "/upload/coursefile",consumes= MediaType.MULTIPART_FORM_DATA_VALUE)     public UploadFileResultDto upload(@RequestPart("filedata")MultipartFile filedata) throws Exception {         //准备上传文件的信息         UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();         //原始文件名称         uploadFileParamsDto.setFilename(filedata.getOriginalFilename());//原始文件名称         //文件大小         uploadFileParamsDto.setFileSize(filedata.getSize());         //文件类型         uploadFileParamsDto.setFileType("001001");         //接收到文件         //创建一个临时文件         File tempFile = File.createTempFile("minio", ".temp");         filedata.transferTo(tempFile);         Long companyId=1232141425L;         //文件路径         String localFilePath = tempFile.getAbsolutePath();         //调用service上传图片         UploadFileResultDto uploadFileResultDto = mediaFileService.uploadFile(companyId, uploadFileParamsDto, localFilePath);         return uploadFileResultDto;     }

xuecheng-plus-media的xuecheng-plus-media-service的service下的MediaFileService的上传图片功能的完整代码:

//本地文件的路径public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath) throws Exception;public MediaFiles addMediaFilesToDb(Long companyId,String fileMd5,UploadFileParamsDto uploadFileParamsDto,String bucket,String objectName);

xuecheng-plus-media的xuecheng-plus-media-service的service的impl下的MediaFileServiceImpl的上传图片功能的完整代码: 

//根据扩展名来获取mimeTypeprivate String getMimeType(String extension){if(extension==null){   extension="";}//根据扩展名取出mimeTypeContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//通用mimeType,字节流if(extensionMatch != null){ mimeType = extensionMatch.getMimeType();}return mimeType;}@AutowiredMinioClient minioClient;//存储普通文件@Value("${minio.bucket.files}")private String bucket_mediafiles;//存储视频@Value("${minio.bucket.videofiles}")private String bucket_video;/** 将文件上传到minio * @param localFilePath 文件本地路径 * @param mimeType 媒体类型 * @param bucket 桶 * @param objectName 对象名 * @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; }}//获取文件默认存储目录路径 年/月/日private String getDefaultFolderPath(){SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");String folder = sdf.format(new Date()).replace("-","/")+"/";return folder;}private String getFileMd5(File file) throws Exception { try(FileInputStream fileInputStream = new FileInputStream(file)){     String fileMd5 = DigestUtils.md5Hex(fileInputStream);     return fileMd5; }catch (Exception e){     e.printStackTrace();     return null; }}@Transactional@Overridepublic UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath) throws Exception { //文件名 String filename = uploadFileParamsDto.getFilename(); //先得到扩展名 String extension = filename.substring(filename.lastIndexOf(".")); //将文件上传到minio String mimeType = getMimeType(extension); //子目录 String defaultFolderPath = getDefaultFolderPath(); //文件的md5值 String fileMd5 = getFileMd5(new File(localFilePath)); String objectName = defaultFolderPath+fileMd5+extension; //上传文件到minio boolean result = addMediaFilesToMinIO(localFilePath,mimeType,bucket_mediafiles,objectName); if(!result){     XueChengPlusException.cast("上传文件失败"); } //上传文件信息 MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_mediafiles, objectName); if(mediaFiles==null){    XueChengPlusException.cast("文件上传后保存信息失败"); } //准备返回的对象UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();BeanUtils.copyProperties(mediaFiles,uploadFileResultDto);return uploadFileResultDto;}@AutowiredMediaFileService currentProxy;@Transactionalpublic MediaFiles addMediaFilesToDb(Long companyId,String fileMd5,UploadFileParamsDto uploadFileParamsDto,String bucket,String objectName){MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);if(mediaFiles == null){    mediaFiles = new MediaFiles();    BeanUtils.copyProperties(uploadFileParamsDto,mediaFiles);    //文件id    mediaFiles.setId(fileMd5);    //机构id    mediaFiles.setCompanyId(companyId);    //桶    mediaFiles.setBucket(bucket);    //file_path    mediaFiles.setFilePath(objectName);    //file_id    mediaFiles.setFileId(fileMd5);    //url    mediaFiles.setUrl("/"+bucket+"/"+objectName);    //上传时间    mediaFiles.setCreateDate(LocalDateTime.now());    //状态    mediaFiles.setStatus("1");    //审核状态    mediaFiles.setAuditStatus("002003");    //插入数据库    int insert = mediaFilesMapper.insert(mediaFiles);    if(insert{media_host}}/media/upload/coursefileContent-Type: multipart/form-data; boundary=WebAppBoundary--WebAppBoundaryContent-Disposition: form-data; name="filedata"; filename="2.jpg"Content-Type: application/octet-stream uploadChunk() -> testMerge() 

1.首先将本地的视频分块。因为minio中要求视频的每个分块要大于5MB,所以大小设定如下:chunkSize = 1024 * 1024 * 5,分块的代码写在xuecheng-plus-media-service的test的BigFileTest代码如下:

//分块测试@Testpublic void testChunk() throws IOException {    //源文件    File sourceFile = new File("C:\xuechengzaixian\1.mp4");    //分块文件存储路径    String chunkFilePath="C:\code\result\";    //分块文件大小    int chunkSize = 1024 * 1024 * 5;    //分块文件的个数    int chunkNum = (int)Math.ceil(sourceFile.length()*1.0 / chunkSize);    //使用流从源文件读数据,向分块文件中写数据    RandomAccessFile raf_r = new RandomAccessFile(sourceFile, "r");    //缓存区    byte[] bytes = new byte[1024];    for(int i=0;i=chunkSize){                break;            }        }        raf_rw.close();    }    raf_r.close();}

2.然后将分块文件上传到minio,模拟的是“上传”。上传minio的代码写在xuecheng-plus-media-service的test的MinioTest中代码如下:

//将分块文件上传到minio@Testpublic void uploadChunk() throws Exception {    for (int i = 0; i  

3.然后将minIo中分块的文件合并到minio。代码写在xuecheng-plus-media-service的test的MinioTest中如下:

//调用minio接口合并分块@Testpublic void testMerge() throws Exception{    /*List sources = null;    for(int i=0;i++i).limit(13).map(i->ComposeSource.builder().bucket("testbucket").object("chunk/"+i).build()).collect(Collectors.toList());    //指定合并后的objectName等信息    ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()            .bucket("testbucket")            .object("merge01.mp4")            .sources(sources)//指定源文件            .build();    //合并文件    //报错size 1048576 must be greater than 5242880 minio 默认的分块文件大小为5M    minioClient.composeObject(composeObjectArgs);}

3.21 (上传视频)上传分块 P72

md5值的前2位作为文件目录。

在xuecheng-plus-media的xuecheng-plus-media-api下的BigFilesController中完善如下的代码:

@ApiOperation(value = "合并文件")@PostMapping("/upload/mergechunks")public RestResponse mergechunks(@RequestParam("fileMd5") String fileMd5,                                @RequestParam("fileName") String fileName,                                @RequestParam("chunkTotal") int chunkTotal) throws Exception {    Long companyId = 1232141425L;    //文件信息对象    UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();    uploadFileParamsDto.setFilename(fileName);    uploadFileParamsDto.setTags("视频文件");    uploadFileParamsDto.setFileType("001002");    RestResponse restResponse = mediaFileService.mergechunks(1232141425L, fileMd5, chunkTotal, uploadFileParamsDto);    return restResponse;}

在xuecheng-plus-media的xuecheng-plus-media-service下的MediaFileService中写入如下1个接口:

public RestResponse mergechunks(Long companyId,String fileMd5,int chunkTotal,UploadFileParamsDto uploadFileParamsDto);

在xuecheng-plus-media的xuecheng-plus-media-service下的MediaFileService中写入如下1个接口的具体实现:

@Overridepublic RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {    //找到分块文件调用minio的sdk进行文件合并    //分块文件所在目录    String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);    //找到所有的分块文件调用minio的sdk进行文件合并    List sources = Stream.iterate(0, i->++i)            .limit(chunkTotal)            .map(i->ComposeSource.builder()                    .bucket(bucket_video)                    .object(chunkFileFolderPath+i)                    .build())            .collect(Collectors.toList());    //源文件名称    String filename = uploadFileParamsDto.getFilename();    //扩展名    String extension = filename.substring(filename.lastIndexOf("."));    //合并后文件的objectName    String objectName = getFilePathByMd5(fileMd5, extension);    //指定合并后的objectName等信息    ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()            .bucket(bucket_video)            .object(objectName) //最终合并后的文件的objectName            .sources(sources)//指定源文件            .build();    //==============合并文件==================    //报错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,"合并文件异常");    }    //===========校验合并后的和源文件是否一致,视频上传才成功============    File file = downloadFileFromMinIO(bucket_video, objectName);    try(FileInputStream fileInputStream = new FileInputStream(file)){        //计算合并后文件的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,"文件校验失败");    }    //============将文件信息入库============    MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_video, objectName);    if(mediaFiles == null){        return RestResponse.validfail(false,"文件入库失败");    }    //=============清理分块文件============    clearChunkFiles(chunkFileFolderPath,chunkTotal);    return RestResponse.success(true);}

下面是上面接口实现方法中用到的支持方法:

//得到分块文件的目录private String getChunkFileFolderPath(String fileMd5) {    return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + "chunk" + "/";}//从minio下载文件public File downloadFileFromMinIO(String bucket,String objectName){    //临时文件    File minioFile = null;    FileOutputStream outputStream = null;    try{        InputStream stream = minioClient.getObject(GetObjectArgs.builder()                .bucket(bucket)                .object(objectName)                .build());        //创建临时文件        minioFile=File.createTempFile("minio", ".merge");        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;}@Transactionalpublic MediaFiles addMediaFilesToDb(Long companyId,String fileMd5,UploadFileParamsDto uploadFileParamsDto,String bucket,String objectName){MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);if(mediaFiles == null){    mediaFiles = new MediaFiles();    BeanUtils.copyProperties(uploadFileParamsDto,mediaFiles);    //文件id    mediaFiles.setId(fileMd5);    //机构id    mediaFiles.setCompanyId(companyId);    //桶    mediaFiles.setBucket(bucket);    //file_path    mediaFiles.setFilePath(objectName);    //file_id    mediaFiles.setFileId(fileMd5);    //url    mediaFiles.setUrl("/"+bucket+"/"+objectName);    //上传时间    mediaFiles.setCreateDate(LocalDateTime.now());    //状态    mediaFiles.setStatus("1");    //审核状态    mediaFiles.setAuditStatus("002003");    //插入数据库    int insert = mediaFilesMapper.insert(mediaFiles);    if(insert++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 results = minioClient.removeObjects(removeObjectArgs);    //真正删除加下列语句    results.forEach(f->{        try{            DeleteError deleteError = f.get();        }catch (Exception e){            e.printStackTrace();        }    });}

完整代码可以在gitee上下载,连接如下:学成在线项目代码(CSDN吾浴西风)

3.22 (上传视频)合并分块 P73

此节视频的代码是在上节代码的基础上进一步完善!

启动下面4个项目:

 在xuecheng-plus-media的xuecheng-plus-media-api下的BigFilesController中写入如下的代码:

@Api(value="大文件上传接口",tags="大文件上传接口")@RestControllerpublic class BigFilesController {    @Autowired    private MediaFileService mediaFileService;    @ApiOperation(value = "文件上传前检查文件")    @PostMapping("/upload/checkfile")    public RestResponse checkfile(            @RequestParam("fileMd5") String fileMd5    ) throws Exception {        RestResponse booleanRestResponse = mediaFileService.checkFile(fileMd5);        return booleanRestResponse;    }    @ApiOperation(value = "分块文件上传前的检测")    @PostMapping("/upload/checkchunk")    public RestResponse checkchunk(@RequestParam("fileMd5") String fileMd5,                                            @RequestParam("chunk") int chunk) throws Exception {        RestResponse booleanRestResponse = mediaFileService.checkChunk(fileMd5, chunk);        return booleanRestResponse;    }    @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();        RestResponse restResponse = mediaFileService.uploadChunk(fileMd5, chunk, localFilePath);        return restResponse;    }    @ApiOperation(value = "合并文件")    @PostMapping("/upload/mergechunks")    public RestResponse mergechunks(@RequestParam("fileMd5") String fileMd5,                                    @RequestParam("fileName") String fileName,                                    @RequestParam("chunkTotal") int chunkTotal) throws Exception {        return null;    }}

在xuecheng-plus-media的xuecheng-plus-media-service下的MediaFileService中写入如下3个接口:

//检查文件是否存在public RestResponse checkFile(String fileMd5);//检查分块是否存在public RestResponse checkChunk(String fileMd5, int chunkIndex);//上传分块public RestResponse uploadChunk(String fileMd5,int chunk,String localChunkFilePath);

在xuecheng-plus-media的xuecheng-plus-media-service下的MediaFileService中写入如下3个接口的具体实现:

@Overridepublic RestResponse checkFile(String fileMd5) {    //先查询数据库    MediaFiles mediaFiles = mediaFilesMapper.selectById(fileMd5);    //如果数据库存在再查询minio    if(mediaFiles != null){        //桶        String bucket = mediaFiles.getBucket();        //objectname        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);}@Overridepublic RestResponse checkChunk(String fileMd5, int chunkIndex) {    //分块存储路径是:md5前2位为2个目录,chunk分块文件    //根据md5得到分块文件的路径    String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);    //如果数据库存在再查询minio    GetObjectArgs getObjectArgs = GetObjectArgs.builder()            .bucket(bucket_video)            .object(chunkFileFolderPath+chunkIndex)            .build();    //查询远程服务获取到一个流对象    try{        FilterInputStream inputStream = minioClient.getObject(getObjectArgs);        if(inputStream!=null){            //文件已存在            return RestResponse.success(true);        }    } catch (Exception e) {        e.printStackTrace();    }    return RestResponse.success(false);}//得到分块文件的目录private String getChunkFileFolderPath(String fileMd5){    return fileMd5.substring(0,1)+"/"+fileMd5.substring(1,2);}@Overridepublic RestResponse uploadChunk(String fileMd5, int chunk, String localChunkFilePath) {    //分块文件的路径    String chunkFilePath = getChunkFileFolderPath(fileMd5)+chunk;    //获取mimeType    String mimeType = getMimeType(null);    //将分支文件上传到minio    boolean b = addMediaFilesToMinIO(localChunkFilePath,mimeType,bucket_video,chunkFilePath);    if(!b){        return RestResponse.validfail(false,"上传分块文件失败");    }    return RestResponse.success(true);}

测试效果如下: 

 传入一个46M的文件,进度条显示成功。

然后在minio中也可以看到文件如下:

3.23 (上传视频)合并测试 P74

上传视频功能的具体代码见上节。

第1步:我的建议是现在下面2处打上断点,第1处断点重点看objectName和sources(我第1次就是sources和objectName内部写错了,排查了很久),第2处断点看是否会抛出错误被捕获。

执行完minioClient.composeObject之后文件就上传成功了,建议先去minio中预览看看合并后的视频有无问题。

第2步:在“校验合并后和源文件是否一致”下一句打上断点,主要看比较原始视频md5值和合并后视频的md5值会不会报错。

第3步:在“将文件信息入库”的下一句打上断点(运行到该断点处md5校验通过)。现在看数据库的media_files表,看看是否出现视频名称,或者和当天日期相等的数据项记录。

第4步:在“清除分块文件”的下一句打上断点,运行过后看看分块文件会不会被删除(只留下合并后的文件)。

最后测试断点续传,上传一个比较大的文件(至少20M以上),传到一半的时候刷新网页,然后进minio对应文件夹看看上传了几块文件。然后此时重新打开网页,重新选择刚才文件,看是否是断点续传只需要看浏览器的日志。发现前面已经传过的文件只需要日志校验。

3.24 什么情况事务失效 P75

非事务方法去调用事务方法,并不是以代理对象调用。

事务成功的情况:要以代理对象的方式调用,要添加@Transactional注解。

依赖注入,把service注入到实现类的成员变量中,通过代理对象来调用事务方法。

Spring控制事务是基于AOP环绕通知的方式,如果事务方法内抛出异常,这个异常会被抛给代理对象,代理对象会对事务进行回滚。

什么情况Spring事务会失效:1.在方法中捕获异常没有抛出。2.非事务方法调用事务方法。3.事务方法内部调用事务方法。4.@Transactional注解标记的方法不是public。5.抛出的异常与rollbackFor指定的异常不匹配,默认rollbackFor指定的异常为RuntimeException。6.数据库不支持事务,比如MySQL的MyISAM。

3.25 断点续传怎么实现 P76

前端对文件分块。前端上传每一块文件之前都会向服务端确认该块文件是否已经上传。前端把文件上传到服务端,服务端把文件保存到minio中。当所有分块上传完毕,服务端就合并所有分块。前端发送一个md5值,后端也计算一个md5值,比较如果相等代表文件没有损坏,然后服务端就会把合并后的文件上传到分布式文件系统minio中。

清理:文件表记录了文件的信息,给文件记录一个状态(上传中),根据上传时间(每24小时扫描),上传中没有完成上传的文件,清理掉这些文件在分布式文件系统中的位置,清空这些分块。

3.26 以下是视频处理小专题

3.27 视频转码需求 P77

.mp4 .avi .rmvb不同的扩展名是视频文件的文件格式。

MPEG H.26X系列是编码格式。

看到的视频是视频编码和音频编码的合成,最常见的是视频H.264,音频AAG。

FFmpeg对视频进行编码,写java程序来调用FFmpeg工具来对视频进行转码。

该工具是在day01的资料中。

ffmpeg.exe -i 初始文件名.后缀名 目标文件名.后缀名

把工具类直接拷贝到xuecheng-plus-base下:

测试一下发现可以用命令行的方式来打开文件应用:

比如下Mp4VideoUtil的main中写入如下代码:

ProcessBuilder builder = new ProcessBuilder();builder.command("C:\xuechengzaixian\学成在线项目—资料\day01 项目介绍&环境搭建\资料\常用软件工具\JSON编辑器JSONedit\JSONedit.exe");//将标准输入流和错误输入流合并,通过标准输入流程读取信息builder.redirectErrorStream(true);Process p = builder.start();

尝试通过java调用FFmpeg来转换视频格式:

 

3.28 分布式任务调度 P78

任务调度:对任务的调度,指系统为了完成特定业务,基于给定时间点,给定时间间隔或者给定执行次数自动执行任务。

分布式任务调度:目的是为了提高任务调度的效率。

对一个视频的转码可以理解为一个任务的执行,如果视频的数量比较多,如何去高效处理一批任务。

多线程:是充分利用单机的资源。

分布式加多线程:利用多台计算机,每台计算机使用多线程处理。

分布式调度要实现的目标:

1.并行任务调度。2.高可用。3.弹性扩容。

3.29 xxl-job配置执行器 P79

xxl-job是一个轻量级的分布式任务调度平台。

由1个调度中心(按照调度配置发出调度请求)来控制多个执行器(执行器管理、任务管理、监控运维、日志管理)。

把xxl-job-2.3.1.zip解压一下

如果想本地连接的话,需要更改配置文件,数据库

可以直接登录到xxl-job

192.168.101.65:8088/xxl-job-admin

账号:admin

密码:123456 

在网页端的任务调度中心的执行器管理中创建执行器:

在xuecheng-plus-media-service的pom.xml中写入如下依赖:

    com.xuxueli    xxl-job-core

 在nacos中打开media-service-dev.yaml配置文件,把appname替换为在xxl-job中配置的:

XxlJobConfig在如下的目录下:

学成在线项目—资料\day06 断点续传 xxl-job\资料\xxl-job-2.3.1\xxl-job-executor-samples\xxl-job-executor-sample-springboot\src\main\java\com\xxl\job\executor\core\config

把XxlJobConfig复制到xuecheng-plus-media-service的config下:

3.30 xxl-job执行任务测试 P80

SampleXxlJob如下:

学成在线项目—资料\day06 断点续传 xxl-job\资料\xxl-job-2.3.1\xxl-job-executor-samples\xxl-job-executor-sample-springboot\src\main\java\com\xxl\job\executor\service\jobhandler

在xuecheng-plus-media-service的service下创建jobhandler,然后把上面SampleXxlJob类复制到jobhandler下面:

@XxlJob注解里面的就是任务名称。

调度类型:固定速度指的是固定多少时间调度,CRON更加灵活年月日时分秒(可以设定每天的多少分多少秒执行)。

在JobHandler中填写@XxlJob注解中的任务名称。

路由策略会在集群配置中使用到。

然后要在任务管理选择媒资服务测试执行器,记得要把任务启动起来:

启动之后启动xuecheng-plus-media的启动类,然后控制台会每隔5秒输出处理视频......

3.31 xxl-job高级配置参数 P81

多启动一个线程,比如多启动一个MediaApplication:63051。

一致性HASH,计算出某个哈希值就调度谁。

分片广播可以在任务调度的时候把工作同时分发给多个任务。

调度过期策略:忽略,如果任务过期了就直接忽略。立即执行一次,任务过期但请求到了执行一次。

阻塞处理策略:单机串行,任务排队执行(在FIFO队列中以串行的方式运行)。丢弃后续调度,后面的任务丢弃(如果执行器中存在运行的调度任务,本次请求将会被丢弃并标记为失败)。覆盖之前调度,前面干的活丢弃,干后面的活(如果执行器存在运行中的调度任务,会终止运行中的调度任务并清空队列,然后运行本地调度任务)。

3.32 xxl-job分片广播 P82

首先要在nacos的media-service-dev.yaml中配置本地优先:

spring:  cloud:    config:      override-none: true

配置如下

-Dserver.port=63051 -Dxxl.job.executor.port=9998

 需要等到testHandler一栏的OnLine机器地址为2时才可以继续。

配置如下,记得路由策略要选择分片广播。

启动分片任务测试:

可以看到控制台不同的启动类下输出的控制台数据不同。

动态扩容,如果新加1个,shardTotal会为3。

3.33 技术方案 P83

如何保证多个执行器不会查询到重复的任务?——每个待处理文件都有一个编号记录,让每个文件的编号余2,为1交给一个执行器处理,为0交给另一个处理器处理。

幂等性:对数据的操作不论多少次,操作的结果始终是一致的。

方法1:数据库约束,如:唯一索引,主键。

方法2:乐观锁,常用于数据库,更新数据时根据乐观锁状态去更新。

方法3:唯一序列号,操作传递一个唯一序列号,操作是判断与该序列号相等则执行。

业务流程:需要从左往右看。

1.首先会通过网关上传视频,视频会被上传到媒资服务器(IDEA中运行),此时媒资服务器会向数据库(待处理的任务表中)写入视频的信息。

2.媒资服务会通过网关把视频上传保存到minio-server(minio)。

3.右边的xxl-job会给微服务的执行器下发任务,文件处理服务会从数据库中取出任务编号,然后除余处理器的数量,得到的结果就是哪台处理器来处理这个数据。处理的时候该台处理器会从minio下载数据到处理器,然后对视频进行转码;处理结束之后会把结果上传到minio,然后记录结果到数据库。

 3.34 查询待处理任务 P84

待处理任务表:

如果文件上传完成,会把该文件的记录删除,然后把记录移动到历史记录表(与待处理任务表结构一致)。

如果视频上传成功,待处理任务表的数据就要添加成功。需要使用事务控制。

所以需要在addMediaFilesToDb这个方法里面写。

在xuecheng-plus-media-service的service的impl下的MediaFileServiceImpl中的addMediaFilesToDb方法的如下位置添加addWaitingTask(mediaFiles);这段代码:

在xuecheng-plus-media-service的service的impl下的MediaFileServiceImpl中写入如下代码:

@AutowiredMediaProcessMapper mediaProcessMapper;private void addWaitingTask(MediaFiles mediaFiles){    //获取文件的mimeType    //文件名称    String filename = mediaFiles.getFilename();    //文件扩展名 mimeType    String extension = filename.substring(filename.lastIndexOf("."));    String mimeType = getMimeType(extension);    if(mimeType.equals("video/x-msvideo")){//如果是avi视频写入待处理任务        MediaProcess mediaProcess = new MediaProcess();        BeanUtils.copyProperties(mediaFiles,mediaProcess);        //状态是未处理        mediaProcess.setStatus("1");        mediaProcess.setCreateDate(LocalDateTime.now());        mediaProcess.setFailCount(0); //失败次数默认0        mediaProcess.setUrl(null);        mediaProcessMapper.insert(mediaProcess);    }    //通过minmeTyoe判断}

在xuecheng-plus-media-service的service下创建MediaFileProcessService接口,写入如下代码:

//任务处理public interface MediaFileProcessService {    public List getMediaProcessList(int shardIndex,int shardTotal,int count);}

在xuecheng-plus-media-service的service的impl下创建MediaFileProcessServiceImpl,写入如下代码:

@Slf4j@Servicepublic class MediaFileProcessServiceImpl implements MediaFileProcessService {    @Autowired    MediaProcessMapper mediaProcessMapper;    @Override    public List getMediaProcessList(int shardIndex, int shardTotal, int count) {        List mediaProcesses = mediaProcessMapper.selectListByShardIndex(shardTotal, shardIndex, count);        return mediaProcesses;    }}

在xuecheng-plus-media-service的mapper下的MediaProcessMapper中,写入如下代码:

public interface MediaProcessMapper extends BaseMapper {    @Select("select * from media_process t where t.id % #{shardTotal} = #{shardIndex} and (t.status=1 or t.status=3) and t.fail_count  

代码如下:

进行前后端测试,上传4个avi视频,观察待处理任务表是否存在记录,记录是否完成。千万要注意这里要传avi视频才会进入待处理列表哦哦!

这里要注意,在数据库里没有fail_count这个字段,所以我们要在数据库设计表新增这个字段。还有id字段记得要设置成自动递增。最后一定要记得点击保存!!!!

然后media_process_history同样这样的操作。然后尝试插入数据,成功。

3.35 分布式锁开启任务 P85

存在问题:当执行器弹性扩容时无法绝对避免任务不重复执行,比如:原来有4个执行器正在执行任务,由于网络问题,原有的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创建一个子目录(节点)只会有一个创建成功,利用此特点可以实现分布式锁,谁创建该节点成功谁就获得锁(zookeeper是一个类似目录结构的,多个进程来创建文件,只会有一个进程成功)。

本次是基于数据库实现分布式锁。

update media_process t set t.status=4 where id=2 and (t.status=1 or t.status=3) and t.fail_count

上一篇:文件下载失败原因解析及解决方案

下一篇:宝马进口650i敞篷中控主机怎么拆视频