Laravel 13 组合模式深度解析

组合模式是一种结构型设计模式,它允许将对象组合成树形结构来表示”部分-整体”的层次结构。本文将深入探讨 Laravel 13 中组合模式的高级用法。

组合模式基础

什么是组合模式

组合模式使客户端可以统一对待单个对象和组合对象,无需关心处理的是单个对象还是组合对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

namespace App\Contracts;

interface ComponentInterface
{
public function operation(): string;

public function add(ComponentInterface $component): void;

public function remove(ComponentInterface $component): void;

public function getChild(int $index): ?ComponentInterface;
}

文件系统组合

文件系统组件接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

namespace App\Contracts\Filesystem;

interface FilesystemComponentInterface
{
public function getName(): string;

public function getPath(): string;

public function getSize(): int;

public function isDirectory(): bool;

public function display(int $indent = 0): string;
}

文件类(叶子节点)

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
<?php

namespace App\Filesystem\Components;

use App\Contracts\Filesystem\FilesystemComponentInterface;

class File implements FilesystemComponentInterface
{
protected string $name;
protected string $path;
protected int $size;

public function __construct(string $name, string $path, int $size = 0)
{
$this->name = $name;
$this->path = $path;
$this->size = $size;
}

public function getName(): string
{
return $this->name;
}

public function getPath(): string
{
return $this->path;
}

public function getSize(): int
{
return $this->size;
}

public function isDirectory(): bool
{
return false;
}

public function display(int $indent = 0): string
{
$prefix = str_repeat(' ', $indent);
return "{$prefix}📄 {$this->name} ({$this->formatSize()})\n";
}

protected function formatSize(): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$size = $this->size;

for ($i = 0; $size > 1024 && $i < count($units) - 1; $i++) {
$size /= 1024;
}

return round($size, 2) . ' ' . $units[$i];
}
}

目录类(组合节点)

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
100
101
102
103
104
105
106
107
108
109
110
111
<?php

namespace App\Filesystem\Components;

use App\Contracts\Filesystem\FilesystemComponentInterface;
use Illuminate\Support\Collection;

class Directory implements FilesystemComponentInterface
{
protected string $name;
protected string $path;
protected Collection $children;

public function __construct(string $name, string $path = '')
{
$this->name = $name;
$this->path = $path;
$this->children = collect();
}

public function getName(): string
{
return $this->name;
}

public function getPath(): string
{
return $this->path;
}

public function getSize(): int
{
return $this->children->sum(fn($child) => $child->getSize());
}

public function isDirectory(): bool
{
return true;
}

public function add(FilesystemComponentInterface $component): self
{
$this->children->push($component);
return $this;
}

public function remove(FilesystemComponentInterface $component): self
{
$this->children = $this->children->reject(
fn($child) => $child === $component
);
return $this;
}

public function getChild(string $name): ?FilesystemComponentInterface
{
return $this->children->first(fn($child) => $child->getName() === $name);
}

public function getChildren(): Collection
{
return $this->children;
}

public function display(int $indent = 0): string
{
$prefix = str_repeat(' ', $indent);
$output = "{$prefix}📁 {$this->name}/\n";

foreach ($this->children as $child) {
$output .= $child->display($indent + 1);
}

return $output;
}

public function findByName(string $name): ?FilesystemComponentInterface
{
if ($this->name === $name) {
return $this;
}

foreach ($this->children as $child) {
if ($child->isDirectory()) {
$found = $child->findByName($name);
if ($found) {
return $found;
}
} elseif ($child->getName() === $name) {
return $child;
}
}

return null;
}

public function flatten(): Collection
{
$items = collect([$this]);

foreach ($this->children as $child) {
if ($child->isDirectory()) {
$items = $items->merge($child->flatten());
} else {
$items->push($child);
}
}

return $items;
}
}

菜单系统组合

菜单组件接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

namespace App\Contracts\Menu;

interface MenuComponentInterface
{
public function render(): string;

public function getUrl(): string;

public function getLabel(): string;

public function isActive(): bool;

public function hasChildren(): bool;
}

菜单项(叶子节点)

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
<?php

namespace App\Menu\Components;

use App\Contracts\Menu\MenuComponentInterface;

class MenuItem implements MenuComponentInterface
{
protected string $label;
protected string $url;
protected string $icon;
protected bool $active = false;

public function __construct(string $label, string $url, string $icon = '')
{
$this->label = $label;
$this->url = $url;
$this->icon = $icon;
}

public function render(): string
{
$iconHtml = $this->icon ? "<i class=\"{$this->icon}\"></i> " : '';
$activeClass = $this->active ? ' class="active"' : '';

return "<li{$activeClass}><a href=\"{$this->url}\">{$iconHtml}{$this->label}</a></li>";
}

public function getUrl(): string
{
return $this->url;
}

public function getLabel(): string
{
return $this->label;
}

public function isActive(): bool
{
return $this->active;
}

public function setActive(bool $active): self
{
$this->active = $active;
return $this;
}

public function hasChildren(): bool
{
return false;
}
}

菜单组(组合节点)

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
100
<?php

namespace App\Menu\Components;

use App\Contracts\Menu\MenuComponentInterface;
use Illuminate\Support\Collection;

class MenuGroup implements MenuComponentInterface
{
protected string $label;
protected string $icon;
protected Collection $children;
protected bool $active = false;

public function __construct(string $label, string $icon = '')
{
$this->label = $label;
$this->icon = $icon;
$this->children = collect();
}

public function add(MenuComponentInterface $item): self
{
$this->children->push($item);
return $this;
}

public function remove(MenuComponentInterface $item): self
{
$this->children = $this->children->reject(fn($child) => $child === $item);
return $this;
}

public function render(): string
{
$iconHtml = $this->icon ? "<i class=\"{$this->icon}\"></i> " : '';
$activeClass = $this->active ? ' active' : '';

$output = "<li class=\"dropdown{$activeClass}\">";
$output .= "<a href=\"#\" class=\"dropdown-toggle\">{$iconHtml}{$this->label}</a>";
$output .= "<ul class=\"dropdown-menu\">";

foreach ($this->children as $child) {
$output .= $child->render();
}

$output .= "</ul></li>";

return $output;
}

public function getUrl(): string
{
return '#';
}

public function getLabel(): string
{
return $this->label;
}

public function isActive(): bool
{
return $this->active || $this->children->contains(fn($child) => $child->isActive());
}

public function setActive(bool $active): self
{
$this->active = $active;
return $this;
}

public function hasChildren(): bool
{
return $this->children->isNotEmpty();
}

public function getChildren(): Collection
{
return $this->children;
}

public function findByUrl(string $url): ?MenuComponentInterface
{
foreach ($this->children as $child) {
if ($child->getUrl() === $url) {
return $child;
}

if ($child->hasChildren()) {
$found = $child->findByUrl($url);
if ($found) {
return $found;
}
}
}

return null;
}
}

菜单构建器

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
<?php

namespace App\Menu;

use App\Menu\Components\{MenuItem, MenuGroup};

class MenuBuilder
{
protected MenuGroup $root;

public function __construct(string $label = 'Main Menu')
{
$this->root = new MenuGroup($label);
}

public function addItem(string $label, string $url, string $icon = ''): self
{
$this->root->add(new MenuItem($label, $url, $icon));
return $this;
}

public function addGroup(string $label, callable $callback, string $icon = ''): self
{
$group = new MenuGroup($label, $icon);
$callback($group);
$this->root->add($group);
return $this;
}

public function setActiveByUrl(string $url): self
{
$item = $this->root->findByUrl($url);

if ($item) {
$item->setActive(true);
}

return $this;
}

public function build(): MenuGroup
{
return $this->root;
}

public function render(): string
{
return '<ul class="menu">' . $this->root->render() . '</ul>';
}
}

权限系统组合

权限组件接口

1
2
3
4
5
6
7
8
9
10
11
12
<?php

namespace App\Contracts\Permission;

interface PermissionComponentInterface
{
public function getName(): string;

public function check($user): bool;

public function getDescription(): string;
}

单个权限(叶子节点)

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
<?php

namespace App\Permission\Components;

use App\Contracts\Permission\PermissionComponentInterface;

class Permission implements PermissionComponentInterface
{
protected string $name;
protected string $description;
protected $checker;

public function __construct(string $name, string $description = '', ?callable $checker = null)
{
$this->name = $name;
$this->description = $description;
$this->checker = $checker ?? fn($user) => $user->can($name);
}

public function getName(): string
{
return $this->name;
}

public function check($user): bool
{
return ($this->checker)($user);
}

public function getDescription(): string
{
return $this->description;
}
}

权限组(组合节点)

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
<?php

namespace App\Permission\Components;

use App\Contracts\Permission\PermissionComponentInterface;
use Illuminate\Support\Collection;

class PermissionGroup implements PermissionComponentInterface
{
protected string $name;
protected string $description;
protected Collection $permissions;
protected string $mode;

public function __construct(string $name, string $description = '', string $mode = 'any')
{
$this->name = $name;
$this->description = $description;
$this->permissions = collect();
$this->mode = $mode;
}

public function getName(): string
{
return $this->name;
}

public function add(PermissionComponentInterface $permission): self
{
$this->permissions->push($permission);
return $this;
}

public function remove(PermissionComponentInterface $permission): self
{
$this->permissions = $this->permissions->reject(fn($p) => $p === $permission);
return $this;
}

public function check($user): bool
{
if ($this->permissions->isEmpty()) {
return false;
}

if ($this->mode === 'all') {
return $this->permissions->every(fn($p) => $p->check($user));
}

return $this->permissions->some(fn($p) => $p->check($user));
}

public function getDescription(): string
{
return $this->description;
}

public function getPermissions(): Collection
{
return $this->permissions;
}

public function flatten(): Collection
{
$items = collect();

foreach ($this->permissions as $permission) {
if ($permission instanceof self) {
$items = $items->merge($permission->flatten());
} else {
$items->push($permission);
}
}

return $items;
}

public function getAllNames(): array
{
return $this->flatten()->map(fn($p) => $p->getName())->toArray();
}
}

权限构建器

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
<?php

namespace App\Permission;

use App\Permission\Components\{Permission, PermissionGroup};

class PermissionBuilder
{
protected PermissionGroup $root;

public function __construct(string $name = 'root')
{
$this->root = new PermissionGroup($name);
}

public function addPermission(string $name, string $description = ''): self
{
$this->root->add(new Permission($name, $description));
return $this;
}

public function addGroup(string $name, callable $callback, string $mode = 'any'): self
{
$group = new PermissionGroup($name, '', $mode);
$callback($group);
$this->root->add($group);
return $this;
}

public function build(): PermissionGroup
{
return $this->root;
}
}

表单组件组合

表单组件接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

namespace App\Contracts\Form;

interface FormComponentInterface
{
public function render(): string;

public function getName(): string;

public function validate(): array;

public function fill(array $data): void;

public function getData(): array;
}

表单字段(叶子节点)

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
<?php

namespace App\Form\Components;

use App\Contracts\Form\FormComponentInterface;

class FormField implements FormComponentInterface
{
protected string $name;
protected string $type;
protected string $label;
protected mixed $value = null;
protected array $rules = [];
protected array $errors = [];

public function __construct(string $name, string $type, string $label = '')
{
$this->name = $name;
$this->type = $type;
$this->label = $label;
}

public function rules(array $rules): self
{
$this->rules = $rules;
return $this;
}

public function render(): string
{
$labelHtml = $this->label ? "<label for=\"{$this->name}\">{$this->label}</label>" : '';
$errorHtml = $this->errors ? "<span class=\"error\">" . implode(', ', $this->errors) . "</span>" : '';

$inputHtml = match ($this->type) {
'textarea' => "<textarea name=\"{$this->name}\" id=\"{$this->name}\">{$this->value}</textarea>",
'select' => $this->renderSelect(),
default => "<input type=\"{$this->type}\" name=\"{$this->name}\" id=\"{$this->name}\" value=\"{$this->value}\">",
};

return "<div class=\"form-field\">{$labelHtml}{$inputHtml}{$errorHtml}</div>";
}

protected function renderSelect(): string
{
$options = collect($this->options ?? [])
->map(fn($label, $value) => "<option value=\"{$value}\"" . ($this->value == $value ? ' selected' : '') . ">{$label}</option>")
->implode('');

return "<select name=\"{$this->name}\" id=\"{$this->name}\">{$options}</select>";
}

public function getName(): string
{
return $this->name;
}

public function validate(): array
{
$validator = validator([$this->name => $this->value], [$this->name => $this->rules]);

if ($validator->fails()) {
$this->errors = $validator->errors()->get($this->name);
return $this->errors;
}

$this->errors = [];
return [];
}

public function fill(array $data): void
{
$this->value = $data[$this->name] ?? null;
}

public function getData(): array
{
return [$this->name => $this->value];
}
}

表单组(组合节点)

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
<?php

namespace App\Form\Components;

use App\Contracts\Form\FormComponentInterface;
use Illuminate\Support\Collection;

class FormGroup implements FormComponentInterface
{
protected string $name;
protected string $legend;
protected Collection $components;

public function __construct(string $name, string $legend = '')
{
$this->name = $name;
$this->legend = $legend;
$this->components = collect();
}

public function add(FormComponentInterface $component): self
{
$this->components->push($component);
return $this;
}

public function render(): string
{
$legendHtml = $this->legend ? "<legend>{$this->legend}</legend>" : '';
$fieldsHtml = $this->components->map(fn($c) => $c->render())->implode('');

return "<fieldset name=\"{$this->name}\">{$legendHtml}{$fieldsHtml}</fieldset>";
}

public function getName(): string
{
return $this->name;
}

public function validate(): array
{
$errors = [];

foreach ($this->components as $component) {
$errors = array_merge($errors, $component->validate());
}

return $errors;
}

public function fill(array $data): void
{
foreach ($this->components as $component) {
$component->fill($data);
}
}

public function getData(): array
{
$data = [];

foreach ($this->components as $component) {
$data = array_merge($data, $component->getData());
}

return $data;
}

public function getComponent(string $name): ?FormComponentInterface
{
return $this->components->first(fn($c) => $c->getName() === $name);
}
}

测试组合模式

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
<?php

namespace Tests\Unit\Composite;

use Tests\TestCase;
use App\Filesystem\Components\{File, Directory};
use App\Menu\Components\{MenuItem, MenuGroup};
use App\Permission\Components\{Permission, PermissionGroup};

class CompositeTest extends TestCase
{
public function test_filesystem_composite(): void
{
$root = new Directory('root');
$root->add(new File('file1.txt', '/root/file1.txt', 100));
$root->add(new File('file2.txt', '/root/file2.txt', 200));

$subdir = new Directory('subdir');
$subdir->add(new File('file3.txt', '/root/subdir/file3.txt', 300));
$root->add($subdir);

$this->assertEquals(600, $root->getSize());
$this->assertTrue($root->isDirectory());
$this->assertFalse($root->getChild('file1.txt')->isDirectory());
}

public function test_menu_composite(): void
{
$menu = new MenuGroup('Main');
$menu->add(new MenuItem('Home', '/'));
$menu->add(new MenuItem('About', '/about'));

$submenu = new MenuGroup('Services');
$submenu->add(new MenuItem('Web', '/services/web'));
$submenu->add(new MenuItem('Mobile', '/services/mobile'));
$menu->add($submenu);

$this->assertTrue($menu->hasChildren());
$this->assertCount(3, $menu->getChildren());
}

public function test_permission_composite(): void
{
$user = new class {
public function can($permission) {
return in_array($permission, ['edit', 'delete']);
}
};

$group = new PermissionGroup('content', '', 'any');
$group->add(new Permission('edit'));
$group->add(new Permission('delete'));
$group->add(new Permission('publish'));

$this->assertTrue($group->check($user));

$allGroup = new PermissionGroup('admin', '', 'all');
$allGroup->add(new Permission('edit'));
$allGroup->add(new Permission('admin'));

$this->assertFalse($allGroup->check($user));
}
}

最佳实践

1. 统一接口

1
2
3
4
5
6
<?php

interface ComponentInterface
{
public function operation(): mixed;
}

2. 叶子和组合实现相同接口

1
2
3
4
5
6
7
8
9
10
11
<?php

class Leaf implements ComponentInterface
{
public function operation(): mixed { }
}

class Composite implements ComponentInterface
{
public function operation(): mixed { }
}

3. 提供默认实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

abstract class BaseComponent implements ComponentInterface
{
public function add(ComponentInterface $component): void
{
throw new \BadMethodCallException('Cannot add to a leaf');
}

public function remove(ComponentInterface $component): void
{
throw new \BadMethodCallException('Cannot remove from a leaf');
}
}

总结

Laravel 13 的组合模式提供了一种优雅的方式来处理树形结构数据。通过合理使用组合模式,可以创建灵活、可扩展的层次结构,同时简化客户端代码的复杂度。