史上最详细《学成在线》项目实操笔记系列【上】,跟视频的每一P对应,全系列12万字,涵盖详细步骤与问题的解决方案。如果你操作到某一步卡壳,参考这篇,相信会带给你极大启发。
一、前期准备
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文件中进行配置:
<mirror>
<id>alimaven</id>
<name>aliyun maven</name>
<url>http://maven.aliyun.com/nexus/content/groups/public</url>
<blocked>central</blocked>
</mirror>
在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模块的<parent>标签下
然后把下面文件中base模块pom.xml中的<dependencies>标签下的内容单独复制,粘贴替换base模块的pom.xml中的<dependencies>下的内容。
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 docker
sh /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文件:
<?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>
<parent>
<artifactId>xuecheng-plus-content</artifactId>
<groupId>com.xuecheng</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>xuecheng-plus-content-model</artifactId>
<dependencies>
<dependency>
<groupId>com.xuecheng</groupId>
<artifactId>xuecheng-plus-base</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
xuecheng-plus-content-service的pom.xml文件:
<?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>
<parent>
<artifactId>xuecheng-plus-content</artifactId>
<groupId>com.xuecheng</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>xuecheng-plus-content-service</artifactId>
<dependencies>
<dependency>
<groupId>com.xuecheng</groupId>
<artifactId>xuecheng-plus-content-model</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
xuecheng-plus-content-api的pom.xml文件:
<?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>
<parent>
<artifactId>xuecheng-plus-content</artifactId>
<groupId>com.xuecheng</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>xuecheng-plus-content-api</artifactId>
<dependencies>
<dependency>
<groupId>com.xuecheng</groupId>
<artifactId>xuecheng-plus-content-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
二、课程模块开发
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文件中:
<!--存在mybatisplus注解添加相关注解保证不报错-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-annotation</artifactId>
<version>${mybatis-plus-boot-starter.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-core</artifactId>
<version>${mybatis-plus-boot-starter.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
现在就不会报错了:
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的基础环境包有点问题,然后我修改了<parent>里面的代码:
第5步:在xuecheng-plus-content-api的com/xuecheng下面创建content/api,然后创建一个CourseBaseInfoController类:
@RestController
public class CourseBaseInfoController {
@RequestMapping("/course/list")
public PageResult<CourseBase> list(PageParams pageParams,@RequestBody QueryCourseParamsDto queryCourseParamsDto){
return null;
}
}
第6步:在xuecheng-plus-content-api的com/xuecheng下面创建ContentApplication启动类,注意这个类一定是要在xucheng下面(不要放到content下)。
@SpringBootApplication
public 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="课程信息管理接口")
@RestController
public class CourseBaseInfoController {
@ApiOperation("课程查询接口")
@PostMapping("/course/list")
public PageResult<CourseBase> 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代码如下:
@SpringBootTest
public 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<CourseBase> 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<CourseBase> page = new Page<>(pageParams.getPageNo(), pageParams.getPageSize());
//开始进行分页查询
Page<CourseBase> pageResult = courseBaseMapper.selectPage(page, queryWrapper);
//数据列表
List<CourseBase> items = pageResult.getRecords();
//总记录数
long total = pageResult.getTotal();
//List<T> items,long counts,long page,long pageSize
PageResult<CourseBase> courseBasePageResult = new PageResult<CourseBase>(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<CourseBase> queryCourseBaseList(PageParams pageParams, QueryCourseParamsDto queryCourseParamsDto);
}
再在service包下创建impl包,在impl包下创建实现类CourseBaseInfoServiceImpl,写入如下代码(注意一定要把return null改过了):
@Slf4j
@Service
public class CourseBaseInfoServiceImpl implements CourseBaseInfoService {
@Autowired
CourseBaseMapper courseBaseMapper;
@Override
public PageResult<CourseBase> queryCourseBaseList(PageParams pageParams, QueryCourseParamsDto courseParamsDto) {
//拼装查询条件
LambdaQueryWrapper<CourseBase> 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<CourseBase> page = new Page<>(pageParams.getPageNo(), pageParams.getPageSize());
//开始进行分页查询
Page<CourseBase> pageResult = courseBaseMapper.selectPage(page, queryWrapper);
//数据列表
List<CourseBase> items = pageResult.getRecords();
//总记录数
long total = pageResult.getTotal();
//List<T> items,long counts,long page,long pageSize
PageResult<CourseBase> courseBasePageResult = new PageResult<CourseBase>(items,total,pageParams.getPageNo(),pageParams.getPageSize());
return courseBasePageResult;
}
}
然后要进行单元测试,复制service模块test下原有的CourseBaseMapperTests粘贴到自己的路径下,然后改名为CourseBaseInfoServiceTests,写入如下代码:
@SpringBootTest
public 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<CourseBase> courseBasePageResult = courseBaseInfoService.queryCourseBaseList(pageParams, courseParamsDto);
System.out.println(courseBasePageResult);
}
}
看一下控制台输出没太大问题:
2.11 (课程查询)接口测试 P26
完善xuecheng-plus-content-api下的CourseBaseInfoController的代码:
@Api(value="课程信息管理接口",tags="课程信息管理接口")
@RestController
public class CourseBaseInfoController {
@Autowired
CourseBaseInfoService courseBaseInfoService;
@ApiOperation("课程查询接口")
@PostMapping("/course/list")
public PageResult<CourseBase> list(PageParams pageParams,@RequestBody(required=false) QueryCourseParamsDto queryCourseParamsDto){
PageResult<CourseBase> courseBasePageResult = courseBaseInfoService.queryCourseBaseList(pageParams, queryCourseParamsDto);
return courseBasePageResult;
}
}
用swagger来测试没有问题:
点击请求这里的地球,然后点击如下,生成一个Http的请求。
把swagger上面的参数转移到下面:
###
POST http://localhost:63040/content/course/list?pageNo=1&pageSize=2
Content-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,写入如下代码:
@Data
public class CourseCategoryTreeDto extends CourseCategory implements java.io.Serializable{
List<CourseCategoryTreeDto> childrenTreeNodes;
}
在xuecheng-plus-content的xuecheng-plus-content-api/src/main/java/com/xuecheng/content/api下新增一个类CourseCategoryController,写入如下代码:
@RestController
public class CourseCategoryController {
@GetMapping("/course-category/tree-nodes")
public List<CourseCategoryTreeDto> queryTreeNodes(){
return null;
}
}
2.17 (分类查询)树型查询 P32
方法1:表的自连接。
select
one.id one_id,
one.label one_label,
two.id two_id,
two.label two_label
from course_category one
inner join course_category two
on two.parentid = one.id
where 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 t1
order 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 t1
order by t1.id
2.18 (分类查询)开发测试 P33
在xuecheng-plus-content的xuecheng-plus-content-service下的mapper中增加CourseCategoryMapper中的代码:
public interface CourseCategoryMapper extends BaseMapper<CourseCategory> {
//使用递归查询分类
public List<CourseCategoryTreeDto> selectTreeNodes(String id);
}
在xuecheng-plus-content的xuecheng-plus-content-service下的mapper中增加CourseCategoryMapper.xml中的代码:
<select id="selectTreeNodes" parameterType="string" resultType="com.xuecheng.content.model.dto.CourseCategoryTreeDto">
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
</select>
在xuecheng-plus-content的xuecheng-plus-content-service的test下的java/com/xuecheng/content下,把CourseBaseMapperTests拷贝一份起名CourseCategoryMapperTests:
写入如下代码:
@SpringBootTest
public class CourseCategoryMapperTests {
@Autowired
CourseCategoryMapper courseCategoryMapper;
@Test
public void testCourseBaseMapper(){
List<CourseCategoryTreeDto> courseCategoryTreeDtos = courseCategoryMapper.selectTreeNodes("1");
System.out.println(courseCategoryTreeDtos);
}
}
在xuecheng-plus-content的xuecheng-plus-content-service下的service下创建CourseCategoryService接口,写入如下代码:
public interface CourseCategoryService {
public List<CourseCategoryTreeDto> queryTreeNodes(String id);
}
在xuecheng-plus-content的xuecheng-plus-content-service下的service下创建CourseCategoryServiceImpl类,写入如下代码:
@Slf4j
@Service
public class CourseCategoryServiceImpl implements CourseCategoryService {
@Autowired
CourseCategoryMapper courseCategoryMapper;
@Override
public List<CourseCategoryTreeDto> queryTreeNodes(String id) {
//调用mapper递归查询出分类信息
List<CourseCategoryTreeDto> courseCategoryTreeDtos = courseCategoryMapper.selectTreeNodes(id);
//找到每个节点的子节点,最终封装成List<CourseCategoryTreeDto>
//先将list转成map,key就是结点的id,value就是CourseCategoryTreeDto对象,目的是为了方便从map获取结点。filter(item->!id.equals(item.getId()))把根节点拍出
Map<String, CourseCategoryTreeDto> mapTemp = courseCategoryTreeDtos.stream().filter(item->!id.equals(item.getId())).collect(Collectors.toMap(key -> key.getId(), value -> value));
//定义一个list作为最终返回的list
List<CourseCategoryTreeDto> courseCategoryList = new ArrayList<>();
//从头遍历List<CourseCategoryTreeDto>,一边遍历一边找子节点放在父节点的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<CourseCategoryTreeDto>());
}
//到每个节点的子节点放在父节点的childrenTreeNodes属性中
courseCategoryTreeDto.getChildrenTreeNodes().add(item);
}
});
return courseCategoryList;
}
}
在xuecheng-plus-content的xuecheng-plus-content-service下的test下创建CourseCategoryServiceTests测试类,写入如下代码:
@SpringBootTest
public class CourseCategoryServiceTests {
@Autowired
CourseCategoryService courseCategoryService;
@Test
public void testCourseBaseInfoService(){
List<CourseCategoryTreeDto> courseCategoryTreeDtos = courseCategoryService.queryTreeNodes("1");
System.out.println(courseCategoryTreeDtos);
}
}
单元测试的效果如下:
在xuecheng-plus-content-api的CourseCategoryController下完善代码如下:
@RestController
public class CourseCategoryController {
@Autowired
CourseCategoryService courseCategoryService;
@GetMapping("/course-category/tree-nodes")
public List<CourseCategoryTreeDto> 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<=0){
throw new RuntimeException("添加课程失败");
}
//向课程营销表course_market写入数据
CourseMarket courseMarketNew = new CourseMarket();
//将页面输入的数据拷贝到courseMarketNew
BeanUtils.copyProperties(dto,courseMarketNew);
//课程的id
Long courseId = courseBaseNew.getId();
courseMarketNew.setId(courseId);
//保存营销信息
saveCourseMarket(courseMarketNew);
//从数据库查询课程的详细信息,包括两部分
CourseBaseInfoDto courseBaseInfo = getCourseBaseInfo(courseId);
return courseBaseInfo;
}
//查询课程信息
public CourseBaseInfoDto getCourseBaseInfo(long courseId){
//从课程基本信息表查询
CourseBase courseBase = courseBaseMapper.selectById(courseId);
if(courseBase==null){
return null;
}
//从课程营销表查询
CourseMarket courseMarket = courseMarketMapper.selectById(courseId);
//组装在一起
CourseBaseInfoDto courseBaseInfoDto = new CourseBaseInfoDto();
BeanUtils.copyProperties(courseBase,courseBaseInfoDto);
BeanUtils.copyProperties(courseMarket,courseBaseInfoDto);
//通过courseCategoryMapper查询分类信息,将分类名称放在courseBaseInfoDto对象
return courseBaseInfoDto;
}
//单独写一个方法保存营销信息,逻辑:存在则更新,不存在则添加
private int saveCourseMarket(CourseMarket courseMarketNew){
//参数的合法性校验
String charge = courseMarketNew.getCharge();
if(StringUtils.isEmpty(charge)){
throw new RuntimeException("收费规则为空");
}
//如果课程收费,价格没有填写也需要抛出异常
if(charge.equals("201001")){
if(courseMarketNew.getPrice()==null || courseMarketNew.getPrice().floatValue()<=0){
throw new RuntimeException("课程的价格不能为空并且必须大于0");
}
}
//从数据库查询营销信息,存在则更新,不存在则添加
Long id = courseMarketNew.getId();
CourseMarket courseMarket = courseMarketMapper.selectById(id);
if(courseMarket==null){
//插入数据库
int insert = courseMarketMapper.insert(courseMarketNew);
return insert;
}else{
//将courseMarketNew拷贝到courseMarket
BeanUtils.copyProperties(courseMarketNew,courseMarket);
courseMarket.setId(courseMarket.getId());
//更新
int i = courseMarketMapper.updateById(courseMarket);
return i;
}
}
2.21 (新增课程)接口测试 P36
首先是完善controller的接口。在xuecheng-plus-content的xuecheng-plus-content-api下的CourseBaseInfoController中完善createCourseBase方法:
@ApiOperation("新增课程")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto){
//获取到用户所述机构的id
Long companyId = 1232141425L;
CourseBaseInfoDto courseBase = courseBaseInfoService.createCourseBase(companyId, addCourseDto);
return courseBase;
}
然后节约时间跳过单元测试,直接倒api-test下的xc-content-api.http中进行接口测试:
### 新增课程
POST {{content_host}}/content/course
Content-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"
}
在controller和service的实现类上打上断点,主要是看看断点调试时接受的数据是否完整。
放行之后数据效果如下:
前端可以直接看到之前新增的课程:
数据库中也有如下数据,没问题:
前端还暂时无法提交。
2.22 Mybatis相关问题 P37
1.Mybatis分页插件的实现原理:
首先分页参数会被放到ThreadLocal中,拦截执行的sql语句,根据数据库的类型(比如是Mysql就会在末尾添加LIMIT)添加对应的分页语句重写sql。
计算出total总记录数,pageNum当前是第几页,pageSize每页的大小。是否为首页,是否为尾页,总页数等。
2.树型表的标记字段是什么?如何查询MySQL树型表?
树型表的标记字段是parentid即父结点的id。
3.查询一个树型表的方法:
当层级固定时可以用表的自连接进行查询。
如果想灵活查询每个层级可以使用mysql递归方法,使用with RECURSIVE实现。
4.Result Type和Result Map的区别:
Result Type:当查询到的SQL字段的名字和Result Type中模型类型的属性名字对应上的,可以由MyBatis自动完成映射。
Result Map:当查询到的SQL字段的名字和Result Type中模型类型的属性名字对应不上时,需要通过Result Map手动完成映射。
- #{}和${}的区别
#{}是标记一个占位符,可以防止sql注入。
${}用于在动态sql中拼接字符串,可能导致sql注入。
2.23 自定义异常类型 P38
如果前端名称为空,会报错500。但仅在控制台输出,前端没展示。
如果写很多try-catch代码,会造成代码冗余。
由增强类来捕获异常,原理是AOP面向切面编程。
用@ControllerAdvice注解来控制器增强,用异常处理注解@ExceptionHandler。
在xuecheng-plus-base的base包下创建exception包。
在exception包下创建RestErrorResponse类,和前端约定返回的异常信息模型:
/**
* 错误响应参数包装
*/
public class RestErrorResponse implements Serializable {
private String errMessage;
public RestErrorResponse(String errMessage){
this.errMessage= errMessage;
}
public String getErrMessage() {
return errMessage;
}
public void setErrMessage(String errMessage) {
this.errMessage = errMessage;
}
}
在exception包下创建XueChengPlusException类:
/**
* @description 学成在线项目异常类
* @author Mr.M
* @date 2022/9/6 11:29
* @version 1.0
*/
public class XueChengPlusException extends RuntimeException {
private String errMessage;
public XueChengPlusException() {
super();
}
public XueChengPlusException(String errMessage) {
super(errMessage);
this.errMessage = errMessage;
}
public String getErrMessage() {
return errMessage;
}
public static void cast(CommonError commonError){
throw new XueChengPlusException(commonError.getErrMessage());
}
public static void cast(String errMessage){
throw new XueChengPlusException(errMessage);
}
}
在exception包下创建CommonError类:
/**
* @description 通用错误信息
* @author Mr.M
* @date 2022/9/6 11:29
* @version 1.0
*/
public enum CommonError {
UNKOWN_ERROR("执行过程异常,请重试。"),
PARAMS_ERROR("非法参数"),
OBJECT_NULL("对象为空"),
QUERY_NULL("查询结果为空"),
REQUEST_NULL("请求参数为空");
private String errMessage;
public String getErrMessage() {
return errMessage;
}
private CommonError( String errMessage) {
this.errMessage = errMessage;
}
}
2.24 异常处理开发测试 P39
现在想测试课程名称为空是否会触发XueChengPlusException异常处理。
首先在service模块的service包的impl包下的CourseBaseInfoServiceImpl类中修改下面的代码:
//参数的合法性校验
if (StringUtils.isBlank(dto.getName())) {
//throw new RuntimeException("课程名称为空");
XueChengPlusException.cast("课程名称为空");
}
在xuecheng-plus-base的exception下的GlobalExceptionHandler类下的如下位置打上断点:
最后在api-test包下的xc-content-api.http下面,让name参数为空。
在如下位置添加自定义的异常处理语句。
把现价改为负数,会触发异常。
2.25 系统异常处理 P40
处理自定义异常:程序在编写代码时根据校验结果主动抛出自定义异常类对象,抛出异常时指定详细的异常信息,异常处理器捕获异常信息记录异常日志并相应给客户。
2.26 JSR303校验 P41
JSR303是一个校验框架。
使用方式:只需要在模型类中通过注解指定校验规则(比如@NOTEmpty来指定不能为空,@Size来指定长度的下限和上限),对不同的应用场景可以通过分组来切换校验规则使用groups,在controller方法上开启校验@Validated。
前端请求后端接口传输参数,在controller和service中都需要校验。
controller校验请求参数的合法性,包括必填项校验、数据格式校验(是否符合一定的日期格式)。
service校验业务规则的相关内容。
因为service是根据业务规则去校验所以不方便写成通用代码,controller中则可以将校验的代码写成通用代码。
引入如下依赖:
合法性校验的注解如下:
如果想要使用需要激活:
首先要把service层校验屏蔽掉(我是直接删掉):
然后记得要把启动类重启一下,代码才能生效。
在xc-content-api.http中把name改为空,走的是系统异常处理,所以一律输出为位置错误,执行过程异常,请重试。
现在出现问题,如果多个接口使用同一个模型类时,对校验的需求不一样时会出现问题。
解决方法:分组校验。
在xuecheng-plus-base的exception下面的ValidationGroups类中写入如下代码:
//用于分组校验,定义一些常用的组
public class ValidationGroups {
public interface Insert{};
public interface Update{};
public interface Delete{};
}
在xuecheng-plus-content的xuecheng-plus-content-model的dto下面的类AddCourseDto替换如下代码:
@NotEmpty(message = "新增课程名称不能为空",groups={ValidationGroups.Insert.class})
@NotEmpty(message = "修改课程名称不能为空",groups={ValidationGroups.Update.class})
@ApiModelProperty(value = "课程名称", required = true)
private String name;
@Validated注解里面写上所分的组类:
2.27 系统参数合法性校验 P42
提问:对表单的数据是怎么校验的?
回答:JSR303校验规则。
如果javax.validation.constraints包下的校验规则满足不了需求怎么办?
1.手写校验代码
2.自定义校验规则注解
2.28 (修改课程)接口开发 P44
在xuecheng-plus-content的xuecheng-plus-content-api的api包下的CourseBaseInfoController中,写入如下代码:
@ApiOperation("根据课程id查询接口")
@GetMapping("/course/{courseId}")
public CourseBaseInfoDto getCourseBaseById(@PathVariable Long courseId){
CourseBaseInfoDto courseBaseInfo = courseBaseInfoService.getCourseBaseInfo(courseId);
return courseBaseInfo;
}
在xuecheng-plus-content的xuecheng-plus-content-service的service包下的CourseBaseInfoService中写入如下代码:
//根据课程id查询课程信息
public CourseBaseInfoDto getCourseBaseInfo(Long courseId);
在xuecheng-plus-content的xuecheng-plus-content-service的service/impl包下的CourseBaseInfoServiceImpl中写入如下代码:
//查询课程信息
public CourseBaseInfoDto getCourseBaseInfo(Long courseId){
//从课程基本信息表查询
CourseBase courseBase = courseBaseMapper.selectById(courseId);
if(courseBase==null){
return null;
}
//从课程营销表查询
CourseMarket courseMarket = courseMarketMapper.selectById(courseId);
//组装在一起
CourseBaseInfoDto courseBaseInfoDto = new CourseBaseInfoDto();
BeanUtils.copyProperties(courseBase,courseBaseInfoDto);
BeanUtils.copyProperties(courseMarket,courseBaseInfoDto);
//通过courseCategoryMapper查询分类信息,将分类名称放在courseBaseInfoDto对象
return courseBaseInfoDto;
}
在api-test的xc-content-api.http中写入如下代码:
### 课程查询
GET {{content_host}}/content/course/40
Content-Type: application/json
返回的结果如下:
在xuecheng-plus-content的xuecheng-plus-content-model下面的dto包下面,重新定义一个类EditCourseDto,代码如下:
因为修改比新增就多了一个id,可以继承新增。
@Data
@ApiModel(value="EditCourseDto",description="修改课程基本信息")
public class EditCourseDto extends AddCourseDto{
@ApiModelProperty(value="课程id",required=true)
private Long Id;
}
在xuecheng-plus-content的xuecheng-plus-content-api的api包下的CourseBaseInfoController中,写入如下代码:
@ApiOperation("修改课程")
@PutMapping("/course")
public CourseBaseInfoDto modifyCourseBase(@RequestBody @Validated EditCourseDto editCourseDto){
//获取到用户所属机构的id
Long companyId = 1232141425L;
CourseBaseInfoDto courseBaseInfoDto = courseBaseInfoService.updateCourseBase(companyId, editCourseDto);
return courseBaseInfoDto;
}
在xuecheng-plus-content的xuecheng-plus-content-service的service包下的CourseBaseInfoService中写入如下代码:
public CourseBaseInfoDto updateCourseBase(Long companyId,EditCourseDto editCourseDto);
在xuecheng-plus-content的xuecheng-plus-content-service的service/impl包下的CourseBaseInfoServiceImpl中写入如下代码(缺少更新营销信息):
@Override
public CourseBaseInfoDto updateCourseBase(Long companyId, EditCourseDto editCourseDto) {
//拿到课程id
Long courseId = editCourseDto.getId();
//查询课程信息
CourseBase courseBase = courseBaseMapper.selectById(courseId);
if(courseBase == null){
XueChengPlusException.cast("课程不存在");
}
//数据合法性校验
//根据具体的业务逻辑去校验
//本机构只能修改本机构的课程
if(!companyId.equals(courseBase.getCompanyId())){
XueChengPlusException.cast("本机构只能膝盖本机构的课程");
}
//封装数据
BeanUtils.copyProperties(editCourseDto,courseBase);
//修改时间
courseBase.setChangeDate(LocalDateTime.now());
//更新数据库
int i = courseBaseMapper.updateById(courseBase);
if(i<=0){
XueChengPlusException.cast("修改课程失败");
}
//查询课程信息
CourseBaseInfoDto courseBaseInfo = getCourseBaseInfo(courseId);
return courseBaseInfo;
}
在api-test的xc-content-api.http中写入如下代码:
### 课程查询
GET {{content_host}}/content/course/40
Content-Type: application/json
2.29 (修改课程)接口测试 P45
输入下面的网址,打开前端界面:
http://localhost:8601/#/
点击编辑按钮,尝试修改基本信息
修改完毕之后,原先的信息会被更改。
返回的结果如下(属于正常,跳转到另外一个还没写的界面):
2.30 (计划查询)接口定义 P46
在xuecheng-plus-content的xuecheng-plus-content-model下面的dto包下面,定义一个新类TeachplanDto,代码如下:
@Data
@ToString
public class TeachplanDto extends Teachplan {
//课程计划关联的媒资信息
private TeachplanMedia teachplanMedia;
//子结点
private List<TeachplanDto> teachPlanTreeNodes;
}
在xuecheng-plus-content的xuecheng-plus-content-api的api包下的TeachplanController中,写入如下代码(初步):
//课程计划管理相关的接口
@Api(value="课程计划编辑接口",tags="课程计划编辑接口")
@RestController
public class TeachplanController {
@ApiOperation("查询课程计划树形结构")
@GetMapping("/teachplan/{courseId}/tree-nodes")
public List<TeachplanDto> getTreeNodes(@PathVariable Long courseId){
return null;
}
}
2.31 (计划查询)sql语句 P47
在xuecheng-plus-content的xuecheng-plus-content-service的mapper下的TeachplanMapper中写入如下代码:
public interface TeachplanMapper extends BaseMapper<Teachplan> {
//课程计划查询
public List<TeachplanDto> selectTreeNodes(Long courseId);
}
在xuecheng-plus-content的xuecheng-plus-content-service的mapper下的TeachplanMapper.xml中写入如下代码:
<select id="selectTreeNodes" parameterType="long" resultMap="treeNodeResultMap">
select
one.id one_id,
one.pname one_pname,
one.parentid one_parentid,
one.grade one_grade,
one.media_type one_mediaType,
one.start_time one_stratTime,
one.end_time one_endTime,
one.orderby one_orderby,
one.course_id one_courseId,
one.course_pub_id one_coursePubId,
two.id two_id,
two.pname two_pname,
two.parentid two_parentid,
two.grade two_grade,
two.media_type two_mediaType,
two.start_time two_stratTime,
two.end_time two_endTime,
two.orderby two_orderby,
two.course_id two_courseId,
two.course_pub_id two_coursePubId,
m1.media_fileName mediaFilename,
m1.id teachplanMeidaId,
m1.media_id mediaId
from teachplan one
INNER JOIN teachplan two on one.id = two.parentid
LEFT JOIN teachplan_media m1 on m1.teachplan_id = two.id
where one.parentid = 0 and one.course_id=#{value}
order by one.orderby,two.orderby
</select>
2.32 (计划查询)接口开发 P48
在xuecheng-plus-content的xuecheng-plus-content-service的java的mapper下的TeachplanMapper下写入如下代码:
public interface TeachplanMapper extends BaseMapper<Teachplan> {
//课程计划查询
public List<TeachplanDto> selectTreeNodes(Long courseId);
}
在xuecheng-plus-content的xuecheng-plus-content-service的java的mapper下的TeachplanMapper.xml下写入如下代码:
column是从数据库查到的字段,property是目标类的属性,也就是将从数据库查到的字段(column)映射到目标类的属性(property)上。
除了id字段用id标签,其它都用result标签。
<resultMap id="treeNodeResultMap" type="com.xuecheng.content.model.dto.TeachplanDto">
<id column="one_id" property="id"/>
<result column="one_pname" property="pname"/>
<result column="one_pname" property="pname" />
<result column="one_parentid" property="parentid" />
<result column="one_grade" property="grade" />
<result column="one_mediaType" property="mediaType" />
<result column="one_stratTime" property="stratTime" />
<result column="one_endTime" property="endTime" />
<result column="one_orderby" property="orderby" />
<result column="one_courseId" property="courseId" />
<result column="one_coursePubId" property="coursePubId" />
<!--映射子节点,一对多映射,ofType填的是list中对象类型-->
<collection property="teachPlanTreeNodes" ofType="com.xuecheng.content.model.dto.TeachplanDto">
<!-- 二级数据映射 -->
<id column="two_id" property="id" />
<result column="two_pname" property="pname" />
<result column="two_parentid" property="parentid" />
<result column="two_grade" property="grade" />
<result column="two_mediaType" property="mediaType" />
<result column="two_stratTime" property="stratTime" />
<result column="two_endTime" property="endTime" />
<result column="two_orderby" property="orderby" />
<result column="two_courseId" property="courseId" />
<result column="two_coursePubId" property="coursePubId" />
<!--一对一映射-->
<association property="teachplanMedia" javaType="com.xuecheng.content.model.po.TeachplanMedia">
<result column="teachplanMeidaId" property="id" />
<result column="mediaFilename" property="mediaFilename" />
<result column="mediaId" property="mediaId" />
<result column="two_id" property="teachplanId" />
<result column="two_courseId" property="courseId" />
<result column="two_coursePubId" property="coursePubId" />
</association>
</collection>
</resultMap>
在xuecheng-plus-content的xuecheng-plus-content-service的test的content下创建一个TeachplanMapperTests类,写入如下代码:
@SpringBootTest
public class TeachplanMapperTests {
@Autowired
TeachplanMapper teachplanMapper;
@Test
public void testSelectTreeNodes(){
List<TeachplanDto> teachplanDtos = teachplanMapper.selectTreeNodes(117L);
System.out.println(teachplanDtos);
}
}
当测试数据为117L的时候,会有2个大章节。
在xuecheng-plus-content的xuecheng-plus-content-service的service包下的TeachplanService
中写入如下代码:
//课程计划管理相关接口
public interface TeachplanService {
//根据课程id查询课程计划
public List<TeachplanDto> findTeachplanTree(Long courseId);
}
在xuecheng-plus-content的xuecheng-plus-content-service的service/impl包下的TeachplanService
中写入如下代码:
@Service
public class TeachplanServiceImpl implements TeachplanService {
@Autowired
TeachplanMapper teachplanMapper;
@Override
public List<TeachplanDto> findTeachplanTree(Long courseId) {
List<TeachplanDto> teachplanTree = teachplanMapper.selectTreeNodes(courseId);
return teachplanTree;
}
}
在xuecheng-plus-content的xuecheng-plus-content-api的api包下的TeachplanController中,写入如下代码(完善):
//课程计划管理相关的接口
@Api(value="课程计划编辑接口",tags="课程计划编辑接口")
@RestController
public class TeachplanController {
@Autowired
private TeachplanService teachplanService;
@ApiOperation("查询课程计划树形结构")
@GetMapping("/teachplan/{courseId}/tree-nodes")
public List<TeachplanDto> getTreeNodes(@PathVariable Long courseId){
List<TeachplanDto> teachplanTree = teachplanService.findTeachplanTree(courseId);
return teachplanTree;
}
}
在api-test的xc-content-api.http中写入如下代码:
### 课程计划查询
GET {{content_host}}/content/teachplan/117/tree-nodes
控制台输出的效果如下,测试成功(前后端联调测试没问题):
2.33 (新增修改计划)定义 P49
点击"添加章"新增第一级课程计划。
点击"添加小节"向某个第一级课程计划下添加小节。
点击"章"、"节"的名称,可以修改名称、选择是否免费。
同一个接口接收新增和修改两个业务请求,以是否传递课程计划id 来判断是新增还是修改(传递了课程计划id说明当前是要修改该课程计划,否则是新增一个课程计划)。
在xuecheng-plus-content的xuecheng-plus-content-model下的dto中新增一个类,写入如下代码:
@Data
@ToString
public 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;
}
在xuecheng-plus-conten的xuecheng-plus-content-api下的content/api下的TeachplanController中写入如下代码:
@ApiOperation("课程计划创建或修改")
@PostMapping("/teachplan")
public void saveTeachplan(@RequestBody SaveTeachplanDto teachplan){
}
2.34 (新增修改计划)开发 P50
在xuecheng-plus-content的xuecheng-plus-content-api的api的TeachplanController完善接口:
@ApiOperation("课程计划创建或修改")
@PostMapping("/teachplan")
public void saveTeachplan(@RequestBody SaveTeachplanDto teachplan){
teachplanService.saveTeachplan(teachplan);
}
在xuecheng-plus-content的xuecheng-plus-content-service的service的TeachplanService下新增接口:
//新增/修改/保存课程计划
public void saveTeachplan(SaveTeachplanDto saveTeachplanDto);
在xuecheng-plus-content的xuecheng-plus-content-service的service的TeachplanServiceImpl下编写如下代码:
@Override
public 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);
}
}
前后端联调测试没问题, 添加小节没问题:
存在一个BUG,添加章的时候不显示。
2.35 项目实战说明 P51
本节主要讲解了,如何下载gogs的windows版本,然后配置环境,方便分工协作开发接口。
删除课程计划。上移、下移排序。
师资管理,对老师表进行增删改查。
删除课程要把课程相关的基本信息、营销信息、课程计划、课程教师信息也删掉。
三、媒资管理模块
3.1 媒资管理需求分析 P52
媒资管理是媒体资源管理,媒体资源主要包括视频、文章、图片、音频等。
图片会存储到分布式文件系统中。
视频上传会通过断点续传的方式上传。
到当前已经有了3个微服务,内容管理(管理课程),系统管理(数据字典),媒资管理。
3.2 为什么用网关 P53
网关是用来路由的,转发请求;可以实现权限控制、限流等功能;避免前端直接请求微服务,编程前端请求网关,网关来请求微服务。
网关从服务注册中心来拿到微服务实例的地址。
3.3 (nacos)服务发现中心 P54
Spring Cloud是一系列微服务技术栈,是一套规范。
Spring Cloud alibaba:nacos服务注册中心、配置中心。
namespace是命名空间,用于区分环境,比如:开发环境、测试环境、生产环境。
group:用于区分项目。
下面是我登录nacos遇到的问题:
起初输入如下的命令,没能登录上nacos,显示的是拒绝访问:
http://192.168.101.65:8848/nacos/#/login
此时需要到虚拟机中,输入docker ps看看哪些服务启动了:
一定要确保先启动mysql再启动docker:
docker stop mysql
docker stop nacos
docker start mysql
docker start nacos
如下图能够正常访问:
具体配置:
1.先要在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.然后要在xuecheng-plus-content-api的pom.xml中写入如下代码:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
3.在api的bootstrap.yml配置文件中写入如下代码,配置nacos的地址:
spring:
application:
name: content-api #服务名
cloud:
nacos:
server-addr: 192.168.101.65:8848
discovery: #服务注册相关配置
namespace: dev #命名空间
group: xuecheng-plus-project
记得刷新maven,然后启动contentApplication启动类,然后到nacos网页可以看到有服务成功注册入
3.4 (nacos)配置api P55
搭建Nacos配置中心,目的是通过Nacos去管理项目的所有配置。更加方便,可以不用重启就去更改配置。
nacos定位一个具体配置文件的方式是:namespace、group、dataid。
dataid由三部分组成:应用名-环境名.配置文件格式
通过spring.profiles.active来指定环境名(开发环境,测试环境,生产环境):
配置文件的名称如下:content-api-dev.yaml
配置如下:
可以把api的配置文件中的datasource给注释掉,记得要添加config的配置,把配置文件配置上,代码如下:
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
记得要在api的pom.xml文件中填写如下依赖,会从nacos定时拉取配置:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
然后刷新maven,然后重启ContentApplication启动类,会出现如下场景:
然后在swagger里面进行简单测试,看数据库能否连上,选择查询的接口,输入参数然后发送请求。
3.5 (nacos)配置service P56
service的配置文件在test里面。
api原本不需要数据库配置连接,但因为api依赖了service模块,所以启动后service的代码依赖都会到api中。
所以现在的思路是:只在service中配置数据库依赖,然后api引用service的配置。
第1步:在service中进行相关配置
在service的pom.xml中加依赖,只需要加config依赖,不需要加discovery依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
在service的bootstrap.yml中修改配置文件(这里要注意2点,profiles这里不要写成profile,还有profiles是和cloud和application同级的):
#微服务配置
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
在nacos中点+号,新建service的配置,把数据库的配置粘贴上即可:
现在启动service的test下的CourseBaseMapperTests单元测试文件:
第2步:让api引用service的配置
注意这里要把api中的数据库连接屏蔽掉,只在service的nacos配置中留有数据库连接:
api的bootstrap.yml配置文件中的代码如下:
#微服务配置
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 #环境名
这里有几点易错的点:1.冒号后面都要空1格。2.config是在nacos的下一级,与server-addr和 discovery同级。3.profiles是在spring的下一级,与application和cloud同级。4.文件的后缀是yaml。5.${spring.profiles.active}注意这个花括号里都是点号分隔
能出数据就没啥问题:
因为swagger在所有的模块中都需要配置,所以我们想如何在nacos中配置项目的公用配置。
配置内容填写如下内容:
swagger:
title: "学成在线项目接口文档"
description: "学成在线项目接口文档"
base-package: com.xuecheng
enabled: true
version: 1.0.0
微服务的完整配置如下,shared-configs必须要在extension-configs的平级位置,在config的次一级位置。
#微服务配置
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 #环境名
这里有几点易错的点:1.冒号后面都要空1格。2.config是在nacos的下一级,与server-addr和 discovery同级。3.profiles是在spring的下一级,与application和cloud同级。4.文件的后缀是yaml。5.${spring.profiles.active}注意这个花括号里都是点号分隔
测试:如果名称改变,控制台有DEBUG语句输出就没问题!
3.6 (nacos)配置优先级 P57
引入配置文件的形式有:
1、以项目应用名方式引入
2、以扩展配置文件方式引入
3、以共享配置文件 方式引入
4、本地配置文件
各配置文件 的优先级:项目应用名配置文件 > 扩展配置文件 > 共享配置文件 > 本地配置文件。
然后我们还要在nacos中加上本地优先。
现在启动contentApplication就没啥问题了。
3.7 (nacos)配置代码导入 P58
把下面的文件解压。
然后点击上传文件把这些配置都上传上来。
3.8 搭建网关 P59
按照如下的方式创建一个网关模块:
先把依赖全部先导入到pom.xml文件中:
把xuecheng-plus-system的配置文件全部拷贝到xuecheng-plus-gateway中:
启动类修改如下:
@SpringBootApplication
public 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中写入如下代码:
<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>
创建一个testbucket,记得把访问方式改为公有
在xuecheng-plus-media的xuecheng-plus-media-servicesrc下创建test包,然后在test下创建com.xuecheng.media包,然后在media包下面创建MinioTest类,然后写入如下的代码:
//测试minio的sdk
public 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")
删除文件的代码如下:
@Test
public 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;
@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 build = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
return build;
}
}
在xuecheng-plus-media-model下的dto的UploadFileResultDto中写入如下代码:
@Data
@ToString
public 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的上传图片功能的完整代码:
//根据扩展名来获取mimeType
private String getMimeType(String extension){
if(extension==null){
extension="";
}
//根据扩展名取出mimeType
ContentInfo extensionMatch = ContentInfoUtil.findExtensionMatch(extension);
String mimeType = MediaType.APPLICATION_OCTET_STREAM_VALUE;//通用mimeType,字节流
if(extensionMatch != null){
mimeType = extensionMatch.getMimeType();
}
return mimeType;
}
@Autowired
MinioClient 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
@Override
public 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;
}
@Autowired
MediaFileService currentProxy;
@Transactional
public 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<=0){
log.debug("向数据库保存文件失败,bucket:{},objectName",bucket,objectName);
return null;
}
return mediaFiles;
}
return mediaFiles;
}
3.16 (上传图片)测试 P67
我在本地路径上保存了一张图片。
在api-test包的xc-media-api.http下,写入如下代码:
### 上传文件
POST {{media_host}}/media/upload/coursefile
Content-Type: multipart/form-data; boundary=WebAppBoundary
--WebAppBoundary
Content-Disposition: form-data; name="filedata"; filename="2.jpg"
Content-Type: application/octet-stream
< c:/xuechengzaixian/2.jpg
然后断点跟踪,在controller的第1行打上一个断点,在uploadFile上打上一个断点,一步步看是否传入的参数符合预期。
最后可以看到文件成功上传,点击预览可以看到文件是否正确。
首先在前端文件中把网关放开。然后在IDEA中把网关模块启动,内容管理和系统管理服务也要启动。然后上网找了一张新的图片。
尝试上传,在数据库中可以查找到该条记录:
3.17 (上传图片)事务优化 P68
如果一个方法中存在网络请求这种,时间不固定的代码,如果只在方法上面加事务注解,可能导致对数据库的占用时间比较长的问题。
事务的原理是在执行方法前开启事务,才执行方法后提交事务。但有一个前提,必须是在代理对象中执行。
如果是一个代理对象且加了注解,一定可以被代理控制。只要有一个条件不满足就不能被控制。
现在的问题是:一个非事务方法调用同类一个事务方法,事务无法控制。
注入的是代理对象,代理对象是在原始对象上包了一层代理对象。
解决思路:在MediaFileServiceImpl中把MediaFileService注入进来,在MediaFileService中加入addMediaFilesToDb方法,然后在addMediaFilesToDb的方法上加上@Transactional注解。
完整的代码见上节。
3.18 (上传视频)断点续传 P69
上传界面如下:
断点续传实现原理:1.前端上传前先把文件分成若干块。2.一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传。3.各分块上传完成最后在服务端合并文件。
3.19 (上传视频)分块合并 P70
在xuecheng-plus-media的xuecheng-plus-media-service下的test下创建BigFileTest,然后写入如下代码:
public class BigFileTest {
//分块测试
@Test
public void testChunk() throws IOException {
//源文件
File sourceFile = new File("C:\\xuechengzaixian\\1.mp4");
//分块文件存储路径
String chunkFilePath="C:\\code\\result\\";
//分块文件大小
int chunkSize = 1024 * 1024 * 1;
//分块文件的个数
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<chunkNum;i++){
File chunkFile = new File(chunkFilePath + i);
//分块文件写入流
RandomAccessFile raf_rw = new RandomAccessFile(chunkFile, "rw");
int len = -1;
while((len=raf_r.read(bytes))!=-1){
raf_rw.write(bytes,0,len);
if(chunkFile.length()>=chunkSize){
break;
}
}
raf_rw.close();
}
raf_r.close();
}
//合并测试
@Test
public void testMerge(){
}
}
效果是: 把位于c:/xuechengzaixian/1.mp4的视频文件进行分片,然后存储到c:/code/result下(这里要注意下面这段代码String chunkFilePath="C:\\code\\result\\";其中result代表文件夹后面两个\\代表文件名为空,会自动用0.1.2.3...命名)
合并的代码如下:
//合并测试
@Test
public void testMerge() throws IOException {
//块文件目录
File chunkFolder = new File("C:\\code\\result");
//源文件
File sourceFile = new File("C:\\xuechengzaixian\\1.mp4");
//合并后的文件
File mergeFile = new File("C:\\code\\res.mp4");
//取出所有的分块文件
File[] files = chunkFolder.listFiles();
//将数组转成list
List<File> filesList = Arrays.asList(files);
//对分块文件排序
Collections.sort(filesList, new Comparator<File>() {
@Override
public int compare(File o1, File o2) {
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("文件合并成功");
}
}
3.20 (上传视频)minio合并 P71
测试的逻辑是:testChunk() -> uploadChunk() -> testMerge()
1.首先将本地的视频分块。因为minio中要求视频的每个分块要大于5MB,所以大小设定如下:chunkSize = 1024 * 1024 * 5,分块的代码写在xuecheng-plus-media-service的test的BigFileTest代码如下:
//分块测试
@Test
public 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<chunkNum;i++){
File chunkFile = new File(chunkFilePath + i);
//分块文件写入流
RandomAccessFile raf_rw = new RandomAccessFile(chunkFile, "rw");
int len = -1;
while((len=raf_r.read(bytes))!=-1){
raf_rw.write(bytes,0,len);
if(chunkFile.length()>=chunkSize){
break;
}
}
raf_rw.close();
}
raf_r.close();
}
2.然后将分块文件上传到minio,模拟的是"上传"。上传minio的代码写在xuecheng-plus-media-service的test的MinioTest中代码如下:
//将分块文件上传到minio
@Test
public void uploadChunk() throws Exception {
for (int i = 0; i < 13; i++) {
//上传文件的参数信息
UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
.bucket("testbucket") //桶
.filename("C:\\code\\result\\"+i)//指定本地文件路径
.object("chunk/" + i)
.build();//对象名
//上传文件
minioClient.uploadObject(uploadObjectArgs);
System.out.println("上传分块"+i+"成功");
}
}
3.然后将minIo中分块的文件合并到minio。代码写在xuecheng-plus-media-service的test的MinioTest中如下:
//调用minio接口合并分块
@Test
public void testMerge() throws Exception{
/*List<ComposeSource> sources = null;
for(int i=0;i<30;i++) {
//指定分块文件的信息
ComposeSource composeSource = ComposeSource.builder().bucket("testbucket").object("chunk/" + i).build();
sources.add(composeSource);
}*/
List<ComposeSource> sources = Stream.iterate(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个接口的具体实现:
@Override
public RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {
//找到分块文件调用minio的sdk进行文件合并
//分块文件所在目录
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());
//源文件名称
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;
}
@Transactional
public 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<=0){
log.debug("向数据库保存文件失败,bucket:{},objectName",bucket,objectName);
return null;
}
return mediaFiles;
}
return mediaFiles;
}
//清除分块文件
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();
}
});
}
完整代码可以在gitee上下载,连接如下:学成在线项目代码(CSDN吾浴西风)
3.22 (上传视频)合并分块 P73
此节视频的代码是在上节代码的基础上进一步完善!
启动下面4个项目:
在xuecheng-plus-media的xuecheng-plus-media-api下的BigFilesController中写入如下的代码:
@Api(value="大文件上传接口",tags="大文件上传接口")
@RestController
public class BigFilesController {
@Autowired
private MediaFileService mediaFileService;
@ApiOperation(value = "文件上传前检查文件")
@PostMapping("/upload/checkfile")
public RestResponse<Boolean> checkfile(
@RequestParam("fileMd5") String fileMd5
) throws Exception {
RestResponse<Boolean> booleanRestResponse = mediaFileService.checkFile(fileMd5);
return booleanRestResponse;
}
@ApiOperation(value = "分块文件上传前的检测")
@PostMapping("/upload/checkchunk")
public RestResponse<Boolean> checkchunk(@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk") int chunk) throws Exception {
RestResponse<Boolean> 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<Boolean> checkFile(String fileMd5);
//检查分块是否存在
public RestResponse<Boolean> checkChunk(String fileMd5, int chunkIndex);
//上传分块
public RestResponse uploadChunk(String fileMd5,int chunk,String localChunkFilePath);
在xuecheng-plus-media的xuecheng-plus-media-service下的MediaFileService中写入如下3个接口的具体实现:
@Override
public RestResponse<Boolean> 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);
}
@Override
public RestResponse<Boolean> 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);
}
@Override
public 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中写入如下依赖:
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
</dependency>
在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,然后记录结果到数据库。