PHP 完整实践:生成器分类账与错误诊断
应用合同
前置:语法与类型、资源与生成器。本地CLI读取无BOM UTF-8,行格式name|amount,无表头。name首位ASCII字母,其后字母/数字/_/-,总长1–32;amount为1–999,拒绝前导零、空格和符号。最多10000行,空文件输出为空且成功,空行失败;接受LF/CRLF及末行无换行。它不是CSV解析器,也不接受PHP代码作为输入。
按字符串键排序输出name=total,成功0;缺参数、多参数、缺文件、格式或读取失败2,stderr错误、stdout无部分报告。名称仅ASCII,所以非ASCII/非法UTF-8字节都不可能组成合法行;这是ASCII业务验证,不声称正则对任意Unicode作完整编码检查。总额<=9990000,本项目及lab要求64位PHP;增大规模必须重新检查整数溢出。
fgets先取得整行,过长行会先占内存,数量上限并非字节总量上限。若用于外部上传需增加字节层大小限制、路径约束与超时,当前项目只面向本地受控文件,不伪称生产上传服务。
所有实现与测试文件
ledger.php
<?php
declare(strict_types=1);
final readonly class Entry {
public function __construct(public string $name, public int $amount) {}
}
function parseRow(string $line, int $number): Entry {
if (preg_match('/\A([A-Za-z][A-Za-z0-9_-]{0,31})\|([1-9][0-9]{0,2})\z/D', $line, $match) !== 1) {
throw new InvalidArgumentException("line $number: expected name|1..999");
}
return new Entry($match[1], (int) $match[2]);
}
/** @param resource $handle @return Generator<int, Entry> Caller owns the handle. */
function entries($handle): Generator {
$number = 0;
while (($line = fgets($handle)) !== false) {
if (++$number > 10000) { throw new InvalidArgumentException('more than 10000 rows'); }
// Only the line terminator is stripped. Spaces are data, not silently corrected.
$line = str_ends_with($line, "\n") ? substr($line, 0, -1) : $line;
$line = str_ends_with($line, "\r") ? substr($line, 0, -1) : $line;
yield parseRow($line, $number);
}
if (!feof($handle)) { throw new RuntimeException('read failed'); }
}
/** @param resource $handle */
function summarize($handle): string {
$totals = [];
foreach (entries($handle) as $entry) {
$totals[$entry->name] = ($totals[$entry->name] ?? 0) + $entry->amount;
}
ksort($totals, SORT_STRING);
$result = '';
foreach ($totals as $name => $amount) { $result .= "$name=$amount\n"; }
return $result;
}main.php
<?php
declare(strict_types=1);
require __DIR__ . '/ledger.php';
try {
if ($argc !== 2) { throw new InvalidArgumentException('usage: php main.php FILE'); }
$handle = @fopen($argv[1], 'rb');
if ($handle === false) { throw new RuntimeException('cannot open file'); }
try { $output = summarize($handle); }
finally { fclose($handle); }
echo $output;
} catch (Throwable $error) {
fwrite(STDERR, $error->getMessage() . "\n"); exit(2);
}test.php
<?php
declare(strict_types=1);
require __DIR__ . '/ledger.php';
function check(bool $ok): void { if (!$ok) { throw new RuntimeException('assertion failed'); } }
$handle = fopen('php://temp', 'w+');
fwrite($handle, "z|2\na|3\nz|4\n"); rewind($handle);
check(summarize($handle) === "a=3\nz=6\n"); check(is_resource($handle)); fclose($handle);
foreach (['a|0', 'a|01', 'a|2|3', 'a| 2', 'a|1000', 'x!|1', ''] as $bad) {
try { parseRow($bad, 1); throw new RuntimeException('accepted invalid'); }
catch (InvalidArgumentException $e) { }
}
try { parseRow(123, 1); throw new RuntimeException('coercion accepted'); }
catch (TypeError $e) { }
echo "PHP module: parse, strict type, totals, borrowed resource passed\n";parseRow在转换int之前检查整行;entries只借用文件、yield Entry,summarize聚合后构建完整输出。main先打开、再try/finally关闭,最后echo;异常不会留下部分汇总。测试使用check抛RuntimeException,不依赖可能被zend.assertions配置消除的assert。strict_types测试从启用严格模式的文件发起调用,TypeError路径才有明确语义。
执行与失败复现
保存sample.txt:z|2、a|3、z|4各一行,无BOM UTF-8。Windows PowerShell7可用Set-Content -Encoding utf8NoBOM sample.txt @('z|2','a|3','z|4');Linux Bash可用printf 'z|2\na|3\nz|4\n' > sample.txt。在项目目录运行:
php -l ledger.php
php -l main.php
php -l test.php
php test.php
php main.php sample.txt
php -n lab.php测试输出PHP module: parse, strict type, totals, borrowed resource passed;业务输出a=3、z=6。改最后一行为z|0,应报line3、退出2且没有stdout;php -l仍成功,因为它只验证脚本语法,不读取业务文件。PowerShell查看
仓库verify.py实际启动17种CLI场景,包括空/CRLF、错误格式/编码、10001行、缺文件/参数,逐项检查退出码、完整输出和stderr。模块测试验证借用句柄仍可用,CLI所有者负责关闭,两者不是同一测试。
定位路径、类型、资源与性能错误
Could not open input file先看php后面脚本路径和工作目录;Failed opening required看__DIR__与文件名大小写,Windows大小写不敏感不能证明Linux路径正确。Call to undefined function先php -m检查扩展;Typed property must not be accessed before initialization先检查构造,不用给所有属性随意null掩盖。
TypeError先确认调用文件strict_types、实际值与声明,不强制(int)所有输入绕过错误。本项目非法记录返回行号,但实际调试还应检查原始字节、是否BOM或额外分隔符;不要加trim把合同变宽后称“修复编码”。读取失败必须区别EOF;句柄关闭责任由main finally拥有,不由生成器隐式猜测。
调试可先在parseRow及catch附近向stderr输出get_debug_type与行号,避免污染stdout。Xdebug是独立扩展,未安装就不能假装单步已验证;若已配置编辑器,可在这些位置设断点观察
综合练习与参考解法
练习:增加每分类记录数,先为重复键、空文件、非法末行写期望;限制类别数,测最大/最大+1;解释为何array参数类型不保证元素都是整数,readonly也不保证全局值合法。若转换为Web应用,列出新增边界。
参考方向
聚合数组的值可改为total/count结构并保持private局部状态,完整成功后再格式化;类数只在新键第一次出现时判断。PHP运行时array声明不验证模板式元素类型,Entry公开构造器也允许内部绕过parse,所以不要承诺所有实例永远满足格式。Web新增请求方法/大小、上传目录与协议、输出上下文转义、状态码和日志;认证、数据库及框架生命周期都需要额外完整项目,未在本批冒称实现。
继续阅读
- 知识专题
- 知识专题