PHP 文件读取与安全渲染:CSV 解析、UTF-8 与 HTML 输出
本文目录
这篇笔记最初写于 2018 年,原示例留下了“把文件数据呈现到表格”的未完成标记,并将文件内容直接拼进 HTML。本文保留原始发布日期,按 2026 年的安全要求重写示例:逐行解析分隔文件、校验字段、按 HTML 上下文转义,再输出表格。
示例使用 | 作为分隔符,运行环境要求 PHP 7.4 或更新版本。若数据可能包含引号、分隔符或换行,应使用有明确格式约定的 CSV;不要把任意文本交给简单的 explode() 后就假设每行结构正确。
准备数据
将以下内容保存为与 PHP 脚本相邻的 data/people.csv。这里的邮箱和网址使用保留的示例域名,不对应真实联系人。
id|name|age|email|website
1|示例读者|20|reader@example.com|https://example.com/
2|另一位读者|31|second@example.net|
即使分隔符不是逗号,fgetcsv() 仍可处理带引号的字段;字段分隔符、包围符和转义方式必须与文件格式一致。PHP 8.4 起应显式传入 escape 参数;这里使用空字符串关闭 PHP 专有的反斜线转义行为。
读取并校验
file_get_contents() 会把完整文件读入一个字符串,适合已知上限的小文件;参考 PHP file_get_contents()。这里使用 fopen() 和 fgetcsv() 逐条解析,避免用简单字符串切分破坏带引号的 CSV 字段。无论采用哪种方式,都先明确文件大小、来源和权限边界。
<?php
declare(strict_types=1);
function readPeople(string $filePath): array
{
$handle = fopen($filePath, 'rb');
if ($handle === false) {
throw new RuntimeException('无法打开人员数据文件');
}
try {
$headers = fgetcsv($handle, 0, '|', '"', '');
if ($headers === false) {
return [];
}
if (isset($headers[0])) {
$headers[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $headers[0]) ?? (string) $headers[0];
}
$headers = array_map(static fn ($value): string => trim((string) $value), $headers);
$expectedHeaders = ['id', 'name', 'age', 'email', 'website'];
if ($headers !== $expectedHeaders) {
throw new UnexpectedValueException('数据文件表头不符合约定');
}
$people = [];
$lineNumber = 1;
while (($columns = fgetcsv($handle, 0, '|', '"', '')) !== false) {
$lineNumber++;
if ($columns === [null]) {
continue;
}
if (count($columns) !== count($expectedHeaders)) {
throw new UnexpectedValueException("第 {$lineNumber} 行字段数量不正确");
}
$row = array_combine(
$expectedHeaders,
array_map(static fn ($value): string => trim((string) $value), $columns),
);
$id = filter_var($row['id'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
$age = filter_var($row['age'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 130]]);
$email = filter_var($row['email'], FILTER_VALIDATE_EMAIL);
if ($id === false || $age === false || $email === false || $row['name'] === '') {
throw new UnexpectedValueException("第 {$lineNumber} 行包含无效字段");
}
$website = $row['website'];
if ($website !== '') {
$parts = parse_url($website);
$scheme = is_array($parts) ? strtolower((string) ($parts['scheme'] ?? '')) : '';
if (filter_var($website, FILTER_VALIDATE_URL) === false
|| $scheme !== 'https'
|| empty($parts['host'])
|| isset($parts['user'])
|| isset($parts['pass'])) {
throw new UnexpectedValueException("第 {$lineNumber} 行的网址必须是有效的 HTTPS 地址");
}
}
$people[] = [
'id' => $id,
'name' => $row['name'],
'age' => $age,
'email' => $email,
'website' => $website,
];
}
return $people;
} finally {
fclose($handle);
}
}
function escapeHtml(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$loadError = null;
try {
$people = readPeople(__DIR__ . '/data/people.csv');
} catch (Throwable $error) {
error_log('People table could not be loaded: ' . $error->getMessage());
$people = [];
$loadError = '列表暂时不可用,请稍后再试。';
}
?>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>人员信息表</title>
</head>
<body>
<main>
<h1>人员信息表</h1>
<?php if ($loadError !== null): ?>
<p role="status"><?= escapeHtml($loadError) ?></p>
<?php else: ?>
<table>
<thead>
<tr><th scope="col">编号</th><th scope="col">姓名</th><th scope="col">年龄</th><th scope="col">邮箱</th><th scope="col">个人主页</th></tr>
</thead>
<tbody>
<?php foreach ($people as $person): ?>
<tr>
<td><?= escapeHtml((string) $person['id']) ?></td>
<td><?= escapeHtml($person['name']) ?></td>
<td><?= escapeHtml((string) $person['age']) ?></td>
<td><?= escapeHtml($person['email']) ?></td>
<td>
<?php if ($person['website'] !== ''): ?>
<?php $host = (string) parse_url($person['website'], PHP_URL_HOST); ?>
<a href="<?= escapeHtml($person['website']) ?>" target="_blank" rel="noopener noreferrer"><?= escapeHtml($host) ?></a>
<?php else: ?>
<span>未填写</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</main>
</body>
</html>
关键边界
fgetcsv()会按 CSV 规则处理包围字段、分隔符和换行;explode()只按字面分割,不认识 CSV 引号语法。循环使用!== false区分 EOF 与有效字段,不用while (!feof(...))推测是否有下一行。- 表头固定、字段数、整数范围、邮箱格式和网址方案都在渲染前验证。畸形行会导致整份文件显示受控错误,不把部分坏数据静默伪装成正常结果。
FILTER_VALIDATE_URL只做格式校验,不能单独决定允许的协议。个人主页额外限制为 HTTPS,并拒绝 URL 中的用户名/密码;链接文字和href都仍要做 HTML 属性/文本上下文转义。htmlspecialchars()用于 HTML 文本和双引号属性,不是通用的 SQL、JavaScript 或 URL 编码器。输出到不同上下文时应使用对应的参数化 API 或编码规则。try/finally保证文件句柄关闭。错误详情只写服务端日志,不把路径、异常堆栈或文件内容返回给访客。生产环境还应限制文件权限,并避免允许用户任意指定服务器文件路径。
字符长度与调试
PHP strlen() 返回字节数,而不是用户感知的字符数量。
strlen() 返回字节数,不是“中文字符数乘以 3”的可靠换算。UTF-8 中不同字符可能占用不同字节;需要按字符计数时使用 mb_strlen($text, 'UTF-8'),并确认运行环境已启用 mbstring 扩展。用 php --ini 查看 CLI 实际加载的配置文件,用 php -m 核对扩展;Web SAPI 可能使用另一份配置,不能只看 CLI 就断言 Apache/FPM 的状态。
phpinfo() 会输出 PHP 版本、路径、模块和环境配置等信息,只能在受控本机诊断时短暂使用,确认后立即删除;绝不能放在公开生产页面。数组也不能直接 echo:需要检查结构时使用本地调试器或受控日志,不要把 print_r() / var_dump() 留在公开响应中。
修改说明
本次修订移除了旧示例中的未完成说明和直接输出文件数据的实现,补入 CSV 解析、字段校验、HTTPS 链接约束、HTML 转义、资源释放、受控错误处理以及 UTF-8/mbstring 的准确说明。示例可作为学习起点,不代替真实项目中的权限、文件来源和数据完整性设计。
参考: PHP fgetcsv()、PHP htmlspecialchars()、PHP filter_var()、PHP mb_strlen()。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。