设计模式:组合模式

欢迎你来读这篇博客,这篇博客主要是关于组合模式
其中包括组合模式的核心思想、适用场景、透明组合模式、安全组合模式、优缺点、与装饰器模式和桥接模式的区别,以及 Java 后端开发中菜单权限树的完整案例。

序言

在 Java 后端开发中,我们经常会遇到树形结构。

例如:

  • 系统菜单树;
  • 权限资源树;
  • 部门组织架构;
  • 商品分类树;
  • 文件目录树;
  • 评论回复树;
  • 地区行政区划;
  • 知识库目录;
  • 工作流节点树。

这些结构都有一个共同特点:

整体和部分具有相似的结构。

比如文件系统中:

1
2
3
4
5
6
目录
├── 文件
├── 文件
└── 子目录
├── 文件
└── 子目录

目录下面可以有文件,也可以有子目录。

文件是叶子节点,目录是组合节点。

但是在很多操作上,我们希望它们能被统一处理。

比如:

  • 计算大小;
  • 打印结构;
  • 判断权限;
  • 渲染菜单;
  • 递归遍历;
  • 批量启用或禁用。

如果每次都写一堆 if else 判断当前节点到底是文件还是目录,代码会越来越乱。

组合模式就是为了解决这种问题:

将对象组合成树形结构,让客户端可以用统一的方式处理单个对象和组合对象。

简单说:

组合模式让“一个对象”和“一组对象”看起来像同一种对象。

这句话很关键。

它不是为了炫技,而是为了让树形结构的处理逻辑变得自然。

正文

chapter 1:什么是组合模式

组合模式,英文是 Composite Pattern,属于结构型设计模式。

它的定义是:

将对象组合成树形结构以表示“部分-整体”的层次结构,使客户端可以统一处理单个对象和组合对象。

组合模式一般包含三个角色:

  1. Component 抽象组件:定义叶子节点和组合节点的公共接口。
  2. Leaf 叶子节点:没有子节点的对象。
  3. Composite 组合节点:可以包含子节点的对象。

结构如下:

1
2
3
4
5
6
7
8
Component


├── Leaf

└── Composite

└── List<Component>

组合节点内部保存的是:

1
List<Component>

而不是:

1
List<Leaf>

这意味着组合节点既可以包含叶子节点,也可以包含其他组合节点。

于是就形成了树。

chapter 2:为什么需要组合模式

假设我们要做一个菜单权限系统。

菜单结构如下:

1
2
3
4
5
6
7
8
9
系统管理
├── 用户管理
│ ├── 新增用户按钮
│ ├── 修改用户按钮
│ └── 删除用户按钮

└── 角色管理
├── 新增角色按钮
└── 分配权限按钮

这里有两类节点:

  1. 菜单目录或菜单项:可以有子节点;
  2. 按钮权限:通常是叶子节点,没有子节点。

如果不用组合模式,可能会写成这样:

1
2
3
4
5
6
7
if (node instanceof MenuNode) {
// 处理菜单节点
}

if (node instanceof ButtonNode) {
// 处理按钮节点
}

当树形结构越来越复杂时,类似判断会散落在各处。

例如:

  • 构建菜单树时判断;
  • 渲染菜单时判断;
  • 过滤权限时判断;
  • 打印结构时判断;
  • 禁用菜单时判断;
  • 统计节点数时判断。

这会导致客户端代码强依赖具体节点类型。

组合模式的思路是:

不要让客户端关心节点到底是叶子还是组合节点,而是让它们实现同一个接口。

例如:

1
2
3
4
5
6
7
8
public interface PermissionComponent {

String getName();

void print(String indent);

boolean hasPermission(String permissionCode);
}

菜单节点和按钮节点都实现这个接口。

客户端只处理 PermissionComponent

这就是组合模式的价值。

chapter 3:组合模式解决的核心问题

组合模式主要解决三个问题。

1. 树形结构表达问题

组合模式天然适合表达树形结构。

例如:

1
2
3
4
5
root
├── child
├── child
└── child
└── child

组合节点保存子组件列表:

1
private final List<Component> children = new ArrayList<>();

这种结构可以无限递归下去。

2. 统一处理问题

叶子节点和组合节点都实现同一个接口。

客户端可以统一调用:

1
2
3
component.print("");
component.hasPermission("user:add");
component.count();

不需要关心具体类型。

3. 递归操作问题

组合模式非常适合递归操作。

例如:

  • 递归打印;
  • 递归搜索;
  • 递归统计;
  • 递归过滤;
  • 递归删除;
  • 递归计算。

这些操作在树形结构中非常常见。

chapter 4:组合模式的两种形式

组合模式通常有两种形式:

  1. 透明组合模式;
  2. 安全组合模式。

这两个概念很重要。

chapter 5:透明组合模式

透明组合模式的特点是:

在抽象组件中声明所有方法,包括管理子节点的方法。

例如:

1
2
3
4
5
6
7
8
9
10
public interface Component {

void add(Component component);

void remove(Component component);

List<Component> getChildren();

void operation();
}

叶子节点也实现这些方法。

但是叶子节点没有子节点,所以通常会抛异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Leaf implements Component {

@Override
public void add(Component component) {
throw new UnsupportedOperationException("Leaf can not add child");
}

@Override
public void remove(Component component) {
throw new UnsupportedOperationException("Leaf can not remove child");
}

@Override
public List<Component> getChildren() {
return List.of();
}

@Override
public void operation() {
System.out.println("Leaf operation");
}
}

透明组合模式的优点

客户端可以完全统一地看待叶子节点和组合节点。

1
2
Component component = getComponent();
component.add(child);

透明组合模式的缺点

叶子节点也暴露了不应该有的方法。

例如文件节点不应该有 add(),但接口要求它实现。

这会牺牲类型安全。

chapter 6:安全组合模式

安全组合模式的特点是:

抽象组件中只声明公共业务方法,不声明管理子节点的方法。

例如:

1
2
3
4
public interface Component {

void operation();
}

只有组合节点才有 add()remove()getChildren()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Composite implements Component {

private final List<Component> children = new ArrayList<>();

public void add(Component component) {
children.add(component);
}

public void remove(Component component) {
children.remove(component);
}

@Override
public void operation() {
for (Component child : children) {
child.operation();
}
}
}

叶子节点只实现公共业务方法:

1
2
3
4
5
6
7
public class Leaf implements Component {

@Override
public void operation() {
System.out.println("Leaf operation");
}
}

安全组合模式的优点

叶子节点不会暴露不该有的子节点管理方法。

类型更安全。

安全组合模式的缺点

客户端如果要管理树结构,需要知道某个节点是不是组合节点。

例如:

1
2
Composite root = new Composite();
root.add(new Leaf());

不能完全只依赖 Component 来完成所有操作。

实际项目中如何选择

真实项目中,我更推荐安全组合模式。

原因很简单:

不要让叶子节点拥有它不该拥有的能力。

比如按钮权限节点不应该有 addChild() 方法。

如果它有这个方法,只是运行时报错,那不如在编译期就不暴露。

chapter 7:案例背景:菜单权限树

下面用一个 Java 后端开发中非常常见的案例来讲组合模式:菜单权限树。

假设我们要设计一个后台管理系统的权限资源结构。

它包含三类节点:

  1. 目录:例如“系统管理”;
  2. 菜单:例如“用户管理”;
  3. 按钮:例如“新增用户”“删除用户”。

目录和菜单可以包含子节点。

按钮是叶子节点,不能包含子节点。

树形结构如下:

1
2
3
4
5
6
7
8
9
系统管理
├── 用户管理
│ ├── 新增用户
│ ├── 修改用户
│ └── 删除用户

└── 角色管理
├── 新增角色
└── 分配权限

我们希望实现几个功能:

  1. 打印权限树;
  2. 判断是否包含某个权限编码;
  3. 统计节点数量;
  4. 收集所有权限编码;
  5. 支持组合节点添加子节点。

这个场景非常适合组合模式。

chapter 8:定义抽象组件 PermissionComponent

先定义抽象组件接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.Set;

public interface PermissionComponent {

String getCode();

String getName();

void print(String indent);

boolean containsPermission(String permissionCode);

int count();

Set<String> collectCodes();
}

这个接口中放的是所有节点都具备的能力:

  • 获取权限编码;
  • 获取权限名称;
  • 打印结构;
  • 判断是否包含权限;
  • 统计节点数量;
  • 收集权限编码。

注意,这里没有定义:

1
2
3
addChild()
removeChild()
getChildren()

因为按钮节点不应该有这些方法。

这是安全组合模式。

chapter 9:定义叶子节点 ButtonPermission

按钮权限是叶子节点。

它没有子节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import java.util.Set;

public class ButtonPermission implements PermissionComponent {

private final String code;

private final String name;

public ButtonPermission(String code, String name) {
if (code == null || code.isBlank()) {
throw new IllegalArgumentException("code can not be blank");
}

if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name can not be blank");
}

this.code = code;
this.name = name;
}

@Override
public String getCode() {
return code;
}

@Override
public String getName() {
return name;
}

@Override
public void print(String indent) {
System.out.println(indent + "- 按钮:" + name + " [" + code + "]");
}

@Override
public boolean containsPermission(String permissionCode) {
return this.code.equals(permissionCode);
}

@Override
public int count() {
return 1;
}

@Override
public Set<String> collectCodes() {
return Set.of(code);
}
}

按钮节点只关心自己。

比如判断权限时,它只判断自己的 code 是否匹配。

1
return this.code.equals(permissionCode);

这就是叶子节点的特点。

chapter 10:定义组合节点 MenuPermission

菜单节点可以包含子节点。

子节点可以是按钮,也可以是其他菜单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class MenuPermission implements PermissionComponent {

private final String code;

private final String name;

private final List<PermissionComponent> children = new ArrayList<>();

public MenuPermission(String code, String name) {
if (code == null || code.isBlank()) {
throw new IllegalArgumentException("code can not be blank");
}

if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name can not be blank");
}

this.code = code;
this.name = name;
}

public void addChild(PermissionComponent child) {
if (child == null) {
throw new IllegalArgumentException("child can not be null");
}

children.add(child);
}

public void removeChild(PermissionComponent child) {
children.remove(child);
}

public List<PermissionComponent> getChildren() {
return List.copyOf(children);
}

@Override
public String getCode() {
return code;
}

@Override
public String getName() {
return name;
}

@Override
public void print(String indent) {
System.out.println(indent + "+ 菜单:" + name + " [" + code + "]");

for (PermissionComponent child : children) {
child.print(indent + " ");
}
}

@Override
public boolean containsPermission(String permissionCode) {
if (this.code.equals(permissionCode)) {
return true;
}

for (PermissionComponent child : children) {
if (child.containsPermission(permissionCode)) {
return true;
}
}

return false;
}

@Override
public int count() {
int total = 1;

for (PermissionComponent child : children) {
total += child.count();
}

return total;
}

@Override
public Set<String> collectCodes() {
Set<String> codes = new HashSet<>();
codes.add(code);

for (PermissionComponent child : children) {
codes.addAll(child.collectCodes());
}

return codes;
}
}

这个类是组合模式中的 Composite。

它的核心字段是:

1
private final List<PermissionComponent> children = new ArrayList<>();

注意,这里保存的是 PermissionComponent,而不是 MenuPermissionButtonPermission

这意味着它可以保存任何组件:

  • 菜单;
  • 按钮;
  • 未来新增的其他权限节点。

chapter 11:构建权限树

现在可以构建一棵权限树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class PermissionTreeDemo {

public static void main(String[] args) {
MenuPermission systemManagement = new MenuPermission("system", "系统管理");

MenuPermission userManagement = new MenuPermission("user", "用户管理");
userManagement.addChild(new ButtonPermission("user:add", "新增用户"));
userManagement.addChild(new ButtonPermission("user:update", "修改用户"));
userManagement.addChild(new ButtonPermission("user:delete", "删除用户"));

MenuPermission roleManagement = new MenuPermission("role", "角色管理");
roleManagement.addChild(new ButtonPermission("role:add", "新增角色"));
roleManagement.addChild(new ButtonPermission("role:assign", "分配权限"));

systemManagement.addChild(userManagement);
systemManagement.addChild(roleManagement);

systemManagement.print("");

System.out.println("节点数量:" + systemManagement.count());

System.out.println("是否包含 user:add 权限:" + systemManagement.containsPermission("user:add"));

System.out.println("所有权限编码:" + systemManagement.collectCodes());
}
}

输出类似:

1
2
3
4
5
6
7
8
9
10
11
+ 菜单:系统管理 [system]
+ 菜单:用户管理 [user]
- 按钮:新增用户 [user:add]
- 按钮:修改用户 [user:update]
- 按钮:删除用户 [user:delete]
+ 菜单:角色管理 [role]
- 按钮:新增角色 [role:add]
- 按钮:分配权限 [role:assign]
节点数量:8
是否包含 user:add 权限:true
所有权限编码:[role:assign, user:update, system, role:add, user:delete, user:add, role, user]

客户端只需要面对统一的 PermissionComponent 接口。

不管节点是菜单还是按钮,都可以调用:

1
2
3
4
print()
containsPermission()
count()
collectCodes()

这就是组合模式带来的统一性。

chapter 12:组合模式中的递归思想

组合模式的核心之一就是递归。

例如打印树:

1
2
3
4
5
6
7
8
@Override
public void print(String indent) {
System.out.println(indent + "+ 菜单:" + name + " [" + code + "]");

for (PermissionComponent child : children) {
child.print(indent + " ");
}
}

菜单节点打印自己之后,再让每个子节点打印自己。

如果子节点是按钮,按钮直接打印。

如果子节点是菜单,菜单继续递归打印自己的子节点。

权限判断也是递归:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public boolean containsPermission(String permissionCode) {
if (this.code.equals(permissionCode)) {
return true;
}

for (PermissionComponent child : children) {
if (child.containsPermission(permissionCode)) {
return true;
}
}

return false;
}

统计数量也是递归:

1
2
3
4
5
6
7
8
9
10
@Override
public int count() {
int total = 1;

for (PermissionComponent child : children) {
total += child.count();
}

return total;
}

组合模式让递归逻辑自然地分布在节点内部,而不是全部堆在客户端。

chapter 13:从数据库构建树形结构

真实后端系统中,菜单和权限通常来自数据库。

例如表结构可能是:

1
2
3
4
5
6
7
sys_permission
id
parent_id
code
name
type
sort

其中 type 表示节点类型:

  • MENU
  • BUTTON

可以先定义数据库实体对应的 DTO。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class PermissionNodeDTO {

private Long id;

private Long parentId;

private String code;

private String name;

private String type;

private Integer sort;

public PermissionNodeDTO(Long id, Long parentId, String code, String name, String type, Integer sort) {
this.id = id;
this.parentId = parentId;
this.code = code;
this.name = name;
this.type = type;
this.sort = sort;
}

public Long getId() {
return id;
}

public Long getParentId() {
return parentId;
}

public String getCode() {
return code;
}

public String getName() {
return name;
}

public String getType() {
return type;
}

public Integer getSort() {
return sort;
}
}

然后写一个构建器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class PermissionTreeBuilder {

public List<PermissionComponent> buildTree(List<PermissionNodeDTO> nodes) {
Map<Long, List<PermissionNodeDTO>> childrenMap = nodes.stream()
.collect(Collectors.groupingBy(PermissionNodeDTO::getParentId));

return buildChildren(0L, childrenMap);
}

private List<PermissionComponent> buildChildren(Long parentId,
Map<Long, List<PermissionNodeDTO>> childrenMap) {
return childrenMap.getOrDefault(parentId, List.of()).stream()
.sorted(Comparator.comparing(PermissionNodeDTO::getSort))
.map(node -> buildNode(node, childrenMap))
.collect(Collectors.toList());
}

private PermissionComponent buildNode(PermissionNodeDTO node,
Map<Long, List<PermissionNodeDTO>> childrenMap) {
if ("BUTTON".equals(node.getType())) {
return new ButtonPermission(node.getCode(), node.getName());
}

MenuPermission menu = new MenuPermission(node.getCode(), node.getName());

List<PermissionComponent> children = buildChildren(node.getId(), childrenMap);

for (PermissionComponent child : children) {
menu.addChild(child);
}

return menu;
}
}

使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.util.List;

public class PermissionTreeBuildDemo {

public static void main(String[] args) {
List<PermissionNodeDTO> nodes = List.of(
new PermissionNodeDTO(1L, 0L, "system", "系统管理", "MENU", 1),
new PermissionNodeDTO(2L, 1L, "user", "用户管理", "MENU", 1),
new PermissionNodeDTO(3L, 2L, "user:add", "新增用户", "BUTTON", 1),
new PermissionNodeDTO(4L, 2L, "user:update", "修改用户", "BUTTON", 2),
new PermissionNodeDTO(5L, 2L, "user:delete", "删除用户", "BUTTON", 3),
new PermissionNodeDTO(6L, 1L, "role", "角色管理", "MENU", 2),
new PermissionNodeDTO(7L, 6L, "role:add", "新增角色", "BUTTON", 1),
new PermissionNodeDTO(8L, 6L, "role:assign", "分配权限", "BUTTON", 2)
);

PermissionTreeBuilder builder = new PermissionTreeBuilder();

List<PermissionComponent> roots = builder.buildTree(nodes);

for (PermissionComponent root : roots) {
root.print("");
}
}
}

这就是组合模式在后端菜单权限系统中的真实落地方式。

chapter 14:在 Spring Boot 中落地

在 Spring Boot 项目中,可以把权限树构建逻辑放到 Service 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Service
public class PermissionService {

private final PermissionRepository permissionRepository;

private final PermissionTreeBuilder permissionTreeBuilder;

public PermissionService(PermissionRepository permissionRepository,
PermissionTreeBuilder permissionTreeBuilder) {
this.permissionRepository = permissionRepository;
this.permissionTreeBuilder = permissionTreeBuilder;
}

public List<PermissionComponent> getPermissionTree(Long userId) {
List<PermissionNodeDTO> nodes = permissionRepository.findPermissionsByUserId(userId);

return permissionTreeBuilder.buildTree(nodes);
}

public boolean hasPermission(Long userId, String permissionCode) {
List<PermissionComponent> roots = getPermissionTree(userId);

for (PermissionComponent root : roots) {
if (root.containsPermission(permissionCode)) {
return true;
}
}

return false;
}

public Set<String> getPermissionCodes(Long userId) {
List<PermissionComponent> roots = getPermissionTree(userId);

return roots.stream()
.flatMap(root -> root.collectCodes().stream())
.collect(Collectors.toSet());
}
}

这里的 PermissionRepository 可以是 JPA、MyBatis、MyBatis-Plus,只负责从数据库取数据。

1
2
3
4
public interface PermissionRepository {

List<PermissionNodeDTO> findPermissionsByUserId(Long userId);
}

组合模式负责表达和操作树结构。

Repository 负责数据访问。

Service 负责应用逻辑。

职责边界清晰,后面维护不会像在毛线团里找耳机线一样痛苦。

chapter 15:组合模式和前端菜单树返回

后端通常需要给前端返回菜单树。

可以定义一个 VO。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.util.ArrayList;
import java.util.List;

public class PermissionTreeVO {

private String code;

private String name;

private List<PermissionTreeVO> children = new ArrayList<>();

public PermissionTreeVO(String code, String name) {
this.code = code;
this.name = name;
}

public void addChild(PermissionTreeVO child) {
this.children.add(child);
}

public String getCode() {
return code;
}

public String getName() {
return name;
}

public List<PermissionTreeVO> getChildren() {
return children;
}
}

为了转换,可以在 PermissionComponent 中增加一个方法:

1
PermissionTreeVO toTreeVO();

接口变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.Set;

public interface PermissionComponent {

String getCode();

String getName();

void print(String indent);

boolean containsPermission(String permissionCode);

int count();

Set<String> collectCodes();

PermissionTreeVO toTreeVO();
}

按钮节点实现:

1
2
3
4
@Override
public PermissionTreeVO toTreeVO() {
return new PermissionTreeVO(code, name);
}

菜单节点实现:

1
2
3
4
5
6
7
8
9
10
@Override
public PermissionTreeVO toTreeVO() {
PermissionTreeVO vo = new PermissionTreeVO(code, name);

for (PermissionComponent child : children) {
vo.addChild(child.toTreeVO());
}

return vo;
}

这样就可以递归生成前端需要的树形结构。

1
2
3
List<PermissionTreeVO> tree = roots.stream()
.map(PermissionComponent::toTreeVO)
.toList();

组合模式非常适合这类树形 DTO 转换。

chapter 16:组合模式和文件系统案例

组合模式最经典的案例是文件系统。

文件和目录都可以看作文件系统节点。

定义统一接口:

1
2
3
4
5
6
7
8
public interface FileSystemNode {

String getName();

long size();

void print(String indent);
}

文件是叶子节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class FileNode implements FileSystemNode {

private final String name;

private final long size;

public FileNode(String name, long size) {
this.name = name;
this.size = size;
}

@Override
public String getName() {
return name;
}

@Override
public long size() {
return size;
}

@Override
public void print(String indent) {
System.out.println(indent + "- 文件:" + name + ",大小:" + size);
}
}

目录是组合节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import java.util.ArrayList;
import java.util.List;

public class DirectoryNode implements FileSystemNode {

private final String name;

private final List<FileSystemNode> children = new ArrayList<>();

public DirectoryNode(String name) {
this.name = name;
}

public void add(FileSystemNode node) {
children.add(node);
}

public void remove(FileSystemNode node) {
children.remove(node);
}

@Override
public String getName() {
return name;
}

@Override
public long size() {
long total = 0;

for (FileSystemNode child : children) {
total += child.size();
}

return total;
}

@Override
public void print(String indent) {
System.out.println(indent + "+ 目录:" + name);

for (FileSystemNode child : children) {
child.print(indent + " ");
}
}
}

使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FileSystemDemo {

public static void main(String[] args) {
DirectoryNode root = new DirectoryNode("root");

root.add(new FileNode("README.md", 10));
root.add(new FileNode("pom.xml", 20));

DirectoryNode src = new DirectoryNode("src");
src.add(new FileNode("Application.java", 100));
src.add(new FileNode("UserService.java", 200));

root.add(src);

root.print("");

System.out.println("总大小:" + root.size());
}
}

这个例子可以非常直观地体现组合模式:

  • 文件和目录都可以计算大小;
  • 文件返回自己的大小;
  • 目录递归计算子节点大小;
  • 客户端统一调用 size()

chapter 17:组合模式和递归查询的关系

树形结构常常有两种处理方式:

  1. 数据库层递归;
  2. 内存中递归。

1. 数据库层递归

如果数据库支持递归查询,例如 PostgreSQL 的递归 CTE,可以直接查询整棵树。

这种方式适合数据量较大、需要数据库层过滤的场景。

2. 内存中递归

先查询一批节点,再在内存中构建树。

这种方式适合:

  • 菜单数量不大;
  • 权限节点数量可控;
  • 需要复杂对象行为;
  • 需要组合模式封装业务操作。

组合模式通常是在内存对象模型中发挥作用。

它不替代数据库查询,而是让查询出来的数据变成一个更好用的对象结构。

chapter 18:组合模式和递归过深问题

组合模式经常使用递归,因此要注意递归深度。

如果树非常深,例如几千层,可能出现:

1
StackOverflowError

普通菜单树、分类树一般不会这么深。

但是以下场景要小心:

  • 用户自定义目录;
  • 评论无限嵌套;
  • 流程节点嵌套;
  • 组织层级异常数据;
  • 知识库目录无限层级。

解决方式包括:

  1. 限制最大层级;
  2. 构建树前做环检测;
  3. 使用迭代方式遍历;
  4. 数据库层限制递归深度;
  5. 保存路径字段,例如 path
  6. 使用闭包表或物化路径模型。

组合模式很优雅,但树如果长歪了,优雅也会栈溢出。

chapter 19:组合模式和循环引用问题

树结构理论上不应该有环。

但是数据库数据可能脏。

例如:

1
2
3
A 的 parent 是 B
B 的 parent 是 C
C 的 parent 是 A

这就不是树了,是环。

如果直接递归,会无限循环。

所以构建树时最好做检测。

简单检测方式:

1
2
3
4
5
6
7
8
9
10
11
private void checkCycle(Long nodeId, Set<Long> visiting) {
if (visiting.contains(nodeId)) {
throw new IllegalStateException("Cycle detected in permission tree, nodeId = " + nodeId);
}

visiting.add(nodeId);

// 递归处理子节点

visiting.remove(nodeId);
}

在真实项目中,菜单、部门、分类这些层级数据都应该有防环校验。

不要等线上递归爆栈之后再说“数据库里怎么会有这种数据”。数据库不会背锅,它只是忠实记录了你允许写进去的混乱。

chapter 20:组合模式和装饰器模式的区别

组合模式和装饰器模式都属于结构型模式,也都使用组合关系。

但它们的目的完全不同。

对比项 组合模式 装饰器模式
目的 表示部分-整体的树形结构 动态增强对象功能
结构 一个节点包含多个子节点 一个装饰器包装一个对象
关系 树形关系 链式包裹关系
典型场景 菜单树、文件树、组织树 日志增强、缓存增强、重试增强
客户端关注点 统一处理单个对象和组合对象 在不改原类的情况下增强行为

组合模式强调:

1
整体由部分组成

装饰器模式强调:

1
对象被功能一层层包裹

文件系统是组合模式。

给文件系统操作加缓存、日志、限流,才是装饰器模式。

chapter 21:组合模式和桥接模式的区别

组合模式和桥接模式也都是结构型模式。

对比项 组合模式 桥接模式
目的 表达树形的部分-整体关系 拆分多个独立变化维度
核心结构 节点包含子节点 抽象层持有实现层
典型关系 递归包含 横向组合
示例 菜单包含子菜单和按钮 消息类型组合发送渠道
重点 一致处理树中节点 独立扩展两个维度

组合模式适合:

1
一个整体里面包含多个部分

桥接模式适合:

1
一个对象有两个变化维度

不要因为两者都用了组合关系就混在一起。

设计模式里“组合”这个词出现很多,但语义不一样。就像 Java 里到处都有 Context,但每个 Context 都像不同部门的“临时负责人”。

chapter 22:组合模式和责任链模式的区别

责任链模式也可能出现链式结构,但它和组合模式不同。

对比项 组合模式 责任链模式
结构
目的 表示整体和部分 将请求沿链传递
调用方式 通常遍历所有或部分子节点 一个节点处理不了交给下一个
典型场景 菜单树、文件树 审批流、过滤器链
重点 统一处理节点 请求传递和处理

组合模式强调树形层级。

责任链强调请求流转。

chapter 23:组合模式的优点

1. 统一处理叶子节点和组合节点

客户端可以统一调用组件接口。

1
2
3
component.print("");
component.count();
component.collectCodes();

2. 非常适合树形结构

菜单树、组织树、文件树、分类树都很自然。

3. 易于扩展新节点类型

可以新增新的叶子节点或组合节点,只要实现统一接口即可。

4. 递归逻辑更清晰

递归操作封装在组件内部,客户端不需要到处写复杂判断。

5. 符合开闭原则

新增节点类型时,尽量不修改已有客户端代码。

chapter 24:组合模式的缺点

1. 抽象设计需要谨慎

抽象组件接口如果设计过大,叶子节点可能被迫实现不需要的方法。

2. 可能隐藏节点差异

统一接口有时会抹平叶子节点和组合节点之间的重要差异。

3. 递归可能带来性能问题

树很大时,递归遍历可能有性能和栈深度风险。

4. 数据结构约束需要额外保证

组合模式假设是树,但数据库中可能出现环、孤儿节点、重复节点。

这些需要在数据层或构建层处理。

5. 调试递归逻辑不如线性逻辑直观

尤其是节点层级很深时,排查问题需要更清晰的日志。

chapter 25:适用场景

组合模式适合以下场景。

1. 需要表示树形结构

例如:

  • 菜单树;
  • 文件目录树;
  • 部门组织树;
  • 商品分类树;
  • 评论回复树。

2. 希望统一处理整体和部分

例如文件和目录都支持:

1
2
3
size()
print()
delete()

菜单和按钮都支持:

1
2
containsPermission()
collectCodes()

3. 需要递归操作

例如:

  • 递归统计;
  • 递归搜索;
  • 递归过滤;
  • 递归转换;
  • 递归渲染。

4. 节点类型比较稳定

如果树中节点类型相对稳定,组合模式会很舒服。

5. 客户端不想关心具体节点类型

客户端只依赖抽象组件即可。

chapter 26:不适合使用的场景

以下场景不建议使用组合模式。

1. 数据不是树形结构

如果对象之间不是部分-整体关系,就不要强行使用组合模式。

2. 叶子节点和组合节点差异极大

如果两者几乎没有共同操作,强行抽象会很别扭。

3. 树结构非常简单

如果只是两层结构,而且操作很少,普通 DTO + List 就够了。

4. 节点之间有复杂图关系

如果数据结构是图,而不是树,组合模式不一定适合。

例如社交关系、知识图谱、网状依赖关系。

5. 需要频繁跨层查询

如果每次都要在巨大树中递归搜索,可能要考虑索引、缓存、数据库模型优化。

chapter 27:真实项目中的实践建议

1. 优先使用安全组合模式

尽量不要让叶子节点暴露 addChild() 这种不该有的方法。

1
2
3
public interface PermissionComponent {
void print(String indent);
}

组合节点自己提供:

1
public void addChild(PermissionComponent child)

2. 抽象组件接口不要过大

只把真正所有节点都需要的行为放到接口里。

不要为了统一而统一。

3. 构建树时处理脏数据

至少要考虑:

  • parentId 不存在;
  • 节点循环引用;
  • 重复节点;
  • 排序字段为空;
  • 根节点不唯一;
  • 深度过大。

4. 大树要考虑缓存

菜单权限树通常可以缓存。

例如:

  • 按用户缓存权限编码;
  • 按角色缓存权限树;
  • 按租户缓存菜单结构。

但是要注意权限变更后的缓存失效。

5. 后端返回前端时不要暴露领域对象

PermissionComponent 是内部对象模型。

返回前端建议转换成 VO。

1
PermissionTreeVO

6. 递归方法要注意命名和边界

例如:

1
2
3
4
collectCodes()
containsPermission()
count()
toTreeVO()

这些方法语义清晰,维护时更容易理解。

7. 不要把数据库实体直接做成组合节点

数据库 Entity 通常只负责持久化数据。

组合模式对象更适合作为领域对象、业务对象或视图构建对象。

否则 Entity 会越来越重,最后变成“数据库字段 + 业务递归 + 前端渲染 + 权限判断”的缝合怪。

chapter 28:完整案例代码汇总

抽象组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.Set;

public interface PermissionComponent {

String getCode();

String getName();

void print(String indent);

boolean containsPermission(String permissionCode);

int count();

Set<String> collectCodes();
}

叶子节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import java.util.Set;

public class ButtonPermission implements PermissionComponent {

private final String code;

private final String name;

public ButtonPermission(String code, String name) {
if (code == null || code.isBlank()) {
throw new IllegalArgumentException("code can not be blank");
}

if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name can not be blank");
}

this.code = code;
this.name = name;
}

@Override
public String getCode() {
return code;
}

@Override
public String getName() {
return name;
}

@Override
public void print(String indent) {
System.out.println(indent + "- 按钮:" + name + " [" + code + "]");
}

@Override
public boolean containsPermission(String permissionCode) {
return this.code.equals(permissionCode);
}

@Override
public int count() {
return 1;
}

@Override
public Set<String> collectCodes() {
return Set.of(code);
}
}

组合节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class MenuPermission implements PermissionComponent {

private final String code;

private final String name;

private final List<PermissionComponent> children = new ArrayList<>();

public MenuPermission(String code, String name) {
if (code == null || code.isBlank()) {
throw new IllegalArgumentException("code can not be blank");
}

if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name can not be blank");
}

this.code = code;
this.name = name;
}

public void addChild(PermissionComponent child) {
if (child == null) {
throw new IllegalArgumentException("child can not be null");
}

children.add(child);
}

public void removeChild(PermissionComponent child) {
children.remove(child);
}

public List<PermissionComponent> getChildren() {
return List.copyOf(children);
}

@Override
public String getCode() {
return code;
}

@Override
public String getName() {
return name;
}

@Override
public void print(String indent) {
System.out.println(indent + "+ 菜单:" + name + " [" + code + "]");

for (PermissionComponent child : children) {
child.print(indent + " ");
}
}

@Override
public boolean containsPermission(String permissionCode) {
if (this.code.equals(permissionCode)) {
return true;
}

for (PermissionComponent child : children) {
if (child.containsPermission(permissionCode)) {
return true;
}
}

return false;
}

@Override
public int count() {
int total = 1;

for (PermissionComponent child : children) {
total += child.count();
}

return total;
}

@Override
public Set<String> collectCodes() {
Set<String> codes = new HashSet<>();
codes.add(code);

for (PermissionComponent child : children) {
codes.addAll(child.collectCodes());
}

return codes;
}
}

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class PermissionTreeDemo {

public static void main(String[] args) {
MenuPermission systemManagement = new MenuPermission("system", "系统管理");

MenuPermission userManagement = new MenuPermission("user", "用户管理");
userManagement.addChild(new ButtonPermission("user:add", "新增用户"));
userManagement.addChild(new ButtonPermission("user:update", "修改用户"));
userManagement.addChild(new ButtonPermission("user:delete", "删除用户"));

MenuPermission roleManagement = new MenuPermission("role", "角色管理");
roleManagement.addChild(new ButtonPermission("role:add", "新增角色"));
roleManagement.addChild(new ButtonPermission("role:assign", "分配权限"));

systemManagement.addChild(userManagement);
systemManagement.addChild(roleManagement);

systemManagement.print("");

System.out.println("节点数量:" + systemManagement.count());

System.out.println("是否包含 user:add 权限:" + systemManagement.containsPermission("user:add"));

System.out.println("所有权限编码:" + systemManagement.collectCodes());
}
}

chapter 29:一句话总结

组合模式的本质是:

用统一的组件接口表示树形结构中的叶子节点和组合节点,让客户端可以一致地处理单个对象和一组对象。

它特别适合:

  • 菜单树;
  • 权限树;
  • 文件树;
  • 分类树;
  • 组织架构树;
  • 评论回复树。

如果你的业务结构天然是“整体-部分”的树形关系,并且你希望对单个节点和组合节点进行统一操作,那么组合模式就非常合适。

但如果数据不是树,或者节点之间几乎没有共同操作,就不要硬套组合模式。

好的组合模式应该让树形结构更清晰,而不是把简单问题递归成森林迷宫。

参考资料

  • Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software.
  • Robert C. Martin. Agile Software Development, Principles, Patterns, and Practices.
  • Joshua Bloch. Effective Java.
  • Spring Framework Documentation: Core Technologies - The IoC Container.
  • Refactoring Guru: Composite Pattern.
  • SourceMaking: Composite Design Pattern.

启示录

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


设计模式:组合模式
https://allendericdalexander.github.io/2026/04/01/java/design/09composite-pattern-blog/
作者
AtLuoFu
发布于
2026年4月1日
许可协议