PHP在线教程:用表单和数据库搭建一个简单留言板

百科常识4小时前发布 jdkjadmin
0 00
PHP在线教程:用表单和数据库搭建一个简单留言板

PHP在线教程:用表单和数据库搭建一个简单留言板

留言板是学习 PHP 表单处理和数据库操作的实用小项目:访客填写昵称与留言,服务器验证数据后保存到数据库,页面再读取并显示留言。这个过程涵盖了常见的 Web 开发步骤,也能帮助理解为什么“把表单内容写进数据库”还远远不够——输入需要验证,SQL 需要参数化,输出需要转义,提交过程还需要考虑跨站请求伪造和重复提交。

本文使用 PHP 8.1 或更高版本、PDO_SQLITE 和 SQLite。示例可以作为本地练习或低流量内部工具的起点;它不是完整的社区系统,也没有实现用户账号、内容审核、垃圾信息识别或高并发优化。上线前应根据实际场景补充相应措施。

一、适用场景与项目结构

SQLite 不需要单独运行数据库服务,数据保存在一个文件中,适合本地学习、原型验证、单机小型应用,以及写入量较低的内部工具。若应用需要多台 Web 服务器共同读写、频繁并发写入、复杂权限管理或独立备份恢复,应评估 MySQL、PostgreSQL 等数据库,而不是简单地把 SQLite 文件放到共享磁盘上。

建议把数据库文件放在网站公开目录之外,避免访客通过 URL 直接下载。例如,项目结构如下:

留言板项目/
├── public/
│   └── index.php
└── var/
    └── (程序首次运行后生成 guestbook.sqlite)

将下面的程序保存为 public/index.php。PHP 进程需要能够在 var 目录中创建和写入文件。生产环境中,应由部署人员创建该目录并配置最小必要的文件权限;不要为了省事把整个项目设为所有用户都可写。若使用 Apache、Nginx 或 PHP 内置服务器,网站根目录应指向 public,而不是项目根目录。

二、可运行的留言板代码

运行环境需要启用 PDO 和 PDO_SQLITE 扩展;本例还使用 mbstring 处理 UTF-8 字符长度。可通过 php -m 查看当前 PHP 命令行环境加载的扩展。命令行 PHP 与 Web 服务器使用的 PHP 配置可能不同,应分别确认。

<?php
declare(strict_types=1);

// 必须在 session_start() 之前设置会话 Cookie 参数。
$isHttps = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_set_cookie_params([
    'httponly' => true,
    'secure' => $isHttps,
    'samesite' => 'Lax',
]);
session_start();

header('Content-Type: text/html; charset=UTF-8');

function escapeHtml(string $value): string
{
    return htmlspecialchars(
        $value,
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    );
}

$projectDir = dirname(__DIR__);
$dataDir = $projectDir . '/var';
$dbFile = $dataDir . '/guestbook.sqlite';

try {
    if (!is_dir($dataDir)) {
        throw new RuntimeException('数据目录不存在,请先创建 var 目录。');
    }

    $pdo = new PDO('sqlite:' . $dbFile, null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]);

    $pdo->exec(
        'CREATE TABLE IF NOT EXISTS messages (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            content TEXT NOT NULL,
            created_at TEXT NOT NULL
        )'
    );
} catch (Throwable $e) {
    // 详细错误写入服务器日志,避免把路径和配置暴露给访客。
    error_log($e->getMessage());
    http_response_code(500);
    exit('留言板暂时无法使用,请联系管理员。');
}

if (!isset($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

$errors = [];
$name = '';
$content = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $submittedToken = $_POST['csrf_token'] ?? '';

    if (
        !is_string($submittedToken)
        || !hash_equals($_SESSION['csrf_token'], $submittedToken)
    ) {
        $errors[] = '表单已过期或请求无效,请刷新页面后重试。';
    }

    $rawName = $_POST['name'] ?? '';
    $rawContent = $_POST['content'] ?? '';

    if (!is_string($rawName) || !is_string($rawContent)) {
        $errors[] = '表单数据格式不正确。';
    } else {
        $name = trim($rawName);
        $content = trim(str_replace(
            ["\\r\\n", "\\r"],
            "\\n",
            $rawContent
        ));

        if (!mb_check_encoding($name, 'UTF-8')
            || !mb_check_encoding($content, 'UTF-8')) {
            $errors[] = '请输入有效的 UTF-8 文本。';
        } else {
            if ($name === '') {
                $errors[] = '昵称不能为空。';
            } elseif (mb_strlen($name, 'UTF-8') > 40) {
                $errors[] = '昵称不能超过 40 个字符。';
            }

            if ($content === '') {
                $errors[] = '留言内容不能为空。';
            } elseif (mb_strlen($content, 'UTF-8') > 2000) {
                $errors[] = '留言内容不能超过 2000 个字符。';
            }
        }
    }

    if ($errors === []) {
        try {
            $statement = $pdo->prepare(
                'INSERT INTO messages (name, content, created_at)
                 VALUES (:name, :content, :created_at)'
            );
            $statement->execute([
                ':name' => $name,
                ':content' => $content,
                ':created_at' => gmdate('Y-m-d H:i:s'),
            ]);

            // 轮换令牌,再用 POST-Redirect-GET 避免刷新时重复提交。
            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
            header('Location: index.php', true, 303);
            exit;
        } catch (Throwable $e) {
            error_log($e->getMessage());
            $errors[] = '保存失败,请稍后重试。';
        }
    }
}

$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT);
$page = ($page !== false && $page !== null) ? max(1, $page) : 1;

$perPage = 20;
$offset = ($page - 1) * $perPage;

try {
    $total = (int) $pdo->query(
        'SELECT COUNT(*) FROM messages'
    )->fetchColumn();

    $statement = $pdo->prepare(
        'SELECT id, name, content, created_at
         FROM messages
         ORDER BY id DESC
         LIMIT :limit OFFSET :offset'
    );
    $statement->bindValue(':limit', $perPage, PDO::PARAM_INT);
    $statement->bindValue(':offset', $offset, PDO::PARAM_INT);
    $statement->execute();
    $messages = $statement->fetchAll();
} catch (Throwable $e) {
    error_log($e->getMessage());
    http_response_code(500);
    exit('读取留言失败,请联系管理员。');
}

$totalPages = max(1, (int) ceil($total / $perPage));
?>
<!doctype html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>简单留言板</title>
    <style>
        body { max-width: 760px; margin: 2rem auto; padding: 0 1rem;
               font: 16px/1.6 sans-serif; }
        label { display: block; margin-top: 1rem; }
        input, textarea { box-sizing: border-box; width: 100%; padding: .6rem; }
        textarea { min-height: 8rem; }
        .error { color: #a00; }
        .message { border-top: 1px solid #ddd; padding: 1rem 0; }
        .meta { color: #666; font-size: .9rem; }
        .content { white-space: pre-wrap; overflow-wrap: anywhere; }
    </style>
</head>
<body>
    <h1>简单留言板</h1>

    <?php if ($errors !== []): ?>
        <ul class="error">
            <?php foreach ($errors as $error): ?>
                <li><?= escapeHtml($error) ?></li>
            <?php endforeach; ?>
        </ul>
    <?php endif; ?>

    <form method="post" action="index.php">
        <input type="hidden" name="csrf_token"
               value="<?= escapeHtml($_SESSION['csrf_token']) ?>">

        <label for="name">昵称(最多 40 个字符)</label>
        <input id="name" name="name" maxlength="40" required
               value="<?= escapeHtml($name) ?>">

        <label for="content">留言(最多 2000 个字符)</label>
        <textarea id="content" name="content" maxlength="2000"
                  required><?= escapeHtml($content) ?></textarea>

        <button type="submit">提交留言</button>
    </form>

    <h2>最新留言</h2>
    <?php if ($messages === []): ?>
        <p>还没有留言。</p>
    <?php endif; ?>

    <?php foreach ($messages as $message): ?>
        <article class="message">
            <p class="meta">
                <?= escapeHtml($message['name']) ?>
                · <?= escapeHtml($message['created_at']) ?> UTC
            </p>
            <div class="content"><?=
                escapeHtml($message['content'])
            ?></div>
        </article>
    <?php endforeach; ?>

    <nav aria-label="留言分页">
        <?php if ($page > 1): ?>
            <a href="?page=<?= $page - 1 ?>">上一页</a>
        <?php endif; ?>
        第 <?= $page ?> / <?= $totalPages ?> 页
        <?php if ($page < $totalPages): ?>
            <a href="?page=<?= $page + 1 ?>">下一页</a>
        <?php endif; ?>
    </nav>
</body>
</html>

在项目根目录创建 var 目录后,可用 PHP 内置服务器进行本地测试:

mkdir -p var
php -S 127.0.0.1:8000 -t public

然后访问 http://127.0.0.1:8000/。内置服务器仅适合本地开发和测试,不应直接作为公开生产服务使用。首次访问时,程序会创建数据表和 SQLite 文件;若目录不可写,页面会返回通用错误,具体原因应查看 PHP 错误日志。

三、代码中的关键边界

1. 表单校验不等于安全过滤

浏览器中的 required 和 maxlength 有助于改善填写体验,但它们可以被绕过,不能代替服务器验证。代码在服务端检查字段类型、UTF-8 编码、空值和字符数。对多字节文本使用 mb_strlen,避免把中文字符误按字节计数;因此运行环境需要 mbstring。

字符数限制也不等于完整的请求体大小限制。服务器仍需配置合理的请求体上限,并结合应用的流量和资源预算评估。示例限制的是昵称与留言内容,不代表 HTTP 请求中的其他字段、请求头或整个请求都受到同样的字节级限制。若扩展成允许上传图片的表单,还需要单独验证文件大小、类型、存储路径和访问权限。

2. SQL 参数化保护的是 SQL 结构

插入数据时使用 PDO 预处理语句,并通过占位符绑定昵称、内容和时间。不要将表单值直接拼接进 SQL 字符串;参数化查询用于把数据和 SQL 语句结构分开,防止这类数据被当作 SQL 语法执行。

参数化查询不能替代输出转义,也不能让所有动态 SQL 都自动安全。例如,排序字段名或表名通常不能按普通值绑定;若将来要让用户选择排序方式,应使用服务器端的固定白名单映射,而不是把用户输入直接拼进 SQL。

3. 输出转义要放在展示时做

留言内容被保存为普通文本,显示时通过 htmlspecialchars 转义。这样,用户提交的 <script> 会作为文字显示,而不会成为页面中的可执行标签。转义针对的是输出上下文:本例输出到 HTML 文本和带引号的 HTML 属性,因此使用 ENT_QUOTES 和 UTF-8 编码。

不要因为“写入数据库前已经过滤过”就省略展示时转义。数据库中的内容可能来自其他程序、历史版本或人工修改,仍应在每个输出位置依据上下文处理。如果以后把数据放进 JavaScript、CSS 或 URL,不能机械地把 HTML 转义当作通用解法,应使用适合对应上下文的安全编码方式。

white-space: pre-wrap 只负责保留换行并调整显示,不会替代 HTML 转义。示例不把留言当作 Markdown 或 HTML 渲染。如果产品确实需要富文本,应采用经过审查的 HTML 清理方案,并明确允许的标签、属性和链接协议。

4. CSRF 令牌与会话 Cookie 的作用

页面为会话生成随机令牌,提交时用 hash_equals 比较。它用于降低用户在已登录或已建立会话的浏览器中,被其他网站诱导提交表单的风险。令牌不是用户身份认证手段,也不能防止垃圾信息、自动化机器人或恶意用户自行访问表单。

会话 Cookie 设置了 HttpOnly、SameSite=Lax,并在 PHP 识别到 HTTPS 时设置 Secure。正式部署应使用 HTTPS。若站点位于反向代理之后,PHP 对 HTTPS 状态的判断需要按可信代理环境正确配置;不要不加验证地信任任意客户端提供的转发请求头。会话配置也不取代站点整体的身份验证和授权设计。

5. PRG、分页与数据增长

提交成功后,代码返回 HTTP 303 并跳转到列表页,这是 POST-Redirect-GET 模式,可减少用户刷新页面时重复提交同一条留言的概率。它不是去重机制:网络重试、并发请求或其他客户端仍可能产生重复数据。如业务上要求幂等,应另外设计请求标识或去重规则。

列表按自增 ID 倒序排列,每页读取 20 条,避免一次取出全部留言。页码经整数验证并限制为至少 1,SQL 中的分页参数以整数类型绑定。随着数据持续增长,偏移量分页在深页上可能变慢;高数据量场景可考虑基于最后一条记录 ID 的游标分页。当前示例没有删除或清理历史数据的功能,部署者需要按保存期限和隐私要求制定数据管理策略。

四、常见错误与排查方法

  • 提示驱动程序不存在:检查 Web 服务器使用的 PHP 是否启用了 PDO_SQLITE。即使命令行的 php -m 显示已启用,Web 环境也可能使用另一套 PHP 配置。
  • 无法打开数据库或创建文件:确认 var 目录存在,并且运行 PHP 的系统用户有必要的读写权限。SQLite 可能还需要在同一目录创建临时文件;不要只检查数据库文件本身的权限。
  • 字符数函数未定义:启用 mbstring 扩展,并重启或重新加载对应的 PHP 服务。
  • 页面报表单格式错误:不要假定 $_POST 中的值永远是字符串。示例显式检查类型,以应对异常构造的请求。
  • 中文显示异常:检查页面响应是否为 UTF-8,文件是否以 UTF-8 保存,数据库和连接设置是否一致。示例在响应头与文档中都声明了 UTF-8。
  • 网页能显示,命令行却报错,或相反:分别检查 PHP 版本、扩展、运行用户、工作目录和错误日志;命令行与 Web 服务经常使用不同配置。
  • 提交后刷新仍出现重复留言:确认成功路径确实执行了 303 跳转。PRG 能减少浏览器刷新造成的重复提交,但不能替代严格的幂等控制。

五、上线前还应评估什么

这个示例没有限流、验证码、内容审核、封禁机制或后台管理。公开开放的留言板容易收到广告、恶意链接和自动化提交。应根据风险增加请求频率限制、内容审核与举报处理,并对管理操作做身份验证和授权。若加验证码,也不要把它视为唯一的反滥用措施。

此外,SQLite 数据库文件应纳入备份计划,并定期验证备份是否可恢复;备份文件同样可能含有个人信息,需要设置访问权限和保留期限。生产错误日志应妥善管理,页面只给用户返回通用提示,避免泄露服务器路径、SQL 细节或配置。若留言会保存或公开展示个人信息,还应结合所在地区的法律要求,说明收集目的、保存期限和删除渠道。

完成这些基础步骤后,留言板就不只是“能把表单写进数据库”的演示,而是一个边界清晰、便于继续扩展的小型 Web 应用。后续可以逐步加入编辑与删除、管理员登录、审核状态、搜索和部署自动化;每增加一种能力,都应重新检查权限、输入验证、数据库查询和输出上下文。

© 版权声明

相关文章