防止 SQL 注入的 PHP MySQLi 准备语句教程

PHP MySQLi Prepared Statements Tutorial to Prevent SQL Injection

介绍

在我开始之前,如果您想了解使用 MySQLi 准备好的语句的更简单方法,请查看我的 wrapper class。此外,这里是学习PDO 准备语句的好资源,对于初学者和大多数人来说,这是更好的选择。

最近发现了一次黑客攻击,似乎他们正试图摧毁整个数据库。凌晨 2 点召开了临时员工会议,公司里的每个人都吓坏了。具有讽刺意味的是,作为数据库管理员,您仍然是最冷静的。为什么?您知道这些磨砂与您编写的那些准备好的语句不匹配!事实上,你会觉得这很幽默,因为这些黑客可能会因为他们在徒劳的尝试上浪费时间而感到恼火。

希望这种情况永远不会发生在您的网站上。但是,采取适当的预防措施无疑是一个好主意。如果正确实施,准备好的语句(也称为参数化查询)可以提供出色的 SQL 注入保护。您基本上只需使用占位符值创建查询模板,然后将虚拟输入替换为真实输入。转义不是必需的,因为它将值视为文字;所有注入 sql 查询的尝试都将被解释为这样。

准备好的陈述一开始可能看起来很吓人,但一旦你掌握了它,它就会成为你的第二天性。本教程的目标是将那些对准备好的陈述知之甚少或一无所知的人转变为专家。

免责声明:实际上不要像这个数据库管理器那样悠闲。谈到安全性,无论您认为自己的系统有多安全,您都不应自满。

SQL 注入的工作原理

以下标志性的漫画,称为Bobby Tables,是 SQL 注入攻击如何工作的绝佳写照。图片的所有功劳都归功于这个经典的言辞。

现在我们已经完成了理论,让我们开始实践。在我开始之前,如果您想知道“ Bobby Tables Attack”究竟是如何工作的,请查看此说明

在一个普通的 MySQL 调用中,您会执行以下操作:

$name = $_POST['name'];
$mysqli->query("SELECT * FROM myTable WHERE name='$name'");
复制

这样做的问题是,如果它是基于用户输入的,就像在示例中一样,那么恶意用户可以执行' OR '1'='1. 现在这个语句将始终评估为真,因为1=1. 在这种情况下,恶意用户现在可以访问您的整个表。想象一下如果它是一个DELETE查询会发生什么。看看声明中实际发生了什么。

SELECT * FROM myTable WHERE name='' OR '1'='1' 
复制

如果您的查询是这样设置的,黑客可能会对您的网站造成很大破坏。一个简单的解决方法是:

$name = $mysqli->real_escape_string($_POST['name']);
$mysqli->query("SELECT * FROM myTable WHERE name='$name'");
复制

请注意,与第一个示例非常相似,我仍然在列值中添加了引号。如果没有引号,字符串仍然同样容易受到 SQL 注入的影响。如果您将使用 LIKE 子句,那么您也应该这样做addcslashes($escaped, '%_'),因为mysqli::real_escape_string不会像这里所说的那样这样做。

正如函数名所暗示的那样,这涵盖了字符串,但是数字呢?你可以这样做(int)$mysqli->real_escape_string($_POST['name']),这肯定会奏效,但这是多余的。如果您将变量转换为 int,则无需转义任何内容。您已经告诉它本质上确保该值是一个整数。做(int)$_POST['name']就够了。由于它是一个整数,显然您也不需要在 sql 列中添加引号name

实际上,如果您完全按照这些说明进行操作,那么mysqli::real_escape_string对于字符串和(int)$var整数应该就足够了。只是不要忘记设置默认字符集。这可以在 php.ini (应该是默认值)中设置,也可以在每个default_charset = "utf-8"使用. 但仅适用于准备好的语句中合法的东西,即 WHERE 语句中的值或列不要将其用于表/列或 SQL 关键字$mysqli->set_charset('utf8mb4')$mysqli->real_escape_string()

无论如何,我仍然强烈建议使用准备好的语句,因为它们显然更适合防止 SQL 注入并且不太容易出错,因为您不必担心手动格式化——相反,您只需用您的值替换虚拟占位符. 当然,您仍然需要过滤和清理您的输入以防止 XSS。使用准备好的语句语句,需要考虑的方面更少,还有一些需要打破的边缘情况 $mysqli->real_escape_string()(未正确设置字符集是原因之一。)。总之,绝对没有充分的理由使用real_escape_string()过度准备的语句。我只是展示了如何使用它手动格式化您的查询,以表明它是可能的。实际上,不使用准备好的语句来防止 SQL 注入是愚蠢的。

MySQLi 准备好的语句如何工作

简单来说,这就是 MySQLi 准备好的语句在 PHP 中的工作方式:

  1. 准备一个使用空值作为占位符的 SQL 查询(每个值都有一个问号)。
  2. 通过说明每个变量及其类型,将变量绑定到占位符。
  3. 执行查询。

允许的四种变量类型:

  • i - Integer
  • d - Double
  • s - String
  • b - Blob

顾名思义,准备好的语句是一种准备 MySQL 调用的方法,而不存储变量。你告诉它变量最终会到达那里——只是还没有。证明它的最好方法是通过例子。

$stmt = $mysqli->prepare("SELECT * FROM myTable WHERE name = ? AND age = ?");
$stmt->bind_param("si", $_POST['name'], $_POST['age']);
$stmt->execute();
//fetching result would go here, but will be covered later
$stmt->close();
复制

如果您以前从未见过准备好的语句,这可能看起来有点奇怪。基本上发生的事情是您正在为 SQL 语句创建一个模板。在这种情况下,我们从myTable、 wherenameageequal中选择所有内容?。问号只是值将去哪里的占位符。

bind_param()方法是将变量附加到准备模板中的虚拟值的地方。请注意变量之前的引号中有两个字母。这告诉数据库变量类型。s指定 name 将是一个字符串值,而强制iage 是一个整数。这正是我没有在名称的问号周围添加引号的原因,就像我通常在 SQL 调用中为字符串添加引号一样。你可能以为我只是忘了,但事实是根本没有必要(事实上,如果你在 周围加上引号,它实际上是行不通的?,因为它将被视为字符串文字,而不是虚拟占位符。)。您已经告诉它,当您调用时它将是一个字符串文字bind_param(),因此即使恶意用户尝试将 SQL 插入到您的用户输入中,它仍然会被视为字符串。$stmt->execute()然后实际运行代码;最后一行只是关闭准备好的语句。我们将在 Select 部分介绍获取结果。

创建新的 MySQLi 连接

创建一个新的 MySQLi 非常简单。我建议命名一个名为的文件mysqli_connect.php并将该文件直接放在根目录之外(html、public_html),这样您的凭据是安全的。我们还将使用异常处理,通过利用mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT). 这对您来说可能看起来很奇怪,尤其是如果您以前从未使用过位运算符。但它所做的只是报告所有错误,同时使用mysqli_sql_exception 类将它们转换为异常。

$mysqli = new mysqli("localhost", "username", "password", "databaseName");
if($mysqli->connect_error) {
  exit('Error connecting to database'); //Should be a message a typical user could understand in production
}
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$mysqli->set_charset("utf8mb4");
复制

很多教程,包括 PHP 手册,$mysqli->connect_error()通过将其打印在exit()die(). 但这并不是真正必要的(更不用说非常愚蠢,因为您将向全世界打印此信息),因为错误消息将附加到您的错误日志中。中的消息exit()应该是普通用户可以理解的,例如exit('Something weird happened').

你会认为utf-8在你的 php.ini 中设置字符集就足够了,utf8mb4对你的整个数据库也一样,但是如果你不在你的 php 文件中设置它,有时会发生奇怪的错误,如此处所述

如果您启用内部报告,您也可以在 try/catch 块中实例化它,我在错误处理部分中提到了这一点。请不要在生产中直接在您的站点上报告错误。你会因为这样一个愚蠢的错误而自责,因为它会打印出你敏感的数据库信息(用户名、密码和数据库名称)。这是您的 php.ini 文件在生产中的样子:同时执行display_errors = Offlog_errors = On。另外,不要在生产中回显错误。

mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
try {
  $mysqli = new mysqli("localhost", "username", "password", "databaseName");
  $mysqli->set_charset("utf8mb4");
} catch(Exception $e) {
  error_log($e->getMessage());
  exit('Error connecting to database'); //Should be a message a typical user could understand
}
复制

如果您更喜欢使用set_exception_handler()而不是try/catch,您可以执行以下操作以避免嵌套。如果您使用此方法,您需要了解它会影响其包含的每个页面。因此,您必须再次使用该函数并为每个页面添加自定义消息,或者使用restore_exception_handler()恢复为内置PHP 一。如果您制作了多个,它将​​转到您制作的上一个。

mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
set_exception_handler(function($e) {
  error_log($e->getMessage());
  exit('Error connecting to database'); //Should be a message a typical user could understand
});
$mysqli = new mysqli("localhost", "username", "password", "databaseName");
$mysqli->set_charset("utf8mb4");
复制

使用 有一个非常严重的影响mysqli_report(),那就是它会报告您的敏感数据库信息。您有三个选项可以继续使用它但报告您的密码。

  1. 除了创建连接之外,您还可以mysqli_report()严格使用所有内容,如果您使用我展示的第一种方法(密码未显示)并放在.$mysqli->connect_errormysqli_report() new mysqli()
  2. 如果您在创建连接mysqli_report() 之前调用,那么您需要确保它在 try/catch 块中,并且您专门在错误日志中打印$e->getMessage(),而不是$e,它仍然包含您的敏感信息。这显然严格适用于构造函数。
  3. set_exception_handler()以与 2 相同的方式使用并使用$e->getMessage().

我强烈建议使用其中一种方法。即使您勤奋并确保所有错误都只出现在您的错误日志中,我个人也不明白为什么有人需要记录他们的密码。无论如何,您已经知道问题所在。

插入、更新和删除

插入、更新和删除具有相同的语法,因此它们将被合并。

插入

$stmt = $mysqli->prepare("INSERT INTO myTable (name, age) VALUES (?, ?)");
$stmt->bind_param("si", $_POST['name'], $_POST['age']);
$stmt->execute();
$stmt->close();
复制

更新

$stmt = $mysqli->prepare("UPDATE myTable SET name = ? WHERE id = ?");
$stmt->bind_param("si", $_POST['name'], $_SESSION['id']);
$stmt->execute();
$stmt->close();
复制

删除

$stmt = $mysqli->prepare("DELETE FROM myTable WHERE id = ?");
$stmt->bind_param("i", $_SESSION['id']);
$stmt->execute();
$stmt->close();
复制

获取受影响的行数

您可能还想检查您插入、更新或删除的行的状态。如果您要更新一行,这就是您的做法。

$stmt = $mysqli->prepare("UPDATE myTable SET name = ?");
$stmt->bind_param("si", $_POST['name'], $_POST['age']);
$stmt->execute();
if($stmt->affected_rows === 0) exit('No rows updated');
$stmt->close();
复制

在这种情况下,我们检查是否有任何行被更新。mysqli::$affected_rows作为参考,这里是返回值的用法。

-1 - 查询返回错误;如果已经存在错误处理,则冗余execute()

0 - 没有更新记录UPDATE,没有匹配WHERE子句的行或没有执行查询

大于 0 - 返回受影响的行数;相当于mysqli_result::$num_rows_SELECT

获取匹配的行

一个常见的问题$mysqli->affectedRows是它无法知道为什么它在 UPDATE 上返回零。这是因为它会打印更改的行数,因此如果您使用相同的数据更新您的值,它会变得模棱两可。

MySQLi 独有的一个很棒的功能,在 PDO 中不存在,它可以获取有关查询的更多信息。技术上可以在PDO中实现,但只能在连接中实现,所以不能选择。

$stmt = $mysqli->prepare("UPDATE myTable SET name = ?");
$stmt->bind_param("si", $_POST['name'], $_POST['age']);
$stmt->execute();
$stmt->close();
echo $mysqli->info;
复制

这将打印:

Rows matched: 1 Changed: 0 Warnings: 0

我发现这是一个相当不谨慎的实现,因为按原样使用它非常不雅。幸运的是,我们可以通过将其转换为关联数组来改变它。所有功劳都归功于这个对PHP 文档很有帮助的评论者。虽然使用mysqli->infoUPDATE 是迄今为止最常见的用例,但它也可以用于其他一些查询类型

preg_match_all('/(\S[^:]+): (\d+)/', $mysqli->info, $matches); 
$infoArr = array_combine ($matches[1], $matches[2]);
var_export($infoArr);
复制

现在这将输出一个数组。

['Rows matched' => '1', 'Changed' => '0', 'Warnings' => '0']
复制

获取插入的最新主键

$stmt = $mysqli->prepare("INSERT INTO myTable (name, age) VALUES (?, ?)");
$stmt->bind_param("si", $_POST['name'], $_POST['age']);
$stmt->execute();
echo $mysqli->insert_id;
$stmt->close();
复制

检查是否重复输入

如果您要在表上创建唯一约束,这很有用,因此不允许重复。您甚至可以对多列执行此操作,因此它必须是精确的排列。如果异常处理已关闭,您将使用$mysqli->errno. 打开异常处理后,您可以在该异常方法或通用异常方法之间进行选择$e->getCode()。请注意,这与 PDOException 不同,后者将打印 SQLSTATE,而不是错误代码。

这是错误消息列表。来自更新或插入的重复行条目的错误代码是1062并且 SQLSTATE 是23000。要专门检查 SQLSTATE,您必须使用$mysqli->sqlstate.

try {
  $stmt = $mysqli->prepare("INSERT INTO myTable (name, age) VALUES (?, ?)");
  $stmt->bind_param("si", $_POST['name'], $_POST['age']);
  $stmt->execute();
  $stmt->close();
} catch(Exception $e) {
  if($mysqli->errno === 1062) echo 'Duplicate entry';
}
复制

这是设置唯一约束的方式:

ALTER TABLE myTable ADD CONSTRAINT unique_person UNIQUE (name, age)
复制

选择

参数化查询中的所有选择语句都将以相同的方式开始。但是,实际存储和获取结果有一个关键区别。存在的两种方法是get_result()bind_result()

获取结果()

这是两者中更通用的一种,因为它可以用于任何场景。需要注意的是,这需要 mysqlnd,它从 5.3 开始就包含在 PHP 中,并且从 5.4 开始一直是默认的本地驱动程序,如此处所述。我怀疑很多人使用的是旧版本,所以你通常应该坚持使用get_result().

这实质上暴露了常规的、未准备好的 mysqli_result api。意思是,一旦你这样做了$result = get_result(),你就可以像使用它一样使用它$result = $mysqli->query()

现在,您可以使用以下方法一次或一次获取一行。这里只是一些最常见的,但您可以查看整个 mysqli_result 类的所有方法。

一排

  • $result->fetch_assoc()- 获取关联数组
  • $result->fetch_row()- 获取数值数组
  • $result->fetch_object()- 获取对象数组

全部

  • $result->fetch_all(MYSQLI_ASSOC)- 获取关联数组
  • $result->fetch_all(MYSQLI_NUM)- 获取数值数组
$stmt = $mysqli->prepare("SELECT * FROM myTable WHERE name = ?");
$stmt->bind_param("s", $_POST['name']);
$stmt->execute();
$result = $stmt->get_result();
if($result->num_rows === 0) exit('No rows');
while($row = $result->fetch_assoc()) {
  $ids[] = $row['id'];
  $names[] = $row['name'];
  $ages[] = $row['age'];
}
var_export($ages);
$stmt->close();
复制

输出:

[22, 18, 19, 27, 36, 7]
复制

绑定结果()

您可能想知道,为什么还要使用bind_result()? 我个人发现它在每种情况下都远不如它get_result(),除非将单行提取到单独的变量中。此外,在get_result()mysqlnd 存在并且内置于 PHP 之前,这是您唯一的选择,这就是为什么许多遗留代码可能正在使用它的原因。

使用最烦人的部分bind_result()是您必须绑定您选择的每一列,然后循环遍历这些值。对于过多的值或与 一起使用,这显然不是理想的*。星形选择器与 一起使用特别烦人bind_result(),因为如果不查看数据库,您甚至不知道这些值是什么。此外,这会使您的代码因表的更改而变得非常难以维护。这通常无关紧要,因为无论如何您都不应该在生产模式下使用通配符选择器(但您知道自己是)。

$stmt = $mysqli->prepare("SELECT id, name, age FROM myTable WHERE name = ?");
$stmt->bind_param("s", $_POST['name']);
$stmt->execute();
$stmt->store_result();
if($stmt->num_rows === 0) exit('No rows');
$stmt->bind_result($idRow, $nameRow, $ageRow); 
while($stmt->fetch()) {
  $ids[] = $idRow;
  $names[] = $nameRow;
  $ages[] = $ageRow;
}
var_export($ids);
$stmt->close();
复制

输出:

[106, 221, 3, 55, 583, 72]
复制

获取关联数组

我发现这通常是最常见的用例。我还将在下面使用链接,尽管这显然不是必需的。

$stmt = $mysqli->prepare("SELECT id, name, age FROM myTable WHERE name = ?");
$stmt->bind_param("s", $_POST['name']);
$stmt->execute();
$arr = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
if(!$arr) exit('No rows');
var_export($arr);
$stmt->close();
复制

如果您需要修改结果集,那么您可能应该使用 while 循环fetch_assoc()并一次获取每一行。

$arr = [];
$stmt = $mysqli->prepare("SELECT id, name, age FROM myTable WHERE name = ?");
$stmt->bind_param("s", $_POST['name']);
$stmt->execute();
$result = $stmt->get_result();
while($row = $result->fetch_assoc()) {
  $arr[] = $row;
}
if(!$arr) exit('No rows');
var_export($arr);
$stmt->close();
复制

输出:

[
  ['id' => 27, 'name' => 'Jessica', 'age' => 27], 
  ['id' => 432, 'name' => 'Jimmy', 'age' => 19]
]
复制

您实际上也可以使用它来执行此操作bind_result(),尽管它显然不是为它设计的。这是一个聪明的解决方案,虽然我个人觉得这很酷,知道是可能的,但实际上不应该使用。

获取数值数组

这遵循与关联数组相同的格式。要在一个命令中获取整个数组,无需循环,您可以使用mysqli_result->fetch_all(MYSQLI_NUM). 如果需要循环获取结果,则必须使用mysqli_result->fetch_row().

$stmt = $mysqli->prepare("SELECT location, favorite_color, age FROM myTable WHERE name = ?");
$stmt->bind_param("s", $_POST['name']);
$stmt->execute();
$arr = $stmt->get_result()->fetch_all(MYSQLI_NUM);
if(!$arr) exit('No rows');
var_export($arr);
$stmt->close();
复制

当然,还有 while 循环适配。

$arr = [];
$stmt = $mysqli->prepare("SELECT location, favorite_color, age FROM myTable WHERE name = ?");
$stmt->bind_param("s", $_POST['name']);
$stmt->execute();
$result = $stmt->get_result();
while($row = $result->fetch_row()) {
  $arr[] = $row;
}
if(!$arr) exit('No rows');
var_export($arr);
$stmt->close();
复制

输出:

[
  ['Boston', 'green', 28], 
  ['Seattle', 'blue', 49],
  ['Atlanta', 'pink', 24]
]
复制

获取单行

bind_result()当我知道我只会获取一行时,我个人觉得使用起来更简单,因为我可以以更简洁的方式访问变量。

$stmt = $mysqli->prepare("SELECT id, name, age FROM myTable WHERE name = ?");
$stmt->bind_param("s", $_POST['name']);
$stmt->execute();
$stmt->store_result();
if($stmt->num_rows === 0) exit('No rows');
$stmt->bind_result($id, $name, $age);
$stmt->fetch();
echo $name; //Output: 'Ryan'
$stmt->close();
复制

现在您可以简单地使用 in 中的变量bind_result()$name因为您知道它们将只包含一个值,而不是数组。

这是get_result()版本:

$stmt = $mysqli->prepare("SELECT id, name, age FROM myTable WHERE name = ?");
$stmt->bind_param("s", $_POST['name']);
$stmt->execute();
$arr = $stmt->get_result()->fetch_assoc();
if(!$arr) exit('No rows');
var_export($arr);
$stmt->close();
复制

然后,您将使用该变量作为$arr['id']示例。

输出:

['id' => 36, 'name' => 'Kevin', 'age' => 39]
复制

获取对象数组

这与获取关联数组非常相似。唯一的主要区别是您将像访问它一样访问它$arr[0]->age。此外,如果您不知道,对象是按值传递的,而数组是按引用传递的。

$arr = []
$stmt = $mysqli->prepare("SELECT id, name, age FROM myTable WHERE id = ?");
$stmt->bind_param("s", $_SESSION['id']);
$stmt->execute();
$result = $stmt->get_result();
while($row = $result->fetch_object()) {
  $arr[] = $row;
}
if(!$arr) exit('No rows');
var_export($arr);
$stmt->close();
复制

输出:

[
  stdClass Object ['id' => 27, 'name' => 'Jessica', 'age' => 27], 
  stdClass Object ['id' => 432, 'name' => 'Jimmy', 'age' => 19]
]
复制

您甚至可以将属性值添加到现有类。但是,应该注意的是,根据PHP 文档中的此评论,存在潜在的问题。问题是,如果您的构造函数中有一个具有重复变量名称的默认值,它将首先获取对象然后设置构造函数值,从而覆盖获取的结果。奇怪的是,从 PHP 5.6.21 到 7.0.6 有一个“错误”,这不会发生。尽管这违反了 OOP 的原则,但有些人会喜欢这个功能,即使它在某些版本中是错误的。类似PDO::FETCH_PROPS_LATEPDO 的东西应该在 MySQLi 中实现,让你有选择的余地。

class myClass {}
$arr = [];
$stmt = $mysqli->prepare("SELECT id, name, age FROM myTable WHERE id = ?");
$stmt->bind_param("s", $_SESSION['id']);
$stmt->execute();
$result = $stmt->get_result();
while($row = $result->fetch_object('myClass')) {
  $arr[] = $row;
}
if(!$arr) exit('No rows');
var_export($arr);
$stmt->close();
复制

正如评论所述,这就是您正确执行此操作的方式。你只需要一个简单的 if 条件来检查变量是否等于构造函数的值——如果不等于,就不要在构造函数中设置它。PDO::FETCH_PROPS_LATE这与在 PDO中使用基本相同。

class myClass {
  private $id;
  public function __construct($id = 0) {
    if($this->id === 0) $this->id = $id;
  }
}
$arr = [];
$stmt = $mysqli->prepare("SELECT id, name, age FROM myTable WHERE id = ?");
$stmt->bind_param("s", $_SESSION['id']);
$stmt->execute();
$result = $stmt->get_result();
while($row = $result->fetch_object('myClass')) {
  $arr[] = $row;
}
if(!$arr) exit('No rows');
var_export($arr);
$stmt->close();
复制

另一个意想不到但可能有用的使用行为fetch_object('myClass')是您可以修改私有变量。我真的不确定我对此有何感受,因为这似乎违反了封装原则。

结论

bind_result() - 最好用于获取没有太多列的单行或*;对于关联数组非常不优雅。

get_result() - 几乎是每个用例的首选。

LIKE

您可能会认为您可以执行以下操作:

$stmt = $mysqli->prepare("SELECT id, name, age FROM myTable WHERE Name LIKE %?%"); 
复制

但这是不允许的。?占位符必须是整个字符串或整数文字值。这就是您正确执行此操作的方式。

$search = "%{$_POST['search']}%";
$stmt = $mysqli->prepare("SELECT id, name, age FROM myTable WHERE name LIKE ?"); 
$stmt->bind_param("s", $search);
$stmt->execute();
$arr = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
if(!$arr) exit('No rows');
var_export($arr);
$stmt->close();
复制

数组中的位置

这绝对是我希望在 MySQLi 中看到的改进。目前,使用 MySQLi 准备好的语句WHERE IN是可能的,但感觉有点啰嗦。

旁注:以下两个示例使用splat 运算符进行参数解包,这需要 PHP 5.6+。如果您使用的是低于该版本的版本,则可以将其替换为call_user_func_array().

$inArr = [12, 23, 44];
$clause = implode(',', array_fill(0, count($inArr), '?')); //create 3 question marks
$types = str_repeat('i', count($inArr)); //create 3 ints for bind_param
$stmt = $mysqli->prepare("SELECT id, name FROM myTable WHERE id IN ($clause)");
$stmt->bind_param($types, ...$inArr);
$stmt->execute();
$resArr = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
if(!$resArr) exit('No rows');
var_export($resArr);
$stmt->close();
复制

与其他占位符

第一个示例展示了如何WHERE IN仅在其中使用带有虚拟占位符的子句。如果您想在不同的地方使用其他占位符怎么办?

$inArr = [12, 23, 44];
$clause = implode(',', array_fill(0, count($inArr), '?')); //create 3 question marks
$types = str_repeat('i', count($inArr)); //create 3 ints for bind_param
$types .= 'i'; //add 1 more int type
$fullArr = array_merge($inArr, [26]); //merge WHERE IN array with other value(s)
$stmt = $mysqli->prepare("SELECT id, name FROM myTable WHERE id IN ($clause) AND age > ?");
$stmt->bind_param($types, ...$fullArr); //4 placeholders to bind
$stmt->execute();
$resArr = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
if(!$resArr) exit('No rows');
var_export($resArr);
$stmt->close();
复制

事务中的多个准备好的语句

这可能看起来很奇怪,为什么它甚至会保证自己的部分,因为您实际上可以一个接一个地使用准备好的语句。虽然这肯定会起作用,但这并不能确保您的查询是原子的。这意味着,如果您要运行 10 个查询,其中一个失败,其他 9 个仍然会成功。如果您希望您的 SQL 查询仅在它们都成功时执行,那么您必须使用事务。

try {
  $mysqli->autocommit(FALSE); //turn on transactions
  $stmt1 = $mysqli->prepare("INSERT INTO myTable (name, age) VALUES (?, ?)");
  $stmt2 = $mysqli->prepare("UPDATE myTable SET name = ? WHERE id = ?");
  $stmt1->bind_param("si", $_POST['name'], $_POST['age']);
  $stmt2->bind_param("si", $_POST['name'], $_SESSION['id']);
  $stmt1->execute();
  $stmt2->execute();
  $stmt1->close();
  $stmt2->close();
  $mysqli->autocommit(TRUE); //turn off transactions + commit queued queries
} catch(Exception $e) {
  $mysqli->rollback(); //remove all queries from queue if error (undo)
  throw $e;
}
复制

重用相同的模板,不同的值

try {
  $mysqli->autocommit(FALSE); //turn on transactions
  $stmt = $mysqli->prepare("INSERT INTO myTable (name, age) VALUES (?, ?)");
  $stmt->bind_param("si", $name, $age);
  $name = 'John';
  $age = 21;
  $stmt->execute();  
  $name = 'Rick';
  $age = 24;
  $stmt->execute();
  $stmt->close();
  $mysqli->autocommit(TRUE); //turn off transactions + commit queued queries
} catch(Exception $e) {
  $mysqli->rollback(); //remove all queries from queue if error (undo)
  throw $e;
}
复制

错误处理

致命错误:未捕获的错误:在布尔值上调用成员函数 bind_param()

任何使用过 MySQLi 准备好的语句的人都曾在某个时候看到过此消息,但它是什么意思?几乎什么都没有。那么你如何解决这个问题,你可能会问?首先,不要忘记在创建新连接mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT)时打开异常处理,而不是错误处理。

异常处理

所有 mysqli 函数在失败时都返回 false,因此您可以轻松地检查每个函数的真实性并使用$mysqli->error. 但是,这非常繁琐,如果您启用内部报告,还有一种更优雅的方式来执行此操作。我建议这样做,因为它更便于从开发到生产。

只要您为所有错误设置了错误日志,这也可以在生产中使用;这需要在 php.ini 中设置。请不要在生产中直接在您的站点上报告错误。你会因为这样一个愚蠢的错误而自责。安置mysqli_report()事宜也。如果您在创建新连接之前放置它,那么它也会输出您的密码;否则,它只会在之后报告所有内容,例如您的查询。

这是您的 php.ini 文件在生产中的样子:同时执行display_errors = Offlog_errors = On。另外,请记住,每个页面实际上应该只使用单个全局try/catch块,而不是单独包装每个查询。唯一的例外是事务,它是嵌套的,但会抛出自己的异常,因此全局try/catch可以“捕获”它。

try {
  $stmt = $mysqli->prepare("DELETE FROM myTable WHERE id = ?");
  $stmt->bind_param("i", $_SESSION['id']);
  $stmt->execute();
  $stmt->close();

  $stmt = $mysqli->prepare("SELECT id, name, age FROM myTable WHERE name = ?");
  $stmt->bind_param("s", $_POST['name']);
  $stmt->execute();
  $arr = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
  $stmt->close();

  try {
    $mysqli->autocommit(FALSE); //turn on transactions
    $stmt = $mysqli->prepare("INSERT INTO myTable (name, age) VALUES (?, ?)");
    $stmt->bind_param("si", $name, $age);
    $name = 'John';
    $age = 21;
    $stmt->execute();  
    $name = 'Rick';
    $age = 24;
    $stmt->execute();
    $stmt->close();
    $mysqli->autocommit(TRUE); //turn off transactions + commit queued queries
  } catch(Exception $e) {
    $mysqli->rollback(); //remove all queries from queue if error (undo)
    throw $e;
  }  
} catch (Exception $e) {
  error_log($e);
  exit('Error message for user to understand');
}
复制

自定义异常处理程序

如前所述,您可以选择set_exception_handler()在每个页面上使用(或全局重定向)。这摆脱了花括号嵌套层。如果您正在使用事务,您仍然应该使用 try catch,但随后抛出您自己的异常。

set_exception_handler(function($e) {
  error_log($e);
  exit('Error deleting');
});
$stmt = $mysqli->prepare("DELETE FROM myTable WHERE id = ?");
$stmt->bind_param("i", $_SESSION['id']);
$stmt->execute();
$stmt->close();
复制

异常处理的陷阱

您希望所有 MySQLi 错误都转换为带有mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT). 奇怪的是,我注意到当bind_param()绑定变量或类型太多或太少时,它仍然给我一个警告错误。输出的消息如下:

警告:mysqli_stmt::bind_param():变量数与准备语句中的参数数不匹配

对此的解决方案是使用全局错误处理程序来触发异常。这方面的一个例子可能是:

set_error_handler(function($errno, $errstr, $errfile, $errline) {
  throw new Exception("$errstr on line $errline in file $errfile");
});
复制

这仅发生在运行时警告上,但我将所有错误都转换为异常。我认为这样做没有问题,但有些人强烈反对。

一些额外的东西

我需要 $stmt->close() 吗?

好问题。两者基本上具有相同的效果$mysqli->close()$stmt->close()前者关闭 MySQLi 连接,而后者关闭准备好的语句。TLDR;在大多数情况下,实际上两者实际上都不是必需的,因为一旦脚本执行完成,两者都会关闭。还有一个函数可以简单地分别释放与 MySQLi 结果和准备语句相关的内存:$result->free()$stmt->free(). 我本人可能永远不会使用它,但如果您有兴趣,这里是resultparameterized query的一个。还应注意以下几点:$stmt->close()无论如何,脚本的执行和结束都会释放内存。

最终判决:我通常只做$mysqli->close()and $stmt->close(),尽管可以说它有点多余。如果您打算$stmt再次为另一个准备好的语句使用相同的变量,那么您必须关闭它,或者使用不同的变量名称,例如$stmt2. 最后,我从来没有发现需要简单地释放它们,而不关闭它们。

类:mysqli vs. mysqli_stmt vs. mysqli_result

在此过程中您可能已经意识到的一件事是,其中两个类中存在某些方法,几乎​​就像别名一样。我个人认为最好只有一个版本,比如在 PDO 中,以避免混淆。

那么使用准备好的语句意味着我可以免受攻击者的攻击吗?

虽然您可以避免 SQL 注入,但您仍然需要验证和清理用户输入的数据。您可以使用filter_var()之类的函数在将其插入数据库之前进行验证,并在检索后使用htmlspecialchars()进行清理。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值