组合模式:Mybatis SqlNode的组合树与菜单权限树设计
组合模式:Mybatis SqlNode的组合树与菜单权限树设计
适读人群:中高级Java开发者 | 阅读时长:约22分钟 | 模式类型:结构型
开篇故事
做后台管理系统的朋友一定都实现过"菜单权限树"功能——把数据库里一张扁平的菜单表,转换成前端需要的树形结构。
我第一次做这个需求是在2016年,当时用了最朴素的递归:查出所有菜单,然后递归遍历组装树。代码写出来之后,我隐约感觉这个"每个节点既可以是叶子节点(菜单项),又可以是枝节点(菜单分组)"的设计有点眼熟,但当时没想到它对应的是哪个设计模式。
后来研究 MyBatis 源码,看到 SqlNode 接口的设计,突然就想通了——对,这就是组合模式!
MyBatis 的动态 SQL(<if>、<choose>、<foreach>)解析后会构建一棵 SqlNode 树,树中的每个节点(无论是叶子的 TextSqlNode 还是复合的 IfSqlNode)都实现了相同的 SqlNode 接口,调用 apply() 方法时会递归地处理整棵树。
这正是组合模式的精髓:让叶子节点和枝节点拥有相同的接口,使调用者无需区分两者,可以用统一的方式处理整棵树。
一、模式动机:统一处理树形结构
组合模式(Composite Pattern)的核心:将对象组合成树形结构以表示"部分-整体"的层次结构,使得用户对单个对象和组合对象的使用具有一致性。
适用场景:
- 树形菜单/权限树
- 文件系统(文件和目录)
- XML/JSON 解析树
- 公司组织架构
- MyBatis 动态 SQL 的 SqlNode 树
- 表达式树(计算器、规则引擎)
二、模式结构
三、MyBatis SqlNode 源码分析
3.1 SqlNode 接口
// MyBatis的SqlNode接口(Component角色)
public interface SqlNode {
/**
* 将这个SqlNode应用到DynamicContext
* 叶子节点(TextSqlNode):直接添加文本到上下文
* 复合节点(IfSqlNode等):递归处理子节点
*/
boolean apply(DynamicContext context);
}3.2 各类 SqlNode 实现
// 文本节点(Leaf角色):静态SQL文本
public class StaticTextSqlNode implements SqlNode {
private final String text;
public StaticTextSqlNode(String text) {
this.text = text;
}
@Override
public boolean apply(DynamicContext context) {
context.appendSql(text); // 直接追加文本,不递归
return true;
}
}
// If节点(Composite角色):<if test="condition">...</if>
public class IfSqlNode implements SqlNode {
private final ExpressionEvaluator evaluator;
private final String test;
private final SqlNode contents; // 子节点(可以是另一个Composite或Leaf)
public IfSqlNode(SqlNode contents, String test) {
this.test = test;
this.contents = contents;
this.evaluator = new ExpressionEvaluator();
}
@Override
public boolean apply(DynamicContext context) {
if (evaluator.evaluateBoolean(test, context.getBindings())) {
contents.apply(context); // 条件满足时,递归应用子节点
return true;
}
return false;
}
}
// MixedSqlNode(Composite角色):包含多个子节点的复合节点
public class MixedSqlNode implements SqlNode {
private final List<SqlNode> contents; // 子节点列表
public MixedSqlNode(List<SqlNode> contents) {
this.contents = contents;
}
@Override
public boolean apply(DynamicContext context) {
// 依次应用所有子节点(递归处理)
contents.forEach(node -> node.apply(context));
return true;
}
}
// ForEach节点(Composite角色):<foreach collection="list">
public class ForEachSqlNode implements SqlNode {
private final ExpressionEvaluator evaluator;
private final String collectionExpression;
private final SqlNode contents;
private final String open;
private final String close;
private final String separator;
private final String item;
private final String index;
// 在集合每个元素上应用子节点
@Override
public boolean apply(DynamicContext context) {
Map<String, Object> bindings = context.getBindings();
Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings);
if (!iterable.iterator().hasNext()) {
return true;
}
boolean first = true;
applyOpen(context);
int i = 0;
for (Object o : iterable) {
DynamicContext oldContext = context;
if (first || separator == null) {
context = new PrefixedContext(context, "");
} else {
context = new PrefixedContext(context, separator);
}
// 为每个元素绑定item和index变量
bindItem(context, o, i);
contents.apply(context); // 递归应用子节点
i++;
first = false;
context = oldContext;
}
applyClose(context);
return true;
}
private void bindItem(DynamicContext context, Object o, int i) {
// 绑定循环变量...
}
private void applyOpen(DynamicContext context) {
if (open != null) context.appendSql(open);
}
private void applyClose(DynamicContext context) {
if (close != null) context.appendSql(close);
}
}一个完整的动态 SQL 解析示例:
<!-- MyBatis XML中的动态SQL -->
<select id="findOrders" resultType="Order">
SELECT * FROM orders
<where>
<if test="userId != null">
AND user_id = #{userId}
</if>
<if test="status != null">
AND status = #{status}
</if>
<if test="orderIds != null and orderIds.size() > 0">
AND id IN
<foreach collection="orderIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</if>
</where>
ORDER BY created_at DESC
</select>解析后的 SqlNode 树:
MixedSqlNode (根节点)
├── StaticTextSqlNode("SELECT * FROM orders")
├── WhereSqlNode (WHERE子句的Composite节点)
│ ├── IfSqlNode(test="userId != null")
│ │ └── StaticTextSqlNode("AND user_id = #{userId}")
│ ├── IfSqlNode(test="status != null")
│ │ └── StaticTextSqlNode("AND status = #{status}")
│ └── IfSqlNode(test="orderIds != null and orderIds.size() > 0")
│ ├── StaticTextSqlNode("AND id IN")
│ └── ForEachSqlNode(collection="orderIds")
│ └── StaticTextSqlNode("#{id}")
└── StaticTextSqlNode("ORDER BY created_at DESC")四、生产级代码实现:菜单权限树
4.1 菜单权限树的完整实现
/**
* 菜单节点(Component接口)
* 叶子节点(MenuLeaf)和枝节点(MenuGroup)都实现这个接口
*/
public interface MenuNode {
/**
* 获取节点ID
*/
Long getId();
/**
* 获取节点显示名称
*/
String getName();
/**
* 获取节点路径/URL
*/
String getPath();
/**
* 获取节点图标
*/
String getIcon();
/**
* 获取节点类型
*/
MenuNodeType getType();
/**
* 获取子节点列表(叶子节点返回空列表)
*/
List<MenuNode> getChildren();
/**
* 检查用户是否有权限访问此节点
*/
boolean hasPermission(Set<String> userPermissions);
/**
* 转换为前端需要的DTO
*/
MenuNodeDTO toDTO();
}
public enum MenuNodeType {
GROUP, // 菜单分组(枝节点)
PAGE, // 页面菜单(叶子节点)
BUTTON, // 按钮权限(叶子节点)
}
/**
* 菜单叶子节点(Leaf角色):具体的页面或按钮
*/
@Data
@Builder
public class MenuLeaf implements MenuNode {
private Long id;
private String name;
private String path;
private String icon;
private MenuNodeType type;
private String permissionCode; // 需要的权限码
private int sortOrder;
@Override
public List<MenuNode> getChildren() {
return Collections.emptyList(); // 叶子节点没有子节点
}
@Override
public boolean hasPermission(Set<String> userPermissions) {
if (StringUtils.isEmpty(permissionCode)) {
return true; // 没有权限要求的节点,默认可访问
}
return userPermissions.contains(permissionCode);
}
@Override
public MenuNodeDTO toDTO() {
return MenuNodeDTO.builder()
.id(id)
.name(name)
.path(path)
.icon(icon)
.type(type.name())
.children(Collections.emptyList())
.build();
}
}
/**
* 菜单枝节点(Composite角色):菜单分组,包含子菜单
*/
@Slf4j
public class MenuGroup implements MenuNode {
private final Long id;
private final String name;
private final String path;
private final String icon;
private final String permissionCode;
private final int sortOrder;
private final List<MenuNode> children = new ArrayList<>();
public MenuGroup(Long id, String name, String path, String icon,
String permissionCode, int sortOrder) {
this.id = id;
this.name = name;
this.path = path;
this.icon = icon;
this.permissionCode = permissionCode;
this.sortOrder = sortOrder;
}
public void addChild(MenuNode child) {
children.add(child);
// 保持子节点按sortOrder排序
children.sort(Comparator.comparingInt(n -> {
if (n instanceof MenuLeaf leaf) return leaf.getSortOrder();
if (n instanceof MenuGroup group) return group.sortOrder;
return 0;
}));
}
public void removeChild(MenuNode child) {
children.remove(child);
}
@Override
public Long getId() { return id; }
@Override
public String getName() { return name; }
@Override
public String getPath() { return path; }
@Override
public String getIcon() { return icon; }
@Override
public MenuNodeType getType() { return MenuNodeType.GROUP; }
@Override
public List<MenuNode> getChildren() {
return Collections.unmodifiableList(children);
}
@Override
public boolean hasPermission(Set<String> userPermissions) {
// 分组节点:只要有任意一个子节点有权限,分组就显示
if (StringUtils.hasText(permissionCode) && !userPermissions.contains(permissionCode)) {
return false; // 分组本身有权限限制
}
return children.stream().anyMatch(child -> child.hasPermission(userPermissions));
}
@Override
public MenuNodeDTO toDTO() {
// 递归转换子节点
List<MenuNodeDTO> childDTOs = children.stream()
.map(MenuNode::toDTO)
.collect(Collectors.toList());
return MenuNodeDTO.builder()
.id(id)
.name(name)
.path(path)
.icon(icon)
.type(MenuNodeType.GROUP.name())
.children(childDTOs)
.build();
}
public int getChildCount() {
return children.size();
}
/**
* 获取该节点下所有叶子节点(递归)
*/
public List<MenuNode> getAllLeaves() {
List<MenuNode> leaves = new ArrayList<>();
collectLeaves(this, leaves);
return leaves;
}
private void collectLeaves(MenuNode node, List<MenuNode> leaves) {
if (node.getType() != MenuNodeType.GROUP) {
leaves.add(node);
return;
}
for (MenuNode child : node.getChildren()) {
collectLeaves(child, leaves);
}
}
}
/**
* 菜单树服务
*/
@Service
@Slf4j
public class MenuTreeService {
@Autowired
private MenuRepository menuRepository;
/**
* 构建完整菜单树
*/
public MenuGroup buildMenuTree() {
List<MenuEntity> allMenus = menuRepository.findAllOrderBySortOrder();
return buildTreeFromList(allMenus);
}
/**
* 构建带权限过滤的菜单树
*/
public Optional<MenuGroup> buildPermissionFilteredTree(Set<String> userPermissions) {
MenuGroup root = buildMenuTree();
return filterTreeByPermission(root, userPermissions);
}
/**
* 从数据库列表构建树(经典的递归构建)
*/
private MenuGroup buildTreeFromList(List<MenuEntity> menuList) {
// 构建ID -> Node的映射
Map<Long, MenuNode> nodeMap = new HashMap<>();
// 创建虚拟根节点
MenuGroup root = new MenuGroup(0L, "root", "/", null, null, 0);
nodeMap.put(0L, root);
// 先创建所有节点
for (MenuEntity entity : menuList) {
MenuNode node = entity.getType() == MenuNodeType.GROUP
? new MenuGroup(entity.getId(), entity.getName(), entity.getPath(),
entity.getIcon(), entity.getPermissionCode(), entity.getSortOrder())
: MenuLeaf.builder()
.id(entity.getId())
.name(entity.getName())
.path(entity.getPath())
.icon(entity.getIcon())
.type(entity.getType())
.permissionCode(entity.getPermissionCode())
.sortOrder(entity.getSortOrder())
.build();
nodeMap.put(entity.getId(), node);
}
// 建立父子关系
for (MenuEntity entity : menuList) {
MenuNode node = nodeMap.get(entity.getId());
Long parentId = entity.getParentId() != null ? entity.getParentId() : 0L;
MenuNode parent = nodeMap.get(parentId);
if (parent instanceof MenuGroup parentGroup) {
parentGroup.addChild(node);
} else {
log.warn("Parent node {} not found or is not a group for menu {}",
parentId, entity.getId());
}
}
return root;
}
/**
* 递归过滤权限树
*/
private Optional<MenuGroup> filterTreeByPermission(MenuGroup group, Set<String> permissions) {
if (!group.hasPermission(permissions)) {
return Optional.empty();
}
// 创建过滤后的新分组
MenuGroup filteredGroup = new MenuGroup(
group.getId(), group.getName(), group.getPath(),
group.getIcon(), null, 0
);
for (MenuNode child : group.getChildren()) {
if (!child.hasPermission(permissions)) continue;
if (child instanceof MenuGroup childGroup) {
// 递归处理子分组
filterTreeByPermission(childGroup, permissions)
.ifPresent(filteredGroup::addChild);
} else {
filteredGroup.addChild(child); // 叶子节点直接添加
}
}
// 如果过滤后分组没有子节点,则不显示
return filteredGroup.getChildCount() > 0
? Optional.of(filteredGroup)
: Optional.empty();
}
/**
* 打印菜单树(调试用)
*/
public void printTree(MenuNode node, int depth) {
String indent = " ".repeat(depth);
log.debug("{}[{}] {} ({})", indent, node.getType(), node.getName(), node.getPath());
for (MenuNode child : node.getChildren()) {
printTree(child, depth + 1);
}
}
}五、与相关模式的对比与选型
组合模式 vs 装饰器模式
两者都是树形结构,但目的不同:
- 组合:管理对象的层次结构,强调"部分-整体"的关系。
- 装饰器:增强单个对象的功能,装饰器与被装饰对象是"单向链"不是树。
六、踩坑实录
坑一:深层树的递归栈溢出
我们的菜单树理论上最多5层,但有人在测试数据里搞了循环引用(A是B的父节点,B又是A的父节点),导致递归无限嵌套,最终栈溢出。
解决方案:在构建树之前检测循环引用:
private void checkCyclicReference(Map<Long, Long> parentMap) {
for (Map.Entry<Long, Long> entry : parentMap.entrySet()) {
Set<Long> visited = new HashSet<>();
Long current = entry.getKey();
while (current != null && current != 0) {
if (!visited.add(current)) {
throw new IllegalStateException("Cyclic reference detected in menu tree at node: " + current);
}
current = parentMap.get(current);
}
}
}坑二:前端树形数据的性能问题
菜单表有几千条记录时,每次请求都从数据库加载全量数据并构建树,响应时间超过500ms。
解决方案:菜单树不经常变更,应该加缓存。在数据库更新时清除缓存,平时直接返回缓存的树形 DTO。
坑三:权限过滤时的并发问题
最初的实现是在原始菜单树上直接删除没权限的节点(修改 children 列表),在高并发下,多线程同时操作同一个菜单树对象,导致 ConcurrentModificationException。
解决方案:权限过滤时不修改原始树,而是创建新的树结构(如上面代码所示),保持原始树的不可变性。
七、总结
组合模式在树形数据处理中是首选方案。MyBatis 的 SqlNode 设计展示了它在框架层面的典型应用,而菜单权限树则是业务开发中最常见的应用场景。
核心价值:调用者对叶子节点和枝节点的处理方式完全一致——无论是 IfSqlNode(枝节点)还是 StaticTextSqlNode(叶子节点),都只需要调用 apply() 方法,无需关心底层是否还有子节点。这种透明性大大简化了树形结构的遍历和处理代码。
