学成在线--内容管理模块开发
在此特别感谢黑马程序员提供的课程
写在最前
模块需求分析
什么是需求分析
- 百度百科汇总对需求分析的定义如下
需求分析也称为软件需求分析、系统需求分析或需求分析工程等,是开发人员经过深入细致的调研和分析,准确理解用户和项目的功能、性能、可靠性等具体需求,将用户非形式的需求表叔转化为完整的需求定义,从而确定系统必须做什么的过程
-
简单理解就是要搞清问题域,问题域就是用户的需求,软件要为用户解决什么问题,实现哪些业务功能,满足什么样的性能要求
-
那么如何做需求分析?
- 首先确认用户需求
- 用户需求即用户的原始需求。通过用户访谈、问卷调查、开会讨论、查阅资料等调研手段梳理用户的原始需求,产品人员根据用户需求绘制界面原型,再通过界面原型让用户确认需求是否符合预期
- 确认关键问题
- 用户的原始需求可能是含糊不清的,需求分析要从繁杂的问题中梳理出关键问题。比如:教学机构的老师想要将课程发布到网上,这是原始需求,根据这个用户需求我们需要进行扩展分析,扩展出以下几个点:
- 课程发布需要哪些信息?
- 如果用户发布了不良信息怎么办?
- 课程发布后用户怎么查看?
- 课程发布需要课程名称、价格、介绍、图片(封面)、师资信息等。继续延伸分析:这么多课程信息进行归类,方便用户编辑,可以分为课程基本信息、课程营销信息、课程师资信息。
- 按照这样的思路对用户需求逐项分析,梳理出若干问题,再从中找到关键信息。比如:上边对课程信息分类后,哪些是关键信息,课程名称、课程图片、课程介绍扥基本信息为关键信息,所以发布课程的第一步要编写课程基本信息。
- 找到了关键问题,下一步就是进行数据建模,创建课程基本信息表,并设计其中的字段
- 用户的原始需求可能是含糊不清的,需求分析要从繁杂的问题中梳理出关键问题。比如:教学机构的老师想要将课程发布到网上,这是原始需求,根据这个用户需求我们需要进行扩展分析,扩展出以下几个点:
- 数据建模
- 数据建模要根据分析的关键问题将其相关的信息全部建模。比如:根据发布课程的用户需求,可创建课程基本信息表、课程营销信息表、课程师资表、课程发布记录表、课程审核记录表等
- 编写需求规格说明书
- 针对每一个关键问题最终都需要编写需求规格说明书,包括:功能名称、功能描述、参与者、基本时间流程、可选事件流、数据描述、前置条件、后置条件等
- 比如添加课程的需求规格如下
- 首先确认用户需求
项目 | 添加课程 |
---|---|
功能名称 | 添加课程 |
功能描述 | 添加课程基本信息 |
参与者 | 教学机构管理员 |
前置条件 | 教学机构管理只允许向自己机构添加课程 拥有添加课程的权限 |
基本事件流程 | 1、登录教学机构平台 2、进入课程列表页面 3、点击添加课程按钮进入添加课程界面 4、填写课程基本信息 5、点击提交。 |
可选事件流程 | 成功:提示添加成功,跳转到课程营销信息添加界面 失败:提示具体的失败信息,用户根据失败信息进行修改。 |
数据描述 | 课程基本信息:课程id、课程名称、课程介绍、课程大分类、课程小分类、课程等级、课程图片、所属机构、课程创建时间、课程修改时间、课程状态 |
后置条件 | 向课程基本信息插入一条记录 |
补充说明 |
模块介绍
- 内容管理这个词存在于很多软件系统,什么是内容管理?
- 内容管理系统(content management system,CMS),是一种位于WEB前端(Web服务器)和后端办公系统或流程(内容创作、编辑)之间的软件系统。内容的创作人员,编辑人员、发布人员使用内容管理系统来提交、修改、审批、发布内容。这里的内容可能包括文件、表格、图片、数据库中的数据甚至视频等一切你想发布到网站的信息
- 本项目作为一个大型的在线教育平台,其内容管理模块主要对课程及相关内容进行管理,包括:课程的基本信息、课程图片、课程师资信息、课程的授课计划、课程视频、课程文档等内容的管理
业务流程
- 内容管理由教学机构人员和平台的运营人员共同完成。
- 教学机构人员的业务流程如下:
- 登录教学机构
- 维护课程信息,添加一门课程需要编辑课程的基本信息、上传课程图片、课程营销信息、课程计划、上传课程视频、课程师资信息等内容
- 课程信息编辑完成,通过课程预览确认无误后提交课程审核。
- 待运营人员课程审核通过后方可进行课程发布
- 运用人员的业务流程如下:
- 查询待审核的课程信息
- 审核课程信息
- 提交审核结果
界面原型
- 产品工程师根据用户需求制作产品界面原型,开发工程师除了根据用户需求进行需求分析以外,还会根据界面原型上的元素信息进行需求分析
数据模型
- 数据模型就是对应的数据库表
创建模块工程
模块工程结构
-
在之前我们已经创建好了项目父工程和基础工程
-
那下面我们继续来创建内容管理模块的工程结构。本项目是一个前后端分离项目,前端与后端开发人员之间主要依据接口进行开发,前后端交互流程如下
- 前端请求后端服务提供的接口
- 后端服务的Controller层接收前端的请求
- Controller层调用Service层进行业务处理
- Service层调用Dao持久层对数据持久化
-
流程分为前端、接口层、业务层三部分,所以模块工程结构如下图所示
- xuecheng-plus-content-api:接口工程,为前端提供接口
- xuecheng-plus-content-service:业务工程,为接口工程提供业务支撑
- xuecheng-plus-content-model:数据模型工程,存储数据模型类、数据传输类型等
-
结合项目父工程、项目基础工程后,如下图
- xuecheng-plus-content:内容管理模块工程,负责聚合xuecheng-plus-content-api、xuecheng-plus-content-service、xuecheng-plus-content-model
- xuecheng-plus-content:内容管理模块工程,负责聚合xuecheng-plus-content-api、xuecheng-plus-content-service、xuecheng-plus-content-model
创建模块工程
- 创建内容管理模块父工程xuecheng-plus-content,修改pom.xml,声明为聚合工程,且有三个子模块
1 |
|
- 创建xuecheng-plus-content-api工程,设置父工程为
xuecheng-plus-content
,按照上图中的依赖关系修改pom文件
1 |
|
- 创建xuecheng-plus-content-model工程,设置父工程为
xuecheng-plus-content
,按照上图中的依赖关系修改pom文件
1 |
|
- 创建xuecheng-plus-content-service工程,设置父工程为
xuecheng-plus-content
,按照上图中的依赖关系修改pom文件
1 |
|
- 创建好的目录结构如下图所示
课程查询
需求分析
业务流程
wasopdjpwodiaonimade ww1. 教学机构恩怨点击课程管理,进入课程查询界面
2. 在课程查询页面输入查询条件查询课程信息
- 当不输入查询条件时,输出全部课程信息
- 输入查询条件,查询符合条件的课程信息
- 约束:教学机构只允许查询本教学机构的课程信息
数据模型
- 课程查询功能涉及到的数据表有;课程基本信息表,教学计划表
- 下面从查询条件、查询列表两方面进行分析
- 查询条件
- 包括:课程名称、课程审核状态、课程发布状态
- 课程名称:可以模糊搜索
- 课程审核状态:未提交、已提交、审核通过、审核未通过
- 课程发布状态:未发布、已发布、已下线
- 因为是分页查询,所以查询条件中还要包括当前页码、每页显示记录数
- 查询结果
- 包括:课程id、课程名称、任务数、创建时间、审核状态、类型
- 从结果上看基本来源于课程基本信息表,任务书需要关联教学计划表查询
- 因为是分页查询,所以查询结果中还要包括总记录数、当前页码、每页显示记录数
- 查询条件
生成PO
- PO即持久对象(Persistent Object),它们是由一组属性和属性的get/set方法组成的。PO对应于数据库的表
- 在开发持久层代码需要根据数据表编写PO类,在实际开发中通常使用代码生成器工具来生成PO类代码(人人开源、MP代码生成器等)
- 这里是使用的MP的generator工程生成PO类,详细操作可以参考我这篇文章
- 将生成好的PO类拷贝至xuecheng-plus-content-model工程的com.xuecheng.content.model.po包下,同时在model工程的pom.xml中添加MP的依赖,版本控制在父工程已经完成了
1 | <dependency> |
接口定义
接口定义分析
-
定义一个接口需要包括以下几个方面
- 协议
- 通常使用HTTP协议,查询类的接口请求方式通常为GET或POST,查询条件较少的时候使用GET,较多的时候使用POST
- 本接口使用http post
- 同时也要确定content-type,参数以什么数据格式提交,结果以什么数据格式响应
- 一般情况下都以json格式响应
- 分析请求参数
- 根据前面对数据模型的分析,请求参数为:课程名称、课程审核状态、当前页码、每页显示的记录数
- 根据分析的请求参数定义模型类
- 分析响应结果
- 根据前面对数据模型的分析,响应结果为数据列表和一些分页信息(总记录数、当前页码、每页显示记录数)
- 数据列表中数据的属性包括:课程id、课程名称、任务数、创建时间、审核状态、类型
- 根据分析的相应结果定义模型类
- 分析完成,使用SpringBoot注解开发一个Http接口
- 使用接口文档工具查看接口的内容
- 接口中调用Service方法完成业务处理
- 协议
-
接口请求示例
1 | POST /content/course/list?pageNo=2&pageSize=1 |
课程查询接口定义
-
定义请求模型类
- 对于查询条件较多的接口定义单独的模型类接收参数
- 由于分页查询这一类的接口在项目中很多地方都会用到,这里针对分页查询的参数(当前页码、每页显示的记录数)单独在xuecheng-plus-base基础工程中定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19package com.xuecheng.base.model;
import lombok.AllArgsConstructor;
import lombok.Data;
public class PageParams {
// 默认起始页码
public static final long DEFAULT_PAGE_CURRENT = 1L;
// 默认每页记录数
public static final long DEFAULT_PAGE_SIZE = 10L;
// 当前页码
private Long pageNo = DEFAULT_PAGE_CURRENT;
// 当前每页记录数
private Long pageSize = DEFAULT_PAGE_SIZE;
}- 除了分页查询参数,剩下的就是课程查询的特有参数,此时需要在内容管理的model工程中定义课程查询的参数模型类
- 定义DTO包,DTO即数据传输对象(Data Transfer Object),用于接口层和业务层之间传输数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15package com.xuecheng.content.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
public class QueryCourseParamDto {
// 审核状态
private String auditStatus;
// 课程名称
private String courseName;
// 发布状态
private String publishStatus;
} -
多样化的模型类
- 现在项目中有两种模型类:DTO数据传输对象、PO持久化对象。
- DTO用于接口层向业务层之间传输数据
- PO用于业务层与持久层之间传输数据
- 有些公司还会设置VO对象,VO对象用在前端和接口层之间传输数据
- 当前端有多个平台且接口存在差异时,就需要设置VO对象用于前端和接口层传输数据。比如:课程列表查询接口,根据用户需求,用户在手机端也要查询课程信息,此时课程查询接口是否需要编写手机端和PC端两个接口呢?
- 如果用户要求通过手机和PC的查询条件或查询结果
不一样
,那此时就需要定义两个Controller课程查询接口,每个接口定义VO对象与前端传输数据- 手机查询:根据课程状态查询,查询结果只有课程名称和课程状态
- PC查询:可以改根据课程名称、课程状态、课程审核状态等条件查询,查询结果也比手机查询的结果内容多
- 此时,Service业务层尽量提供一个业务接口,即使两个前端接口需要的数据不一样,Service可以提供一个最群的查询结果,由Controller层进行数据整合。
- 如果前端接口没有多样性,且比较固定,此时可以取消VO,只用DTO即可
- 现在项目中有两种模型类:DTO数据传输对象、PO持久化对象。
-
定义响应模型类
- 根据接口分析,下面定义响应结果模型类
- 针对分页查询结果经过分析,也存在固定的数据和格式,所以还是在base工程定义一个基础的结果模型类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import lombok.AllArgsConstructor;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
public class PageResult<T> implements Serializable {
// 数据列表
private List<T> items;
// 总记录数
private long counts;
// 当前页码
private long page;
// 每页记录数
private long pageSize;
} -
定义接口
- 根据分析,此接口提供http post协议,查询条件以json格式提交,响应结果为json格式
- 首先咋xuecheng-plus-content-api中添加依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51<dependencies>
<dependency>
<groupId>com.xuecheng</groupId>
<artifactId>xuecheng-plus-content-model</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.xuecheng</groupId>
<artifactId>xuecheng-plus-content-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<!--cloud的基础环境包-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-context</artifactId>
</dependency>
<!-- Spring Boot 的 Spring Web MVC 集成 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 排除 Spring Boot 依赖的日志包冲突 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Boot 集成 log4j2 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
<!-- Spring Boot 集成 swagger -->
<dependency>
<groupId>com.spring4all</groupId>
<artifactId>swagger-spring-boot-starter</artifactId>
<version>1.9.0.RELEASE</version>
</dependency>
</dependencies>- 之后定义Controller方法
- 说明:pageParams分页参数通过url的key/value传入,queryCourseParams通过json数据传入,所以queryCourseParams前面需要用@RequestBody注解将json转为QueryCourseParamDto对象。这里的两个@Api注解是swagger的,用于描述接口的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CourseBaseInfoController {
public PageResult<CourseBase> list(PageParams pageParams, { QueryCourseParamDto queryCourseParams)
CourseBase courseBase = new CourseBase();
courseBase.setId(15L);
courseBase.setDescription("测试课程");
PageResult<CourseBase> result = new PageResult<>();
result.setItems(Arrays.asList(courseBase));
result.setPage(1);
result.setPageSize(10);
result.setCounts(1);
return result;
}
}- 定义启动类,使用@EnableSwagger2Doc注解,启用Swagger
1
2
3
4
5
6
7
8
9
public class ContentApiApplication {
public static void main(String[] args) {
SpringApplication.run(ContentApiApplication.class, args);
}
}- 添加配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<Configuration monitorInterval="180" packages="">
<properties>
<property name="logdir">logs</property>
<property name="PATTERN">%date{YYYY-MM-dd HH:mm:ss,SSS} %level [%thread][%file:%line] - %msg%n%throwable</property>
</properties>
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="${PATTERN}"/>
</Console>
<RollingFile name="ErrorAppender" fileName="${logdir}/error.log"
filePattern="${logdir}/$${date:yyyy-MM-dd}/error.%d{yyyy-MM-dd-HH}.log" append="true">
<PatternLayout pattern="${PATTERN}"/>
<ThresholdFilter level="ERROR" onMatch="ACCEPT" onMismatch="DENY"/>
<Policies>
<TimeBasedTriggeringPolicy interval="1" modulate="true" />
</Policies>
</RollingFile>
<RollingFile name="DebugAppender" fileName="${logdir}/info.log"
filePattern="${logdir}/$${date:yyyy-MM-dd}/info.%d{yyyy-MM-dd-HH}.log" append="true">
<PatternLayout pattern="${PATTERN}"/>
<ThresholdFilter level="DEBUG" onMatch="ACCEPT" onMismatch="DENY"/>
<Policies>
<TimeBasedTriggeringPolicy interval="1" modulate="true" />
</Policies>
</RollingFile>
<!--异步appender-->
<Async name="AsyncAppender" includeLocation="true">
<AppenderRef ref="ErrorAppender"/>
<AppenderRef ref="DebugAppender"/>
</Async>
</Appenders>
<Loggers>
<!--过滤掉spring和mybatis的一些无用的debug信息-->
<logger name="org.springframework" level="INFO">
</logger>
<logger name="org.mybatis" level="INFO">
</logger>
<logger name="cn.itcast.wanxinp2p.consumer.mapper" level="DEBUG">
</logger>
<logger name="springfox" level="INFO">
</logger>
<logger name="org.apache.http" level="INFO">
</logger>
<logger name="com.netflix.discovery" level="INFO">
</logger>
<logger name="RocketmqCommon" level="INFO" >
</logger>
<logger name="RocketmqRemoting" level="INFO" >
</logger>
<logger name="RocketmqClient" level="WARN">
</logger>
<logger name="org.dromara.hmily" level="WARN">
</logger>
<logger name="org.dromara.hmily.lottery" level="WARN">
</logger>
<logger name="org.dromara.hmily.bonuspoint" level="WARN">
</logger>
<!--OFF 0-->
<!--FATAL 100-->
<!--ERROR 200-->
<!--WARN 300-->
<!--INFO 400-->
<!--DEBUG 500-->
<!--TRACE 600-->
<!--ALL Integer.MAX_VALUE-->
<Root level="DEBUG" includeLocation="true">
<AppenderRef ref="AsyncAppender"/>
<AppenderRef ref="Console"/>
<AppenderRef ref="DebugAppender"/>
</Root>
</Loggers>
</Configuration>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20server:
servlet:
context-path: /content
port: 63040
## 微服务配置
spring:
application:
name: content-api
## 日志文件配置路径
logging:
config: classpath:log4j2-dev.xml
## swagger 文档配置
swagger:
title: "学成在线内容管理系统"
description: "内容系统管理系统对课程相关信息进行业务管理数据"
base-package: com.xuecheng.content
enabled: true
version: 1.0.0- 运行启动类,访问http://localhost:63040/content/swagger-ui.html, 查看接口信息
- 使用PostMan测试我们的接口也能返回我们的测试数据
Swagger介绍
- 什么是Swagger?
- OpenAPI规范(OpenAPI Specification 简称OAS)是Linux基金会的一个项目,试图通过定义一种用来描述API格式或API定义的语言,来规范RESTful服务开发过程,并且已经发布并开源在GitHub上:https://github.com/OAI/OpenAPI-Specification
- Swagger是全球最大的OpenAPI规范(OAS)API开发工具框架,Swagger是一个在线接口文档的生成工具,前后端开发人员依据接口文档进行开发,只要添加Swagger的依赖和配置信息即可使用它
1
2
3
4
5<!-- Spring Boot 集成 swagger -->
<dependency>
<groupId>com.spring4all</groupId>
<artifactId>swagger-spring-boot-starter</artifactId>
</dependency>- base-package为包扫描路径,扫描Controller类
1
2
3
4
5
6swagger:
title: "学成在线内容管理系统"
description: "内容系统管理系统对课程相关信息进行业务管理数据"
base-package: com.xuecheng.content
enabled: true
version: 1.0.0 - SpringBoot可以集成Swagger,Swagger根据Controller类中的注解生成接口文档,在模型类上也可以添加注解对模型类的属性进行说明,方便对接口文档的阅读,例如在我们之前编写的PageParams模型类上添加注解
1 | @Data |
- 重启服务,再次进入接口文档,可以看到添加的描述
- Swagger常用的注解如下
@Api | 修饰整个类,描述Controller的作用 |
---|---|
@ApiOperation | 描述一个类的一个方法,或者说一个接口 |
@ApiParam | 单个参数描述 |
@ApiModel | 用对象来接收参数 |
@ApiModelProperty | 用对象接收参数时,描述对象的一个字段 |
@ApiResponse | HTTP响应其中1个描述 |
@ApiResponses | HTTP响应整体描述 |
@ApiIgnore | 使用该注解忽略这个API |
@ApiError | 发生错误返回的信息 |
@ApiImplicitParam | 一个请求参数 |
@ApiImplicitParams | 多个请求参数 |
接口开发
DAO开发
- 业务层为接口层提供业务处理支撑,本项目业务层包括了持久层代码,一些大型公司的团队职责划分更细,会将持久层和业务层分为两个工程,不过这需要增加成本
- DAO即数据访问对象,通过DAO去访问数据库对数据进行持久化,本项目持久层使用MyBatis-Plus进行开发
- 持久层的基础代码我们使用MP提供的代码生成器生成,将生成的Mapper和对应的xml拷贝纸service工程的com.xuecheng.content.mapper包下
- 同时在service的pom.xml中添加MP和日志的一些依赖
1 | <dependencies> |
- 在com.xuecheng.content.config包下创建MP配置类,配置分页拦截器
1 | /** |
- 然后在yml中配置数据库连接信息和日志信息等
1 | spring: |
- 最后编写测试方法并进行测试,控制台可以输出查到的数据(前提保证你的id真的对应有数据)
1 |
|
Service开发
-
数据字典表
- 课程基本信息查询的主要数据来源是课程基本信息表,这里有一点需要注意:课程的审核状态、发布状态
- 审核状态在查询条件和查询结果中都存在,包括:未审核、审核通过、审核未通过这三种
- 那么我们思考一个问题:直接在数据库表中的字段填充
审核未通过
这几个大字,合适吗?- 如果将
审核未通过
这五个字记录在课程基本信息表中,查询出来的状态就是审核未通过
这几个字,那么如果有一天客户想把审核未通过
改为未通过
,怎么办? - 词汇我们可以批量处理数据库中的数据,写一个update语句,将所有的
审核未通过
更新为未通过
。看起来解决了问题,但是万一后期客户抽风又想改呢?真实情况就是这样,但是这一类数据也有共同点:它有一些分类项,且这些分类项比较固定,大致的意思都是一样的,只是表述方式不一样。
- 如果将
- 那么针对这一类数据,为了提高系统的可扩展性,专门定义数据字典去维护,例如
1
2
3
4
5[
{"code":"202001","desc":"审核未通过"},
{"code":"202002","desc":"未审核"},
{"code":"202003","desc":"审核通过"}
] - 那么我们创建系统管理数据库xc_system,在其中创建管理系统服务的数据表,导入黑马提供的SQL脚本就好了。这样查询出的数据在前端展示时,就根据代码取出它对应的内容显示给用户。如果客户需要修改
审核未通过
的显示内容,直接在数据字典中修改就好了,无需修改课程基本信息表
-
Service开发
- 首先创建Service接口
1
2
3
4
5
6
7
8
9public interface CourseBaseInfoService {
/**
* 课程查询接口
* @param pageParams 分页参数
* @param queryCourseParams 查询条件
* @return
*/
PageResult<CourseBase> queryCourseBaseList(PageParams pageParams, QueryCourseParamDto queryCourseParams);
}- 然后创建实现类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class CourseBaseInfoServiceImpl implements CourseBaseInfoService {
CourseBaseMapper courseBaseMapper;
public PageResult<CourseBase> queryCourseBaseList(PageParams pageParams, QueryCourseParamDto queryCourseParams) {
// 构建条件查询器
LambdaQueryWrapper<CourseBase> queryWrapper = new LambdaQueryWrapper<>();
// 构建查询条件:按照课程名称模糊查询
queryWrapper.like(StringUtils.isNotEmpty(queryCourseParams.getCourseName()), CourseBase::getCompanyName, queryCourseParams.getCourseName());
// 构建查询条件,按照课程审核状态查询
queryWrapper.eq(StringUtils.isNotEmpty(queryCourseParams.getAuditStatus()), CourseBase::getAuditStatus, queryCourseParams.getAuditStatus());
// 构建查询条件,按照课程发布状态查询
queryWrapper.eq(StringUtils.isNotEmpty(queryCourseParams.getPublishStatus()), CourseBase::getStatus, queryCourseParams.getPublishStatus());
// 分页对象
Page<CourseBase> page = new Page<>(pageParams.getPageNo(), pageParams.getPageSize());
// 查询数据内容获得结果
Page<CourseBase> pageInfo = courseBaseMapper.selectPage(page, queryWrapper);
// 获取数据列表
List<CourseBase> items = pageInfo.getRecords();
// 获取数据总条数
long counts = pageInfo.getTotal();
// 构建结果集
return new PageResult<>(items, counts, pageParams.getPageNo(), pageParams.getPageSize());
}
}- 使用单元测试类进行测试,
1
2
3
4
5
6
7
8
CourseBaseInfoService courseBaseInfoService;
void contextQueryCourseTest() {
PageResult<CourseBase> result = courseBaseInfoService.queryCourseBaseList(new PageParams(1L, 10L), new QueryCourseParamDto());
log.info("查询到数据:{}", result);
}
接口代码完善
- 控制层、业务层、持久层三层通常可以面向接口并行开发,比如:业务层开发的同时可以先只编写一个Service接口,接口层的同时即可以面向Service接口去开发,待接口层和业务层完成后进行联调。
- 下面是课程查询接口的实现
1 |
|
- 我们可以在Swagger中进行测试,也可以在PostMan中进行测试
- 这里演示一下在Swagger中测试,输入参数,这里查询条件均设为空,当前页码1,页大小2
- 测试成功,查询到了数据
- 这里演示一下在Swagger中测试,输入参数,这里查询条件均设为空,当前页码1,页大小2
接口测试
HttpClient测试
- Swagger是一个在线接口文档,虽然使用它也能测试,但是需要浏览器进入Swagger,最关键的是它不能保存测试数据。
- PostMan对内存的消耗也比较大,而且需要下载客户端。
- 在IDEA中有一个非常方便的http接口测试工具HTTP Client,下面介绍它的使用方法,后面我们使用它来进行接口测试
- 进入Controller类,找到HTTP接口对应的方法
- 点击
在HTTP客户端中生成请求
,即可生成一个测试用例,IDEA会为我们生成一个.http结尾的文件,我们可以添加请求参数进行测试
1 | #### 课程查询列表 |
- 同样通过测试,可以查询到数据
- .http文件即测试用例文档,它可以随着项目工程一起保存(也可以提交git),这样测试数据就可以保存下来,方便进行测试
- 为了方便保存.http文件,我们单独在项目工程的根目录下创建一个目录单独来存放他们
- 同时为了将来方便和网关集成测试,这里把测试主机地址在配置文件http-client.env.json中配置
1 | { |
- 那么现在就可以用
{{content_host}}
替换掉原来的http://localhost:63040 了,同时环境改为dev
导入系统管理服务
- 要进行前后端联调首先启动前端工程,浏览器访问http://localhost:8601/ ,此时会报错,因为还有一个接口我们还没有完成:http://localhost:63110/system/dictionary/all
- 该接口指向的是系统管理服务,次链接是前端请求后端获取数据字典数据的接口地址
- 拷贝黑马提供的xuecheng-plus-system工程到项目根目录即可,然后修改数据库连接配置,该工程仅包含数据字典对应的PO类,Mapper映射和一些配置类,然后提供了两个简单的接口,查询全部数据文档。
- 启动系统管理服务,浏览器访问http://localhost:63110/system/dictionary/all ,如果可以正常读取数据字典的信息,则说明导入成功
解决跨域问题
- 启动前端工程,工程首页不能正常显示,查看浏览器报错如下
1 | Access to XMLHttpRequest at 'http://harib-eir.info/xuecheng-plus.com?adTagId=dbb6a410-0bec-11ec-8010-0a70670a1f67&fallbackUrl=ww87.xuecheng-plus.com' (redirected from 'http://localhost:8601/api/content/course/list?pageNo=1&pageSize=10') from origin 'http://localhost:8601' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. |
-
提示:从http://localhost:8601访问http://localhost:63110/system/dictionary/all被CORS policy阻止,因为没有Access-Control-Allow-Origin 头信息。CORS全称是 cross origin resource share 表示跨域资源共享。
-
比如:
- 从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.11:8601 由于协议不同,是跨域。
- 浏览器判断是跨域请求会在请求头上添加origin,表示这个请求来源哪里,例如
1 | GET / |
- 服务器接收到请求判断这个Origin是否跨域,如果允许则在响应头中说明允许该来源的跨域请求,如下
1 | Access-Control-Allow-Origin:http://localhost:8601 |
- 如果允许域名来源的跨域请求,则响应如下
1 | Access-Control-Allow-Origin:* |
-
解决跨域的方法
- JSONP
- 通过script标签的src属性进行跨域请求,如果服务端要响应内容,则先读取请求参数callback值,callback是一个回调函数的名称,服务端读取callback的值后将响应内容通过调用callback函数的方式告诉请求方
- 通过script标签的src属性进行跨域请求,如果服务端要响应内容,则先读取请求参数callback值,callback是一个回调函数的名称,服务端读取callback的值后将响应内容通过调用callback函数的方式告诉请求方
- 添加响应头
- 服务端在响应头添加
Access-Control-Allow-Origin: *
- 服务端在响应头添加
- 通过nginx代理跨域
- 由于服务端之间没有跨域,浏览器通过nginx去访问跨域地址
- 浏览器先访问http://192.168.101.10:8601 nginx提供的地址,进入页面
- 此页面要跨域访问http://192.168.101.11:8601 ,不能直接跨域访问http://www.baidu.com:8601 ,而是访问nginx的一个同源地址,比如:http://192.168.101.11:8601/api ,通过http://192.168.101.11:8601/api 的代理去访问http://www.baidu.com:8601。
- 这样就实现了跨域访问。
- 浏览器到http://192.168.101.11:8601/api 没有跨域
- nginx到http://www.baidu.com:8601 通过服务端通信,没有跨域。
- JSONP
-
这里采用添加请求头的方式解决跨域问题。在xuecheng-plus-system-api模块下新建配置类GlobalCorsConfig
1 |
|
- 此配置类实现了跨域过滤器,在响应头添加
Access-Control-Allow-Origin
- 重启系统管理服务,前端工程可以正常进入http://localhost:8601 ,查看NetWork选项卡,跨域问题成功解决
前后端联调
- 前端启动完毕,在启动内容管理服务端。
- 前端默认连接的是项目的网关地址,由于现在网关工程还没有创建,这里需要更改前端工程的参数配置文件,修改网关地址为内容管理服务的地址
- 编辑.env文件
1 | ## 前台管理页面-端口 |
- 启动前端工程,进入课程管理,可以看到界面显示的课程信息,那么到此就基本完成了前后端联调
课程分类查询
需求分析
- 下面我们进行添加课程的接口开发,在新增课程界面,有三处信息需要选择,课程分类、课程等级、课程类型
- 其中,课程等级和课程类型都来源于数字字典表,此部分的信息前端已从系统管理服务中读取。但是课程类型的数据是通过另外一个接口来读取的,现在还没有编写
1 | 请求网址: http://localhost:8601/api/content/course-category/tree-nodes |
- 课程分类信息没有在数据字典表中存储,而是有单独一张课程分类表,下面我们来看看课程分类表的结构
- 这张表是一个树形结构,通过父节点id将各元素组成一个树,下面是一部分数据
- 那么现在的需求就是:在内容管理服务中编写一个接口,读取课程分类表的数据,组成一个树形结构返回给前端
接口定义
- 通过查看前端的请求记录,可以得出该接口的协议为:HTTP GET,请求参数为空
1 | 请求网址: http://localhost:8601/api/content/course-category/tree-nodes |
- 此接口要返回全部课程分类,以树状结构返回,下面是示例
1 | [ |
- 可以看到,上面的数据格式是一个数组结构,数组的元素即为分类信息,分类信息设计两级分类
- 第一级的分类信息示例如下,这部分字段其实就是课程分类信息表的属性,即我们之前生成的CourseCategory类
1
2
3
4
5
6
7"id" : "1-2",
"isLeaf" : null,
"isShow" : null,
"label" : "移动开发",
"name" : "移动开发",
"orderby" : 2,
"parentid" : "1"- 第二级的分类是第一级分类中的childrenTreeNode属性,它是一个数组结构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32"childrenTreeNodes" : [
{
"childrenTreeNodes" : null,
"id" : "1-2-1",
"isLeaf" : null,
"isShow" : null,
"label" : "微信开发",
"name" : "微信开发",
"orderby" : 1,
"parentid" : "1-2"
},
{
"childrenTreeNodes" : null,
"id" : "1-2-2",
"isLeaf" : null,
"isShow" : null,
"label" : "iOS",
"name" : "iOS",
"orderby" : 2,
"parentid" : "1-2"
},
{
"childrenTreeNodes" : null,
"id" : "1-2-3",
"isLeaf" : null,
"isShow" : null,
"label" : "其它",
"name" : "其它",
"orderby" : 8,
"parentid" : "1-2"
}
] - 那么我们可以定义一个DTO类表示分类信息的模型类,让其继承CourseCategory(对应一级分类),然后设置一个List属性(对应二级分类)
1 |
|
- 这样模型类就定义好了,下面我们来定义接口
1 |
|
接口开发
- 如何生成一个树形结构对象?这里有两种方案
- 可以将数据从数据库中读取出来,在Java程序中遍历数据组成一个树形结构对象
- 那这里先简单介绍一下如何编写SQL语句,注意到表中设置了每条数据的id和parentid,我们可以据此来创建内连接查询
- 连接条件为
two.parentid = one.id
(二级分类的父节点为一级分类),同时one.parentid = 1
(一级分类的父节点为根节点),这样就可以查询出所有的数据
1 | SELECT * FROM |
- 但是此种方式存在一点问题,如果我们有三级分类的话,那么还得继续修改SQL语句
- 所以如果当树的层级不固定时,此时就可以使用MySQL的递归实现,使用with语法,下面举一个简单的例子
1 | WITH RECURSIVE t1 AS ( |
- 它会把查询出来的结果再次代入到查询子句中继续查询,这里的
SELECT 1 AS n
,就是给一个递归的初始值,WHERE n < 5
则是终止条件 - 那么我们现在来使用递归查询全部数据
1 | WITH RECURSIVE t1 AS ( |
- 成功查询到数据了之后,我们现在就需要用Java代码将其组装成树形结构,在此之前,我们先来编写mapper(采用更灵活的递归查询)
- 现在service中编写mapper对应的接口
1
2
3public interface CourseCategoryMapper extends BaseMapper<CourseCategory> {
List<CourseCategoryTreeDto> selectTreeNodes();
}- 编写xml
1
2
3
4
5
6
7
8<select id="selectTreeNodes" parameterType="string" resultMap="com.xuecheng.content.model.dto.CourseCategoryTreeDto">
WITH RECURSIVE t1 AS (
SELECT p.* FROM course_category p WHERE p.id = #{id}
UNION ALL
SELECT c.* FROM course_category c JOIN t1 WHERE c.parentid = t1.id
)
SELECT * FROM t1;
</select> - mapper写好了之后,我们来编写Service,首先定义接口
1 | public interface CourseCategoryService { |
- 具体实现,我们的mapper接口只返回了所有子节点的数据,那么现在我们要将这些子节点封装成一个树形结构
1 |
|
- 编写测试方法,进行调试
1 |
|
- 使用HTTP Client测试接口
- 编写Controller方法
1 |
|
- 重启服务,已经可以看到课程分类的信息了
- 通过表的自连接查出数据,使用mybatis映射成一个树形结构
- 定义mapper接口方法,编写SQL
1 | public interface CourseCategoryMapper extends BaseMapper<CourseCategory> { |
- 编写对应的XML
1 | <select id="selectTreeNodes" resultMap="treeNodeResultMap" > |
- 编写resultMap映射
1 | <!-- 课程分类树型结构查询映射结果 --> |
- service接口和对应的实现类,测试方法,均与方案一一致,这里就不予赘述了
新增课程
需求分析
业务流程
- 根据前面对内容管理模块的数据模型分析,课程相关的信息有:课程基本信息、课程营销信息、课程图片信息、课程计划、课程师资信息,所以新增一门课程就需要完成这几部分的信息的编写
- 以下是业务流程
- 进入课程查询列表
- 点击添加课程,选择课程类型是直播还是录播,课程类型不同,课程的授课方式也不同
- 选择完毕,点击下一步,进入课程基本信息添加界面
- 本界面分为两部分信息,一部分是课程基本信息,一部分是课程营销信息
- 添加课程计划信息
- 课程计划即课程的大纲目录
- 课程计划分为两级,章节和小节
- 每个小节需要上传课程视频,用户点击小节标题即可开始播放视频
- 如果是直播课程,则会进入直播间
- 课程计划填写完毕,进入课程师资管理
- 在课程师资界面维护该课程的授课老师
- 到此,一门课程新增完成
数据模型
- 新增课程功能,只向课程基本信息表、课程营销信息表添加记录
- 课程基本信息表
- 课程营销信息表
- 课程基本信息表
- 新建课程的初始审核状态为
未提交
,初始发布状态为未发布
接口定义
- 根据业务流程,这里先定义提交课程基本信息的接口
1 | 请求网址: http://localhost:8601/api/content/course |
- 接口协议:HTTP POST
- 接口请求示例如下
1 | #### 创建课程 |
- 请求参数和CourseBase模型类不一致,所以我们需要定义一个模型类来接收请求参数;同时,响应结果中包含了课程基本信息和课程营销信息还有课程分类信息,所以我们也需要定义一个响应结果模型类
1 | package com.xuecheng.content.model.dto; |
1 | package com.xuecheng.content.model.dto; |
- 定义接口如下,返回类型和请求参数类型均为我们刚刚创建的模型类
1 |
|
接口开发
- 定义service接口,这里额外需要一个机构id,因为我们的业务是教学机构登录账号,然后添加该教学机构的下属课程
1 | /** |
- 编写service接口实现类
- 首先我们需要对请求参数做合法性校验,判断一下用户是否输入了必填项,还有一些项的默认值
- 然后对请求参数进行封装,调用mapper进行数据持久化
- 组装返回结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
CourseBaseMapper courseBaseMapper;
CourseMarketMapper courseMarketMapper;
CourseCategoryMapper courseCategoryMapper;
public CourseBaseInfoDto createCourseBase(Long companyId, AddCourseDto addCourseDto) {
// 1. 合法性校验
if (StringUtils.isBlank(addCourseDto.getName())) {
throw new RuntimeException("课程名称为空");
}
if (StringUtils.isBlank(addCourseDto.getMt())) {
throw new RuntimeException("课程分类为空");
}
if (StringUtils.isBlank(addCourseDto.getSt())) {
throw new RuntimeException("课程分类为空");
}
if (StringUtils.isBlank(addCourseDto.getGrade())) {
throw new RuntimeException("课程等级为空");
}
if (StringUtils.isBlank(addCourseDto.getTeachmode())) {
throw new RuntimeException("教育模式为空");
}
if (StringUtils.isBlank(addCourseDto.getUsers())) {
throw new RuntimeException("适应人群为空");
}
if (StringUtils.isBlank(addCourseDto.getCharge())) {
throw new RuntimeException("收费规则为空");
}
// 2. 封装请求参数
// 封装课程基本信息
CourseBase courseBase = new CourseBase();
BeanUtils.copyProperties(addCourseDto, courseBase);
// 2.1 设置默认审核状态(去数据字典表中查询状态码)
courseBase.setAuditStatus("202002");
// 2.2 设置默认发布状态
courseBase.setStatus("203001");
// 2.3 设置机构id
courseBase.setCompanyId(companyId);
// 2.4 设置添加时间
courseBase.setCreateDate(LocalDateTime.now());
// 2.5 插入课程基本信息表
int baseInsert = courseBaseMapper.insert(courseBase);
Long courseId = courseBase.getId();
// 封装课程营销信息
CourseMarket courseMarket = new CourseMarket();
BeanUtils.copyProperties(addCourseDto, courseMarket);
courseMarket.setId(courseId);
// 2.6 判断收费规则,若课程收费,则价格必须大于0
String charge = courseMarket.getCharge();
if ("201001".equals(charge)) {
Float price = addCourseDto.getPrice();
if (price == null || price.floatValue() <= 0) {
throw new RuntimeException("课程设置了收费,价格不能为空,且必须大于0");
}
}
// 2.7 插入课程营销信息表
int marketInsert = courseMarketMapper.insert(courseMarket);
if (baseInsert <= 0 || marketInsert <= 0) {
throw new RuntimeException("新增课程基本信息失败");
}
// 3. 返回添加的课程信息
return getCourseBaseInfo(courseId);
}
private CourseBaseInfoDto getCourseBaseInfo(Long courseId) {
CourseBaseInfoDto courseBaseInfoDto = new CourseBaseInfoDto();
// 1. 根据课程id查询课程基本信息
CourseBase courseBase = courseBaseMapper.selectById(courseId);
if (courseBase == null)
return null;
// 1.1 拷贝属性
BeanUtils.copyProperties(courseBase, courseBaseInfoDto);
// 2. 根据课程id查询课程营销信息
CourseMarket courseMarket = courseMarketMapper.selectById(courseId);
// 2.1 拷贝属性
if (courseMarket != null)
BeanUtils.copyProperties(courseMarket, courseBaseInfoDto);
// 3. 查询课程分类名称,并设置属性
// 3.1 根据小分类id查询课程分类对象
CourseCategory courseCategoryBySt = courseCategoryMapper.selectById(courseBase.getSt());
// 3.2 设置课程的小分类名称
courseBaseInfoDto.setStName(courseCategoryBySt.getName());
// 3.3 根据大分类id查询课程分类对象
CourseCategory courseCategoryByMt = courseCategoryMapper.selectById(courseBase.getMt());
// 3.4 设置课程大分类名称
courseBaseInfoDto.setMtName(courseCategoryByMt.getName());
return courseBaseInfoDto;
}
- 下面编写Controller方法
1 |
|
接口测试
- 使用HTTP Client测试
1 | #### 新增课程 |
- 测试成功,去数据库中查询,也可以看到数据
异常处理
异常问题分析
- 在service方法中,有很多的参数合法性校验,当参数不合法的时候抛出异常,例如我们添加课程时,设置一个负数的课程价格,会报500异常
1 | { |
- 现在存在一个问题:并没有输出我们抛出异常时的指定异常信息,只有查看控制台日志才能看到异常信息
1 | java.lang.RuntimeException: 课程设置了收费,价格不能为空,且必须大于0 |
- 所以我们现在要对异常信息进行处理,异常处理除了输出在日志中,还需要提示给用户。
- 前端和后端需要做一些约定
- 错误体会信息统一以json格式返回给前端
- 以HTTP状态码决定当前是否出错,非
200
状态码为操作异常
- 那么如何规范异常信息?
- 代码中统一抛出项目的自定义异常类型,这样可以统一去捕获这一类或几类的异常
- 规范了异常类型,就可以去获取异常信息
- 如果捕获了非项目自定义的异常类型,则统一向用户提示
执行过程异常,请重试
的错误信息
- 如何捕获异常?
- 代码统一使用try/catch方式去捕获代码比较臃肿,可以通过SpringMVC提供的控制器增强类统一由一个类去完成异常的捕获
- 代码统一使用try/catch方式去捕获代码比较臃肿,可以通过SpringMVC提供的控制器增强类统一由一个类去完成异常的捕获
统一异常处理实现
- 根据上面分析的方案,统一在base基础工程实现统一异常处理,各模块依赖了base基础工程,都可以使用
- 统一异常处理的步骤如下
- 在base工程中添加依赖
1
2
3
4
5
6
7
8<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>- 定义一个枚举类,枚举一些通用的异常信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20/**
* @description 通用错误信息
*/
public enum CommonError {
UNKOWN_ERROR("执行过程异常,请重试"),
PARAS_ERROR("非法参数"),
OBJECT_NULL("对象为空"),
QUERY_NULL("查询结果为空"),
REQUEST_NULL("请求参数为空");
private String errMessage;
public String getErrMessage() {
return errMessage;
}
CommonError(String errMessage) {
this.errMessage = errMessage;
}
}- 自定义异常类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27/**
* @description 学成在线项目异常类
*/
public class XueChengPlusException extends RuntimeException {
private String errMessage;
public String getErrMessage() {
return errMessage;
}
public XueChengPlusException() {
super();
}
public XueChengPlusException(String errMessage) {
super(errMessage);
this.errMessage = errMessage;
}
public static void cast(CommonError commonError) {
throw new XueChengPlusException(commonError.getErrMessage());
}
public static void cast(String errMessage) {
throw new XueChengPlusException(errMessage);
}
}- 响应用户的统一类型
1
2
3
4
5
6
7
public class RestErrorResponse implements Serializable {
private String errMessage;
}- 全局异常处理器
- 从
Spring 3.0
-Spring 3.2
版本之间,对Spring架构和SpringMVC的Controller的异常捕获提供了相应的异常处理@ExceptionHandler
:Spring3.0
提供的标识,在方法上或类上的注解,用于表明方法的处理异常类型@ControllerAdvice
:Spring3.2
提供的新注解,用于增强SpringMVC中的Controller。通常与@ExceptionHandler
结合使用,来处理SpringMVC的异常信息@ResponseStatus
:Spring3.0
提供的标识在方法或类上的注解,用状态码和应返回的原因标记方法或异常类。调用处理程序方法时,状态码将应用于HTTP响应
- 通过上面的注解便可实现微服务全局异常处理,具体代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22/**
* @description 全局异常处理器
*/
public class GlobalExceptionHandler {
// 该异常枚举错误码为500,
public RestErrorResponse customException(XueChengPlusException exception) {
log.error("系统异常:{}", exception.getErrMessage());
return new RestErrorResponse(exception.getErrMessage());
}
public RestErrorResponse exception(Exception exception) {
log.error("系统异常:{}", exception.getMessage());
return new RestErrorResponse(exception.getMessage());
}
} - 从
异常处理测试
- 在ContentAPI的启动类上添加
scanBasePackages = "com.xuecheng"
1 |
|
- 在异常处理测试之前,我们首先要将代码中抛出自定义类型的异常
1 | if (price == null || price.floatValue() <= 0) { |
- 使用HTTP Client进行测试,故意将收费课程价格设置为负数,捕获到的响应信息如下
1 | POST http://localhost:53040/content/course |
- 前后端联调,测试前端会不会抛出我们自定义的异常信息
- 那么至此,项目的异常处理测试完毕,我们在开发中对于业务分支中的错误,要抛出项目自定义的异常类型
面试
- 系统如何处理异常?
- 我们自定义一个统一的异常处理器去捕获并处理异常
- 使用控制器增强注解@ControllerAdvice(也可以用@RestControllerAdvice)和异常处理注解@ExceptionHandler来实现
- 如何处理自定义异常?
- 程序在编写代码时,根据校验结果主动抛出自定义异常类对象,抛出异常时指定详细的异常信息,异常处理器捕获异常信息记录、异常日志,并响应给用户
- 如何处理未知异常?
- 接口执行过程中的一些运行时异常也会被异常处理器统一捕获,记录异常日志,统一响应给用户500错误
- 在异常处理其中还可以对某个异常类型进行单独处理(使用@ExceptionHandler来声明要捕获的异常类型)
JSR303校验
统一校验的需求
- 前端请求后端接口传输参数,是在
Controller
中校验还是Service
中校验?- 答案是都需要校验,只是分工不同
Controller
中校验请求参数的合法性,包括:必填项校验、数据格式校验,比如:参数是否符合一定的日期格式等Controller
中可以将校验的代码写成通用代码
Service
中要校验的是业务规则相关的内容,比如:课程已经审核通过,所以提交失败等Service
中需要根据业务规则去校验,所以不方便写成通用代码
- 早在
JavaEE6
规范中,就定义了参数校验的规范,它就是JSR-303
,它定义了Bean Validation
,即对bean属性进行校验 SpringBoot
提供了JSR-303
的支持,它就是spring-boot-stater-validation
,它的底层使用Hibernate Validation
,Hibernate Validation
是Bean Validation
的参考实现- 所以我们打算在
Controller
层使用spring-boot-stater-validation
完成对参数的基本合法性进行校验
统一校验实现
- 首先在Base工程中引入
spring-boot-starter-validation
依赖
1 | <dependency> |
- 现在准备对内容管理模块添加课程接口进行校验
1 |
|
- 此接口使用AddCourseDto模型对象接收参数,所以进入AddCourseDto模型类,在属性上添加校验规则
1 | @Data |
- 上面用到了
@NotEmpty
和@Size
两个注解,@NotEmpty
表示属性不能为空,@Size
表示限制属性内容的长度 - 在javax.validation.constraints包下有很多这样的校验注解
限制 | 说明 |
---|---|
@Nul | 限制只能为null |
@NotNull | 限制制必须不为null |
@AssertFalse | 限制必须为false |
@AssertTrue | 限制必须为true |
@DecimalMax(value) | 限制必须为一个不大于指定值的数字 |
@DecimalMin(value) | 限制必须为一个不小于指定值的数字 |
@Digits(integer fraction | 限制必须为一个小数,且整数部分的位数不能超过integer,小数部分的位数不能超过fraction |
@Future | 限制必须是一个将来的日期 |
@Max(value) | 限制必须为一个不大于指定值的数字 |
@Min(value) | 限制必须为一个不小于指定值的数字 |
@Past | 限制必须是一个过去的日期 |
@Pattern(value) | 限制必须符合指定的正则表达式 |
@Size(max, min) | 限制字符长度必须在min到max之间 |
@NotEmpty | 验证注解的元素值不为null且不为空(字符串长度不为0、集合大小不为0) |
@NotBlank | 验证注解的元素值不为空(不为null、去除首位空格后长度为0),不同于@NotEmpty,@NotBlank只应用于字符串且在比较时会去除字符串的空格 |
验证注解的元素值是Email,也可以通过正则表达式和flag指定自定义的email格式 |
- 定义好了规则校验,还需要开启校验,在Controller方法中添加
@Validated
注解,如下
1 |
|
- 如果校验出错,Spring会抛出
MethodArgumentNotValidException
异常,我们需要在全局异常处理器中捕获异常,解析出异常信息
1 |
|
- 重启内容管理服务,使用HTTP Client进行测试,将必填项设置为空,适用人群设置小于10个字,执行测试,接口响应结果如下
1 | POST http://localhost:53040/content/course |
- 可以看到校验器已生效
分组校验
- 有时候在同一个属性上设置一个校验规则不能满足要求
- 比如:订单标号是由系统生成,所以在添加订单时,要求订单编号为空;但是在更新订单时,要求订单编号不能为空。
- 此时就需要用到分组校验,在同一个属性定义多个校验规则属于不同的分组
- 比如:添加订单定义
@NULL
规则属于insert
分组,更新订单定义@NotEmpty
属于update
分组,insert
和update
是分组的名称,可以自定义的
- 比如:添加订单定义
1 |
|
- 下面举例说明,我们用class类型来表示不同的分组,所以我们定义不同的接口类型(空接口)表示不同的分组,由于校验分组是公用的,所以定义在base工程中
1 | package com.xuecheng.base.exception; |
- 下面在定义校验规则时指定分组
1 |
|
- 在Controller方法中启动校验规则时指定要使用的分组名
1 |
|
- 重启服务,使用HTTP Client进行测试
1 | POST http://localhost:53040/content/course |
- 由于这里指定的是Insert分组,所以抛出异常信息
添加课程名称不能为空
,如果修改分组为Update分组,则异常信息为修改课程名称不能为空
,符合我们的预期
校验规则不满足?
- 如果
javax.validation.constraints
包下的校验规则满足不了需求怎么办?- 手写校验代码
- 自定义校验规则注解
面试
- 请求参数的合法性如何校验?
- 使用基于
JSR-303
的校验框架实现,SpringBoot
提供了JSR-303
的支持,它就是spring-boot-starter-validation
,它包括了很多校验规则,只需要在模型类中通过注解指定校验规则,在Controller
方法上开启校验。
- 使用基于
修改课程
需求分析
业务流程
- 点击课程列表查询
- 点击编辑,此时应该看到对应数据的回显数据
- 点击保存,完成修改
数据模型
- 修改课程还是涉及到之前的课程基本信息表和课程营销信息表
- 但是修改课程提交的数据比新增课程多了一项课程id,因为修改课程需要对某个具体的课程进行修改,新增课程的课程id是系统生成的
- 修改完成保存数据时,还需要更新课程基本信息表中的修改时间
接口定义
查询课程信息
- 定义根据课程id查询课程信息的接口,接口示例如下
1 | GET /content/course/40 |
- 查询结果为单挑课程信息,内容和新增课程的返回结果一一致,接口定义如下
1 |
|
修改课程信息
- 根据前面的数据模型分析,修改课程提交的数据比新增课程提交的数据多了一个课程id,接口示例如下
1 | #### 修改课程 |
- 那么我们这里就需要再定义一个模型类,用于接收修改课程提交的数据,定义一个课程id属性,然后继承AddCourseDto就好了
1 |
|
- 接口定义如下,请求路径与新增课程的一致,只是请求方式不同
1 |
|
接口开发
查询课程信息
- 之前我们在做新增课程的时候,就已经编写过了查询课程的代码
1 | private CourseBaseInfoDto getCourseBaseInfo(Long courseId) { |
- 现在只需要将查询课程信息的方法提到接口上,这样在Controller中就可以通过Service接口调用此方法
1 | /** |
- 完善Controller层代码
1 |
|
修改课程信息
- 在Service层新增修改课程的接口与方法
1 | /** |
- 对应的实现方法如下
1 |
|
- 需要注意的一点是:saveOrUpdate方法是MP提供的,我们要事先编写courseMarketServiceImpl,并将其注入
1 |
|
- 完善接口层代码
1 |
|
接口测试
- 使用HTTP Client进行测试查询课程,可以看到响应的正常数据
1 | #### 根据课程id查询课程基本信息 |
- 使用HTTP Client测试修改课程信息
1 | #### 修改课程 |
- 修改成功后,去数据库中查看数据是否修改成功
代码优化
- 程序员写的代码不仅要完成功能实现,还要养成代码重构优化的习惯,这样久而久之在写代码的过程中就养成了代码抽取和封装的习惯
- 例如刚刚我们写的代码,在新增课程和修改课程中,都对课程营销信息进行了保存,且都校验了课程营销信息的价格字段,都是先判断收费状况,然后校验价格,最后保存,那么这里就可以将对课程营销信息的校验和保存相关代码进行抽取,如下
1 | private int saveCourseMarket(CourseMarket courseMarket) { |
查询课程计划
需求分析
业务流程
- 当我们添加/修改完课程基本信息后,将自动进入课程计划编辑界面
- 课程计划即课程的大纲目录
- 课程计划分为两级:章节和小节
- 本小节完成课程计划信息的查询
数据模型
- 课程计划查询也是一个树状结构,表结构如下
- 每个课程计划都有所属课程
- 每个课程的课程计划有两个级别
- 第一级为章,grade为1
- 第二季为小节,grade为2
- 第二级的parentid为第一级的id
- 根据业务流程中的界面原型,课程计划列表展示时还有课程计划关联的视频信息
- 课程计划关联的视频信息在teachplan_media表
- 两张表是一对一关系,每个课程计划只能在teachplan_media表中存在一个视频
接口定义
- 接口示例如下
1 | GET /teachplan/22/tree-nodes |
- 查询课程计划的请求参数:课程id
- 查询课程计划的响应结果为一个数组,数组中的元素除了包括Teachplan表中的内容,还关联了TeachplanMedia和teachPlanTreeNodes,即课程媒资信息和子目录
- 所以对于响应结果,我们需要自定义一个模型类,让该模型类继承Teachplan,然后其中再新增
TeachplanMedia
和List<TeachPlanTreeNodes>
属性(子目录不止一个,可以有多个)
1 |
|
- 接口定义如下
1 |
|
接口开发
- 使用SQL语句查询课程计划,组成一个树形结构
- 在TeachplanMapper中定义方法
1 | public interface TeachplanMapper extends BaseMapper<Teachplan> { |
- 编写SQL语句
- 一级分类和二级分类通过teachplan表的自连接进行,如果一级分类旗下没有二级分类,此时也需要显示一级分类,所以这里使用左连接,左边是一级分类,右边是二级分类
- 同时课程的媒资信息teachplan_media也需要和teachplan左连接,左边是teachplan,右边是媒资信息teachplan_media
1 | SELECT |
- 定义mapper.xml,我们需要按照响应结果手动配置查询结果的映射,最终查询的结果要返回的是TeachplanDto类型
1 | { |
1 | <!-- 课程分类树形结构查询映射结果 --> |
- 最后定义Service接口,ServiceImpl实现类,完善Controller层代码
1 | public interface TeachplanService { |
1 |
|
1 |
|
接口测试
- 使用HTTP Client测试
1 | #### 根据课程id查询课程计划 |
- 前后端联调,进入课程编辑界面,点击保存,进入到课程计划编辑界面,可以看到已经存在的课程计划
新增/修改课程计划
需求分析
业务流程
- 进入课程计划界面
- 点击添加章,新增第一级课程计划
- 点击添加小节,可以向某第一级课程计划下添加小节
- 点击章/节的名称,可以修改名称、选择是否免费
数据模型
- 新增第一级课程计划
- 默认名称:
新章名称[点击修改]
- grade:1
- orderby:按添加顺序
- 默认名称:
- 新增第二级课程计划
- 默认名称:
新小节名称[点击修改]
- grade:2
- orderby:按添加顺序
- 默认名称:
- 修改第一级、第二级课程计划的名称,修改第二级课程计划是否免费
接口定义
- 接口示例如下
1 | #### 新增课程计划--章,当grade为1时parentid为0 |
- 我们可以通过同一个接口接收新增和修改两个业务的请求,以是否传递课程计划id来判断该请求是新增还是修改
- 新增和修改的唯一区别就是是否有id,如果传递了课程计划id,说明当前是要修改该课程计划,否则是新增一个课程计划
- 接收请求参数的模型类我这里用的是Teachplan,但黑马课件中用的是下面这个。
- 该类中的所有属性,在Teachplan类中均有,且前端的请求载荷不止这些属性,Teachplan中的属性更全面,且本人测试使用Teachplan作为模型类也能完成相同的功能
1 |
|
- 定义接口如下,我这里就用Teachplan当接收请求参数的模型类了
1 |
|
接口开发
- 定义保存课程计划的接口
1 | void saveTeachplan(Teachplan teachplan); |
- 编写接口实现
1 |
|
1 |
|
接口测试
- 前后端联调,分别测试新增章、新增小节、修改章/小节
BUG修改
- BUG是看不到添加的章节信息
- 我其实没遇到bug,因为代码是我自己敲的,看不到章节信息是因为SQL语句有问题,不应该用内连接,得用左外连接
- 当初看视频的时候,感觉这个内连接就有问题,新增章节下面没有小节信息,用内连接必然查不出来,因为连接条件是
c.parentid = p.id
- 用左外连接(章节当左),会显示左表的全部内容,那么不管章节下有没有小节信息,都会显示章节
- 当初看视频的时候,感觉这个内连接就有问题,新增章节下面没有小节信息,用内连接必然查不出来,因为连接条件是
1 | SELECT * FROM teachplan p |
内容管理模块实战
- 这部分要完成的内容包括
- 添加课程、添加课程计划、添加师资信息
- 修改课程、修改课程计划、修改师资信息
- 删除课程、删除课程计划、删除师资信息
- 课程计划上移、下移功能
删除课程计划
需求分析
- 课程计划添加成功,如果课程还没有提交,可以删除课程计划
- 删除第一级别的章时,要求章下边没有小节方可删除
- 删除第二级别的小节的同时,也需要将其关联的媒资信息也删除
- 删除课程计划需要传输课程计划的id
接口定义
- 删除课程计划的接口示例如下
1 | 删除结点 |
- 定义接口如下
1 |
|
接口开发
- 定义删除课程计划的接口
1 | void deleteTeachplan(Long teachplanId); |
- 对应的接口实现
1 |
|
- 该方案是由B站用户Topsail提出的
- 可以将判断条件改为只判断当前课程计划下是否有小节
- 有小节,则抛异常
- 无小节,则直接删除该课程计划和对应的媒资信息
1 |
|
- 完善Controller层接口
1 |
|
接口测试
- 删除有子计划的章节
- 删除没有子计划的章节和小节
- 删除有媒资信息的小节后,去数据库中查看对应的媒资信息是否真的被删除
课程计划排序
需求分析
- 课程计划新增后默认排在同级别后面,课程计划排序的功能是可以灵活调整课程计划的显示顺序
- 上移表示将该课程计划向上移动
- 下移表示将该课程计划向下移动
- 向上移动表示和上面的课程计划交换位置,将两个课程计划的排序字段值交换
- 向下移动表示和下面的课程计划交换位置,将两个课程计划的排序字段值交换
接口定义
- 接口示例如下
- 向下移动
1
2
3Request URL: http://localhost:8601/api/content/teachplan/movedown/43
Request Method: POST
43为课程计划id- 向上移动
1
2
3Request URL: http://localhost:8601/api/content/teachplan/moveup/43
Request Method: POST
43为课程计划id - 每次传递两个参数
- 移动类型:movedown和moveup
- 课程计划id
- 定义接口如下
1 |
|
接口开发
- 定义课程计划排序接口
1 | void orderByTeachplan(String moveType, Long teachplanId); |
- 编写对应的实现方法
- 写的稍微复杂了些,其实也可以简化,在lt和orderby的前面加上判断条件就可以,但是可读性会降低
1 |
|
- 完善Controller
1 |
|
接口测试
- 可以随意打乱顺序,且移动到最上或最下也会给出提示
师资管理
需求分析
- 这部分需要完成师资信息的查询、修改、新增、删除功能
- 不过这里机构校验在前面的新增课程已经完成了,不允许修改本机构外的课程
接口定义
- 接口示例
1 | get /courseTeacher/list/75 |
1 | post /courseTeacher |
1 | post /courseTeacher |
1 | delete /ourseTeacher/course/75/26 |
- 从接口示例中可以看到,新增和删除用的是同一个接口,判断请求是新增还是删除,是根据请求参数中是否传递了id来决定的
- 请求参数中没有id,则为新增教师
- 请求参数中有id,则为修改教师
- 定义的接口如下
1 |
|
接口开发
- 定义Service接口
1 | public interface CourseTeacherService { |
- 编写对应的实现方法
1 |
|
接口测试
- 进入教师设置页页面,对教师信息进行增删改查
删除课程
需求分析
- 课程的审核状态为未提交时方可删除。
- 删除课程需要删除课程相关的基本信息、营销信息、课程计划、课程教师信息。
接口定义
- 删除课程接口示例如下
1 | delete /course/87 |
- 定义的接口如下,这里需要指定companyId,只能删除本机构的课程
1 |
|
接口开发
- 定义Service接口
1 | void delectCourse(Long companyId, Long courseId); |
- 编写接口实现
1 |
|
接口测试
- 当我们尝试删除非本机构的课程时,会给出错误提示信息
- 当我们删除本机构课程时,会将课程对应的教师信息、课程计划、营销信息、课程基本信息均删除