Skip to content

PHP 异常、资源、生成器与应用边界 ​

前置:严格类型与数组。本页解释CLI和Web的边界,不搭建未经验证的服务。

Throwable、异常、Error与warning ​

Throwable包含Exception与Error两大类;TypeError属于Error。业务代码用InvalidArgumentException描述格式不合要求,用RuntimeException描述资源操作失败;只在可恢复或应用最外层捕获,不能把所有错误转成空结果。finally无论正常返回还是异常都执行清理;在finally里return会覆盖原结果/异常,应避免。

不是所有PHP失败都会自动抛Exception。fopen等可能返回false并产生warning;应检查返回值。项目CLI对预期打开失败使用局部@抑制重复warning,再显式检查并写一条stderr;@不是忽略失败的解决方案,也不应用于整个处理链。需要统一错误处理时可以设计set_error_handler转换特定错误,但必须明确范围与恢复规则,不能把所有级别混成可重试异常。

生产Web响应不应包含完整内部堆栈或文件路径;日志保留诊断信息,响应只给稳定错误合同。CLI供本机操作,输出错误说明与非零退出;异常传播策略取决于应用边界,不是“PHP错误都catch(Throwable)后继续”。项目最外层catch映射退出2,不继续处理下一条而谎称整份报告成功。

文件、生成器与所有权 ​

fopen返回resource句柄,需要fclose;库接收句柄时明确是否借用。我们的entries生成器和summarize只借用,CLI拥有打开与finally关闭责任。生成器遇到yield暂停,惰性消费使文件读取延迟发生;若在函数内打开文件后直接返回没有清理协议的Generator,消费者提前停止时句柄生存期会难以判断。最简单的方案是把整个消费包在拥有者try/finally作用域里。

fgets读取一行直到换行或EOF,返回false时应区分EOF与读取失败;while (!feof(...))常导致末次多处理空结果,使用返回值控制循环更清楚。只去除换行符,不用trim把格式错误吞掉。路径应视为应用输入,当前CLI接收本地操作者提供路径;若改成Web上传,不能直接把用户字段传给任意fopen协议包装器,需要限定上传目录、路径与协议,这属于新的信任边界。

内存由引用计数与循环回收等机制管理,但外部资源仍应及时关闭;unset一个别名不表示其他引用都消失。长驻worker和传统每请求进程模型不同,缓存、静态变量、连接和错误状态可能跨请求保留,应审计复用对象的生命周期。不能用“请求结束会清空”解释长驻服务的泄漏。

CLI、内置服务器、FPM与依赖 ​

CLI的$argv/STDIN/退出码与HTTP的请求/响应头/状态码不同;从CLI迁移到Web不是把main.php丢到根目录就完事。内置php -S 127.0.0.1:8000 -t public用于开发,监听范围、文档根和路由需清楚,不作为生产服务器。FPM由Web服务器调度,配置与CLI php.ini可能不同,检查php --ini只说明当前CLI。

Web边界应显式验证输入、限定方法与大小、用htmlspecialchars对HTML文本上下文转义、数据库用参数化查询,并区分会话/CSRF等额外要求;HTML转义不能替代SQL参数,也不能任意用于JavaScript/CSS上下文。本课程不声称建立完整Web认证系统,纯标准库文件项目不存在虚构HTTP接口。

引入Composer时先写composer.json,install按composer.lock安装固定依赖,update重新求解并改锁文件;不要在生产例行部署中无意识update。PSR-4自动加载不等于业务校验;环境所需扩展需声明并实际安装。当前项目无第三方包,旧Composer命令页保留工具入口,不用它充当PHP语言课程。

数组与Generator的实际性能实验 ​

lab.php
php
<?php
declare(strict_types=1);
function squares(int $n): Generator {
    for ($i = 0; $i < $n; ++$i) { if ($i % 2 === 0) { yield $i * $i; } }
}
$keys = ['1' => 'string', 1 => 'integer', '01' => 'leading-zero'];
if (count($keys) !== 2 || $keys[1] !== 'integer') { throw new RuntimeException('key coercion'); }
$values = [1, 2, 3];
foreach ($values as &$value) { $value *= 2; }
unset($value); // end reference binding before a later by-value foreach
foreach ($values as $value) { }
if ($values !== [2, 4, 6]) { throw new RuntimeException('dangling reference'); }
foreach ([false, true] as $materialize) {
    $start = hrtime(true); $memory = memory_get_usage();
    $sequence = $materialize ? iterator_to_array(squares(100000), false) : squares(100000);
    $sum = 0; foreach ($sequence as $n) { $sum += $n; }
    if ($sum !== 166661666700000) { throw new RuntimeException('unequal semantics; requires 64-bit PHP'); }
    printf("materialize=%d sum=%d ns=%d live_bytes=%d\n", $materialize, $sum, hrtime(true) - $start, memory_get_usage() - $memory);
    unset($sequence);
}
echo "array keys, reference cleanup and generator passed\n";

在工程目录执行php -n lab.php,-n表示不加载php.ini,方便核对标准库基线;实际部署有扩展与OPcache时应另外记录。两次sum必须是166661666700000,要求64位整数;脚本同时检查数组键转换和引用循环清理。计时用单调高精度hrtime,memory_get_usage记录当前PHP分配量,不是进程RSS;live_bytes测的是序列仍存活时的增量,Generator与数组不会代表相同存储状态。

Generator降低全量数组的同时存储,不意味着所有生成器算法都常数内存:消费者若调用iterator_to_array就物化了全部值,聚合字典也会随不同分类数增长。时间数据受解释器、JIT/OPcache、顺序和热身影响,单次实验只作诊断起点,不作“生成器永远更快”的结论。需要定位热点时先固定结果,再使用可用的采样/分析工具,不为一个计时数引入复杂缓存。

练习与参考解法 ​

练习:消费者只取一项就break,谁关闭文件?fopen返回false为什么catch(Exception)未必足够?给数组引用循环去掉unset后预测后续遍历结果。传统请求与长驻worker中的static缓存有何区别?

参考解法

拥有者的finally必须覆盖整个消费过程,即使生成器没耗尽也关闭;测试库借用句柄仍有效。fopen通常warning+false,需显式检查,不是假定所有错误都是Exception。未unset时$value仍绑最后一格,后续赋值可能改最后元素;lab的[2,4,6]断言会发现。长驻worker跨请求保留状态,应有上限、重置与生命周期,不能借用请求结束回收假设。

依据:异常、生成器、文件、内置服务器、Composer安装与更新。

继续阅读