做过一个权限系统,菜单结构长这样:
系统管理
├── 用户管理
│ ├── 新增用户
│ ├── 编辑用户
│ └── 删除用户
├── 角色管理
│ ├── 新增角色
│ └── 分配权限
└── 日志查看
├── 操作日志
└── 登录日志
最初的设计很直觉------两个类:
java
class MenuItem {
String name;
String url;
String permission;
}
class MenuGroup {
String name;
List items;
List
subGroups;
}
遍历和渲染的时候麻烦来了:
java
public String renderMenu(List
groups) {
StringBuilder html = new StringBuilder("
*
");
for (MenuGroup group : groups) {
html.append("
* ").append(group.name);
html.append("
*
");
for (MenuItem item : group.items) {
html.append("
* "%29.append%28item.url%29.append%28"
"%29.append%28item.url%29.append%28"
* ");
}
// 还有子分组...
for (MenuGroup sub : group.subGroups) {
html.append("
* ").append(sub.name);
html.append("
*
");
for (MenuItem item : sub.items) {
// ... 又嵌套一层
}
html.append("
* ");
}
html.append("
* ");
}
html.append("
");
return html.toString();
}
三层嵌套已经写成这样了,如果再来一层------组织架构里部门下面有子部门,子部门下面还有小组,小组下面还有------你需要写一个递归函数,而且每次写树形结构都要重新写一遍。
组合模式解决的就是这个问题:**让单个对象和组合对象可以被一致对待**。
组合模式的核心------一个接口搞定一切
java
// 抽象组件------叶子和组合节点都实现这个接口
interface MenuComponent {
String getName();
String render(); // 核心:叶子和组合节点都有这个方法
void add(MenuComponent component); // 默认抛异常
void remove(MenuComponent component); // 默认抛异常
}
// 叶子节点
class MenuItem implements MenuComponent {
private String name;
private String url;
public MenuItem(String name, String url) {
this.name = name;
this.url = url;
}
@Override
public String getName() { return name; }
@Override
public String render() {
return "
* "%20+%20url%20+%20"
* ";
}
@Override
public void add(MenuComponent c) {
throw new UnsupportedOperationException("叶子节点不能添加子节点");
}
@Override
public void remove(MenuComponent c) {
throw new UnsupportedOperationException("叶子节点不能删除子节点");
}
}
// 组合节点
class Menu implements MenuComponent {
private String name;
private List
children = new ArrayList<>();
public Menu(String name) {
this.name = name;
}
@Override
public String getName() { return name; }
@Override
public String render() {
StringBuilder html = new StringBuilder();
html.append("
* ").append(name).append("
*
");
// 关键:递归调用 render(),不管是叶子还是组合节点
for (MenuComponent child : children) {
html.append(child.render());
}
html.append("
* ");
return html.toString();
}
@Override
public void add(MenuComponent component) {
children.add(component);
}
@Override
public void remove(MenuComponent component) {
children.remove(component);
}
}
现在构建菜单树和渲染都简洁了:
java
MenuComponent root = new Menu("系统管理");
MenuComponent userMgr = new Menu("用户管理");
userMgr.add(new MenuItem("新增用户", "/user/add"));
userMgr.add(new MenuItem("编辑用户", "/user/edit"));
userMgr.add(new MenuItem("删除用户", "/user/delete"));
MenuComponent roleMgr = new Menu("角色管理");
roleMgr.add(new MenuItem("新增角色", "/role/add"));
roleMgr.add(new MenuItem("分配权限", "/role/assign"));
root.add(userMgr);
root.add(roleMgr);
// 渲染------一行搞定,递归自动处理嵌套
System.out.println(root.render());
这就是组合模式的价值:**客户端代码不需要区分叶子还是组合节点,统一调用 render() 就行**。
安全性和透明性的取舍
上面那个实现有个明显的问题------MenuItem 的 add() 和 remove() 抛异常。这在设计模式里是一个经典的权衡:
**透明式组合**(上面的做法):add() 和 remove() 定义在抽象接口里,叶子节点抛异常。调用方不需要做类型判断,但可能在运行时炸。
java
// 透明式------不需要 instanceof 判断
menuComponent.render(); // 管你是叶子还是组合,都能调
menuComponent.add(child); // 叶子节点会抛异常,但编译不报错
**安全式组合**:add() 和 remove() 只定义在 Menu 类里,不在接口中。调用方需要判断类型,但编译期就能发现错误。
java
// 安全式------需要判断类型
if (menuComponent instanceof Menu) {
((Menu) menuComponent).add(child); // 编译期安全
}
menuComponent.render(); // 这个叶子也能做
怎么选?如果你的树结构在运行时基本不变(菜单、组织结构、文件系统),透明式更方便。如果是动态构建的复杂树,安全式更稳妥------至少不会在生产环境 UnsupportedOperationException。
文件系统------组合模式的教科书
文件系统的设计天生就是组合模式:
java
interface FileSystemNode {
String getName();
long getSize();
void ls(String indent);
}
class File implements FileSystemNode {
private String name;
private long size;
public File(String name, long size) {
this.name = name;
this.size = size;
}
@Override
public String getName() { return name; }
@Override
public long getSize() { return size; }
@Override
public void ls(String indent) {
System.out.println(indent + name + " (" + size + " bytes)");
}
}
class Directory implements FileSystemNode {
private String name;
private List
children = new ArrayList<>();
@Override
public String getName() { return name; }
@Override
public long getSize() {
// 递归汇总所有子节点大小
return children.stream()
.mapToLong(FileSystemNode::getSize)
.sum();
}
@Override
public void ls(String indent) {
System.out.println(indent + name + "/");
for (FileSystemNode child : children) {
child.ls(indent + " ");
}
}
public void add(FileSystemNode node) {
children.add(node);
}
}
计算一个目录的总大小------递归自动穿透到所有子目录和文件。Directory.getSize() 不需要知道子节点是 File 还是 Directory。
Java 标准库的 java.io.File 也在一定程度上体现了组合模式的思路------listFiles() 返回的可以是文件也可以是目录,不过没有抽象出统一的组件接口。
规则引擎------一个不常见但很实用的变体
组合模式不只是处理树形 UI。在规则引擎里判定条件本身就是一棵树:
java
// 抽象条件
interface Condition {
boolean evaluate(Map
context);
}
// 原子条件------叶子
class EqualCondition implements Condition {
private String field;
private Object value;
@Override
public boolean evaluate(Map
ctx) {
return value.equals(ctx.get(field));
}
}
class GreaterThanCondition implements Condition {
private String field;
private double threshold;
@Override
public boolean evaluate(Map
ctx) {
return ((Number) ctx.get(field)).doubleValue() > threshold;
}
}
// 组合条件------AND 和 OR
class AndCondition implements Condition {
private List
conditions;
@Override
public boolean evaluate(Map
ctx) {
return conditions.stream().allMatch(c -> c.evaluate(ctx));
}
}
class OrCondition implements Condition {
private List
conditions;
@Override
public boolean evaluate(Map
ctx) {
return conditions.stream().anyMatch(c -> c.evaluate(ctx));
}
}
用起来:
java
// 定义一个规则:金额 > 1000 且 (用户等级是 VIP 或 信用分 > 80)
Condition rule = new AndCondition(List.of(
new GreaterThanCondition("amount", 1000),
new OrCondition(List.of(
new EqualCondition("level", "VIP"),
new GreaterThanCondition("creditScore", 80)
))
));
Map
order = Map.of(
"amount", 1500,
"level", "VIP",
"creditScore", 75
);
boolean pass = rule.evaluate(order); // true
这个设计的精妙之处在于:AND/OR 本身就是组合节点,evaluate() 递归穿透整棵树。你可以用 JSON 或 DSL 来定义规则树,存储到数据库里,然后在运行时动态构建条件树------不会写一行多余的 if-else。
什么时候不该用组合模式
组合模式有适用边界,强行用只会反效果:
**叶子节点和组合节点的行为差异太大时。** 如果你发现自己在写一堆 if (node instanceof Leaf) 判断,组合模式的价值已经没了。老老实实用两个不同的类,让调用方显式处理。
**树的遍历规则不统一时。** 组合模式隐含了一个假设:对每个节点的操作可以统一递归。如果子节点的处理逻辑需要依赖父节点的状态(比如路径上下文),统一接口会让代码很别扭。
**层级不深、结构不复杂时。** 两层三层的简单树,直接嵌套 List 就够了,引入接口和两个实现类属于过度设计。
一段能让代码干净三倍的写法
Java 17 之后,可以用 sealed interface 和 switch pattern matching 让组合模式更安全:
java
sealed interface TreeNode permits Leaf, Branch {
String render();
}
record Leaf(String name, String value) implements TreeNode {
@Override
public String render() {
return "" + name + ": " + value + "";
}
}
record Branch(String name, List
children) implements TreeNode {
@Override
public String render() {
String childHtml = children.stream()
.map(TreeNode::render)
.collect(Collectors.joining());
return "
#### " + name + "
" + childHtml + "
";
}
}
// 调用方可以用 switch 做模式匹配
String render(TreeNode node) {
return switch (node) {
case Leaf l -> l.render();
case Branch b -> b.render();
};
}
sealed interface 限制了只能有 Leaf 和 Branch 两个实现,编译器会帮你在 switch 里检查完整性。这解决了透明式组合的安全问题------你不需要抛 UnsupportedOperationException,因为 Leaf 上根本没有 add() 方法。
「爪爪代码冒险记」是我们正在开发的微信小程序,用卡皮巴拉漫画把 23 个设计模式讲成冒险故事。组合模式那关的主题是「家族树」------卡皮巴拉要帮小动物们绘制族谱,发现爷爷、爸爸、孙子其实都是「家庭成员」。搜一下「爪爪代码冒险记」可以看到开发进展,或者等我后面的文章。