☰
PHP8.2怎么使用模板方法模式定义流程
2026/10/1 12:06:12 网站建设 项目流程

前言

先明确一点:模板方法模式(Template Method Pattern)不是 PHP 8.2 的特性。它是《设计模式》里的行为型模式,靠继承和抽象类实现,与语言版本无关。标题里带"PHP8.2"的合理理解是"在 PHP 8.2 环境下怎么写",本文按这个角度展开,并顺带用上 PHP 8.2 新增的几项语言能力——readonly类、DNF 类型、独立的null/false/true类型,让代码更紧凑也更安全。

模板方法要解决的问题是:流程是固定的,但其中若干步骤在不同业务下实现不同。数据导入、支付下单、报表生成、消息推送都属于这一类。它们的共同特征是"骨架顺序不能乱"——导入必须先校验再落库,支付必须先冻结再扣款再通知——但每一步的细节因渠道而异。

写法上,模板方法把流程骨架放在父类的一个final方法里,把可变部分声明为abstract(必须实现)或留成有默认实现的钩子方法(hook method,子类按需覆盖)。是父类决定"什么时候调用子类",而不是子类决定——这就是"好莱坞原则"(Hollywood Principle):Don't call us, we'll call you。

一、模板方法的四个组成

角色职责约束
模板方法定义流程骨架,按顺序调用各个步骤必须是final,否则子类可以破坏流程
抽象步骤每个子类都必须实现的差异点abstract protected,不能是public
钩子方法(hook)有默认实现,子类按需覆盖的可选扩展点默认实现通常是空方法或恒等变换
子类只实现/覆盖步骤,不碰流程不能重新定义步骤的调用顺序

判断一个方法该做成抽象步骤还是钩子,看"每个子类是不是都必须提供不同实现":大部分子类实现一致的就给默认实现做成钩子;每个子类都不同且没有合理默认值的才做成抽象方法。抽象方法给多了,子类被迫写一堆空方法,这是模板方法最常见的失败形态。

二、用 PHP 8.2 写一个数据导入骨架

典型场景:要支持 CSV 和 JSON 两种来源,但"读 → 校验 → 变换 → 落库 → 收尾"的顺序对两者一样。

<?php // template-method.php —— 需要 PHP 8.2+ // 用法:php template-method.php declare(strict_types=1); /** 只读结果对象:readonly 类是 PHP 8.2 引入的 */ final readonly class ImportReport { public function __construct( public string $source, public int $imported, public int $skipped, ) {} public function summary(): string { return sprintf('来源=%-24s 成功=%d 跳过=%d', $this->source, $this->imported, $this->skipped); } } /** 骨架类:流程在这里定死、细节交给子类(run() 是 final,流程不允许改写) */ abstract class AbstractImporter { /** 模板方法:固定顺序,不接受子类修改 */ final public function run(string $path): ImportReport { $this->beforeRun($path); // 钩子:准备阶段 $imported = 0; $skipped = 0; try { foreach ($this->read($path) as $row) { if (!$this->validate($row)) { $skipped++; // 数据问题:跳过这一行 continue; } $this->persist($this->transform($row)); $imported++; } } finally { $this->cleanup(); // 无论成功失败都要收尾 } $report = new ImportReport($path, $imported, $skipped); $this->afterRun($report); // 钩子:通知阶段 return $report; } // —— 抽象步骤:子类必须实现 —— abstract protected function read(string $path): iterable; abstract protected function validate(array $row): bool; abstract protected function persist(array $row): void; // —— 钩子方法:有默认实现,子类按需覆盖 —— /** 默认只给字符串做 trim,子类可以覆盖后调用 parent */ protected function transform(array $row): array { return array_map( static fn(mixed $v): mixed => is_string($v) ? trim($v) : $v, $row ); } protected function beforeRun(string $path): void {} protected function afterRun(ImportReport $report): void {} protected function cleanup(): void {} } /** CSV 实现:只关心"怎么读 CSV"和"怎么落库" */ final class CsvImporter extends AbstractImporter { private array $rows = []; protected function read(string $path): iterable { $handle = fopen($path, 'rb'); if ($handle === false) { throw new RuntimeException("无法打开 CSV 文件: {$path}"); } try { $header = fgetcsv($handle); if ($header === false) { return; // 空文件:生成器直接结束 } while (($line = fgetcsv($handle)) !== false) { if (count($line) !== count($header)) { continue; // 列数对不上,跳过这一行 } /** @var array<string, string|null> $assoc */ $assoc = array_combine($header, $line); yield $assoc; } } finally { fclose($handle); } } protected function validate(array $row): bool { return isset($row['name']) && $row['name'] !== ''; } protected function persist(array $row): void { // 真实项目里这里是 INSERT;示例中先缓存在内存里 $this->rows[] = $row; } protected function afterRun(ImportReport $report): void { echo " 落库行数: ", count($this->rows), PHP_EOL; } } /** JSON 实现:复用同一套流程,只覆盖差异部分 */ final class JsonImporter extends AbstractImporter { protected function read(string $path): iterable { $raw = file_get_contents($path); if ($raw === false) { throw new RuntimeException("无法读取 JSON 文件: {$path}"); } // JSON_THROW_ON_ERROR 是 PHP 7.3 引入的 $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); if (!is_array($data)) { return; } foreach ($data as $row) { if (is_array($row)) { yield $row; } } } protected function validate(array $row): bool { return isset($row['name']) && is_string($row['name']) && $row['name'] !== ''; } protected function persist(array $row): void { // 真实项目里这里是 INSERT INTO ... } /** 覆盖钩子:先让父类做通用清洗,再补自己的业务规则 */ protected function transform(array $row): array { $row = parent::transform($row); // 不要丢掉父类行为 if (isset($row['amount'])) { $row['amount'] = (float) $row['amount']; } return $row; } protected function beforeRun(string $path): void { echo " 开始导入 JSON: ", basename($path), PHP_EOL; } } // ———— 跑起来 ———— $csv = sys_get_temp_dir() . '/tm-demo.csv'; file_put_contents($csv, "name,amount\n 手机 ,1999\n,0\n耳机,299\n"); $json = sys_get_temp_dir() . '/tm-demo.json'; file_put_contents($json, json_encode([ ['name' => ' 键盘 ', 'amount' => '399.5'], ['name' => '', 'amount' => '1'], ['name' => '显示器', 'amount' => '1299'], ], JSON_UNESCAPED_UNICODE)); // 两个子类共用同一个模板方法,只是传入的文件不同 echo CsvImporter::class, PHP_EOL; echo (new CsvImporter())->run($csv)->summary(), PHP_EOL, PHP_EOL; echo JsonImporter::class, PHP_EOL; echo (new JsonImporter())->run($json)->summary(), PHP_EOL; unlink($csv); unlink($json);

输出(路径随临时目录不同而变化):

CsvImporter 落库行数: 2 来源=.../tm-demo.csv 成功=2 跳过=1 JsonImporter 开始导入 JSON: tm-demo.json 来源=.../tm-demo.json 成功=2 跳过=1

这段代码有几个设计点:


  • run()是final。这是整个模式的立身之本:流程顺序由父类单方面决定,子类只能填空。少了final,任何子类都能悄悄改掉"先校验还是先落库",而这种改动在 code review 时极难发现。

  • 抽象步骤是protected而不是public。persist()若设成public,外部就能绕过run()直接调用,流程约束形同虚设。

  • 钩子方法提供"默认无害行为"。transform()默认只做 trim,beforeRun()、cleanup()默认是空方法,所以新子类只需实现三个抽象方法。

  • cleanup()放在finally里、read()用iterable+yield。前者保证抛异常也会收尾,后者让大文件导入不必把全部行读进内存。


PHP 8.2 语言特性在这个模式里的位置

PHP 8.2 特性在模板方法里的用途
readonly类把ImportReport这类产出物做成不可变对象,避免子类在afterRun()里篡改统计数字
DNF 类型 `(A&B)\C`
独立的null/false/true类型钩子返回值可直接声明为false,不必写bool再补一句"只返回 false"

DNF 类型的写法容易记错,它要求交叉类型部分必须用括号包起来,形如(Countable&Iterator)|array。要留意的是这类交叉类型的要求很具体:Generator实现了Iterator但不实现Countable,所以不满足该类型,传进去会抛TypeError。

三、和"直接写两个类"比,模板方法赢在哪

一种常见的反对意见是:既然流程一样,直接写两个类各复制一份不就行了?短期可行,长期出问题:

对比项各自复制的两个类模板方法
流程变更要改 N 处,容易漏改一处改父类一处,全部生效
新增实现复制整个流程再改细节只实现 3 个抽象方法
收尾/异常处理每个类各写一遍,容易漏finally在父类统一处理
流程一致性靠约定,容易被改坏由final保证
测试每个类都要重测流程流程可单测父类,子类只测步骤

模板方法还让"流程"变成可测试的实体:写一个假子类把每个步骤记下来,就能断言"校验一定发生在落库之前"。

常见坑点

1. 模板方法没加final,流程被某个子类悄悄改掉

// ❌ 子类可以覆盖 run(),把"先校验"改成"先落库",出问题很难定位 public function run(string $path): ImportReport { /* ... */ }
// ✅ 模板方法一律 final final public function run(string $path): ImportReport { /* ... */ }

2. 抽象步骤写成public,外部可以绕过流程直接调用

// ❌ 外部能直接 $importer->persist($row),校验和变换全部被跳过 abstract public function persist(array $row): void;
// ✅ 步骤只对子类开放 abstract protected function persist(array $row): void;

3. 覆盖钩子时忘了parent::,父类行为被整段丢掉

// ❌ 父类做的通用清洗(trim 等)全部失效,只有子类的逻辑生效 protected function transform(array $row): array { $row['amount'] = (float) $row['amount']; return $row; }
// ✅ 先父后子,除非你明确就是要完全替换父类行为 protected function transform(array $row): array { $row = parent::transform($row); $row['amount'] = (float) $row['amount']; return $row; }

4. 父类里用self::调用步骤,子类的覆盖没生效

// ❌ self:: 绑定在定义它的类上,子类覆盖 step() 也调不到 abstract class Base { final public function run(): string { return self::step(); } protected static function step(): string { return 'base'; } } final class Child extends Base { protected static function step(): string { return 'child'; } } echo (new Child())->run(); // 输出 base,不是 child
// ✅ 需要子类改写生效时用 static::(晚静态绑定) abstract class Base { final public function run(): string { return static::step(); } protected static function step(): string { return 'base'; } }

需要说明的是,用$this->method()调用实例方法时走的是虚方法分派,不会有这个问题;这个坑只出现在self::调用静态方法或静态属性上。

5. 抽象方法给太多,子类被迫实现一堆空方法

// ❌ 五个抽象方法,而大部分子类只关心其中两个,剩下三个只能写空实现 abstract protected function read(string $path): iterable; abstract protected function validate(array $row): bool; abstract protected function transform(array $row): array; abstract protected function beforeRun(string $path): void; abstract protected function cleanup(): void;
// ✅ 只有"没有合理默认值"的才做抽象方法,其余做成钩子 abstract protected function read(string $path): iterable; abstract protected function validate(array $row): bool; abstract protected function persist(array $row): void; // 其余四个给默认实现

6. 在父类里直接new具体依赖,子类无法替换

// ❌ 测试时没法把 HTTP 客户端换成替身,也没法换一个日志目标 abstract class AbstractImporter { final public function run(string $path): ImportReport { $logger = new FileLogger('/var/log/import.log'); // ... } }
// ✅ 依赖从构造函数注入,父类只管调用 abstract class AbstractImporter { public function __construct(protected LoggerInterface $logger) {} final public function run(string $path): ImportReport { /* 用 $this->logger */ } }

7. 继承层次超过两层,模板方法变成了"考古现场"

// ❌ 子类又被子类继承,步骤被覆盖了三层,读代码要先翻三代父类 class A { final public function run(): void { $this->step(); } protected function step(): void {} } class B extends A { protected function step(): void { parent::step(); /* ... */ } } class C extends B { protected function step(): void { parent::step(); /* ... */ } } // ✅ 保持一层继承:子类直接继承骨架,需要组合时把可变部分做成对象注入进来 final class CsvImporter extends AbstractImporter { /* 只实现步骤 */ }

总结

关注点结论
模式归属设计模式,与版本无关;标题里的 8.2 指运行环境而非特性来源
骨架方法必须final,且只负责调用顺序,不负责细节
抽象步骤abstract protected,只放"每个子类都必须不同"的部分
钩子方法有默认实现;覆盖时记得决定要不要parent::
调用方式实例方法用$this->,静态方法要留扩展点就用static::
依赖 / 收尾依赖从构造函数注入;收尾逻辑放在父类的finally中,只有一处
8.2 加成readonly类保护产出物,DNF 类型精确表达参数

结论:模板方法的价值在于把"流程"从"实现"里抽出来,并让流程不可被破坏。判断要不要用它,只需问一句:这段流程的顺序是不是业务契约的一部分?如果"必须先校验再落库"这样的约束必须被保证,就该用模板方法并把骨架方法写成final;如果只是几个独立算法换来换去、顺序本身没有约束,那么组合(把算法作为对象注入)会比继承更合适。另外,请把抽象方法的数量压到最低——钩子给默认实现、依赖走构造函数注入、收尾放进finally,做到这三条,模板方法就能长期保持可读。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询