Phase 2: 树形结构实现 🌲 ✅
学习目标: 掌握 Materialized Path、递归查询、深度控制、树形数据组装
✅ Phase 2 已于 2025-12-15 完成并验证!
📚 核心知识点
什么是 Materialized Path?
Materialized Path(物化路径)是一种存储树形结构的高效算法,通过记录从根节点到当前节点的完整路径来表示层级关系。
示例:
评论ID=1 (根评论) path: /1/
├─ 评论ID=2 (回复1) path: /1/2/
│ └─ 评论ID=5 (回复2) path: /1/2/5/
└─ 评论ID=3 (回复1) path: /1/3/
└─ 评论ID=4 (回复3) path: /1/3/4/
优势:
- ✅ 查询某条评论的所有子孙:
WHERE path LIKE '/1/%' - ✅ 查询某条评论的祖先链:解析 path 字段
- ✅ 无需递归查询,单次SQL即可获取整棵树
- ✅ 支持任意深度限制
劣势:
- ❌ 移动节点成本较高(需更新所有子孙的 path)
- ❌ path 字段占用空间(但评论系统不常移动节点)
🗄️ Step 1: 数据库设计升级
1.1 创建 Flyway 迁移脚本
文件路径: blog-application/src/main/resources/db/V1.2.1__add_comment_tree_fields.sql
-- ========================================================
-- 文件名: V1.2.1__add_comment_tree_fields.sql
-- 描述: 为评论表添加树形结构字段(Materialized Path)
-- 作者: liusxml
-- 日期: 2025-12-15
-- 版本: 1.2.1
-- ========================================================
USE blog_db;
-- 添加树形结构字段
ALTER TABLE `cmt_comment`
ADD COLUMN `path` VARCHAR(500) NULL COMMENT '物化路径 (例: /1/2/5/)',
ADD COLUMN `depth` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '评论深度 (0-根评论, 1-一级回复, ...)',
ADD COLUMN `root_id` BIGINT NULL COMMENT '根评论ID (顶级评论时为自身ID)';
-- 创建索引优化树形查询
CREATE INDEX `idx_path` ON `cmt_comment`(`path`);
CREATE INDEX `idx_root_depth` ON `cmt_comment`(`root_id`, `depth`);
-- 更新现有数据(如果有)
UPDATE `cmt_comment`
SET
`path` = CONCAT('/', `id`, '/'),
`depth` = IF(`parent_id` IS NULL, 0, 1),
`root_id` = IF(`parent_id` IS NULL, `id`, `parent_id`)
WHERE `path` IS NULL;
知识点:
path VARCHAR(500): 假设最大深度5层,每个ID最长19位,/分隔符,总长度足够depth TINYINT(1): 0-根评论,1-5 表示回复深度root_id BIGINT: 冗余字段,方便查询某篇文章下的所有根评论
📦 Step 2: 扩展 VO - CommentTreeVO
2.1 创建树形 VO
文件路径: blog-comment-api/src/main/java/com/blog/comment/api/vo/CommentTreeVO.java
package com.blog.comment.api.vo;
import com.blog.comment.api.enums.CommentStatus;
import com.blog.comment.api.enums.CommentTargetType;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 评论树形结构 VO
*
* <p>包含子评论列表,用于前端树形展示</p>
*
* @author liusxml
* @since 1.2.1
*/
@Data
@Schema(description = "评论树形结构对象")
public class CommentTreeVO implements Serializable {
@Schema(description = "评论ID")
private Long id;
@Schema(description = "目标类型")
private CommentTargetType targetType;
@Schema(description = "目标ID")
private Long targetId;
@Schema(description = "父评论ID")
private Long parentId;
@Schema(description = "评论内容")
private String content;
@Schema(description = "状态")
private CommentStatus status;
@Schema(description = "点赞数")
private Integer likeCount;
@Schema(description = "回复数")
private Integer replyCount;
@Schema(description = "评论者ID")
private Long createBy;
@Schema(description = "评论时间", example = "2025-12-15 12:00:00")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime;
@Schema(description = "更新时间", example = "2025-12-15 12:00:00")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime updateTime;
// ========== 树形结构字段 ==========
@Schema(description = "物化路径", example = "/1/2/5/")
private String path;
@Schema(description = "评论深度 (0-根评论)", example = "2")
private Integer depth;
@Schema(description = "根评论ID")
private Long rootId;
@Schema(description = "子评论列表")
private List<CommentTreeVO> children = new ArrayList<>();
/**
* 判断是否为根评论
*/
public boolean isRoot() {
return depth != null && depth == 0;
}
/**
* 添加子评论
*/
public void addChild(CommentTreeVO child) {
this.children.add(child);
}
}
知识点:
children列表:存储直接子评论,前端可递归渲染isRoot()方法:业务逻辑判断addChild()方法:方便树形组装
🔧 Step 3: 通用树形工具类 - TreeBuilder
3.1 创建工具类
文件路径: blog-common/src/main/java/com/blog/common/util/TreeBuilder.java
package com.blog.common.util;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.function.Function;
/**
* 通用树形结构构建器
*
* <p>支持任意类型的列表转换为树形结构</p>
*
* @param <T> 节点类型
* @param <ID> ID类型
* @author liusxml
* @since 1.2.1
*/
public class TreeBuilder<T, ID> {
private final Function<T, ID> idGetter;
private final Function<T, ID> parentIdGetter;
private final BiConsumer<T, List<T>> childrenSetter;
/**
* 构造函数
*
* @param idGetter 获取节点ID的函数
* @param parentIdGetter 获取父节点ID的函数
* @param childrenSetter 设置子节点列表的函数
*/
public TreeBuilder(
Function<T, ID> idGetter,
Function<T, ID> parentIdGetter,
BiConsumer<T, List<T>> childrenSetter) {
this.idGetter = idGetter;
this.parentIdGetter = parentIdGetter;
this.childrenSetter = childrenSetter;
}
/**
* 构建树形结构(单根)
*
* @param nodes 扁平列表
* @param rootId 根节点ID
* @return 树形结构(单个根节点)
*/
public T buildTree(List<T> nodes, ID rootId) {
if (nodes == null || nodes.isEmpty()) {
return null;
}
List<T> roots = buildForest(nodes);
return roots.stream()
.filter(node -> Objects.equals(idGetter.apply(node), rootId))
.findFirst()
.orElse(null);
}
/**
* 构建树形结构(森林,多个根节点)
*
* @param nodes 扁平列表
* @return 树形列表(根节点列表)
*/
public List<T> buildForest(List<T> nodes) {
if (nodes == null || nodes.isEmpty()) {
return Collections.emptyList();
}
// 1. 按ID建立索引
Map<ID, T> nodeMap = new HashMap<>(nodes.size());
for (T node : nodes) {
nodeMap.put(idGetter.apply(node), node);
}
// 2. 构建父子关系
List<T> roots = new ArrayList<>();
for (T node : nodes) {
ID parentId = parentIdGetter.apply(node);
if (parentId == null) {
// 根节点
roots.add(node);
} else {
// 子节点,添加到父节点的children
T parent = nodeMap.get(parentId);
if (parent != null) {
List<T> children = getOrCreateChildren(parent);
children.add(node);
} else {
// 父节点不存在,当作根节点处理(数据不一致)
roots.add(node);
}
}
}
return roots;
}
/**
* 获取或创建子节点列表
*/
@SuppressWarnings("unchecked")
private List<T> getOrCreateChildren(T parent) {
// 通过反射获取children字段(简化处理)
try {
var field = parent.getClass().getDeclaredField("children");
field.setAccessible(true);
List<T> children = (List<T>) field.get(parent);
if (children == null) {
children = new ArrayList<>();
childrenSetter.accept(parent, children);
}
return children;
} catch (Exception e) {
List<T> children = new ArrayList<>();
childrenSetter.accept(parent, children);
return children;
}
}
}
使用示例:
TreeBuilder<CommentTreeVO, Long> builder = new TreeBuilder<>(
CommentTreeVO::getId, // ID getter
CommentTreeVO::getParentId, // Parent ID getter
CommentTreeVO::setChildren // Children setter
);
List<CommentTreeVO> forest = builder.buildForest(flatList);
🏛️ Step 4: Service 层实现
4.1 更新 Entity
文件路径: blog-comment-service/src/main/java/com/blog/comment/domain/entity/CommentEntity.java
// 在 CommentEntity 中添加以下字段:
private String path;
private Integer depth;
private Long rootId;
4.2 更新 Converter
MapStruct 会自动处理新字段的映射,无需修改。
4.3 扩展 Service 接口
文件路径: blog-comment-service/src/main/java/com/blog/comment/service/ICommentService.java
/**
* 获取评论树(某篇文章下的所有评论)
*
* @param targetType 目标类型
* @param targetId 目标ID
* @return 评论树列表
*/
List<CommentTreeVO> getCommentTree(CommentTargetType targetType, Long targetId);
/**
* 回复评论
*
* @param dto 评论DTO
* @return 评论ID
*/
Long replyComment(CommentDTO dto);
4.4 实现 Service
文件路径: blog-comment-service/src/main/java/com/blog/comment/service/impl/CommentServiceImpl.java
package com.blog.comment.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.blog.comment.api.dto.CommentDTO;
import com.blog.comment.api.enums.CommentStatus;
import com.blog.comment.api.enums.CommentTargetType;
import com.blog.comment.api.vo.CommentTreeVO;
import com.blog.comment.api.vo.CommentVO;
import com.blog.comment.domain.entity.CommentEntity;
import com.blog.comment.infrastructure.converter.CommentConverter;
import com.blog.comment.infrastructure.mapper.CommentMapper;
import com.blog.comment.service.ICommentService;
import com.blog.common.base.BaseServiceImpl;
import com.blog.common.exception.BusinessException;
import com.blog.common.exception.SystemErrorCode;
import com.blog.common.utils.TreeBuilder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 评论服务实现(Phase 2 扩展)
*/
@Slf4j
@Service
public class CommentServiceImpl
extends BaseServiceImpl<CommentMapper, CommentEntity, CommentVO, CommentDTO, CommentConverter>
implements ICommentService {
/**
* 最大嵌套深度
*/
private static final int MAX_DEPTH = 5;
private final TreeBuilder<CommentTreeVO, Long> treeBuilder;
public CommentServiceImpl(CommentConverter converter) {
super(converter);
this.treeBuilder = new TreeBuilder<>(
CommentTreeVO::getId,
CommentTreeVO::getParentId,
CommentTreeVO::setChildren
);
}
@Override
protected void preSave(CommentEntity entity) {
// 设置默认状态
if (Objects.isNull(entity.getStatus())) {
entity.setStatus(CommentStatus.APPROVED);
}
// 初始化统计字段
if (Objects.isNull(entity.getLikeCount())) {
entity.setLikeCount(0);
}
if (Objects.isNull(entity.getReplyCount())) {
entity.setReplyCount(0);
}
// 计算树形字段
if (Objects.isNull(entity.getParentId())) {
// 根评论
entity.setDepth(0);
entity.setRootId(null); // 待保存后更新为自身ID
} else {
// 回复评论,从父评论继承路径信息
CommentEntity parent = getById(entity.getParentId());
if (parent == null) {
throw new BusinessException(SystemErrorCode.NOT_FOUND, "父评论不存在");
}
// 深度限制
if (parent.getDepth() >= MAX_DEPTH) {
throw new BusinessException(SystemErrorCode.PARAM_ERROR,
"评论嵌套深度超过限制(最大" + MAX_DEPTH + "层)");
}
entity.setDepth(parent.getDepth() + 1);
entity.setRootId(parent.getRootId() != null ? parent.getRootId() : parent.getId());
}
log.debug("评论保存前处理: depth={}, rootId={}", entity.getDepth(), entity.getRootId());
}
@Override
@Transactional(rollback = true)
public Long replyComment(CommentDTO dto) {
// 校验父评论存在
if (dto.getParentId() == null) {
throw new BusinessException(SystemErrorCode.PARAM_ERROR, "回复评论时必须指定父评论ID");
}
// 保存评论(preSave会处理树形字段)
Long commentId = (Long) saveByDto(dto);
// 更新path字段(需要用到ID)
CommentEntity entity = getById(commentId);
CommentEntity parent = getById(dto.getParentId());
String newPath = (parent.getPath() != null ? parent.getPath() : "/" + parent.getId() + "/")
+ commentId + "/";
entity.setPath(newPath);
// 如果是根评论,rootId设为自身
if (entity.getRootId() == null) {
entity.setRootId(commentId);
}
updateById(entity);
// 更新父评论的reply_count
parent.setReplyCount(parent.getReplyCount() + 1);
updateById(parent);
return commentId;
}
@Override
public List<CommentTreeVO> getCommentTree(CommentTargetType targetType, Long targetId) {
// 查询所有评论(扁平列表)
List<CommentEntity> entities = list(new LambdaQueryWrapper<CommentEntity>()
.eq(CommentEntity::getTargetType, targetType)
.eq(CommentEntity::getTargetId, targetId)
.eq(CommentEntity::getStatus, CommentStatus.APPROVED)
.orderByAsc(CommentEntity::getCreateTime));
// 转换为 TreeVO
List<CommentTreeVO> flatList = entities.stream()
.map(this::entityToTreeVO)
.collect(Collectors.toList());
// 组装树形结构
return treeBuilder.buildForest(flatList);
}
/**
* Entity 转 TreeVO
*/
private CommentTreeVO entityToTreeVO(CommentEntity entity) {
CommentTreeVO vo = new CommentTreeVO();
vo.setId(entity.getId());
vo.setTargetType(entity.getTargetType());
vo.setTargetId(entity.getTargetId());
vo.setParentId(entity.getParentId());
vo.setContent(entity.getContent());
vo.setStatus(entity.getStatus());
vo.setLikeCount(entity.getLikeCount());
vo.setReplyCount(entity.getReplyCount());
vo.setCreateBy(entity.getCreateBy());
vo.setCreateTime(entity.getCreateTime());
vo.setUpdateTime(entity.getUpdateTime());
vo.setPath(entity.getPath());
vo.setDepth(entity.getDepth());
vo.setRootId(entity.getRootId());
return vo;
}
}
🎮 Step 5: Controller 层扩展
/**
* 获取评论树
*
* @param targetType 目标类型
* @param targetId 目标ID
* @return 评论树
*/
@GetMapping("/tree")
@Operation(summary = "获取评论树")
public Result<List<CommentTreeVO>> getTree(
@RequestParam CommentTargetType targetType,
@RequestParam Long targetId) {
return Result.success(commentService.getCommentTree(targetType, targetId));
}
/**
* 回复评论
*
* @param dto 评论DTO
* @return 评论ID
*/
@PostMapping("/reply")
@Operation(summary = "回复评论")
public Result<Long> reply(@Valid @RequestBody CommentDTO dto) {
return Result.success(commentService.replyComment(dto));
}
✅ Phase 2 完成检查清单
- Flyway 脚本创建成功(已整合为 V1.2.0)
- 数据库字段和索引创建成功
- CommentTreeVO 创建完成
- TreeBuilder 工具类可用
- Entity 添加新字段
- Service 实现 replyComment 和 getCommentTree
- Controller 添加新接口
- 测试回复功能(嵌套6层)✅
- 测试深度限制(第6层已生效)✅
- 测试树形查询(正确组装)✅
- 根评论 path 和 rootId 自动填充 ✅
- 错误提示优化(清晰的业务错误消息)✅
- 错误码修正(参数错误返回 400)✅
🎉 Phase 2 已于 2025-12-15 完成!
💡 关键知识点总结
| 知识点 | 核心概念 | 实际作用 |
|---|---|---|
| Materialized Path | 物化路径算法 | 单次SQL查询整棵树 |
| depth 字段 | 深度标识 | 限制嵌套层级 |
| root_id 字段 | 根节点冗余 | 快速查询同一根的所有评论 |
| TreeBuilder | 通用树构建器 | 扁平列表→树形结构 |
| preSave 钩子 | 自动计算树字段 | 简化业务代码 |
| saveByDto 重写 | 后置处理 | 处理需要ID的字段更新 |
| @Transactional | 事务控制 | 确保数据一致性 |
🎯 Phase 2 完成总结
✅ 已实现的功能
-
树形结构设计
- ✅ Materialized Path 算法实现
- ✅ path、depth、root_id 字段
- ✅ 高效的树形查询索引
-
深度控制
- ✅ 最大深度限制(5层)
- ✅ 清晰的错误提示
- ✅ 深度自动计算
-
树形数据组装
- ✅ TreeBuilder 通用工具类
- ✅ CommentTreeVO 嵌套结构
- ✅ 高效的树形组装算法
-
错误处理优化
- ✅ GlobalExceptionHandler 支持自定义消息
- ✅ 语义化错误码(400 vs 404)
- ✅ 友好的错误提示
-
Bug 修复
- ✅ 根评论 path 和 rootId 正确填充
- ✅ BusinessException 消息正确传递
📊 测试验证
- ✅ 6层嵌套评论测试通过
- ✅ 树形结构查询正确
- ✅ 深度限制生效
- ✅ 错误处理符合预期
🚀 下一步:Phase 3
已完成! 查看 phase3_guide.md 了解状态模式实现详情。
Phase 2 完成时间: 2025-12-15
Phase 3 完成时间: 2025-12-15