ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP8.2怎么使用模板方法模式定义流程

PHP8.2怎么使用模板方法模式定义流程 前言先明确一点模板方法模式Template Method Pattern不是 PHP 8.2 的特性。它是《设计模式》里的行为型模式靠继承和抽象类实现与语言版本无关。标题里带PHP8.2的合理理解是在 PHP 8.2 环境下怎么写本文按这个角度展开并顺带用上 PHP 8.2 新增的几项语言能力——readonly类、DNF 类型、独立的null/false/true类型让代码更紧凑也更安全。模板方法要解决的问题是流程是固定的但其中若干步骤在不同业务下实现不同。数据导入、支付下单、报表生成、消息推送都属于这一类。它们的共同特征是骨架顺序不能乱——导入必须先校验再落库支付必须先冻结再扣款再通知——但每一步的细节因渠道而异。写法上模板方法把流程骨架放在父类的一个final方法里把可变部分声明为abstract必须实现或留成有默认实现的钩子方法hook method子类按需覆盖。是父类决定什么时候调用子类而不是子类决定——这就是好莱坞原则Hollywood PrincipleDont call us, well 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_types1); /** 只读结果对象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 arraystring, 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()默认只做 trimbeforeRun()、cleanup()默认是空方法所以新子类只需实现三个抽象方法。cleanup()放在finally里、read()用iterableyield。前者保证抛异常也会收尾后者让大文件导入不必把全部行读进内存。PHP 8.2 语言特性在这个模式里的位置PHP 8.2 特性在模板方法里的用途readonly类把ImportReport这类产出物做成不可变对象避免子类在afterRun()里篡改统计数字DNF 类型 (AB)\C独立的null/false/true类型钩子返回值可直接声明为false不必写bool再补一句只返回 falseDNF 类型的写法容易记错它要求交叉类型部分必须用括号包起来形如(CountableIterator)|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做到这三条模板方法就能长期保持可读。
返回列表