Laravel 13 访问者模式深度解析

访问者模式是一种行为型设计模式,它将算法与对象结构分离,使得可以在不修改对象结构的情况下定义新的操作。本文将深入探讨 Laravel 13 中访问者模式的高级用法。

访问者模式基础

什么是访问者模式

访问者模式允许在不修改对象结构的情况下,定义作用于这些对象的新操作。

1
2
3
4
5
6
7
8
<?php

namespace App\Contracts;

interface VisitorInterface
{
public function visit(ElementInterface $element): mixed;
}
1
2
3
4
5
6
7
8
<?php

namespace App\Contracts;

interface ElementInterface
{
public function accept(VisitorInterface $visitor): mixed;
}

文档元素访问者

文档元素接口

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

namespace App\Contracts\Document;

interface DocumentElementInterface
{
public function accept(DocumentVisitorInterface $visitor): mixed;

public function getContent(): string;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

namespace App\Contracts\Document;

interface DocumentVisitorInterface
{
public function visitParagraph(ParagraphElement $paragraph): mixed;

public function visitHeading(HeadingElement $heading): mixed;

public function visitImage(ImageElement $image): mixed;

public function visitTable(TableElement $table): mixed;

public function visitCodeBlock(CodeBlockElement $codeBlock): mixed;
}

具体元素实现

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

namespace App\Elements\Document;

use App\Contracts\Document\DocumentElementInterface;
use App\Contracts\Document\DocumentVisitorInterface;

class ParagraphElement implements DocumentElementInterface
{
protected string $content;

public function __construct(string $content)
{
$this->content = $content;
}

public function accept(DocumentVisitorInterface $visitor): mixed
{
return $visitor->visitParagraph($this);
}

public function getContent(): string
{
return $this->content;
}
}
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
<?php

namespace App\Elements\Document;

use App\Contracts\Document\DocumentElementInterface;
use App\Contracts\Document\DocumentVisitorInterface;

class HeadingElement implements DocumentElementInterface
{
protected string $content;
protected int $level;

public function __construct(string $content, int $level = 1)
{
$this->content = $content;
$this->level = $level;
}

public function accept(DocumentVisitorInterface $visitor): mixed
{
return $visitor->visitHeading($this);
}

public function getContent(): string
{
return $this->content;
}

public function getLevel(): int
{
return $this->level;
}
}
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
<?php

namespace App\Elements\Document;

use App\Contracts\Document\DocumentElementInterface;
use App\Contracts\Document\DocumentVisitorInterface;

class ImageElement implements DocumentElementInterface
{
protected string $src;
protected string $alt;
protected int $width;
protected int $height;

public function __construct(string $src, string $alt = '', int $width = 0, int $height = 0)
{
$this->src = $src;
$this->alt = $alt;
$this->width = $width;
$this->height = $height;
}

public function accept(DocumentVisitorInterface $visitor): mixed
{
return $visitor->visitImage($this);
}

public function getContent(): string
{
return $this->alt;
}

public function getSrc(): string
{
return $this->src;
}

public function getAlt(): string
{
return $this->alt;
}

public function getWidth(): int
{
return $this->width;
}

public function getHeight(): int
{
return $this->height;
}
}
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
<?php

namespace App\Elements\Document;

use App\Contracts\Document\DocumentElementInterface;
use App\Contracts\Document\DocumentVisitorInterface;

class TableElement implements DocumentElementInterface
{
protected array $headers;
protected array $rows;

public function __construct(array $headers, array $rows)
{
$this->headers = $headers;
$this->rows = $rows;
}

public function accept(DocumentVisitorInterface $visitor): mixed
{
return $visitor->visitTable($this);
}

public function getContent(): string
{
return '';
}

public function getHeaders(): array
{
return $this->headers;
}

public function getRows(): array
{
return $this->rows;
}
}
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
<?php

namespace App\Elements\Document;

use App\Contracts\Document\DocumentElementInterface;
use App\Contracts\Document\DocumentVisitorInterface;

class CodeBlockElement implements DocumentElementInterface
{
protected string $code;
protected string $language;

public function __construct(string $code, string $language = 'php')
{
$this->code = $code;
$this->language = $language;
}

public function accept(DocumentVisitorInterface $visitor): mixed
{
return $visitor->visitCodeBlock($this);
}

public function getContent(): string
{
return $this->code;
}

public function getCode(): string
{
return $this->code;
}

public function getLanguage(): string
{
return $this->language;
}
}

HTML 渲染访问者

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

namespace App\Visitors\Document;

use App\Contracts\Document\DocumentVisitorInterface;
use App\Elements\Document\{ParagraphElement, HeadingElement, ImageElement, TableElement, CodeBlockElement};

class HtmlRenderVisitor implements DocumentVisitorInterface
{
public function visitParagraph(ParagraphElement $paragraph): string
{
return "<p>{$paragraph->getContent()}</p>";
}

public function visitHeading(HeadingElement $heading): string
{
$level = $heading->getLevel();
$content = htmlspecialchars($heading->getContent());
return "<h{$level}>{$content}</h{$level}>";
}

public function visitImage(ImageElement $image): string
{
$src = htmlspecialchars($image->getSrc());
$alt = htmlspecialchars($image->getAlt());
$width = $image->getWidth();
$height = $image->getHeight();

$attrs = "src=\"{$src}\" alt=\"{$alt}\"";

if ($width) {
$attrs .= " width=\"{$width}\"";
}

if ($height) {
$attrs .= " height=\"{$height}\"";
}

return "<img {$attrs}>";
}

public function visitTable(TableElement $table): string
{
$html = '<table>';

$html .= '<thead><tr>';
foreach ($table->getHeaders() as $header) {
$html .= '<th>' . htmlspecialchars($header) . '</th>';
}
$html .= '</tr></thead>';

$html .= '<tbody>';
foreach ($table->getRows() as $row) {
$html .= '<tr>';
foreach ($row as $cell) {
$html .= '<td>' . htmlspecialchars($cell) . '</td>';
}
$html .= '</tr>';
}
$html .= '</tbody></table>';

return $html;
}

public function visitCodeBlock(CodeBlockElement $codeBlock): string
{
$code = htmlspecialchars($codeBlock->getCode());
$language = htmlspecialchars($codeBlock->getLanguage());

return "<pre><code class=\"language-{$language}\">{$code}</code></pre>";
}
}

Markdown 渲染访问者

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

namespace App\Visitors\Document;

use App\Contracts\Document\DocumentVisitorInterface;
use App\Elements\Document\{ParagraphElement, HeadingElement, ImageElement, TableElement, CodeBlockElement};

class MarkdownRenderVisitor implements DocumentVisitorInterface
{
public function visitParagraph(ParagraphElement $paragraph): string
{
return $paragraph->getContent() . "\n\n";
}

public function visitHeading(HeadingElement $heading): string
{
$prefix = str_repeat('#', $heading->getLevel());
return "{$prefix} {$heading->getContent()}\n\n";
}

public function visitImage(ImageElement $image): string
{
return "![{$image->getAlt()}]({$image->getSrc()})\n\n";
}

public function visitTable(TableElement $table): string
{
$headers = $table->getHeaders();
$rows = $table->getRows();

$md = '| ' . implode(' | ', $headers) . ' |' . "\n";
$md .= '| ' . implode(' | ', array_fill(0, count($headers), '---')) . ' |' . "\n";

foreach ($rows as $row) {
$md .= '| ' . implode(' | ', $row) . ' |' . "\n";
}

return $md . "\n";
}

public function visitCodeBlock(CodeBlockElement $codeBlock): string
{
return "```{$codeBlock->getLanguage()}\n{$codeBlock->getCode()}\n```\n\n";
}
}

统计访问者

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\Visitors\Document;

use App\Contracts\Document\DocumentVisitorInterface;
use App\Elements\Document\{ParagraphElement, HeadingElement, ImageElement, TableElement, CodeBlockElement};

class StatisticsVisitor implements DocumentVisitorInterface
{
protected array $stats = [
'paragraphs' => 0,
'headings' => 0,
'images' => 0,
'tables' => 0,
'code_blocks' => 0,
'total_words' => 0,
'total_characters' => 0,
];

public function visitParagraph(ParagraphElement $paragraph): array
{
$content = $paragraph->getContent();
$this->stats['paragraphs']++;
$this->stats['total_words'] += str_word_count($content);
$this->stats['total_characters'] += strlen($content);

return $this->stats;
}

public function visitHeading(HeadingElement $heading): array
{
$content = $heading->getContent();
$this->stats['headings']++;
$this->stats['total_words'] += str_word_count($content);
$this->stats['total_characters'] += strlen($content);

return $this->stats;
}

public function visitImage(ImageElement $image): array
{
$this->stats['images']++;

return $this->stats;
}

public function visitTable(TableElement $table): array
{
$this->stats['tables']++;

foreach ($table->getRows() as $row) {
foreach ($row as $cell) {
$this->stats['total_words'] += str_word_count($cell);
$this->stats['total_characters'] += strlen($cell);
}
}

return $this->stats;
}

public function visitCodeBlock(CodeBlockElement $codeBlock): array
{
$this->stats['code_blocks']++;
$this->stats['total_characters'] += strlen($codeBlock->getCode());

return $this->stats;
}

public function getStats(): array
{
return $this->stats;
}
}

产品访问者

产品元素接口

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

namespace App\Contracts\Product;

interface ProductElementInterface
{
public function accept(ProductVisitorInterface $visitor): mixed;

public function getName(): string;

public function getPrice(): float;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

namespace App\Contracts\Product;

interface ProductVisitorInterface
{
public function visitSimpleProduct(SimpleProduct $product): mixed;

public function visitBundleProduct(BundleProduct $product): mixed;

public function visitConfigurableProduct(ConfigurableProduct $product): mixed;

public function visitDigitalProduct(DigitalProduct $product): mixed;
}

具体产品元素

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

namespace App\Elements\Product;

use App\Contracts\Product\ProductElementInterface;
use App\Contracts\Product\ProductVisitorInterface;

class SimpleProduct implements ProductElementInterface
{
protected string $name;
protected float $price;
protected int $stock;

public function __construct(string $name, float $price, int $stock)
{
$this->name = $name;
$this->price = $price;
$this->stock = $stock;
}

public function accept(ProductVisitorInterface $visitor): mixed
{
return $visitor->visitSimpleProduct($this);
}

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

public function getPrice(): float
{
return $this->price;
}

public function getStock(): int
{
return $this->stock;
}
}
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
<?php

namespace App\Elements\Product;

use App\Contracts\Product\ProductElementInterface;
use App\Contracts\Product\ProductVisitorInterface;
use Illuminate\Support\Collection;

class BundleProduct implements ProductElementInterface
{
protected string $name;
protected Collection $items;
protected float $discount;

public function __construct(string $name, array $items, float $discount = 0)
{
$this->name = $name;
$this->items = collect($items);
$this->discount = $discount;
}

public function accept(ProductVisitorInterface $visitor): mixed
{
return $visitor->visitBundleProduct($this);
}

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

public function getPrice(): float
{
$total = $this->items->sum(fn($item) => $item->getPrice());
return $total * (1 - $this->discount);
}

public function getItems(): Collection
{
return $this->items;
}

public function getDiscount(): float
{
return $this->discount;
}
}

价格计算访问者

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

namespace App\Visitors\Product;

use App\Contracts\Product\ProductVisitorInterface;
use App\Elements\Product\{SimpleProduct, BundleProduct, ConfigurableProduct, DigitalProduct};

class PriceCalculationVisitor implements ProductVisitorInterface
{
protected float $taxRate;
protected array $prices = [];

public function __construct(float $taxRate = 0.1)
{
$this->taxRate = $taxRate;
}

public function visitSimpleProduct(SimpleProduct $product): array
{
$basePrice = $product->getPrice();
$tax = $basePrice * $this->taxRate;

return $this->prices[$product->getName()] = [
'base_price' => $basePrice,
'tax' => $tax,
'total' => $basePrice + $tax,
];
}

public function visitBundleProduct(BundleProduct $product): array
{
$basePrice = $product->getPrice();
$tax = $basePrice * $this->taxRate;
$savings = $product->getDiscount() > 0
? $product->getItems()->sum(fn($item) => $item->getPrice()) - $basePrice
: 0;

return $this->prices[$product->getName()] = [
'base_price' => $basePrice,
'tax' => $tax,
'total' => $basePrice + $tax,
'savings' => $savings,
];
}

public function visitConfigurableProduct(ConfigurableProduct $product): array
{
$basePrice = $product->getPrice();
$tax = $basePrice * $this->taxRate;

return $this->prices[$product->getName()] = [
'base_price' => $basePrice,
'tax' => $tax,
'total' => $basePrice + $tax,
];
}

public function visitDigitalProduct(DigitalProduct $product): array
{
$basePrice = $product->getPrice();
$tax = $basePrice * $this->taxRate;

return $this->prices[$product->getName()] = [
'base_price' => $basePrice,
'tax' => $tax,
'total' => $basePrice + $tax,
];
}

public function getPrices(): array
{
return $this->prices;
}
}

AST 访问者

AST 节点接口

1
2
3
4
5
6
7
8
<?php

namespace App\Contracts\AST;

interface AstNodeInterface
{
public function accept(AstVisitorInterface $visitor): mixed;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php

namespace App\Contracts\AST;

interface AstVisitorInterface
{
public function visitNumberNode(NumberNode $node): mixed;

public function visitStringNode(StringNode $node): mixed;

public function visitBinaryOpNode(BinaryOpNode $node): mixed;

public function visitFunctionCallNode(FunctionCallNode $node): mixed;

public function visitVariableNode(VariableNode $node): mixed;
}

表达式求值访问者

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

namespace App\Visitors\AST;

use App\Contracts\AST\AstVisitorInterface;
use App\Nodes\AST\{NumberNode, StringNode, BinaryOpNode, FunctionCallNode, VariableNode};

class EvaluatorVisitor implements AstVisitorInterface
{
protected array $variables = [];
protected array $functions = [];

public function __construct(array $variables = [], array $functions = [])
{
$this->variables = $variables;
$this->functions = $functions;
}

public function visitNumberNode(NumberNode $node): float
{
return $node->getValue();
}

public function visitStringNode(StringNode $node): string
{
return $node->getValue();
}

public function visitBinaryOpNode(BinaryOpNode $node): mixed
{
$left = $node->getLeft()->accept($this);
$right = $node->getRight()->accept($this);

return match ($node->getOperator()) {
'+' => $left + $right,
'-' => $left - $right,
'*' => $left * $right,
'/' => $left / $right,
'==' => $left == $right,
'!=' => $left != $right,
'<' => $left < $right,
'>' => $left > $right,
default => throw new \RuntimeException("Unknown operator: {$node->getOperator()}"),
};
}

public function visitFunctionCallNode(FunctionCallNode $node): mixed
{
$name = $node->getName();
$args = array_map(fn($arg) => $arg->accept($this), $node->getArguments());

if (isset($this->functions[$name])) {
return ($this->functions[$name])(...$args);
}

throw new \RuntimeException("Unknown function: {$name}");
}

public function visitVariableNode(VariableNode $node): mixed
{
$name = $node->getName();

if (array_key_exists($name, $this->variables)) {
return $this->variables[$name];
}

throw new \RuntimeException("Undefined variable: {$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
<?php

namespace Tests\Unit\Visitors;

use Tests\TestCase;
use App\Elements\Document\{ParagraphElement, HeadingElement, ImageElement};
use App\Visitors\Document\{HtmlRenderVisitor, MarkdownRenderVisitor, StatisticsVisitor};

class VisitorTest extends TestCase
{
public function test_html_render_visitor(): void
{
$visitor = new HtmlRenderVisitor();

$paragraph = new ParagraphElement('Hello World');
$this->assertEquals('<p>Hello World</p>', $paragraph->accept($visitor));

$heading = new HeadingElement('Title', 1);
$this->assertEquals('<h1>Title</h1>', $heading->accept($visitor));
}

public function test_markdown_render_visitor(): void
{
$visitor = new MarkdownRenderVisitor();

$paragraph = new ParagraphElement('Hello World');
$this->assertEquals("Hello World\n\n", $paragraph->accept($visitor));

$heading = new HeadingElement('Title', 2);
$this->assertEquals("## Title\n\n", $heading->accept($visitor));
}

public function test_statistics_visitor(): void
{
$visitor = new StatisticsVisitor();

$paragraph = new ParagraphElement('Hello World');
$paragraph->accept($visitor);

$heading = new HeadingElement('Title', 1);
$heading->accept($visitor);

$image = new ImageElement('image.jpg', 'Alt text');
$image->accept($visitor);

$stats = $visitor->getStats();

$this->assertEquals(1, $stats['paragraphs']);
$this->assertEquals(1, $stats['headings']);
$this->assertEquals(1, $stats['images']);
}
}

最佳实践

1. 访问者应该无状态

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

class StatelessVisitor implements VisitorInterface
{
public function visit(ElementInterface $element): mixed
{
return $this->process($element);
}

protected function process(ElementInterface $element): mixed
{
return null;
}
}

2. 使用双分派

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

interface ElementInterface
{
public function accept(VisitorInterface $visitor): mixed;
}

class ConcreteElement implements ElementInterface
{
public function accept(VisitorInterface $visitor): mixed
{
return $visitor->visitConcreteElement($this);
}
}

3. 提供默认实现

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

abstract class BaseVisitor implements VisitorInterface
{
public function visitElementA(ElementA $element): mixed
{
return $this->defaultVisit($element);
}

public function visitElementB(ElementB $element): mixed
{
return $this->defaultVisit($element);
}

protected function defaultVisit($element): mixed
{
return null;
}
}

总结

Laravel 13 的访问者模式提供了一种优雅的方式来分离算法和对象结构。通过合理使用访问者模式,可以在不修改对象结构的情况下添加新的操作,提高代码的可扩展性和可维护性。