2025 PHP7/8 实战入门:15 天精通现代 Web 开发——第 10 课:数据库基础(PDO 实战)
第 10 课:数据库基础(PDO 实战)
一、学习目标
- 掌握 PHP 数据操作核心扩展 PDO 的使用方法(替代废弃的
mysql_*
函数) - 熟练实现数据库 CRUD(增删改查)操作,理解预处理语句防 SQL 注入原理
- 运用面向对象思想封装数据库操作类,提升代码复用性
- 解决数据库连接、错误处理等实际开发问题
二、核心知识点
(一)PHP 数据库扩展演进与 PDO 优势
扩展对比与选型
- 废弃扩展:
mysql_*
(PHP5.5 废弃,PHP7 完全移除),无预处理,安全性低 - 过渡扩展:
mysqli_*
(支持面向对象和预处理,仅支持 MySQL) - 推荐扩展:
PDO
(PHP Data Objects,PHP5.1+,支持多数据库(MySQL、PostgreSQL 等),强制参数绑定,安全性高)
选型建议:新项目优先使用 PDO,兼顾多数据库兼容性和安全性。
- 废弃扩展:
PDO 核心优势
- 跨数据库兼容性:一套 API 操作多种数据库,切换数据库无需大幅修改代码
- 预处理语句:通过参数绑定防止 SQL 注入,是 Web 开发安全的核心手段
- 面向对象设计:支持链式调用,代码更简洁
- 错误处理:支持异常模式(
ERRMODE_EXCEPTION
),便于统一捕获错误
示例(PDO 与 mysqli 连接对比):
<?php // 1. PDO连接MySQL(推荐) try {$pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', // DSN(数据源名称)'root', // 用户名'', // 密码[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 开启异常模式PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC // 默认关联数组返回]);echo "PDO连接成功<br>"; } catch (PDOException $e) {die("PDO连接失败:" . $e->getMessage()); }// 2. mysqli连接MySQL(仅作对比) $mysqli = new mysqli('localhost', 'root', '', 'shop'); if ($mysqli->connect_error) {die("mysqli连接失败:" . $mysqli->connect_error); } $mysqli->set_charset('utf8mb4'); echo "mysqli连接成功<br>"; ?>
(二)PDO 核心操作(CRUD)
查询操作(Read)
- 核心方法:
query()
(简单查询)、prepare()
+execute()
(预处理查询) - 结果获取方式:
FETCH_ASSOC
:关联数组(推荐,字段名作为键)FETCH_OBJ
:对象(类似原始文档的mysql_fetch_object
)FETCH_COLUMN
:单列数据FETCH_ALL
:获取所有结果(适合小数据集)
示例(预处理查询用户列表):
<?php try {// 承接上文的$pdo连接// 1. 简单查询(无参数时使用)$stmt = $pdo->query("SELECT id, username, email FROM users ORDER BY id DESC");echo "所有用户:<br>";while ($user = $stmt->fetch()) { // 默认FETCH_ASSOCecho "ID:{$user['id']},用户名:{$user['username']},邮箱:{$user['email']}<br>";}// 2. 预处理查询(带参数,防SQL注入)$user_id = 1;$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id"); // :id为命名占位符$stmt->execute(['id' => $user_id]); // 绑定参数并执行$user = $stmt->fetch(); // 获取单条结果if ($user) {echo "<br>ID={$user_id}的用户信息:<br>";print_r($user);} else {echo "<br>未找到ID={$user_id}的用户<br>";}// 3. 多参数预处理(查询年龄大于指定值且角色为member的用户)$min_age = 18;$role = 'member';$stmt = $pdo->prepare("SELECT username, age FROM users WHERE age > ? AND role = ?"); // ?为问号占位符$stmt->execute([$min_age, $role]); // 按顺序绑定参数$members = $stmt->fetchAll(); // 获取所有结果echo "<br>年龄大于{$min_age}的{$role}用户:<br>";print_r($members); } catch (PDOException $e) {echo "查询失败:" . $e->getMessage(); } ?>
- 核心方法:
新增操作(Create)
- 核心:通过预处理语句插入数据,支持获取自增 ID
- 注意:插入字符串、日期等类型无需手动加引号,PDO 自动处理
示例(新增用户并获取自增 ID):
<?php try {$user_data = ['username' => 'php8user','password' => password_hash('123456', PASSWORD_DEFAULT), // 密码加密(PHP5.5+)'email' => 'php8@example.com','age' => 22,'role' => 'member','create_time' => date('Y-m-d H:i:s')];// 预处理插入语句$stmt = $pdo->prepare("INSERT INTO users (username, password, email, age, role, create_time)VALUES (:username, :password, :email, :age, :role, :create_time)");// 执行插入(参数数组与占位符对应)$stmt->execute($user_data);// 获取自增ID(新增用户的ID)$new_user_id = $pdo->lastInsertId();echo "新增用户成功,ID:{$new_user_id}"; } catch (PDOException $e) {echo "新增失败:" . $e->getMessage(); } ?>
更新操作(Update)
- 核心:必须带
WHERE
条件,避免全表更新;通过预处理绑定更新值和条件值
示例(更新用户信息):
<?php try {$update_data = ['email' => 'updated@example.com','age' => 23,'id' => 1 // WHERE条件中的ID];$stmt = $pdo->prepare("UPDATE users SET email = :email, age = :age, update_time = NOW()WHERE id = :id");$stmt->execute($update_data);// 获取受影响的行数$affected_rows = $stmt->rowCount();if ($affected_rows > 0) {echo "更新成功,受影响行数:{$affected_rows}";} else {echo "无数据更新(可能ID不存在或值未变化)";} } catch (PDOException $e) {echo "更新失败:" . $e->getMessage(); } ?>
- 核心:必须带
删除操作(Delete)
- 核心:严格带
WHERE
条件,避免误删全表;建议先查询再删除(可选)
示例(删除指定 ID 用户):
<?php try {$user_id = 10;// 先查询用户是否存在(可选,提升用户体验)$stmt = $pdo->prepare("SELECT id FROM users WHERE id = :id");$stmt->execute(['id' => $user_id]);if (!$stmt->fetch()) {echo "删除失败:ID={$user_id}的用户不存在";exit;}// 执行删除$stmt = $pdo->prepare("DELETE FROM users WHERE id = :id");$stmt->execute(['id' => $user_id]);$affected_rows = $stmt->rowCount();echo "删除成功,受影响行数:{$affected_rows}"; } catch (PDOException $e) {echo "删除失败:" . $e->getMessage(); } ?>
- 核心:严格带
(三)PDO 预处理与防 SQL 注入
SQL 注入原理
- 攻击方式:通过用户输入注入恶意 SQL 片段(如
' OR 1=1 #
),篡改 SQL 逻辑 - 示例(未使用预处理的风险):
// 危险写法:直接拼接用户输入到SQL $username = $_GET['username']; // 假设用户输入:' OR 1=1 # $sql = "SELECT * FROM users WHERE username = '{$username}'"; // 最终SQL:SELECT * FROM users WHERE username = '' OR 1=1 #' // 后果:查询出所有用户数据,导致信息泄露
- 攻击方式:通过用户输入注入恶意 SQL 片段(如
PDO 预处理防注入机制
- 核心:SQL 语句与参数分离,参数由 PDO 自动转义,恶意字符失去作用
- 两种占位符:
- 命名占位符(
:name
):适合参数较多的场景,可读性高 - 问号占位符(
?
):适合参数较少的场景,简洁
- 命名占位符(
示例(预处理防注入对比):
<?php // 恶意输入 $malicious_input = "' OR 1=1 #";// 1. 危险写法(拼接SQL) $sql_dangerous = "SELECT * FROM users WHERE username = '{$malicious_input}'"; echo "危险SQL:{$sql_dangerous}<br>"; // 输出:SELECT * FROM users WHERE username = '' OR 1=1 #'(会查询所有用户)// 2. 安全写法(PDO预处理) $stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username"); $stmt->execute(['username' => $malicious_input]); $result = $stmt->fetchAll(); echo "预处理查询结果数:" . count($result) . "<br>"; // 输出:0(PDO将恶意输入转义为普通字符串,无匹配用户) ?>
PDO 安全配置要点
- 必须开启异常模式:
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
,便于捕获错误 - 禁用模拟预处理:
PDO::ATTR_EMULATE_PREPARES => false
(强制数据库原生预处理,部分旧版本 MySQL 需开启) - 显式设置字符集:DSN 中添加
charset=utf8mb4
(支持 emoji 表情,避免中文乱码)
- 必须开启异常模式:
(四)面向对象封装数据库操作类
基础封装思路
- 封装连接参数(主机、数据库名、用户名、密码)
- 提供 CRUD 通用方法(
query
、insert
、update
、delete
) - 统一错误处理(通过异常捕获)
- 支持事务操作(复杂业务场景必备)
实战封装示例(PHP8 特性优化)
<?php class DB {// 单例模式:确保只创建一个PDO实例(节省资源)private static ?PDO $pdo = null;// 数据库配置(可从配置文件读取)private const CONFIG = ['host' => 'localhost','dbname' => 'shop','username' => 'root','password' => '','charset' => 'utf8mb4'];// 初始化PDO连接private static function init(): void {if (self::$pdo !== null) {return; // 已连接,直接返回}$dsn = "mysql:host=" . self::CONFIG['host'] . ";dbname=" . self::CONFIG['dbname'] . ";charset=" . self::CONFIG['charset'];$options = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,PDO::ATTR_EMULATE_PREPARES => false // 禁用模拟预处理];try {self::$pdo = new PDO($dsn, self::CONFIG['username'], self::CONFIG['password'], $options);} catch (PDOException $e) {die("数据库连接失败:" . $e->getMessage());}}// 通用查询方法(返回单条结果)public static function getOne(string $sql, array $params = []): array|null {self::init();$stmt = self::$pdo->prepare($sql);$stmt->execute($params);return $stmt->fetch() ?: null;}// 通用查询方法(返回多条结果)public static function getAll(string $sql, array $params = []): array {self::init();$stmt = self::$pdo->prepare($sql);$stmt->execute($params);return $stmt->fetchAll();}// 新增方法(返回自增ID)public static function insert(string $table, array $data): int|false {self::init();$columns = implode(', ', array_keys($data));$placeholders = ':' . implode(', :', array_keys($data));$sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})";try {$stmt = self::$pdo->prepare($sql);$stmt->execute($data);return (int)self::$pdo->lastInsertId();} catch (PDOException $e) {echo "插入失败:" . $e->getMessage();return false;}}// 更新方法(返回受影响行数)public static function update(string $table, array $data, array $where): int {self::init();$set_clause = [];foreach (array_keys($data) as $key) {$set_clause[] = "{$key} = :{$key}";}$set_clause = implode(', ', $set_clause);$where_clause = [];$where_params = [];foreach ($where as $key => $value) {$where_key = "where_{$key}";$where_clause[] = "{$key} = :{$where_key}";$where_params[$where_key] = $value;}$where_clause = implode(' AND ', $where_clause);$sql = "UPDATE {$table} SET {$set_clause} WHERE {$where_clause}";$params = array_merge($data, $where_params);try {$stmt = self::$pdo->prepare($sql);$stmt->execute($params);return $stmt->rowCount();} catch (PDOException $e) {echo "更新失败:" . $e->getMessage();return 0;}}// 删除方法(返回受影响行数)public static function delete(string $table, array $where): int {self::init();$where_clause = [];$params = [];foreach ($where as $key => $value) {$where_clause[] = "{$key} = :{$key}";$params[$key] = $value;}$where_clause = implode(' AND ', $where_clause);$sql = "DELETE FROM {$table} WHERE {$where_clause}";try {$stmt = self::$pdo->prepare($sql);$stmt->execute($params);return $stmt->rowCount();} catch (PDOException $e) {echo "删除失败:" . $e->getMessage();return 0;}}// 事务开始public static function beginTransaction(): void {self::init();self::$pdo->beginTransaction();}// 事务提交public static function commit(): void {self::init();self::$pdo->commit();}// 事务回滚public static function rollBack(): void {self::init();self::$pdo->rollBack();} }// 类使用示例 // 1. 查询 $user = DB::getOne("SELECT username, email FROM users WHERE id = :id", ['id' => 1]); echo "查询用户:<br>"; print_r($user);// 2. 新增 $new_id = DB::insert('users', ['username' => 'pdouser','password' => password_hash('654321', PASSWORD_DEFAULT),'email' => 'pdo@example.com','age' => 25,'role' => 'member','create_time' => date('Y-m-d H:i:s') ]); echo "<br>新增用户ID:{$new_id}";// 3. 更新 $update_rows = DB::update('users', ['age' => 26], // 要更新的数据['id' => $new_id] // WHERE条件 ); echo "<br>更新行数:{$update_rows}";// 4. 删除 $delete_rows = DB::delete('users', ['id' => $new_id]); echo "<br>删除行数:{$delete_rows}"; ?>
三、注意事项
数据库连接问题
- 避免在脚本中硬编码数据库密码,建议存放在独立配置文件(如
config/db.php
),并限制文件访问权限 - 生产环境中禁用 root 用户连接数据库,创建专用数据库用户并分配最小权限(如仅
SELECT
/INSERT
权限) - 长时间运行的脚本(如定时任务)需处理连接超时问题,可通过
PDO::ATTR_TIMEOUT
设置超时时间
- 避免在脚本中硬编码数据库密码,建议存放在独立配置文件(如
字符集与编码
- 必须在 DSN 中显式指定
charset=utf8mb4
(而非utf8
),utf8mb4
支持完整的 UTF-8 编码(包括 emoji 表情) - 数据库表和字段的编码也需设置为
utf8mb4
,避免中文乱码
- 必须在 DSN 中显式指定
事务使用场景
- 当一个业务需要执行多个 SQL 操作(如转账:扣减 A 账户→增加 B 账户),必须使用事务确保原子性(要么全成功,要么全失败)
- 事务中若执行
SELECT FOR UPDATE
(行锁),需注意避免死锁,控制事务执行时间
性能优化
- 高频查询字段需建立索引(如
users
表的id
、username
字段) - 避免
SELECT *
,只查询需要的字段,减少数据传输量 - 大量数据查询使用分页(
LIMIT offset, count
),避免一次性加载过多数据
- 高频查询字段需建立索引(如
四、实战练习
创建
day10
文件夹,新建user_crud.php
文件:- 基于上文封装的
DB
类,实现一个完整的用户管理功能,包含:- 用户列表页:分页显示所有用户(每页 10 条,使用
LIMIT
实现),显示用户名、邮箱、年龄、角色、创建时间 - 用户添加页:表单提交用户名、密码、邮箱、年龄、角色,密码需用
password_hash
加密,表单需验证(非空、邮箱格式等) - 用户编辑页:根据 ID 查询用户信息并回显到表单,支持修改邮箱、年龄、角色(不允许修改用户名和密码)
- 用户删除功能:点击删除按钮时弹出确认框,确认后执行删除(需防误删,可先查询用户信息确认)
- 所有操作需通过 PDO 预处理实现,确保防 SQL 注入
- 用户列表页:分页显示所有用户(每页 10 条,使用
- 基于上文封装的
新建
order_transaction.php
文件:- 模拟电商订单创建场景,使用事务确保数据一致性:
- 业务逻辑:创建订单(
orders
表插入)→ 扣减商品库存(products
表更新)→ 记录订单商品(order_products
表插入) - 要求:三个操作必须全部成功,若任意一步失败则回滚所有操作
- 模拟失败场景:故意设置库存不足,观察事务是否回滚(库存不变,订单不创建)
- 记录操作日志:成功 / 失败信息写入
logs/order.log
,包含时间、订单号、操作结果
- 业务逻辑:创建订单(
- 模拟电商订单创建场景,使用事务确保数据一致性: