Laravel 13 模板方法模式深度解析

模板方法模式是一种行为型设计模式,它在父类中定义算法的骨架,将某些步骤延迟到子类中实现。本文将深入探讨 Laravel 13 中模板方法模式的高级用法。

模板方法模式基础

什么是模板方法模式

模板方法模式定义了一个操作中的算法骨架,将一些步骤延迟到子类中,使得子类可以不改变算法结构即可重定义该算法的某些特定步骤。

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

namespace App\Contracts;

abstract class AbstractTemplate
{
final public function execute(): mixed
{
$this->validate();
$this->beforeExecute();
$result = $this->doExecute();
$this->afterExecute($result);

return $result;
}

abstract protected function validate(): void;

abstract protected function doExecute(): mixed;

protected function beforeExecute(): void
{
}

protected function afterExecute(mixed $result): void
{
}
}

数据导出模板

导出模板基类

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

namespace App\Services\Export;

use Illuminate\Support\Collection;

abstract class DataExporter
{
protected Collection $data;
protected array $options = [];

public function __construct(Collection $data, array $options = [])
{
$this->data = $data;
$this->options = $options;
}

final public function export(): string
{
$this->validateData();
$this->prepareData();
$content = $this->generateContent();
$this->addMetadata($content);

return $content;
}

protected function validateData(): void
{
if ($this->data->isEmpty()) {
throw new \InvalidArgumentException('Data cannot be empty');
}
}

protected function prepareData(): void
{
$this->data = $this->data->map(function ($item) {
return $this->formatItem($item);
});
}

abstract protected function generateContent(): string;

protected function formatItem($item): array
{
return is_array($item) ? $item : $item->toArray();
}

protected function addMetadata(string &$content): void
{
}

abstract public function getMimeType(): string;

abstract public function getFileExtension(): string;
}

CSV 导出器

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

namespace App\Services\Export;

class CsvExporter extends DataExporter
{
protected string $delimiter = ',';
protected string $enclosure = '"';

public function setDelimiter(string $delimiter): self
{
$this->delimiter = $delimiter;
return $this;
}

protected function generateContent(): string
{
$output = fopen('php://temp', 'r+');

$headers = $this->getHeaders();
if ($headers) {
fputcsv($output, $headers, $this->delimiter, $this->enclosure);
}

foreach ($this->data as $row) {
fputcsv($output, array_values($row), $this->delimiter, $this->enclosure);
}

rewind($output);
$content = stream_get_contents($output);
fclose($output);

return $content;
}

protected function getHeaders(): ?array
{
$firstItem = $this->data->first();
return $firstItem ? array_keys($firstItem) : null;
}

public function getMimeType(): string
{
return 'text/csv';
}

public function getFileExtension(): string
{
return 'csv';
}
}

JSON 导出器

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

namespace App\Services\Export;

class JsonExporter extends DataExporter
{
protected int $jsonOptions = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE;

public function setJsonOptions(int $options): self
{
$this->jsonOptions = $options;
return $this;
}

protected function generateContent(): string
{
$structure = $this->buildStructure();

return json_encode($structure, $this->jsonOptions);
}

protected function buildStructure(): array
{
return [
'data' => $this->data->values()->toArray(),
'meta' => [
'count' => $this->data->count(),
'exported_at' => now()->toIso8601String(),
],
];
}

public function getMimeType(): string
{
return 'application/json';
}

public function getFileExtension(): string
{
return 'json';
}
}

Excel 导出器

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

namespace App\Services\Export;

use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;

class ExcelExporter extends DataExporter
{
protected string $sheetTitle = 'Sheet1';
protected array $columnWidths = [];

public function setSheetTitle(string $title): self
{
$this->sheetTitle = $title;
return $this;
}

protected function generateContent(): string
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle($this->sheetTitle);

$this->writeHeaders($sheet);
$this->writeData($sheet);
$this->applyStyles($sheet);

$writer = new Xlsx($spreadsheet);

$tempFile = tempnam(sys_get_temp_dir(), 'excel_');
$writer->save($tempFile);

$content = file_get_contents($tempFile);
unlink($tempFile);

return $content;
}

protected function writeHeaders($sheet): void
{
$headers = $this->getHeaders();
if ($headers) {
$column = 1;
foreach ($headers as $header) {
$sheet->setCellValue([$column, 1], $header);
$sheet->getStyle([$column, 1])->getFont()->setBold(true);
$column++;
}
}
}

protected function writeData($sheet): void
{
$row = 2;
foreach ($this->data as $item) {
$column = 1;
foreach (array_values($item) as $value) {
$sheet->setCellValue([$column, $row], $value);
$column++;
}
$row++;
}
}

protected function applyStyles($sheet): void
{
foreach ($this->columnWidths as $column => $width) {
$sheet->getColumnDimensionByColumn($column)->setWidth($width);
}
}

protected function getHeaders(): ?array
{
$firstItem = $this->data->first();
return $firstItem ? array_keys($firstItem) : null;
}

public function getMimeType(): string
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}

public function getFileExtension(): string
{
return 'xlsx';
}
}

报表生成模板

报表模板基类

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

namespace App\Services\Reports;

use Illuminate\Support\Collection;

abstract class ReportGenerator
{
protected Collection $data;
protected array $parameters;
protected array $results = [];

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

final public function generate(): array
{
$this->validateParameters();
$this->fetchData();
$this->processData();
$this->calculateMetrics();
$this->formatResults();

return $this->results;
}

protected function validateParameters(): void
{
foreach ($this->getRequiredParameters() as $param) {
if (!isset($this->parameters[$param])) {
throw new \InvalidArgumentException("Missing required parameter: {$param}");
}
}
}

abstract protected function getRequiredParameters(): array;

abstract protected function fetchData(): void;

protected function processData(): void
{
$this->data = $this->data->map(function ($item) {
return $this->transformItem($item);
});
}

protected function transformItem($item)
{
return $item;
}

abstract protected function calculateMetrics(): void;

protected function formatResults(): void
{
$this->results = [
'title' => $this->getTitle(),
'generated_at' => now()->toIso8601String(),
'data' => $this->data->toArray(),
'metrics' => $this->results['metrics'] ?? [],
'summary' => $this->results['summary'] ?? '',
];
}

abstract protected function getTitle(): 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
<?php

namespace App\Services\Reports;

use App\Models\Order;
use Illuminate\Support\Facades\DB;

class SalesReportGenerator extends ReportGenerator
{
protected function getRequiredParameters(): array
{
return ['start_date', 'end_date'];
}

protected function fetchData(): void
{
$this->data = Order::query()
->whereBetween('created_at', [
$this->parameters['start_date'],
$this->parameters['end_date'],
])
->with(['items', 'customer'])
->get();
}

protected function transformItem($item): array
{
return [
'order_id' => $item->id,
'order_number' => $item->order_number,
'customer' => $item->customer->name,
'total' => $item->total,
'items_count' => $item->items->count(),
'created_at' => $item->created_at->toDateString(),
];
}

protected function calculateMetrics(): void
{
$this->results['metrics'] = [
'total_orders' => $this->data->count(),
'total_revenue' => $this->data->sum('total'),
'average_order_value' => $this->data->avg('total'),
'total_items' => $this->data->sum('items_count'),
];
}

protected function getTitle(): string
{
return 'Sales Report';
}
}

用户活动报表

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

namespace App\Services\Reports;

use App\Models\UserActivity;
use Illuminate\Support\Facades\DB;

class UserActivityReportGenerator extends ReportGenerator
{
protected function getRequiredParameters(): array
{
return ['start_date', 'end_date'];
}

protected function fetchData(): void
{
$this->data = UserActivity::query()
->whereBetween('created_at', [
$this->parameters['start_date'],
$this->parameters['end_date'],
])
->with('user')
->get();
}

protected function transformItem($item): array
{
return [
'user_id' => $item->user_id,
'user_name' => $item->user->name,
'activity_type' => $item->activity_type,
'description' => $item->description,
'ip_address' => $item->ip_address,
'created_at' => $item->created_at->toIso8601String(),
];
}

protected function calculateMetrics(): void
{
$this->results['metrics'] = [
'total_activities' => $this->data->count(),
'unique_users' => $this->data->unique('user_id')->count(),
'activities_by_type' => $this->data->groupBy('activity_type')
->map(fn($items) => $items->count())
->toArray(),
];
}

protected function getTitle(): string
{
return 'User Activity Report';
}
}

通知模板

通知模板基类

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

namespace App\Services\Notifications;

abstract class NotificationTemplate
{
protected $recipient;
protected array $data;
protected array $channels = [];

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

final public function send(): array
{
$this->validateRecipient();
$this->prepareData();

$results = [];

foreach ($this->getChannels() as $channel) {
$results[$channel] = $this->sendViaChannel($channel);
}

$this->afterSend($results);

return $results;
}

protected function validateRecipient(): void
{
if (!$this->recipient) {
throw new \InvalidArgumentException('Recipient is required');
}
}

protected function prepareData(): void
{
$this->data = array_merge($this->getDefaultData(), $this->data);
}

protected function getDefaultData(): array
{
return [];
}

protected function getChannels(): array
{
return $this->channels;
}

protected function sendViaChannel(string $channel): bool
{
return match ($channel) {
'email' => $this->sendEmail(),
'sms' => $this->sendSms(),
'push' => $this->sendPush(),
'database' => $this->sendToDatabase(),
default => false,
};
}

abstract protected function getSubject(): string;

abstract protected function getBody(): string;

protected function sendEmail(): bool
{
return \Mail::to($this->recipient->email)
->send(new $this->emailClass($this->getSubject(), $this->getBody(), $this->data));
}

protected function sendSms(): bool
{
return false;
}

protected function sendPush(): bool
{
return false;
}

protected function sendToDatabase(): bool
{
return true;
}

protected function afterSend(array $results): void
{
}
}

欢迎通知

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

namespace App\Services\Notifications;

class WelcomeNotification extends NotificationTemplate
{
protected array $channels = ['email', 'database'];

protected function getDefaultData(): array
{
return [
'app_name' => config('app.name'),
'support_email' => config('mail.support.address'),
];
}

protected function getSubject(): string
{
return "Welcome to {$this->data['app_name']}!";
}

protected function getBody(): string
{
return "Hello {$this->recipient->name}, welcome to {$this->data['app_name']}!";
}

protected function sendEmail(): bool
{
\Mail::to($this->recipient->email)->send(
new \App\Mail\WelcomeEmail($this->recipient, $this->data)
);

return true;
}
}

订单确认通知

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

namespace App\Services\Notifications;

class OrderConfirmationNotification extends NotificationTemplate
{
protected array $channels = ['email', 'sms', 'database'];

protected function getDefaultData(): array
{
return [
'order' => $this->data['order'] ?? null,
];
}

protected function getSubject(): string
{
$order = $this->data['order'];
return "Order #{$order->order_number} Confirmed";
}

protected function getBody(): string
{
$order = $this->data['order'];
return "Your order #{$order->order_number} has been confirmed. Total: \${$order->total}";
}

protected function sendSms(): bool
{
$order = $this->data['order'];

if ($this->recipient->phone) {
\App\Services\SmsService::send(
$this->recipient->phone,
$this->getBody()
);
return true;
}

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

namespace App\Services\Import;

use Illuminate\Support\Collection;

abstract class DataImporter
{
protected string $filePath;
protected array $options;
protected Collection $data;
protected array $results = [
'imported' => 0,
'skipped' => 0,
'errors' => [],
];

public function __construct(string $filePath, array $options = [])
{
$this->filePath = $filePath;
$this->options = $options;
}

final public function import(): array
{
$this->validateFile();
$this->parseFile();
$this->validateData();
$this->processData();

return $this->results;
}

protected function validateFile(): void
{
if (!file_exists($this->filePath)) {
throw new \InvalidArgumentException("File not found: {$this->filePath}");
}
}

abstract protected function parseFile(): void;

protected function validateData(): void
{
$this->data = $this->data->filter(function ($item, $key) {
$errors = $this->validateItem($item);

if (!empty($errors)) {
$this->results['errors'][$key] = $errors;
$this->results['skipped']++;
return false;
}

return true;
});
}

protected function validateItem(array $item): array
{
return [];
}

protected function processData(): void
{
$this->beforeProcess();

foreach ($this->data as $key => $item) {
try {
$this->importItem($item);
$this->results['imported']++;
} catch (\Exception $e) {
$this->results['errors'][$key] = $e->getMessage();
$this->results['skipped']++;
}
}

$this->afterProcess();
}

protected function beforeProcess(): void
{
}

abstract protected function importItem(array $item): void;

protected function afterProcess(): void
{
}
}

CSV 导入器

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

namespace App\Services\Import;

class CsvImporter extends DataImporter
{
protected string $delimiter = ',';

public function setDelimiter(string $delimiter): self
{
$this->delimiter = $delimiter;
return $this;
}

protected function parseFile(): void
{
$handle = fopen($this->filePath, 'r');

$headers = fgetcsv($handle, 0, $this->delimiter);

$rows = collect();
while (($row = fgetcsv($handle, 0, $this->delimiter)) !== false) {
if (count($row) === count($headers)) {
$rows->push(array_combine($headers, $row));
}
}

fclose($handle);

$this->data = $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
<?php

namespace App\Services\Import;

use App\Models\User;
use Illuminate\Support\Facades\Hash;

class UserImporter extends CsvImporter
{
protected function validateItem(array $item): array
{
$errors = [];

if (empty($item['email'])) {
$errors[] = 'Email is required';
} elseif (User::where('email', $item['email'])->exists()) {
$errors[] = 'Email already exists';
}

if (empty($item['name'])) {
$errors[] = 'Name is required';
}

return $errors;
}

protected function importItem(array $item): void
{
User::create([
'name' => $item['name'],
'email' => $item['email'],
'password' => Hash::make($item['password'] ?? 'password'),
]);
}
}

测试模板方法模式

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

namespace Tests\Unit\Services;

use Tests\TestCase;
use App\Services\Export\CsvExporter;
use App\Services\Export\JsonExporter;
use Illuminate\Support\Collection;

class TemplateMethodTest extends TestCase
{
public function test_csv_exporter(): void
{
$data = collect([
['name' => 'John', 'email' => 'john@example.com'],
['name' => 'Jane', 'email' => 'jane@example.com'],
]);

$exporter = new CsvExporter($data);
$content = $exporter->export();

$this->assertStringContainsString('name,email', $content);
$this->assertStringContainsString('John,john@example.com', $content);
$this->assertEquals('text/csv', $exporter->getMimeType());
}

public function test_json_exporter(): void
{
$data = collect([
['name' => 'John', 'email' => 'john@example.com'],
]);

$exporter = new JsonExporter($data);
$content = $exporter->export();

$decoded = json_decode($content, true);

$this->assertArrayHasKey('data', $decoded);
$this->assertArrayHasKey('meta', $decoded);
$this->assertEquals(1, $decoded['meta']['count']);
}
}

最佳实践

1. 使用 final 关键字

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

abstract class BaseTemplate
{
final public function execute(): mixed
{
return $this->doExecute();
}

abstract protected function doExecute(): mixed;
}

2. 提供钩子方法

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

abstract class TemplateWithHooks
{
final public function process(): void
{
$this->before();
$this->doProcess();
$this->after();
}

protected function before(): void
{
}

abstract protected function doProcess(): void;

protected function after(): void
{
}
}

3. 使用 protected 方法

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

abstract class SecureTemplate
{
public function run(): mixed
{
return $this->executeInternal();
}

protected abstract function executeInternal(): mixed;
}

总结

Laravel 13 的模板方法模式提供了一种优雅的方式来定义算法骨架。通过合理使用模板方法模式,可以复用代码结构,同时允许子类自定义特定步骤的实现。