在管理系统中,经常会有树形结构的数据需求,例如如图所示的课程一级分类和二级分类
对应这样的情况在数据库层面就需要对字段进行设计,表字段信息和示例数据说明如下图所示
通过上述说明可以看出这张表的数据是树形结构,通过根节点为0的ID可以检索到表中所有数据(前提是一棵完整的树),所以在实际项目中,后端需要一个接口,用于给前端提供该课程分类表的数据,且是树形结构的形态返回给前端。
下面进入实际代码:
课程分类的PO类(PO类是与数据库直接一一对应的)
java
package com.gavin.content.model.po;
import java.io.Serializable;
import lombok.Data;
import com.baomidou.mybatisplus.annotation.TableName;
/**
* 课程分类
* @author gavin
*/
@Data
@TableName("course_category")
public class CourseCategory implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
private String id;
/**
* 分类名称
*/
private String name;
/**
* 分类标签默认和名称一样
*/
private String label;
/**
* 父结点id(第一级的父节点是0,自关联字段id)
*/
private String parentid;
/**
* 是否显示
*/
private Integer isShow;
/**
* 排序字段
*/
private Integer orderby;
/**
* 是否叶子
*/
private Integer isLeaf;
}
定义DTO类继承PO表示分类信息的模型(DTO通常用于数据转化)
java
package com.gavin.content.model.dto;
import com.gavin.content.model.po.CourseCategory;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* @author Gavin
* @description 课程分类信息模型类
* @date 2024/10/14
**/
@Data
public class CourseCategoryTreeDto extends CourseCategory implements Serializable {
List<CourseCategoryTreeDto> childrenTreeNodes;
}
数据字典控制器
java
package com.gavin.content.api;
import com.gavin.content.model.dto.CourseCategoryTreeDto;
import com.gavin.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 gavin
*/
@Slf4j
@RestController
public class CourseCategoryController {
@Autowired
private CourseCategoryService courseCategoryService;
@GetMapping("/course-category/tree-nodes")
public List<CourseCategoryTreeDto> queryTreeNodes() {
return courseCategoryService.queryTreeNodes("1");
}
}
接下来的接口实现的逻辑是核心。因为这里表的层级结构是固定的,总共就两级,所以可以直接用拼接的方式定义SQL语句,如下
sql
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
上述过程使用INNER内连接将同一个表course_category连接两次,条件是子分类的parentid与父分类的id匹配。所以这条SQL查询的作用就是,从course_category表中查找所有parentid为1的父分类,并显示其下属的子分类(同时满足is_show为1),返回的结果按照父分类和子分类的排序顺序排列。
但是上述过程还是有局限性的,因为层级固定,通常情况下可以采用递归遍历的方式来实现查询,使用WITH RECURSIVE语法进行实现,示例如下:
sql
WITH RECURSIVE t1 AS
(
SELECT 1 AS n -- 初始查询:设定递归查询的起始值为 1
UNION ALL
SELECT n + 1 FROM t1 -- 递归部分:每次递归查询在前一次的基础上加 1
WHERE n < 5 -- 递归条件:当 n >= 5 时终止递归
)
SELECT * FROM t1; -- 查询递归生成的结果集
上述过程就展现了生成1到5的数字序列。
根据上述的逻辑对应到上述的课程分类表中,SQL语句如下:
sql
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;
上述过程是从上往下递归,即根节点为"1",如果要实现从下往上递归则修改初始节点及条件即可,如下:
sql
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;
mysql为了避免无限递归默认递归次数为1000,可以通过设置cte_max_recursion_depth参数增加递归深度,还可以通过max_execution_time限制执行时间,超过此时间也会终止递归操作。
mysql递归相当于在存储过程中执行若干次sql语句,java程序仅与数据库建立一次链接执行递归操作,所以只要控制好递归深度,控制好数据量性能就没有问题。
思考:如果java程序在递归操作中连接数据库去查询数据组装数据,这个性能高吗?
参考回答:不会太高,因为多次数据库连接会有开销,查询次数多且事务处理复杂会导致响应时间过长。所以除了上述递归查询,还可以使用批量查询或缓存进内存的方法提高性能(因为字典数据通常情况下变化不大)
回到主线,定义mapper接口及查询语句
java
package com.gavin.content.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.gavin.content.model.dto.CourseCategoryTreeDto;
import com.gavin.content.model.po.CourseCategory;
import java.util.List;
/**
* 课程分类 Mapper 接口
* @author gavin
*/
public interface CourseCategoryMapper extends BaseMapper<CourseCategory> {
public List<CourseCategoryTreeDto> selectTreeNodes(String id);
}
java
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gavin.content.mapper.CourseCategoryMapper">
<!-- 通用查询映射结果 -->
<resultMap id="BaseResultMap" type="com.gavin.content.model.po.CourseCategory">
<id column="id" property="id" />
<result column="name" property="name" />
<result column="label" property="label" />
<result column="parentid" property="parentid" />
<result column="is_show" property="isShow" />
<result column="orderby" property="orderby" />
<result column="is_leaf" property="isLeaf" />
</resultMap>
<!-- 通用查询结果列 -->
<sql id="Base_Column_List">
id, name, label, parentid, is_show, orderby, is_leaf
</sql>
<select id="selectTreeNodes" resultType="com.gavin.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>
</mapper>
定义service接口及实现类
java
package com.gavin.content.service;
import com.gavin.content.model.dto.CourseCategoryTreeDto;
import java.util.List;
public interface CourseCategoryService {
/**
* 课程分类树形结构查询
*/
public List<CourseCategoryTreeDto> queryTreeNodes(String id);
}
java
package com.gavin.content.service.impl;
import com.gavin.content.mapper.CourseCategoryMapper;
import com.gavin.content.model.dto.CourseCategoryTreeDto;
import com.gavin.content.service.CourseCategoryService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Service
public class CourseCategoryServiceImpl implements CourseCategoryService {
@Autowired
CourseCategoryMapper courseCategoryMapper;
public List<CourseCategoryTreeDto> queryTreeNodes(String id) {
List<CourseCategoryTreeDto> courseCategoryTreeDtos = courseCategoryMapper.selectTreeNodes(id);
//通过Java Stream API,将courseCategoryTreeDtos转换为一个Map<String, CourseCategoryTreeDto>
//过滤掉根节点,即id.equals(item.getId())为true的元素不参与转换
//collect(Collectors.toMap(...)) 用于将列表转换为Map
//key -> key.getId():节点的ID作为Map的键
//value -> value:节点对象作为Map的值
//(key1, key2) -> key2:处理键冲突的情况,如果有重复键,则保留最新的值。
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<>();
//依次遍历每个元素,filter(item -> !id.equals(item.getId()))过滤掉根节点
//对每个节点进行处理:
// 添加到顶级节点列表:如果当前节点的parentid等于根节点id,则将其添加到categoryTreeDtos列表
// 构建子节点:
// 从mapTemp中找到当前节点的父节点对象(courseCategoryTreeDto)
// 如果找到的父节点不为空,且它的childrenTreeNodes属性为空,则初始化它为一个空的List
// 将当前节点作为子节点添加到父节点的childrenTreeNodes列表中
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>());
}
courseCategoryTreeDto.getChildrenTreeNodes().add(item);
}
});
return categoryTreeDtos;
}
}
定义单元测试类对service接口进行测试
java
package com.gavin.content;
import com.gavin.content.model.dto.CourseCategoryTreeDto;
import com.gavin.content.service.CourseCategoryService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
@SpringBootTest
class CourseCategoryServiceTests {
@Autowired
CourseCategoryService courseCategoryService;
@Test
void testqueryTreeNodes() {
List<CourseCategoryTreeDto> categoryTreeDtos = courseCategoryService.queryTreeNodes("1");
System.out.println(categoryTreeDtos);
}
}
测试后返回的数据结构示例如下所示:
javascript
[
{
"id": "1-1",
"name": "前端开发",
"label": "前端开发",
"parentid": "1",
"isShow": 1,
"orderby": 1,
"isLeaf": 0,
"childrenTreeNodes": [
{
"id": "1-1-1",
"name": "HTML/CSS",
"label": "HTML/CSS",
"parentid": "1-1",
"isShow": 1,
"orderby": 1,
"isLeaf": 1,
"childrenTreeNodes": null
},
{
"id": "1-1-2",
"name": "JavaScript",
"label": "JavaScript",
"parentid": "1-1",
"isShow": 1,
"orderby": 2,
"isLeaf": 1,
"childrenTreeNodes": null
},
{
"id": "1-1-3",
"name": "Vue",
"label": "Vue",
"parentid": "1-1",
"isShow": 1,
"orderby": 9,
"isLeaf": 1,
"childrenTreeNodes": null
}
]
},
{
"id": "1-2",
"name": "移动开发",
"label": "移动开发",
"parentid": "1",
"isShow": 1,
"orderby": 2,
"isLeaf": 0,
"childrenTreeNodes": [
{
"id": "1-2-1",
"name": "微信开发",
"label": "微信开发",
"parentid": "1-2",
"isShow": 1,
"orderby": 1,
"isLeaf": 1,
"childrenTreeNodes": null
},
{
"id": "1-2-2",
"name": "iOS",
"label": "iOS",
"parentid": "1-2",
"isShow": 1,
"orderby": 2,
"isLeaf": 1,
"childrenTreeNodes": null
}
]
},
{
"id": "1-3",
"name": "编程开发",
"label": "编程开发",
"parentid": "1",
"isShow": 1,
"orderby": 3,
"isLeaf": 0,
"childrenTreeNodes": [
{
"id": "1-3-1",
"name": "C/C++",
"label": "C/C++",
"parentid": "1-3",
"isShow": 1,
"orderby": 1,
"isLeaf": 1,
"childrenTreeNodes": null
},
{
"id": "1-3-2",
"name": "Java",
"label": "Java",
"parentid": "1-3",
"isShow": 1,
"orderby": 2,
"isLeaf": 1,
"childrenTreeNodes": null
}
]
},
{
"id": "1-4",
"name": "数据库",
"label": "数据库",
"parentid": "1",
"isShow": 1,
"orderby": 4,
"isLeaf": 0,
"childrenTreeNodes": [
{
"id": "1-4-1",
"name": "Oracle",
"label": "Oracle",
"parentid": "1-4",
"isShow": 1,
"orderby": 1,
"isLeaf": 1,
"childrenTreeNodes": null
},
{
"id": "1-4-2",
"name": "MySQL",
"label": "MySQL",
"parentid": "1-4",
"isShow": 1,
"orderby": 2,
"isLeaf": 1,
"childrenTreeNodes": null
}
]
},
{
"id": "1-5",
"name": "人工智能",
"label": "人工智能",
"parentid": "1",
"isShow": 1,
"orderby": 5,
"isLeaf": 0,
"childrenTreeNodes": [
{
"id": "1-5-1",
"name": "机器学习",
"label": "机器学习",
"parentid": "1-5",
"isShow": 1,
"orderby": 1,
"isLeaf": 1,
"childrenTreeNodes": null
},
{
"id": "1-5-2",
"name": "深度学习",
"label": "深度学习",
"parentid": "1-5",
"isShow": 1,
"orderby": 2,
"isLeaf": 1,
"childrenTreeNodes": null
}
]
},
{
"id": "1-6",
"name": "云计算/大数据",
"label": "云计算/大数据",
"parentid": "1",
"isShow": 1,
"orderby": 6,
"isLeaf": 0,
"childrenTreeNodes": [
{
"id": "1-6-1",
"name": "Spark",
"label": "Spark",
"parentid": "1-6",
"isShow": 1,
"orderby": 1,
"isLeaf": 1,
"childrenTreeNodes": null
},
{
"id": "1-6-2",
"name": "Hadoop",
"label": "Hadoop",
"parentid": "1-6",
"isShow": 1,
"orderby": 2,
"isLeaf": 1,
"childrenTreeNodes": null
}
]
},
{
"id": "1-7",
"name": "UI设计",
"label": "UI设计",
"parentid": "1",
"isShow": 1,
"orderby": 7,
"isLeaf": 0,
"childrenTreeNodes": [
{
"id": "1-7-1",
"name": "Photoshop",
"label": "Photoshop",
"parentid": "1-7",
"isShow": 1,
"orderby": 1,
"isLeaf": 1,
"childrenTreeNodes": null
}
]
},
{
"id": "1-8",
"name": "游戏开发",
"label": "游戏开发",
"parentid": "1",
"isShow": 1,
"orderby": 8,
"isLeaf": 0,
"childrenTreeNodes": [
{
"id": "1-8-1",
"name": "Cocos",
"label": "Cocos",
"parentid": "1-8",
"isShow": 1,
"orderby": 1,
"isLeaf": 1,
"childrenTreeNodes": null
},
{
"id": "1-8-2",
"name": "Unity3D",
"label": "Unity3D",
"parentid": "1-8",
"isShow": 1,
"orderby": 2,
"isLeaf": 1,
"childrenTreeNodes": null
}
]
},
{
"id": "1-9",
"name": "智能硬件/物联网",
"label": "智能硬件/物联网",
"parentid": "1",
"isShow": 1,
"orderby": 9,
"isLeaf": 0,
"childrenTreeNodes": [
{
"id": "1-9-1",
"name": "无线通信",
"label": "无线通信",
"parentid": "1-9",
"isShow": 1,
"orderby": 1,
"isLeaf": 1,
"childrenTreeNodes": null
},
{
"id": "1-9-10",
"name": "物联网技术",
"label": "物联网技术",
"parentid": "1-9",
"isShow": 1,
"orderby": 10,
"isLeaf": 1,
"childrenTreeNodes": null
}
]
}
]
至此,树形结构的字段数据的后端解决方案完成,对于类似结构或业务可推而广之。