下边根据内容管理模块的业务流程,下一步要实现新增课程,在新增课程界面,有三处信息需要选择,如下图:
课程等级、课程类型来源于数据字典表,此部分的信息前端已从系统管理服务读取。
课程分类信息没有在数据字典表中存储,而是由单独一张课程分类表,存储在内容管理数据库中。
这张表是一个树型结构,通过父结点id将各元素组成一个树。
我们可以看下该表的数据,下图是一部分数据:
现在的需求是需要在内容管理服务中编写一个接口读取该课程分类表的数据,组成一个树型结构返回给前端。
课程分类的PO类如下:
如果没有此po类则需要生成的此表的po类拷贝到内容管理模块的model工程中,将mapper拷贝到内容管理模块的service工程中。
http://localhost:8601/api/content/course-category/tree-nodes 该地址正是前端获取课程分类的接口地址。
通过上图界面的内容可以看出该接口的协议为:HTTP GET
请求参数为空。
通过查阅接口文档,此接口要返回全部课程分类,以树型结构返回,如下所示。
JSON
[
{
"childrenTreeNodes" : [
{
"childrenTreeNodes" : null,
"id" : "1-1-1",
"isLeaf" : null,
"isShow" : null,
"label" : "HTML/CSS",
"name" : "HTML/CSS",
"orderby" : 1,
"parentid" : "1-1"
},
{
"childrenTreeNodes" : null,
"id" : "1-1-2",
"isLeaf" : null,
"isShow" : null,
"label" : "JavaScript",
"name" : "JavaScript",
"orderby" : 2,
"parentid" : "1-1"
},
{
"childrenTreeNodes" : null,
"id" : "1-1-3",
"isLeaf" : null,
"isShow" : null,
"label" : "jQuery",
"name" : "jQuery",
"orderby" : 3,
"parentid" : "1-1"
},
{
"childrenTreeNodes" : null,
"id" : "1-1-4",
"isLeaf" : null,
"isShow" : null,
"label" : "ExtJS",
"name" : "ExtJS",
"orderby" : 4,
"parentid" : "1-1"
},
{
"childrenTreeNodes" : null,
"id" : "1-1-5",
"isLeaf" : null,
"isShow" : null,
"label" : "AngularJS",
"name" : "AngularJS",
"orderby" : 5,
"parentid" : "1-1"
},
{
"childrenTreeNodes" : null,
"id" : "1-1-6",
"isLeaf" : null,
"isShow" : null,
"label" : "ReactJS",
"name" : "ReactJS",
"orderby" : 6,
"parentid" : "1-1"
},
{
"childrenTreeNodes" : null,
"id" : "1-1-7",
"isLeaf" : null,
"isShow" : null,
"label" : "Bootstrap",
"name" : "Bootstrap",
"orderby" : 7,
"parentid" : "1-1"
},
{
"childrenTreeNodes" : null,
"id" : "1-1-8",
"isLeaf" : null,
"isShow" : null,
"label" : "Node.js",
"name" : "Node.js",
"orderby" : 8,
"parentid" : "1-1"
},
{
"childrenTreeNodes" : null,
"id" : "1-1-9",
"isLeaf" : null,
"isShow" : null,
"label" : "Vue",
"name" : "Vue",
"orderby" : 9,
"parentid" : "1-1"
},
{
"childrenTreeNodes" : null,
"id" : "1-1-10",
"isLeaf" : null,
"isShow" : null,
"label" : "其它",
"name" : "其它",
"orderby" : 10,
"parentid" : "1-1"
}
],
"id" : "1-1",
"isLeaf" : null,
"isShow" : null,
"label" : "前端开发",
"name" : "前端开发",
"orderby" : 1,
"parentid" : "1"
},
{
"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" : 3,
"parentid" : "1-2"
},
{
"childrenTreeNodes" : null,
"id" : "1-2-4",
"isLeaf" : null,
"isShow" : null,
"label" : "Swift",
"name" : "Swift",
"orderby" : 4,
"parentid" : "1-2"
},
{
"childrenTreeNodes" : null,
"id" : "1-2-5",
"isLeaf" : null,
"isShow" : null,
"label" : "Android",
"name" : "Android",
"orderby" : 5,
"parentid" : "1-2"
},
{
"childrenTreeNodes" : null,
"id" : "1-2-6",
"isLeaf" : null,
"isShow" : null,
"label" : "ReactNative",
"name" : "ReactNative",
"orderby" : 6,
"parentid" : "1-2"
},
{
"childrenTreeNodes" : null,
"id" : "1-2-7",
"isLeaf" : null,
"isShow" : null,
"label" : "Cordova",
"name" : "Cordova",
"orderby" : 7,
"parentid" : "1-2"
},
{
"childrenTreeNodes" : null,
"id" : "1-2-8",
"isLeaf" : null,
"isShow" : null,
"label" : "其它",
"name" : "其它",
"orderby" : 8,
"parentid" : "1-2"
}
],
"id" : "1-2",
"isLeaf" : null,
"isShow" : null,
"label" : "移动开发",
"name" : "移动开发",
"orderby" : 2,
"parentid" : "1"
}
]
上边的数据格式是一个数组结构,数组的元素即为分类信息,分类信息设计两级分类,第一级的分类信息示例如下:
JSON
"id" : "1-2",
"isLeaf" : null,
"isShow" : null,
"label" : "移动开发",
"name" : "移动开发",
"orderby" : 2,
"parentid" : "1"
第二级的分类是第一级分类中childrenTreeNodes属性,它是一个数组结构:
JSON
{
"id" : "1-2",
"isLeaf" : null,
"isShow" : null,
"label" : "移动开发",
"name" : "移动开发",
"orderby" : 2,
"parentid" : "1",
"childrenTreeNodes" : [
{
"childrenTreeNodes" : null,
"id" : "1-2-1",
"isLeaf" : null,
"isShow" : null,
"label" : "微信开发",
"name" : "微信开发",
"orderby" : 1,
"parentid" : "1-2"
}
}
所以,定义一个DTO类表示分类信息的模型类,如下:
Java
package com.xuecheng.content.model.dto;
import com.xuecheng.content.model.po.CourseCategory;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* @description 课程分类树型结点dto
* @author Mr.M
* @date 2022/9/7 15:16
* @version 1.0
*/
@Data
public class CourseCategoryTreeDto extends CourseCategory implements Serializable {
List<CourseCategoryTreeDto> childrenTreeNodes;
}
接口定义如下:
Java
package com.xuecheng.content.api;
import com.xuecheng.content.model.dto.CourseCategoryTreeDto;
import com.xuecheng.content.service.CourseCategoryService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
*
* 数据字典 前端控制器
*
*
* @author itcast
*/
@Slf4j
@RestController
public class CourseCategoryController {
@GetMapping("/course-category/tree-nodes")
public List<CourseCategoryTreeDto> queryTreeNodes() {
return null;
}
}
课程分类表是一个树型结构,其中parentid字段为父结点ID,它是树型结构的标志字段。
如果树的层级固定可以使用表的自链接去查询,比如:我们只查询两级课程分类,可以用下边的SQL
Java
select
one.id one_id,
one.name one_name,
one.parentid one_parentid,
one.orderby one_orderby,
one.label one_label,
two.id two_id,
two.name two_name,
two.parentid two_parentid,
two.orderby two_orderby,
two.label two_label
from course_category one
inner join course_category two on one.id = two.parentid
where one.parentid = 1
and one.is_show = 1
and two.is_show = 1
order by one.orderby,
two.orderby
如果树的层级不确定,此时可以使用MySQL递归实现,使用with语法,如下:
WITH [RECURSIVE]
cte_name [(col_name [, col_name] ...)] AS (subquery)
[, cte_name [(col_name [, col_name] ...)] AS (subquery)] ...
cte_name :公共表达式的名称,可以理解为表名,用来表示as后面跟着的子查询
col_name :公共表达式包含的列名,可以写也可以不写
下边是一个递归的简单例子:
Java
with RECURSIVE t1 AS
(
SELECT 1 as n
UNION ALL
SELECT n + 1 FROM t1 WHERE n < 5
)
SELECT * FROM t1;
输出:
说明:
t1 相当于一个表名
select 1 相当于这个表的初始值,这里使用UNION ALL 不断将每次递归得到的数据加入到表中。
n<5为递归执行的条件,当n>=5时结束递归调用。
下边我们使用递归实现课程分类的查询
Java
with recursive t1 as (
select * from course_category p where id= '1'
union all
select t.* from course_category t inner join t1 on t1.id = t.parentid
)
select * from t1 order by t1.id, t1.orderby
t1表中初始的数据是id等于1的记录,即根结点。
通过inner join t1 t2 on t2.id = t.parentid 找到id='1’的下级节点 。
通过这种方法就找到了id='1’的所有下级节点,下级节点包括了所有层级的节点。
上边这种方法是向下递归,即找到初始节点的所有下级节点。
如何向上递归?
下边的sql实现了向上递归:
Java
with recursive t1 as (
select * from course_category p where id= '1-1-1'
union all
select t.* from course_category t inner join t1 on t1.parentid = t.id
)
select * from t1 order by t1.id, t1.orderby
初始节点为1-1-1,通过递归找到它的父级节点,父级节点包括所有级别的节点。
以上是我们研究了树型表的查询方法,通过递归的方式查询课程分类比较灵活,因为它可以不限制层级。
mysql为了避免无限递归默认递归次数为1000,可以通过设置cte_max_recursion_depth参数增加递归深度,还可以通过max_execution_time限制执行时间,超过此时间也会终止递归操作。
mysql递归相当于在存储过程中执行若干次sql语句,java程序仅与数据库建立一次链接执行递归操作,所以只要控制好递归深度,控制好数据量性能就没有问题。
思考:如果java程序在递归操作中连接数据库去查询数据组装数据,这个性能高吗?
下边我们可自定义mapper方法查询课程分类,最终将查询结果映射到List中。
生成课程分类表的mapper文件并拷贝至内容管理模块 的service工程中。
1、下边 定义一个mapper方法,并定义sql语句。
Java
public interface CourseCategoryMapper extends BaseMapper<CourseCategory> {
public List<CourseCategoryTreeDto> selectTreeNodes(String id);
}
2、找到对应 的mapper.xml文件,编写sql语句。
Java
<select id="selectTreeNodes" resultType="com.xuecheng.content.model.dto.CourseCategoryTreeDto" parameterType="string">
with recursive t1 as (
select * from course_category p where id= #{id}
union all
select t.* from course_category t inner join t1 on t1.id = t.parentid
)
select * from t1 order by t1.id, t1.orderby
select>
4.3.3 开发service
定义service接口,调用mapper查询课程分类,遍历数据按照接口要求对数据进行封装
Java
public interface CourseCategoryService {
/**
* 课程分类树形结构查询
*
* @return
*/
public List<CourseCategoryTreeDto> queryTreeNodes(String id);
}
编写service接口实现
Java
@Slf4j
@Service
public class CourseCategoryServiceImpl implements CourseCategoryService {
@Autowired
CourseCategoryMapper courseCategoryMapper;
public List<CourseCategoryTreeDto> queryTreeNodes(String id) {
List<CourseCategoryTreeDto> courseCategoryTreeDtos = courseCategoryMapper.selectTreeNodes(id);
//将list转map,以备使用,排除根节点
Map<String, CourseCategoryTreeDto> mapTemp = courseCategoryTreeDtos.stream().filter(item->!id.equals(item.getId())).collect(Collectors.toMap(key -> key.getId(), value -> value, (key1, key2) -> key2));
//最终返回的list
List<CourseCategoryTreeDto> categoryTreeDtos = new ArrayList<>();
//依次遍历每个元素,排除根节点
courseCategoryTreeDtos.stream().filter(item->!id.equals(item.getId())).forEach(item->{
if(item.getParentid().equals(id)){
categoryTreeDtos.add(item);
}
//找到当前节点的父节点
CourseCategoryTreeDto courseCategoryTreeDto = mapTemp.get(item.getParentid());
if(courseCategoryTreeDto!=null){
if(courseCategoryTreeDto.getChildrenTreeNodes() ==null){
courseCategoryTreeDto.setChildrenTreeNodes(new ArrayList<CourseCategoryTreeDto>());
}
//下边开始往ChildrenTreeNodes属性中放子节点
courseCategoryTreeDto.getChildrenTreeNodes().add(item);
}
});
return categoryTreeDtos;
}
}
4.3.4 单元测试
定义单元测试类对service接口进行测试
Java
@SpringBootTest
class CourseCategoryServiceTests {
@Autowired
CourseCategoryService courseCategoryService;
@Test
void testqueryTreeNodes() {
List<CourseCategoryTreeDto> categoryTreeDtos = courseCategoryService.queryTreeNodes("1");
System.out.println(categoryTreeDtos);
}
}
完善controller方法,注入service调用业务层方法查询课程分类。
Java
package com.xuecheng.content.controller;
import com.xuecheng.content.model.dto.CourseCategoryTreeDto;
import com.xuecheng.content.model.po.Dictionary;
import com.xuecheng.content.service.CourseCategoryService;
import com.xuecheng.content.service.DictionaryService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
*
* 数据字典 前端控制器
*
*
* @author itcast
*/
@Slf4j
@RestController
public class CourseCategoryController {
@Autowired
CourseCategoryService courseCategoryService;
@GetMapping("/course-category/tree-nodes")
public List<CourseCategoryTreeDto> queryTreeNodes() {
return courseCategoryService.queryTreeNodes("1");
}
}
运行测试。
完成前后端连调:
打开前端工程,进入新增课程页面。
课程分类下拉框可以正常显示
根据前边对内容管理模块的数据模型分析,课程相关的信息有:课程基本信息、课程营销信息、课程图片信息、课程计划、课程师资信息,所以新增一门课程需要完成这几部分信息的填写。
以下是业务流程:
1、进入课程查询列表
3、选择完毕,点击下一步,进入课程基本信息添加界面。
本界面分两部分信息,一部分是课程基本信息上,一部分是课程营销信息。
课程基本信息:
在这个界面中填写课程的基本信息、课程营销信息上。
填写完毕,保存并进行下一步。
4、在此界面填写课程计划信息
课程计划即课程的大纲目录。
课程计划分为两级,章节和小节。
每个小节需要上传课程视频,用户点击 小节的标题即开始播放视频。
如果是直播课程则会进入直播间。
5、课程 计划填写完毕进入课程师资的管理。
至此,一门课程新增完成。
通过业务流程可知,一门课程信息涉及:课程基本信息、课程营销信息、课程计划信息、课程师资信息。
本节开发新增课程按钮功能, 只向课程基本信息、课程营销信息添加记录。
这两部分信息分别在course_base、course_market两张表存储。当点击保存按钮时向这两张表插入数据。这两张表是一对一关联关系。
在这里插入图片描述
新建课程的初始审核状态为“未提交”、初始发布状态为“未发布”。
生成课程基本信息、课程营销信息的PO、Mapper文件
根据业务流程,这里先定义提交课程基本信息的接口。
1、接口协议 :HTTP POST,Content-Type为application/json
2、请求及响应结果如下
在这里插入图片描述
3、接口请求示例如下
Java
### 创建课程
POST {{content_host}}/content/course
Content-Type: application/json
{
"mt": "",
"st": "",
"name": "",
"pic": "",
"teachmode": "200002",
"users": "初级人员",
"tags": "",
"grade": "204001",
"description": "",
"charge": "201000",
"price": 0,
"originalPrice":0,
"qq": "",
"wechat": "",
"phone": "",
"validDays": 365
}
###响应结果如下
#成功响应结果如下
{
"id": 109,
"companyId": 1,
"companyName": null,
"name": "测试课程103",
"users": "初级人员",
"tags": "",
"mt": "1-1",
"mtName": null,
"st": "1-1-1",
"stName": null,
"grade": "204001",
"teachmode": "200002",
"description": "",
"pic": "",
"createDate": "2022-09-08 07:35:16",
"changeDate": null,
"createPeople": null,
"changePeople": null,
"auditStatus": "202002",
"status": 1,
"coursePubId": null,
"coursePubDate": null,
"charge": "201000",
"price": null,
"originalPrice":0,
"qq": "",
"wechat": "",
"phone": "",
"validDays": 365
}
3、定义请求参数类型和响应结构类型
根据接口定义内容,请求参数相比 CourseBase模型类不一致,需要在dto包下自定义,模型类从课程资料/工程目录获取。
4、定义接口如下
Java
@ApiOperation("新增课程基础信息")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto){
return null;
}
根据需求分析,新增课程表单中包括了课程基本信息、课程营销信息,需要分别向课程基本信息表、课程营销表保证数据。
首先定义service接口,
/**
* @description 添加课程基本信息
* @param companyId 教学机构id
* @param addCourseDto 课程基本信息
* @return com.xuecheng.content.model.dto.CourseBaseInfoDto
* @author Mr.M
* @date 2022/9/7 17:51
*/
CourseBaseInfoDto createCourseBase(Long companyId,AddCourseDto addCourseDto);
//编写service接口实现类,实现向课程基本信息表保存数据:
@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("收费规则为空");
}
//新增对象
CourseBase courseBaseNew = new CourseBase();
//将填写的课程信息赋值给新增对象
BeanUtils.copyProperties(dto,courseBaseNew);
//设置审核状态
courseBaseNew.setAuditStatus("202002");
//设置发布状态
courseBaseNew.setStatus("203001");
//机构id
courseBaseNew.setCompanyId(companyId);
//添加时间
courseBaseNew.setCreateDate(LocalDateTime.now());
//插入课程基本信息表
int insert = courseBaseMapper.insert(courseBaseNew);
if(insert<=0){
throw new RuntimeException("新增课程基本信息失败");
}
//todo:向课程营销表保存课程营销信息
//todo:查询课程基本信息及营销信息并返回
}
下边实现向课程营销表保存课程营销信息
Java
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("收费规则为空");
}
//新增对象
CourseBase courseBaseNew = new CourseBase();
//将填写的课程信息赋值给新增对象
BeanUtils.copyProperties(dto,courseBaseNew);
//设置审核状态
courseBaseNew.setAuditStatus("202002");
//设置发布状态
courseBaseNew.setStatus("203001");
//机构id
courseBaseNew.setCompanyId(companyId);
//添加时间
courseBaseNew.setCreateDate(LocalDateTime.now());
//插入课程基本信息表
int insert = courseBaseMapper.insert(courseBaseNew);
if(insert<=0){
throw new RuntimeException("新增课程基本信息失败");
}
//向课程营销表保存课程营销信息
//课程营销信息
CourseMarket courseMarketNew = new CourseMarket();
Long courseId = courseBaseNew.getId();
BeanUtils.copyProperties(dto,courseMarketNew);
courseMarketNew.setId(courseId);
int i = saveCourseMarket(courseMarketNew);
if(i<=0){
throw new RuntimeException("保存课程营销信息失败");
}
//查询课程基本信息及营销信息并返回
return getCourseBaseInfo(courseId);
}
//保存课程营销信息
private int saveCourseMarket(CourseMarket courseMarketNew){
//收费规则
String charge = courseMarketNew.getCharge();
if(StringUtils.isBlank(charge)){
throw new RuntimeException("收费规则没有选择");
}
//收费规则为收费
if(charge.equals("201001")){
if(courseMarketNew.getPrice() == null || courseMarketNew.getPrice().floatValue()<=0){
throw new RuntimeException("课程为收费价格不能为空且必须大于0");
}
}
//根据id从课程营销表查询
CourseMarket courseMarketObj = courseMarketMapper.selectById(courseMarketNew.getId());
if(courseMarketObj == null){
return courseMarketMapper.insert(courseMarketNew);
}else{
BeanUtils.copyProperties(courseMarketNew,courseMarketObj);
courseMarketObj.setId(courseMarketNew.getId());
return courseMarketMapper.updateById(courseMarketObj);
}
}
//根据课程id查询课程基本信息,包括基本信息和营销信息
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);
if(courseMarket != null){
BeanUtils.copyProperties(courseMarket,courseBaseInfoDto);
}
//查询分类名称
CourseCategory courseCategoryBySt = courseCategoryMapper.selectById(courseBase.getSt());
courseBaseInfoDto.setStName(courseCategoryBySt.getName());
CourseCategory courseCategoryByMt = courseCategoryMapper.selectById(courseBase.getMt());
courseBaseInfoDto.setMtName(courseCategoryByMt.getName());
return courseBaseInfoDto;
}
1、首先去完善controller方法:
Java
@ApiOperation("新增课程基础信息")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto){
//机构id,由于认证系统没有上线暂时硬编码
Long companyId = 1232141425L;
return courseBaseInfoService.createCourseBase(companyId,addCourseDto);
}
2、使用httpclient测试
在xc-content-api.http中定义:
Java
### 创建课程
POST {{content_host}}/content/course
Content-Type: application/json
{
"charge": "201000",
"price": 0,
"originalPrice":0,
"qq": "22333",
"wechat": "223344",
"phone": "13333333",
"validDays": 365,
"mt": "1-1",
"st": "1-1-1",
"name": "测试课程103",
"pic": "",
"teachmode": "200002",
"users": "初级人员",
"tags": "",
"grade": "204001",
"description": ""
}
3、前后端联调
打开新增课程页面,除了课程图片其它信息全部输入。
点击保存,观察浏览器请求接口参数及响应结果是否正常。
在service方法中有很多的参数合法性校验,当参数不合法则抛出异常,下边我们测试下异常处理。
请求创建课程基本信息,故意将必填项设置为空。
测试发现报500异常,如下:
Java
http://localhost:63040/content/course
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 07 Sep 2022 11:40:29 GMT
Connection: close
{
"timestamp": "2022-09-07T11:40:29.677+00:00",
"status": 500,
"error": "Internal Server Error",
"message": "",
"path": "/content/course"
}
问题:并没有输出我们抛出异常时指定的异常信息。
所以,现在我们的需求是当正常操作时按接口要求返回数据,当非正常流程时要获取异常信息进行记录,并提示给用户。
异常处理除了输出在日志中,还需要提示给用户,前端和后端需要作一些约定:
1、错误提示信息统一以json格式返回给前端。
2、以HTTP状态码决定当前是否出错,非200为操作异常。
如何规范异常信息?
代码中统一抛出项目的自定义异常类型,这样可以统一去捕获这一类或几类的异常。
规范了异常类型就可以去获取异常信息。
如果捕获了非项目自定义的异常类型统一向用户提示“执行过程异常,请重试”的错误信息。
如何捕获异常?
代码统一用try/catch方式去捕获代码比较臃肿,可以通过SpringMVC提供的控制器增强类统一由一个类去完成异常的捕获。
如下图:
根据上边分析的方案,统一在base基础工程实现统一异常处理,各模块依赖了base基础工程都 可以使用。
首先在base基础工程添加需要依赖的包:
<dependency>
<groupId>org.springframeworkgroupId>
<artifactId>spring-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-log4j2artifactId>
dependency>
1、定义一些通用的异常信息
从课程资料/工程目录 拷贝CommonError 类到base工程
com.xuecheng.base.execption下。
Java
package com.xuecheng.base.execption;
/**
* @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、自定义异常类型
在base工程com.xuecheng.base.execption下自定义异常类。
Java
package com.xuecheng.base.execption;
/**
* @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);
}
}
3、响应用户的统一类型
Java
package com.xuecheng.base.execption;
import java.io.Serializable;
/**
* 错误响应参数包装
*/
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;
}
}
4、全局异常处理器
从 Spring 3.0 - Spring 3.2 版本之间,对 Spring 架构和 SpringMVC 的Controller 的异常捕获提供了相应的异常处理。
•@ExceptionHandler
•Spring3.0提供的标识在方法上或类上的注解,用来表明方法的处理异常类型。
•@ControllerAdvice
•Spring3.2提供的新注解,从名字上可以看出大体意思是控制器增强, 在项目中来增强SpringMVC中的Controller。通常和@ExceptionHandler 结合使用,来处理SpringMVC的异常信息。
•@ResponseStatus
•Spring3.0提供的标识在方法上或类上的注解,用状态代码和应返回的原因标记方法或异常类。
调用处理程序方法时,状态代码将应用于HTTP响应。
通过上面的两个注解便可实现微服务端全局异常处理,具体代码如下:
Java
package com.xuecheng.base.execption;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
/**
* @description 全局异常处理器
* @author Mr.M
* @date 2022/9/6 11:29
* @version 1.0
*/
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
@ResponseBody
@ExceptionHandler(XueChengPlusException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse customException(XueChengPlusException e) {
log.error("【系统异常】{}",e.getErrMessage(),e);
return new RestErrorResponse(e.getErrMessage());
}
@ResponseBody
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse exception(Exception e) {
log.error("【系统异常】{}",e.getMessage(),e);
return new RestErrorResponse(CommonError.UNKOWN_ERROR.getErrMessage());
}
}
在内容管理的api工程添加base工程的依赖
<dependency>
<groupId>com.xuechenggroupId>
<artifactId>xuecheng-plus-baseartifactId>
<version>0.0.1-SNAPSHOTversion>
dependency>
在异常处理测试之前首先在代码中抛出自定义类型的异常,这里以新增课程的service方法为例进行代码修改。
Java
@Override
public CourseBaseInfoDto createCourseBase(Long companyId,AddCourseDto dto) {
...
//合法性校验
if (StringUtils.isBlank(dto.getName())) {
throw new XueChengPlusException("课程名称为空");
}
if (StringUtils.isBlank(dto.getMt())) {
throw new XueChengPlusException("课程分类为空");
}
if (StringUtils.isBlank(dto.getSt())) {
throw new XueChengPlusException("课程分类为空");
}
if (StringUtils.isBlank(dto.getGrade())) {
throw new XueChengPlusException("课程等级为空");
}
if (StringUtils.isBlank(dto.getTeachmode())) {
throw new XueChengPlusException("教育模式为空");
}
if (StringUtils.isBlank(dto.getUsers())) {
throw new XueChengPlusException("适应人群");
}
if (StringUtils.isBlank(dto.getCharge())) {
throw new XueChengPlusException("收费规则为空");
}
。。。
if(charge.equals("201001")){
if(courseMarketNew.getPrice() ==null || courseMarketNew.getPrice().floatValue()<=0){
throw new XueChengPlusException("课程的价格不能为空并且必须大于0");
}
}
}
1、首先使用httpclient测试
请求新增课程接口,故意将必填项课程名称设置为空。
测试结果与预期一致,可以捕获异常并响应异常信息,如下:
Java
http://localhost:63040/content/course
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 07 Sep 2022 13:17:14 GMT
Connection: close
{
"errMessage": "课程名称为空。"
}
2、前后端调试
仍然测试新增课程接口,当课程收费的时候必须填写价格,这里设置课程为收费,价格设置为空。
至此,项目异常处理的测试完毕,我们在开发中对于业务分支中错误的情况要抛出项目自定义的异常类型。
前端请求后端接口传输参数,是在controller中校验还是在Service中校验?
答案是都需要校验,只是分工不同。
Contoller中校验请求参数的合法性,包括:必填项校验,数据格式校验,比如:是否是符合一定的日期格式,等。
Service中要校验的是业务规则相关的内容,比如:课程已经审核通过所以提交失败。
Service中根据业务规则去校验不方便写成通用代码,Controller中则可以将校验的代码写成通用代码。
早在JavaEE6规范中就定义了参数校验的规范,它就是JSR-303,它定义了Bean Validation,即对bean属性进行校验。
SpringBoot提供了JSR-303的支持,它就是spring-boot-starter-validation,它的底层使用Hibernate Validator,Hibernate Validator是Bean Validation 的参考实现。
所以,我们准备在Controller层使用spring-boot-starter-validation完成对请求参数的基本合法性进行校验。
首先在Base工程添加spring-boot-starter-validation的依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-validationartifactId>
dependency>
在javax.validation.constraints包下有很多这样的校验注解,直接使用注解定义校验规则即可。
规则如下:
现在准备对内容管理模块添加课程接口进行参数校验,如下接口
Java
@ApiOperation("新增课程基础信息")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody AddCourseDto addCourseDto){
//机构id,由于认证系统没有上线暂时硬编码
Long companyId = 1232141425L;
return courseBaseInfoService.createCourseBase(companyId,addCourseDto);
}
此接口使用AddCourseDto模型对象接收参数,所以进入AddCourseDto类,在属性上添加校验规则。
Java
package com.xuecheng.content.model.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import java.math.BigDecimal;
/**
* @description 添加课程dto
* @author Mr.M
* @date 2022/9/7 17:40
* @version 1.0
*/
@Data
@ApiModel(value="AddCourseDto", description="新增课程基本信息")
public class AddCourseDto {
@NotEmpty(message = "课程名称不能为空")
@ApiModelProperty(value = "课程名称", required = true)
private String name;
@NotEmpty(message = "适用人群不能为空")
@Size(message = "适用人群内容过少",min = 10)
@ApiModelProperty(value = "适用人群", required = true)
private String users;
@ApiModelProperty(value = "课程标签")
private String tags;
@NotEmpty(message = "课程分类不能为空")
@ApiModelProperty(value = "大分类", required = true)
private String mt;
@NotEmpty(message = "课程分类不能为空")
@ApiModelProperty(value = "小分类", required = true)
private String st;
@NotEmpty(message = "课程等级不能为空")
@ApiModelProperty(value = "课程等级", required = true)
private String grade;
@ApiModelProperty(value = "教学模式(普通,录播,直播等)", required = true)
private String teachmode;
@ApiModelProperty(value = "课程介绍")
private String description;
@ApiModelProperty(value = "课程图片", required = true)
private String pic;
@NotEmpty(message = "收费规则不能为空")
@ApiModelProperty(value = "收费规则,对应数据字典", required = true)
private String charge;
@ApiModelProperty(value = "价格")
private BigDecimal price;
}
上边用到了@NotEmpty和@Size两个注解,@NotEmpty表示属性不能为空,@Size表示限制属性内容的长短。
定义好校验规则还需要开启校验,在controller方法中添加@Validated注解,如下:
@ApiOperation("新增课程基础信息")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody @Validated AddCourseDto addCourseDto){
//机构id,由于认证系统没有上线暂时硬编码
Long companyId = 1L;
return courseBaseInfoService.createCourseBase(companyId,addCourseDto);
}
如果校验出错Spring会抛出MethodArgumentNotValidException异常,我们需要在统一异常处理器中捕获异常,解析出异常信息。
代码 如下:
@ResponseBody
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse methodArgumentNotValidException(MethodArgumentNotValidException e) {
BindingResult bindingResult = e.getBindingResult();
List<String> msgList = new ArrayList<>();
//将错误信息放在msgList
bindingResult.getFieldErrors().stream().forEach(item->msgList.add(item.getDefaultMessage()));
//拼接错误信息
String msg = StringUtils.join(msgList, ",");
log.error("【系统异常】{}",msg);
return new RestErrorResponse(msg);
}
重启内容管理服务。
使用httpclient进行测试,将必填项设置为空,“适用人群” 属性的内容设置1个字。
执行测试,接口响应结果如下:
{
"errMessage": "课程名称不能为空,课程分类不能为空,课程分类不能为空,适用人群内容过少"
}
可以看到校验器生效。
有时候在同一个属性上设置一个校验规则不能满足要求,比如:订单编号由系统生成,在添加订单时要求订单编号为空,在更新 订单时要求订单编写不能为空。此时就用到了分组校验,同一个属性定义多个校验规则属于不同的分组,比如:添加订单定义@NULL规则属于insert分组,更新订单定义@NotEmpty规则属于update分组,insert和update是分组的名称,是可以修改的。
下边举例说明
我们用class类型来表示不同的分组,所以我们定义不同的接口类型(空接口)表示不同的分组,由于校验分组是公用的,所以定义在 base工程中。如下:
package com.xuecheng.base.execption;
/**
* @description 校验分组
* @author Mr.M
* @date 2022/9/8 15:05
* @version 1.0
*/
public class ValidationGroups {
public interface Inster{};
public interface Update{};
public interface Delete{};
}
下边在定义校验规则时指定分组:
@NotEmpty(groups = {ValidationGroups.Inster.class},message = "添加课程名称不能为空")
@NotEmpty(groups = {ValidationGroups.Update.class},message = "修改课程名称不能为空")
// @NotEmpty(message = "课程名称不能为空")
@ApiModelProperty(value = "课程名称", required = true)
private String name;
在Controller方法中启动校验规则指定要使用的分组名:
Bash
@ApiOperation("新增课程基础信息")
@PostMapping("/course")
public CourseBaseInfoDto createCourseBase(@RequestBody @Validated({ValidationGroups.Inster.class}) AddCourseDto addCourseDto){
//机构id,由于认证系统没有上线暂时硬编码
Long companyId = 1L;
return courseBaseInfoService.createCourseBase(companyId,addCourseDto);
}
再次测试,由于这里指定了Insert分组,所以抛出 异常信息:添加课程名称不能为空。
如果修改分组为ValidationGroups.Update.class,异常信息为:修改课程名称不能为空。
如果javax.validation.constraints包下的校验规则满足不了需求怎么办?
1、手写校验代码 。
2、自定义校验规则注解。
如何自定义校验规则注解,请自行查阅资料实现。