第2章:数据库操作进阶:PDO、预处理与事务处理
章节介绍
学习目标
通过本章学习,您将能够:
- 理解并运用PDO(PHP Data Objects)扩展进行类型统一、安全高效的数据库操作。
- 掌握预处理语句(Prepared Statements)的原理与用法,从根本上杜绝SQL注入漏洞。
- 学会使用事务(Transaction)来保证数据库操作的原子性与数据一致性。
- 掌握PDO的错误处理机制,编写更健壮的数据访问代码。
- 完成从过时、不安全的
mysql_*函数向现代化、安全的数据库操作方式的升级。
在教程中的作用
本章是《零基础学PHP》系列从基础语法迈向工程化开发的关键一步。数据库是动态网站的核心,安全、可靠的数据存取是任何Web应用的基石。本章所讲解的PDO、预处理和事务,是PHP进行专业级数据库开发的必备技能,直接关系到应用的安全性、稳定性和可维护性。掌握本章内容,是为后续学习会话安全、框架原理以及完成综合实战项目铺平道路。
与前面章节的衔接
在第1章中,我们学习了面向对象编程(OOP),理解了类、对象、封装等概念。本章将首次在实战中运用OOP思想,例如,我们可以创建一个Database类来封装PDO连接和操作。同时,我们将使用PDO来操作MySQL数据库,这要求您已经掌握了基本的SQL语法(如SELECT, INSERT, UPDATE, DELETE)和MySQL的基本使用,这些知识在更早的模块中已经学习过。
本章主要内容概览
本章将首先介绍PDO的优越性及其建立连接的方法。然后,核心部分将深入讲解预处理语句 ,包括其原理、使用方法(prepare, execute, bindParam)以及如何有效防止SQL注入。接着,我们将学习事务 的概念,并通过beginTransaction, commit, rollBack来管理复杂操作。最后,我们会探讨PDO的错误处理模式,确保程序在遇到数据库问题时能优雅地应对。所有知识点都将通过代码示例和实战项目进行巩固。
核心概念讲解
1. PDO:PHP数据对象
概念 :PDO提供了一个轻量级、一致性的接口用于访问多种数据库(如MySQL, PostgreSQL, SQLite)。这意味着使用PDO编写的代码,在更换数据库类型时,通常只需修改连接字符串(DSN),极大地提高了代码的可移植性。
为何要取代mysql_*函数?
- 安全性 :
mysql_*函数不支持原生的预处理语句,容易导致SQL注入。自PHP 5.5.0起已被废弃,PHP 7.0.0中已被移除。 - 一致性:为不同数据库提供统一的API。
- 面向对象:完全的面向对象接口,与现代PHP开发范式一致。
- 性能:支持预处理语句的复用,在某些场景下性能更优。
2. 预处理语句与参数绑定
原理:预处理语句将SQL查询的结构与数据分离。它分为两步:
- 准备阶段 :发送一个SQL语句模板到数据库服务器进行编译。例如:
SELECT * FROM users WHERE username = ? AND email = ?。此处的?是占位符。 - 执行阶段 :将具体的数据(参数)绑定到占位符上,然后执行。数据库服务器将数据视为纯数据,而非SQL代码的一部分,因此恶意注入的SQL代码不会被解析执行。
关键方法:
PDO::prepare($sql): 准备一个SQL语句,返回一个PDOStatement对象。PDOStatement::execute($params): 执行准备好的语句,可传入一个参数数组。PDOStatement::bindParam($param, &$variable): 将一个PHP变量绑定到占位符(引用绑定)。PDOStatement::bindValue($param, $value): 将一个值绑定到占位符。PDOStatement::fetch()/fetchAll(): 从结果集中获取数据。
应用场景 :任何包含用户输入或可变数据的SQL操作(SELECT,INSERT,UPDATE,DELETE)都必须使用预处理语句。
3. 事务处理
概念:事务是将一系列数据库操作作为一个不可分割的单元来执行的机制。它遵循ACID原则:
- 原子性(Atomicity):事务内的所有操作要么全部成功,要么全部失败回滚。
- 一致性(Consistency):事务必须使数据库从一个一致性状态变换到另一个一致性状态。
- 隔离性(Isolation):并发执行的事务之间互不干扰。
- 持久性(Durability) :事务一旦提交,其结果就是永久性的。
关键方法: PDO::beginTransaction(): 启动一个事务。PDO::commit(): 提交事务,使所有更改永久生效。PDO::rollBack(): 回滚事务,撤销所有未提交的更改。
应用场景:银行转账(A账户扣款和B账户加款必须同时成功或失败)、订单创建(减库存、生成订单、记录日志等多个操作)。
4. 错误处理模式
PDO提供了三种错误处理模式:
PDO::ERRMODE_SILENT(默认):发生错误时不主动提示,需要手动检查$pdo->errorCode()和$pdo->errorInfo()。PDO::ERRMODE_WARNING:产生一个E_WARNING级别的PHP警告。PDO::ERRMODE_EXCEPTION:抛出PDOException异常。这是推荐模式 ,可以方便地使用try...catch块进行集中处理。
代码示例
示例1:使用PDO连接数据库
php
<?php
/**
* 使用PDO连接MySQL数据库
*/
// 数据库配置信息
$host = 'localhost';
$dbname = 'test_db';
$username = 'root';
$password = '';
$charset = 'utf8mb4'; // 推荐使用utf8mb4以支持完整的Unicode(如表情符号)
// 构造数据源名称(DSN)
$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";
// 设置PDO连接选项
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 设置错误模式为异常模式,便于捕获错误
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // 设置默认的获取模式为关联数组
PDO::ATTR_EMULATE_PREPARES => false, // 禁用预处理语句的模拟,使用真正的MySQL预处理,更安全
];
try {
// 创建PDO实例,建立数据库连接
$pdo = new PDO($dsn, $username, $password, $options);
echo "数据库连接成功!<br>";
} catch (PDOException $e) {
// 连接失败时,捕获异常并安全地显示错误信息(生产环境中应记录日志,而非直接输出)
die('数据库连接失败: ' . $e->getMessage());
}
// ... 后续的数据库操作
?>
示例2:使用query执行简单查询与fetch获取数据
php
<?php
// 假设 $pdo 已成功连接(来自示例1)
$sql = "SELECT id, username, email FROM users WHERE status = 1";
try {
// 使用query方法执行不包含变量的简单查询
$stmt = $pdo->query($sql);
echo "<h3>用户列表:</h3>";
// 使用fetchAll一次性获取所有结果行(适用于结果集不大的情况)
$users = $stmt->fetchAll();
if (empty($users)) {
echo "没有找到活跃用户。";
} else {
echo "<ul>";
foreach ($users as $user) {
echo "<li>ID: {$user['id']}, 用户名: {$user['username']}, 邮箱: {$user['email']}</li>";
}
echo "</ul>";
}
// 或者,使用fetch逐行获取(适用于处理大型结果集,节省内存)
// $stmt->execute(); // 对于query()返回的语句,通常不需要再execute
// while ($row = $stmt->fetch()) {
// print_r($row);
// }
} catch (PDOException $e) {
echo "查询失败: " . $e->getMessage();
}
?>
示例3:使用预处理语句防止SQL注入(INSERT/UPDATE/DELETE)
php
<?php
// 假设 $pdo 已成功连接
// 这是一个用户注册的场景,接收来自表单的用户名和邮箱
// 【模拟攻击】恶意用户输入
$maliciousUsername = "admin' -- ";
$maliciousEmail = "hacker@example.com";
// **危险!直接拼接SQL(SQL注入攻击演示)**
$unsafeSql = "INSERT INTO users (username, email) VALUES ('$maliciousUsername', '$maliciousEmail')";
// 执行后,SQL变为:INSERT INTO users (username, email) VALUES ('admin' -- ', 'hacker@example.com')
// `--` 是SQL注释符,导致后面的单引号被注释,SQL语句可能被篡改,例如绕过验证或删除数据。
echo "<b>危险的不安全SQL示例(请勿在生产环境使用):</b><br>";
echo "SQL: " . htmlspecialchars($unsafeSql) . "<br><br>";
// **安全!使用PDO预处理语句**
$safeSql = "INSERT INTO users (username, email) VALUES (?, ?)"; // 使用`?`作为匿名占位符
try {
// 1. 准备SQL语句
$stmt = $pdo->prepare($safeSql);
// 2. 执行语句,并绑定参数(即使参数是恶意的,也会被安全地当作数据处理)
$stmt->execute([$maliciousUsername, $maliciousEmail]);
// 获取最后插入的ID
$lastInsertId = $pdo->lastInsertId();
echo "<b>安全的预处理SQL执行成功!</b><br>";
echo "新用户的ID是:$lastInsertId<br>";
echo "即使输入包含SQL关键字(如admin' --),也被安全地存储为普通字符串,不会破坏SQL结构。<br>";
} catch (PDOException $e) {
echo "插入数据失败: " . $e->getMessage();
}
// **另一种绑定方式:使用命名占位符**
$safeSqlNamed = "UPDATE users SET email = :email WHERE id = :id";
$stmt2 = $pdo->prepare($safeSqlNamed);
$newEmail = 'updated@example.com';
$userId = 1;
// 使用命名占位符,参数数组的键名需与占位符一致
$stmt2->execute([':email' => $newEmail, ':id' => $userId]);
echo "<br>更新操作完成。";
?>
示例4:使用预处理语句进行查询(SELECT)与fetch
php
<?php
// 假设 $pdo 已成功连接
// 用户登录验证场景:根据用户名查找用户
$inputUsername = $_POST['username'] ?? ''; // 模拟从表单获取的用户名
$sql = "SELECT id, username, password_hash FROM users WHERE username = :username LIMIT 1";
try {
$stmt = $pdo->prepare($sql);
// 将用户输入绑定到 :username 占位符
$stmt->bindParam(':username', $inputUsername, PDO::PARAM_STR); // 显式指定参数类型为字符串
$stmt->execute();
// 获取单条结果
$user = $stmt->fetch();
if ($user) {
echo "找到用户: {$user['username']} (ID: {$user['id']})<br>";
// 后续可以在此处进行密码验证(如使用 password_verify)
// if (password_verify($_POST['password'], $user['password_hash'])) { ... }
} else {
echo "用户名 '$inputUsername' 不存在。<br>";
}
} catch (PDOException $e) {
echo "查询失败: " . $e->getMessage();
}
// **使用 bindValue 和 fetchAll 的示例**
$minId = 5;
$sql2 = "SELECT * FROM users WHERE id > ?";
$stmt2 = $pdo->prepare($sql2);
$stmt2->bindValue(1, $minId, PDO::PARAM_INT); // 将值 5 绑定到第一个占位符,类型为整数
$stmt2->execute();
$users = $stmt2->fetchAll(PDO::FETCH_ASSOC); // 明确指定获取模式
echo "<pre>";
print_r($users);
echo "</pre>";
?>
示例5:使用事务处理银行转账场景
php
<?php
// 假设 $pdo 已成功连接,且有一个accounts表(id, username, balance)
// 模拟从A账户向B账户转账
$fromAccountId = 1;
$toAccountId = 2;
$amount = 100.00;
try {
// 1. 开启事务
$pdo->beginTransaction();
echo "事务开始。<br>";
// 2. 执行一系列数据库操作(这些操作要么全部成功,要么全部失败)
// 操作1:检查转出账户余额是否充足
$checkSql = "SELECT balance FROM accounts WHERE id = ? FOR UPDATE";
// `FOR UPDATE` 为当前行加锁,防止并发修改导致余额不一致(进阶知识)。
$stmtCheck = $pdo->prepare($checkSql);
$stmtCheck->execute([$fromAccountId]);
$fromAccount = $stmtCheck->fetch();
if (!$fromAccount) {
throw new Exception("转出账户不存在!");
}
if ($fromAccount['balance'] < $amount) {
throw new Exception("转出账户余额不足!");
}
// 操作2:从转出账户扣款
$deductSql = "UPDATE accounts SET balance = balance - ? WHERE id = ?";
$stmtDeduct = $pdo->prepare($deductSql);
$stmtDeduct->execute([$amount, $fromAccountId]);
echo "从账户 {$fromAccountId} 扣除 {$amount} 元。<br>";
// 操作3:向转入账户加款
$addSql = "UPDATE accounts SET balance = balance + ? WHERE id = ?";
$stmtAdd = $pdo->prepare($addSql);
$stmtAdd->execute([$amount, $toAccountId]);
echo "向账户 {$toAccountId} 转入 {$amount} 元。<br>";
// 模拟一个可能失败的操作(例如,网络中断、唯一键冲突等)
// $someError = true; // 取消注释此行以测试事务回滚
// if ($someError) {
// throw new Exception("模拟的意外错误!");
// }
// 3. 所有操作成功,提交事务
$pdo->commit();
echo "事务提交成功,转账完成!<br>";
} catch (Exception $e) {
// 4. 如果任何一步出现异常,回滚事务,撤销所有更改
$pdo->rollBack();
echo "事务失败,已回滚。原因:" . $e->getMessage() . "<br>";
}
?>
实战项目
项目:重构用户注册/登录模块并模拟银行转账
1. 需求分析与技术方案
目标:综合运用PDO、预处理语句和事务,创建一个更安全、健壮的用户数据操作模块。
- 功能1:用户注册。安全地将新用户信息(用户名、邮箱、加密密码)存入数据库。
- 功能2:用户登录。安全地验证用户名和密码。
- 功能3:账户转账模拟 。演示使用事务保证资金转移的原子性。
技术选型: - 数据库:MySQL。
- PHP扩展:PDO。
- 密码存储:
password_hash()和password_verify()。 - 错误处理:PDO异常模式。
2. 数据库表结构
在MySQL中执行以下SQL语句创建示例表:
sql
-- 创建数据库
CREATE DATABASE IF NOT EXISTS php_advance CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE php_advance;
-- 用户表
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL COMMENT '用户名',
email VARCHAR(100) UNIQUE NOT NULL COMMENT '邮箱',
password_hash VARCHAR(255) NOT NULL COMMENT '加密后的密码',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 账户表(模拟银行账户)
CREATE TABLE accounts (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL COMMENT '关联的用户ID',
balance DECIMAL(10, 2) DEFAULT 0.00 COMMENT '账户余额',
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='模拟账户表';
-- 插入初始测试用户(密码均为'123456'的hash值)
INSERT INTO users (username, email, password_hash) VALUES
('小明', 'xiaoming@example.com', '$2y$10$SomeRandomHashForDemo123456789012'),
('小红', 'xiaohong@example.com', '$2y$10$AnotherRandomHashForDemo987654321');
-- 为初始用户创建账户
INSERT INTO accounts (user_id, balance) VALUES (1, 1000.00), (2, 500.00);
3. 分步骤实现代码
步骤1:创建数据库连接公共文件 (config/database.php)
php
<?php
// config/database.php
/**
* 数据库配置及连接
*/
class Database {
private static $instance = null; // 单例模式的实例
private $pdo;
// 私有构造函数,防止外部直接实例化
private function __construct() {
$host = 'localhost';
$dbname = 'php_advance';
$username = 'root';
$password = '';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$this->pdo = new PDO($dsn, $username, $password, $options);
} catch (PDOException $e) {
die('数据库连接失败: ' . $e->getMessage()); // 生产环境应记录日志
}
}
// 获取单例实例的公共静态方法
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
// 获取PDO实例
public function getConnection() {
return $this->pdo;
}
// 防止克隆和反序列化
private function __clone() {}
public function __wakeup() {}
}
// 获取数据库连接的快捷方式
function getDB() {
return Database::getInstance()->getConnection();
}
?>
步骤2:用户注册功能 (register.php)
php
<?php
// register.php
require_once 'config/database.php';
// 处理表单提交
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
$errors = [];
// 简单的表单验证
if (empty($username)) $errors[] = '用户名不能为空。';
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = '邮箱格式不正确。';
if (strlen($password) < 6) $errors[] = '密码长度至少6位。';
if ($password !== $confirmPassword) $errors[] = '两次输入的密码不一致。';
if (empty($errors)) {
$pdo = getDB();
// 检查用户名和邮箱是否已存在
$checkSql = "SELECT COUNT(*) FROM users WHERE username = ? OR email = ?";
$stmtCheck = $pdo->prepare($checkSql);
$stmtCheck->execute([$username, $email]);
if ($stmtCheck->fetchColumn() > 0) {
$errors[] = '用户名或邮箱已被注册。';
} else {
// 使用强密码哈希算法
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
// 使用预处理语句插入新用户
$insertSql = "INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)";
try {
$pdo->beginTransaction(); // 虽然不是必须,但可以在这里开始事务,用于演示
$stmtInsert = $pdo->prepare($insertSql);
$stmtInsert->execute([$username, $email, $passwordHash]);
$newUserId = $pdo->lastInsertId();
// 同时为用户创建一个初始账户(余额为0)
$accountSql = "INSERT INTO accounts (user_id, balance) VALUES (?, 0.00)";
$stmtAccount = $pdo->prepare($accountSql);
$stmtAccount->execute([$newUserId]);
$pdo->commit();
$success = "注册成功!您的用户ID是:$newUserId";
} catch (PDOException $e) {
$pdo->rollBack();
$errors[] = '注册失败,请重试。系统错误:' . $e->getMessage();
}
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>用户注册</title>
</head>
<body>
<h2>用户注册</h2>
<?php if (!empty($errors)): ?>
<div style="color: red;">
<ul>
<?php foreach ($errors as $error): ?>
<li><?= htmlspecialchars($error) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if (isset($success)): ?>
<div style="color: green;"><?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<form method="POST">
<div>
<label for="username">用户名:</label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="email">邮箱:</label>
<input type="email" id="email" name="email" required>
</div>
<div>
<label for="password">密码:</label>
<input type="password" id="password" name="password" required>
</div>
<div>
<label for="confirm_password">确认密码:</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<div>
<button type="submit">注册</button>
</div>
</form>
<p><a href="login.php">已有账号?去登录</a></p>
</body>
</html>
步骤3:用户登录功能 (login.php)
php
<?php
// login.php
session_start(); // 为后续使用Session保存登录状态做准备
require_once 'config/database.php';
// 处理登录表单
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if (empty($username) || empty($password)) {
$error = '用户名和密码不能为空。';
} else {
$pdo = getDB();
// 使用预处理语句根据用户名查询用户
$sql = "SELECT id, username, password_hash FROM users WHERE username = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
// 登录成功
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
// 重定向到欢迎页面或主页
header('Location: dashboard.php');
exit;
} else {
$error = '用户名或密码错误。';
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>用户登录</title>
</head>
<body>
<h2>用户登录</h2>
<?php if (isset($error)): ?>
<div style="color: red;"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST">
<div>
<label for="username">用户名:</label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="password">密码:</label>
<input type="password" id="password" name="password" required>
</div>
<div>
<button type="submit">登录</button>
</div>
</form>
<p><a href="register.php">没有账号?去注册</a></p>
</body>
</html>
步骤4:模拟转账功能 (transfer.php - 需登录后访问)
php
<?php
// transfer.php
session_start();
require_once 'config/database.php';
// 简单的登录检查
if (!isset($_SESSION['user_id'])) {
header('Location: login.php');
exit;
}
$currentUserId = $_SESSION['user_id'];
$currentUsername = $_SESSION['username'];
$message = '';
$pdo = getDB();
// 获取当前用户账户信息
$sql = "SELECT a.id, a.balance, u.username FROM accounts a JOIN users u ON a.user_id = u.id WHERE u.id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$currentUserId]);
$currentAccount = $stmt->fetch();
// 获取所有其他用户的列表(用于选择转账目标)
$otherUsersSql = "SELECT u.id, u.username, a.balance FROM users u JOIN accounts a ON u.id = a.user_id WHERE u.id != ?";
$stmtOthers = $pdo->prepare($otherUsersSql);
$stmtOthers->execute([$currentUserId]);
$otherUsers = $stmtOthers->fetchAll();
// 处理转账请求
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['to_user_id'], $_POST['amount'])) {
$toUserId = (int)$_POST['to_user_id'];
$amount = (float)$_POST['amount'];
if ($amount <= 0) {
$message = '转账金额必须大于0。';
} elseif ($amount > $currentAccount['balance']) {
$message = '您的账户余额不足。';
} else {
try {
$pdo->beginTransaction();
// 1. 从当前账户扣款
$deductSql = "UPDATE accounts SET balance = balance - ? WHERE user_id = ? AND balance >= ?";
$stmtDeduct = $pdo->prepare($deductSql);
$stmtDeduct->execute([$amount, $currentUserId, $amount]);
if ($stmtDeduct->rowCount() === 0) {
throw new Exception("扣款失败,余额可能已不足。");
}
// 2. 向目标账户加款
$addSql = "UPDATE accounts SET balance = balance + ? WHERE user_id = ?";
$stmtAdd = $pdo->prepare($addSql);
$stmtAdd->execute([$amount, $toUserId]);
if ($stmtAdd->rowCount() === 0) {
throw new Exception("目标账户不存在或更新失败。");
}
// 3. 记录交易日志(可选,此处为演示添加一个简单的日志表插入)
// $logSql = "INSERT INTO transfer_logs (from_user_id, to_user_id, amount) VALUES (?, ?, ?)";
// $pdo->prepare($logSql)->execute([$currentUserId, $toUserId, $amount]);
$pdo->commit();
$message = "成功向用户ID {$toUserId} 转账 {$amount} 元。";
// 重新查询当前账户余额以刷新显示
$stmt->execute([$currentUserId]);
$currentAccount = $stmt->fetch();
} catch (Exception $e) {
$pdo->rollBack();
$message = "转账失败:" . $e->getMessage();
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>模拟转账</title>
</head>
<body>
<h2>欢迎,<?= htmlspecialchars($currentUsername) ?>!</h2>
<p>您的账户余额:<strong><?= htmlspecialchars($currentAccount['balance']) ?></strong> 元</p>
<?php if ($message): ?>
<div style="padding: 10px; background-color: #dff0d8; border: 1px solid #3c763d; color: #3c763d;"><?= htmlspecialchars($message) ?></div>
<?php endif; ?>
<h3>转账操作</h3>
<form method="POST">
<div>
<label for="to_user_id">转账给:</label>
<select id="to_user_id" name="to_user_id" required>
<option value="">-- 请选择用户 --</option>
<?php foreach ($otherUsers as $user): ?>
<option value="<?= $user['id'] ?>"><?= htmlspecialchars($user['username']) ?> (余额: <?= $user['balance'] ?>元)</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="amount">转账金额(元):</label>
<input type="number" id="amount" name="amount" step="0.01" min="0.01" required>
</div>
<div>
<button type="submit">确认转账</button>
</div>
</form>
<p><a href="logout.php">退出登录</a></p>
</body>
</html>
步骤5:简单的注销和仪表板页面
php
<?php
// logout.php
session_start();
session_destroy(); // 销毁所有Session数据
header('Location: login.php');
exit;
?>
php
<?php
// dashboard.php (简单的仪表板,展示登录状态)
session_start();
if (!isset($_SESSION['user_id'])) {
header('Location: login.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>仪表板</title></head>
<body>
<h1>欢迎回来,<?= htmlspecialchars($_SESSION['username']) ?>!</h1>
<p>您已成功登录。</p>
<ul>
<li><a href="transfer.php">去转账</a></li>
<li><a href="logout.php">退出登录</a></li>
</ul>
</body>
</html>
4. 项目测试和部署指南
测试:
- 数据库连接:访问任意一个页面,检查是否能正确连接到数据库。
- 用户注册:尝试注册新用户,包括使用特殊字符作为用户名和邮箱,观察是否能被正确处理和安全存储。
- SQL注入测试 :在登录或注册表单的输入框中,尝试输入类似
admin' --或' OR '1'='1的字符串,验证系统是否仍然安全(应返回"用户名或密码错误"或"已被注册",而不是异常或登录成功)。 - 登录功能:用注册的账号登录,检查Session是否正确设置。
- 转账功能:
- 登录用户A,向用户B转账。
- 检查转账后A、B的余额变化是否正确。
- 测试事务回滚 :在
transfer.php的$pdo->commit();前,手动抛出一个异常(如throw new Exception("测试异常");),然后尝试转账。观察是否A的余额没有减少,B的余额没有增加。
部署:
- 将项目文件上传至支持PHP和MySQL的Web服务器。
- 修改
config/database.php中的数据库连接信息(主机名、数据库名、用户名、密码)。 - 在服务器上创建对应的数据库和表(执行
数据库表结构部分的SQL)。 - 确保Web服务器对
config目录有读取权限,但最好将其置于Web根目录之外,或使用.htaccess等限制访问。
5. 项目扩展和优化建议
- 封装数据库操作类 :将常见的CRUD操作封装到一个
UserModel或AccountModel类中,使代码更清晰。 - 添加验证类:创建独立的表单验证类,提供更丰富的验证规则(如邮箱唯一性、密码强度)。
- 完善事务日志 :创建
transfer_logs表,记录每笔转账的详细信息(时间、双方ID、金额、状态等)。 - 实现更复杂的权限:区分普通用户和管理员,管理员可以查看所有转账记录。
- 前端增强:使用Ajax进行表单提交,提升用户体验。
最佳实践
1. 行业标准和开发规范
- 始终使用PDO或MySQLi :完全弃用古老的
mysql_*函数。 - 坚持使用预处理语句 :对于任何包含变量的SQL,无论是来自用户输入还是内部变量,都使用
prepare和execute。 - 采用异常处理错误 :设置
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,并用try...catch块包裹数据库操作。 - 使用UTF-8字符集 :在DSN中指定
charset=utf8mb4,并在HTML头部设置<meta charset="UTF-8">。 - 遵循PSR编码规范:保持代码风格一致。
2. 常见错误和避坑指南
- 错误1:连接参数错误或未设置字符集 。确保DSN字符串正确,并始终 设置
charset。 - 错误2:忘记调用
execute()方法 。prepare()之后必须调用execute()才能执行。 - 错误3:混淆
bindParam和bindValue。bindParam绑定变量引用,执行时取变量的当前值;bindValue绑定的是调用时的值。在循环中绑定值到同一条语句时,要特别注意。 - 错误4:事务未正确关闭 。确保每个
beginTransaction()都有对应的commit()或rollBack(),避免留下未完成的事务锁。 - 错误5:在生产环境显示详细错误。捕获异常后,应向用户显示友好信息,而将详细错误记录到日志文件。
3. 性能优化技巧
- 复用预处理语句对象 :如果一个SQL语句需要在循环中执行多次,应在循环外
prepare一次,然后在循环内execute多次。 - 选择合适的数据获取方式:
- 小结果集:
fetchAll()。 - 大结果集:循环使用
fetch(),或使用PDOStatement::fetchAll(PDO::FETCH_COLUMN)获取单列。 - 考虑使用持久连接(
PDO::ATTR_PERSISTENT => true):在高并发场景下可能提升性能,但需妥善管理连接数,且不适用于所有环境。 - 为频繁查询的列添加索引:这是数据库层面的优化,但对PHP应用性能影响巨大。
4. 安全性考虑和建议(重点:SQL注入)
- 核心防护:预处理语句:这是防止SQL注入最有效、最根本的方法。PDO和MySQLi都支持。
- 漏洞案例(再现):
php
// 攻击者输入:$userInput = "admin' -- ";
$sql = "SELECT * FROM users WHERE username = '$userInput' AND password = 'xxx'";
// 执行后: SELECT * FROM users WHERE username = 'admin' -- ' AND password = 'xxx'
// 密码验证被注释掉,攻击者可能以admin身份登录。
- **防护代码**:
php
$sql = "SELECT * FROM users WHERE username = ? AND password = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$userInput, $passwordInput]); // $userInput中的单引号会被转义/视为数据
- 绝不信任用户输入:即使使用了预处理,也应对输入进行业务逻辑上的验证和过滤(如长度、格式)。
- 最小权限原则 :为PHP连接数据库使用的MySQL账户分配最小的必要权限(通常只需
SELECT,INSERT,UPDATE,DELETE),避免使用root账户。 - 密码安全存储 :务必使用
password_hash()和password_verify(),绝对不要 明文存储密码或使用弱哈希(如md5,sha1)。 - 错误信息不泄露细节:生产环境中,不要将PDO异常信息直接输出给用户,以免泄露数据库结构。
练习题与挑战
基础练习题
1. 建立PDO连接
- 题目 :编写一个PHP脚本
connect.php,使用PDO连接到本地名为exercise_db的MySQL数据库。用户名为dev_user,密码为DevPass123!。要求设置字符集为utf8mb4,错误模式为异常模式。连接成功后输出"连接成功",失败时捕获异常并输出友好信息。 - 难度:★☆☆☆☆
- 提示:参考核心概念讲解中的DSN构造和选项设置。
- 参考思路:
php
// ... 定义连接参数 ...
try {
$pdo = new PDO($dsn, $username, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ...]);
echo "连接成功";
} catch (PDOException $e) {
echo "连接失败,请检查配置。"; // 生产环境应记录$e->getMessage()到日志
}
2. 使用预处理语句查询数据
- 题目 :假设有一个
products表(id,name,price)。编写脚本search.php,接收一个GET参数keyword,使用PDO预处理语句查询产品名称中包含该关键词的所有产品,并列出它们的ID、名称和价格。如果没有输入关键词,则列出所有产品。 - 难度:★★☆☆☆
- 提示 :在SQL的
LIKE子句中使用占位符时,需要将通配符%与参数值一起绑定,如WHERE name LIKE ?,绑定值为"%{$keyword}%"。 - 参考思路:
php
$keyword = $_GET['keyword'] ?? '';
$sql = "SELECT * FROM products";
$params = [];
if (!empty($keyword)) {
$sql .= " WHERE name LIKE ?";
$params[] = "%$keyword%";
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$products = $stmt->fetchAll();
// ... 循环输出 ...
进阶练习题
3. 实现安全的批量插入
- 题目 :编写一个脚本
batch_insert.php,模拟接收一批新用户数据(例如,一个包含多个[username, email]对的数组)。使用PDO预处理语句,以高效且安全的方式将所有用户批量插入到users表中。注意处理可能出现的重复用户名或邮箱错误。 - 难度:★★★☆☆
- 提示 :可以在循环外
prepare一次INSERT语句,在循环内execute多次。使用try...catch在单个插入失败时决定是继续还是停止(或使用事务回滚整个批量操作)。 - 参考思路:
php
$newUsers = [ ['alice', 'alice@example.com'], ['bob', 'bob@example.com'] ];
$pdo->beginTransaction();
$sql = "INSERT INTO users (username, email) VALUES (?, ?)";
$stmt = $pdo->prepare($sql);
try {
foreach ($newUsers as $user) {
$stmt->execute($user);
}
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
echo "批量插入失败: " . $e->getMessage();
}
4. 封装一个简单的数据库操作类
- 题目 :基于单例模式(或直接实例化),创建一个
DB类。该类应提供以下方法: __construct()或静态方法getInstance():负责建立PDO连接。query($sql, $params=[]):执行带参数的查询,并返回结果集(数组)。execute($sql, $params=[]):执行带参数的写操作(INSERT/UPDATE/DELETE),并返回影响的行数。lastInsertId():返回最后插入行的ID。- 难度:★★★☆☆
- 提示 :参考实战项目中的
Database类,并扩展其功能。 - 参考思路:(部分代码)
php
class DB {
private $pdo;
// ... 单例或构造方法 ...
public function query($sql, $params = []) {
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
// ... 其他方法 ...
}
// 使用:$db = DB::getInstance(); $users = $db->query("SELECT * FROM users WHERE status=?", [1]);
综合挑战题
5. 实现一个简易的"问答"功能
- 题目:创建一个简单的问答系统,包含两张表:
questions(id,title,content,user_id,created_at)answers(id,content,question_id,user_id,created_at)
你需要实现以下功能页面(需考虑基本的XSS防护,使用htmlspecialchars输出内容):
index.php:列出所有问题(标题、提问者、时间),最新问题在前。ask.php:登录用户(使用Session模拟)可以在此页面提交新问题(表单包含标题和内容)。question.php?id=XXX:显示单个问题的详情及其所有回答。页面底部有一个表单,允许登录用户提交回答。- (进阶)在
index.php实现分页功能。
- 要求:
- 所有数据库操作必须使用PDO和预处理语句。
- 用户ID可以通过Session模拟(硬编码一个用户ID即可)。
- 提交问题和回答时,需插入
user_id和当前时间。 - 难度:★★★★☆
- 提示 :这是对本章及之前章节知识的综合运用。先设计数据库表,然后按功能模块逐个实现。注意SQL中
JOIN的使用来关联用户信息。 - 参考思路 :这是一个小型项目,没有标准答案。建议分模块开发,先完成后台数据操作(创建问题、查询问题列表、创建回答、查询回答列表),再套上前端页面。注意处理未登录用户访问
ask.php和提交回答的情况。
章节总结
本章重点知识回顾
- PDO的优势与连接:理解了PDO作为现代、安全、可移植的数据库扩展的重要性,掌握了建立PDO连接的正确方法(DSN、选项设置)。
- 预处理语句 :这是本章的核心 。你学会了使用
prepare()、execute()以及bindParam()/bindValue()来执行安全的SQL操作,并深刻理解了其防止SQL注入的原理------将SQL代码与数据分离。 - 事务处理 :掌握了事务的概念(ACID)和基本操作(
beginTransaction、commit、rollBack),能够在需要保证数据一致性的业务场景(如转账)中正确应用事务。 - 错误处理 :学会了将PDO设置为异常错误模式,并使用
try...catch块来优雅地处理数据库操作中可能出现的异常,使程序更健壮。 - 实战应用:通过重构用户注册/登录模块和模拟转账项目,你将上述知识融会贯通,体验了如何在实际项目中运用PDO、预处理和事务来构建安全、可靠的数据层。
技能掌握要求
完成本章学习后,你应该能够:
- 熟练地使用PDO连接MySQL数据库并进行配置。
- 在任何涉及数据库操作的场景中,毫不犹豫地使用预处理语句。
- 判断何时需要使用事务,并能够正确编写事务代码。
- 识别并修正使用老式
mysql_*函数或直接拼接SQL的不安全代码。 - 开始尝试用面向对象的思想来组织你的数据库访问代码(如创建工具类)。
进一步学习建议
- 深入PDO :探索PDO更多高级特性,如设置不同的获取模式(
PDO::FETCH_CLASS映射到对象)、处理大数据集(fetch的游标)、调用存储过程等。 - 学习ORM:了解对象关系映射(ORM)概念和流行的PHP ORM库,如Doctrine或Eloquent(Laravel框架内置)。ORM能让你用操作对象的方式来操作数据库,进一步提升开发效率。
- 数据库设计:本章聚焦于操作,下一步可以深入学习数据库设计原则(范式、索引优化、关系设计)。
- 准备下一章 :第3章将聚焦于会话控制(Session/Cookie)和Web安全基础。你将利用本章学到的安全数据库操作,结合Session来构建完整的用户认证系统,并学习防御XSS、CSRF等常见Web攻击。请确保本章的知识已经牢固掌握。