八、将 SQL 语句提取到网关

现在,我们已经将所有面向类的功能移动到了一个中心目录位置(并且为这些类提供了一个合理的测试套件),我们可以开始从页面脚本中提取更多的逻辑,并将该逻辑放入类中。这将有两个好处:第一,我们将能够将应用程序的各种关注点分开;其次,我们将能够测试提取的逻辑,以便在部署到生产环境之前很容易发现任何中断。

这些提取的第一步是将所有面向 SQL 的代码移动到它自己的类集合中。出于我们的目的,SQL 是任何数据存储读写系统的替代品。这可能是无 SQL 系统、CSV 文件、远程资源或其他任何内容。在本章中,我们将重点介绍面向 SQL 的数据存储,因为它们在遗留应用程序中非常常见,但这些原则适用于任何形式的数据存储。

嵌入式 SQL 语句

现在,我们的页面脚本(可能还有我们的一些类)通过嵌入的 SQL 语句直接与数据库交互。例如,页面脚本可能具有如下逻辑:

page_script.php
1 <?php
2 $db = new Db($db_host, $db_user, $db_pass);
3 $post_id = $_GET['post_id'];
4 $stm = "SELECT * FROM comments WHERE post_id = $post_id";
5 $rows = $db->query($stm);
6 foreach ($rows as $row) {
7 // output each row
8 }
9 ?>

使用嵌入式 SQL 字符串的问题很多。除其他事项外,我们希望:

  • 独立于代码的其余部分测试 SQL 交互
  • 减少整个代码库中重复的 SQL 字符串的数量
  • 收集相关 SQL 命令以进行泛化和重用
  • 隔离并删除安全缺陷,如 SQL 注入

这些问题和更多的问题让我们得出结论,我们需要将所有与 SQL 相关的代码提取到 SQL 层,并用调用与 SQL 相关的类方法来替换嵌入式 SQL 逻辑。我们将通过创建一系列Gateway类来实现这一点。这些Gateway类唯一要做的就是从我们的数据源获取数据,并将数据发送回数据源。

本章中的Gateway类在技术上更像是表数据网关。但是,您可以选择设置适合您的数据源的任何类型的Gateway

提取过程

一般来说,这是我们将遵循的过程:

  1. 在整个代码库中搜索 SQL 语句。
  2. 对于不在Gateway中的每个语句,将该语句和相关逻辑移动到相关的Gateway类方法中。
  3. 为新的Gateway方法编写一个测试。
  4. 将原始文件中的语句和相关逻辑替换为对Gateway类方法的调用。
  5. 测试、提交、推送和通知 QA。
  6. Gateway类之外的下一条 SQL 语句重复。

搜索 SQL 语句

与前面的章节一样,我们在这里使用了项目范围的搜索功能。使用如下所示的正则表达式来标识 SQL 语句关键字在代码库中的位置:

搜索:

(SELECT|INSERT|UPDATE|DELETE)

我们可能会发现我们的代码库也使用其他 SQL 命令。如果是这样,我们应该在搜索表达式中包含它们。

如果代码库始终只使用一个大写或小写的 SQL 关键字,那么对我们来说就容易多了。然而,这并不总是遗留代码中的约定。如果我们的代码库在 SQL 关键字的大小写方面不一致,并且我们的项目范围的搜索工具有一个不区分大小写的选项,那么我们应该在这个搜索中使用这个选项。否则,我们需要扩展搜索术语,以包括 SQL 关键字的小写(可能是混合大小写)变体。

最后,搜索结果可能包含误报。例如,“选择以下选项之一”等叙述性文本将出现在结果列表中。我们需要单独检查结果,以确定它们是 SQL 语句还是仅仅是叙述性文本。

将 SQL 移动到网关类

将 SQL 提取到Gateway的任务是面向细节和特定于上下文的任务。遗留代码库本身的结构将决定执行此任务的一种或多种正确方法。

首先,像下面这样提取一条普通的 SQL 语句似乎很简单:

1 <?php
2 $stm = "SELECT * FROM comments WHERE post_id = $post_id";
3 $rows = $db->query($stm);
4 ?>

但事实证明,即使是这个简单的例子,我们也需要做出很多决定:

  • 我们应该给Gateway类和方法命名什么?
  • 我们应该如何处理查询的参数?
  • 我们如何避免安全漏洞?
  • 正确的返回值是多少?

名称空间和类名

要确定我们的名称空间和类名,我们首先需要决定是按层组织还是按实体组织。

  • 如果我们按实现层组织,我们类的顶级名称空间可能是GatewayDataSource\Gateway。这种命名安排根据类在代码库中的操作目的来构造类。
  • 如果我们按域实体组织,顶级名称空间将类似于Comments,甚至是Domain\Comments。这种命名安排根据类在业务逻辑域中的用途来构造类。

遗留的代码库很可能决定的方向。如果已经有一个或另一个组织的代码,那么最好继续在已建立的结构中进行,而不是重做现有的工作。我们希望避免在代码库中设置冲突或不一致的组织结构。

在这两者中,我建议按域实体进行组织。我发现在相关名称空间中收集与特定域实体类型相关的功能比在多个名称空间中分散操作实现更为明智。我们还可以在特定的域特性中进一步分离实现片段,这在按层组织时是不容易做到的。

为了反映我对域实体的偏见,本章其余部分中的示例将按照域线而不是实现层进行构造。

一旦我们有了Gateway类的组织原则,我们就可以很容易地找到好的类名。例如,我们在 PHP5.3 及更高版本中与Gateway相关的注释可能命名为Domain\Comments\CommentsGateway。如果我们使用的是 PHP5.2 或更早版本,我们需要避免使用正确的名称空间,而是在类名中使用下划线;e、 g.,Domain_Comments_CommentsGateway

方法名称

然而,选择一个合适的方法名有点困难。再一次,我们应该在现有的遗留代码库中寻找约定。常见的习语可能是get()数据、find()数据、fetch()数据、select()数据,或者完全其他的东西。

我们应该尽可能地坚持任何现有的命名约定。虽然方法名本身并不重要,但命名的一致性确实重要。一致性将使我们更容易查看对Gateway对象的调用,理解正在发生的事情,而无需阅读底层方法代码,并在代码库中搜索数据访问调用。

如果我们的遗留代码库没有显示一致的模式,那么我们就应该为新的Gateway方法选择一致的命名约定。由于Gateway类被认为是包装 SQL 调用的简单层,因此本章中的示例将使用selectinsert等方法名称来标识被包装的行为。

最后,方法名称可能应该指示正在执行的select()类型。我们选择的是一条记录还是全部记录?我们是否根据具体标准进行选择?关于这个查询还有其他考虑吗?这些和其他问题将为我们提供如何命名Gateway方法的提示。

初始网关类方法

在将逻辑提取到类方法时,我们应该仔细学习前面章节中关于依赖项注入的所有经验教训。这意味着:不使用全局变量,用Request对象替换超全局变量,不在Factory类之外使用new关键字,以及(当然)根据需要通过构造函数注入对象。

考虑到上面的命名原则,以及检索注释行的原始SELECT语句,我们可以构建一个如下所示的Gateway

classes/Domain/Comments/CommentsGateway.php
1 <?php
2 namespace Domain\Comments;
3
4 class CommentsGateway
5 {
6 protected $db;
7
8 public function __construct(Db $db)
9 {
10 $this->db = $db;
11 }
12
13 public function selectAllByPostId($post_id)
14 {
15 $stm = "SELECT * FROM comments WHERE post_id = {$post_id}";
16 return $this->db->query($stm);
17 }
18 }
19 ?>

这实际上是原始页面脚本中逻辑的精确副本。不过,它至少留下了一个主要问题:它直接在查询中使用输入参数。这使我们容易受到 SQL 注入攻击。

什么是 SQL 注入

关于小 Bobby 桌子的经典 XKCD 漫画应该有助于说明问题。直接在数据库查询中使用的输入参数是恶意形成的,用于更改查询,从而损坏或以其他方式利用数据库。

击败 SQL 注入

当我们创建Gateway方法时,我们永远不应该假设参数值是安全的。我们是否期望参数在每次调用时硬编码为常量值,或者以其他方式保证安全,这并不重要。在某个时刻,有人会修改一些调用Gateway方法的代码,我们会遇到安全问题。相反,我们需要将每个参数值视为不安全的,并相应地进行处理。

因此,为了阻止 SQL 注入尝试,我们应该对Gateway方法中的每个查询(实际上,在代码库中任何地方的任何 SQL 语句中)执行以下三项操作之一:

  1. 最好的解决方案是使用准备好的语句和参数绑定,而不是查询字符串插值。
  2. 第二个最佳解决方案是在将每个参数插入查询字符串之前,对其使用数据库层的“引用和转义”机制。
  3. 第三个最佳解决方案是在将每个输入参数插入查询字符串之前对其进行转义。

提示

或者,我们可以通过将期望的数值强制转换为intfloat来完全避免字符串问题。

让我们首先研究第三个最佳解决方案,因为它很可能已经存在于我们的遗留代码库中。在查询字符串中使用每个参数之前,我们使用数据库的escape功能对其进行转义,并为数据库适当地引用它。因此,我们可以像这样重写selectAllByPostId()方法,假设一个 MySQL 数据库:

<?php
2 public function selectAllByPostId($post_id)
3 {
4 $post_id = "'" . $this->db->escape($post_id) . "'";
5 $stm = "SELECT * FROM comments WHERE post_id = {$post_id}";
6 return $this->db->query($stm);
7 }
8 ?>

由于以下几个原因,将插值值转义到字符串中是第三个最佳解决方案。主要的一点是,转义逻辑有时是不够的。像mysql_escape_string()这样的函数对于我们这里的目的来说根本不够好。即使是mysql_real_escape_string()方法也有一个缺陷,使得攻击者能够根据当前字符集成功地尝试 SQL 注入。但是,这可能是底层数据库驱动程序可用的唯一选项。

第二个最好的解决方案是一种称为 quote 和 escape 的转义变体。此功能仅通过PDO::quote()方法提供,比转义更安全,因为它还将值括在引号中,并自动处理正确的字符集。这就避免了仅仅转义和添加引号所固有的字符集不匹配问题。

重写的selectAllByPostId()方法可能与公开PDO::quote()方法的Db对象类似:

<?php
2 public function selectAllByPostId($post_id)
3 {
4 $post_id = $this->db->quote($post_id);
5 $stm = "SELECT * FROM comments WHERE post_id = {$post_id}";
6 return $this->db->query($stm);
7 }
8 ?>

当我们记住使用它时,这是一种安全的方法。当然,这里的问题是,如果我们向方法中添加一个参数,我们可能会忘记引用它,然后我们会再次受到 SQL 注入的攻击。

最后,最好的解决方案是:准备语句和参数绑定。这些功能只能通过 PDO(可用于几乎所有数据库)和mysqli扩展来使用。在如何处理语句准备方面,每种方法都有自己的变化。这里我们将使用PDO样式的示例。

我们使用命名占位符来指示参数应放置在查询字符串中的位置,而不是将值插入查询字符串中。然后,我们告诉PDO将字符串准备为PDOStatement对象,并在通过该准备好的语句执行查询时将值绑定到命名占位符。PDO自动使用参数值的安全表示形式,使我们能够抵御 SQL 注入攻击。

下面是一个例子,说明使用一个公开了PDO语句准备逻辑和执行的Db对象进行重写会是什么样子:

1 <?php
2 public function selectAllByPostId($post_id)
3 {
4 $stm = "SELECT * FROM comments WHERE post_id = :post_id";
5 $bind = array('post_id' => $post_id);
6
7 $sth = $this->db->prepare($stm);
8 $sth->execute($bind);
9 return $sth->fetchAll(PDO::FETCH_ASSOC);
10 }
11 ?>

这里最大的好处是我们从不在查询字符串中使用参数变量。我们始终且仅使用命名占位符,并将占位符与参数值绑定到准备好的语句中。当我们不正确地使用插值变量时,PDO会自动抱怨是否有额外的或丢失的界值,因此意外做出不安全更改的可能性大大降低。

写一个测试

现在是为我们的新类方法编写测试的时候了。此时我们编写的测试没有我们希望的那么完美,因为我们需要与数据库交互。然而,不完美的测试总比没有测试好。正如睾丸的方式告诉我们的,我们可以在我们可以的时候编写测试。

我们新的Gateway方法的测试可能如下所示:

tests/classes/Domain/Comments/CommentsGatewayTest.php
1 <?php
2 namespace Domain\Comments;
3
4 use Db;
5
6 class CommentsGatewayTest
7 {
8 protected $db;
9
10 protected $gateway;
11
12 public function setUp()
13 {
14 $this->db = new Db('test_host', 'test_user', 'test_pass');
15 $this->gateway = new CommentsGateway($this->db);
16 }
17
18 public function testSelectAllByPostId()
19 {
20 // a range of known IDs in the comments table
21 $post_id = mt_rand(1,100);
22
23 // get the comment rows
24 $rows = $this->gateway->selectAllByPostId($post_id);
25
26 // make sure they all match the post_id
27 foreach ($rows as $row) {
28 $this->assertEquals($post_id, $row['post_id']);
29 }
30 }
31 }
32 ?>

现在我们运行测试套件,看看测试是否通过。如果真是这样,我们将欢欣鼓舞,继续前进!如果没有,我们将继续完善Gateway方法和相关测试,直到两者都正常工作。

提示

完善我们的测试

如前所述,这是一个非常不完善的测试。除其他事项外,它取决于工作的数据库连接,以及首先将数据种子植入数据库。通过依赖于数据库,我们依赖于它处于正确的状态。如果数据库中没有正确的数据,则测试将失败。失败不会来自我们正在测试的代码,而是来自数据库,这在很大程度上是我们无法控制的。改进测试的一个机会是将Gateway类更改为依赖DbInterface类,而不是具体的Db类。然后我们将创建一个用于测试目的的FakeDb类来实现DbInterface,并将FakeDb实例注入Gateway而不是真实的Db实例。这样做可以让我们更深入地了解 SQL 查询字符串的正确性,并更好地控制返回到Gateway的数据。最重要的是,它将使测试与其对工作数据库的依赖性分离。现在,为了权宜之计,我们将使用不完美测试。

更换原代码

现在我们有了一个工作且经过测试的Gateway方法,我们用对Gateway的调用替换了原始代码。而旧代码看起来是这样的:

page_script.php (before)
1 <?php
2 $db = new Db($db_host, $db_user, $db_pass);
3 $post_id = $_GET['post_id'];
4 $stm = "SELECT * FROM comments WHERE post_id = $post_id";
5 $rows = $db->query($stm);
6 foreach ($rows as $row) {
7 // output each row
8 }
9 ?>

新版本将如下所示:

page_script.php (after)
1 <?php
2 $db = new Db($db_host, $db_user, $db_pass);
3 $comments_gateway = new CommentsGateway($db);
4 $rows = $comments_gateway->selectAllByPostId($_GET['post_id']);
5 foreach ($rows as $row) {\
6 // output each row
7 }
8 ?>

请注意,我们几乎没有修改操作逻辑。例如,我们没有添加以前不存在的错误检查。我们修改的最大程度是通过预处理语句保护查询不受 SQL 注入的影响。

测试、提交、推送、通知 QA

与前面的章节一样,我们现在需要抽查遗留应用程序。虽然我们对新的Gateway方法进行了单元测试,但我们仍然需要抽查我们修改的应用程序部分。如果我们早些时候准备了一个特性测试,涵盖了遗留应用程序的这一部分,那么现在就可以运行它了。否则,我们可以通过浏览或调用应用程序的更改部分来实现这一点。

一旦我们通过调用新的Gateway方法成功地替换了嵌入式 SQL,我们就会将更改(包括测试)提交给修订控制。然后,我们推送到中央存储库,并将我们的更改通知 QA 团队。

做。。。虽然

完成后,我们再次搜索代码库,以使用 SQL 关键字来指示嵌入的查询字符串。如果它们存在于Gateway类之外,我们将继续将查询提取到适当的Gateway。一旦所有 SQL 语句都移动到Gateway类,我们就完成了。

常见问题

INSERT、UPDATE 和 DELETE 语句怎么样?

到目前为止,我们只研究了SELECT语句,因为它们可能是我们遗留代码库中最常见的情况。然而,将有大量的INSERTUPDATEDELETE和可能还有其他陈述。为了提取到 aGateway,这些物质的处理基本上与SELECT相同,但存在一些细微差异。

具体而言,INSERTUPDATE语句可能包含大量参数,指示要插入或更新的列值。向提取的Gateway方法签名添加太多参数将使其难以使用。

在这些情况下,我们可以使用数据数组来指示列名及其对应的值。不过,我们需要确保只插入或更新正确的列。

例如,假设我们从页面脚本中的以下代码开始,用注释者的名称、注释正文、注释者的 IP 地址和注释附加到的帖子 ID 保存新注释:

page_script.php
1 <?php
2 $db = new Db($db_host, $db_user, $db_pass);
3
4 $name = $db->escape($_POST['name']);
5 $body = $db->escape($_POST['body']);
6 $post_id = (int) $_POST['id'];
7 $ip = $db->escape($_SERVER['REMOTE_ADDR']);
8
9 $stm = "INSERT INTO comments (post_id, name, body, ip) "
10 .= "VALUES ($post_id, '{$name}', '{$body}', '{$ip}'";
11
12 $db->query($stm);
13 $comment_id = $db->lastInsertId();
14 ?>

当我们将其提取到CommentsGateway中的方法时,我们可以为插入的每个列值设置一个参数。在本例中,只有四列,但如果有十二列,则方法签名将更难处理。

作为每列一个参数的替代方法,我们可以将数据数组作为单个参数传递,然后在方法内部使用该参数。此使用数据数组的示例包括一个带有占位符的准备语句,用于抵御 SQL 注入攻击:

1 <?php
2 public function insert(array $bind)
3 {
4 $stm = "INSERT INTO comments (post_id, name, body, ip) "
5 .= "VALUES (:post_id, :name, :body, :ip)";
6 $this->db->query($stm, $bind);
7 return $this->db->lastInsertId();
8 }
9 ?>

一旦我们在CommentsGateway中有了这样的方法,我们就可以修改原始代码,使其更像以下内容:

page_script.php
1 <?php
2 $db = new Db($db_host, $db_user, $db_pass);
3 $comments_gateway = new CommentsGateway($db);
4
5 $input = array(
6 'name' => $_POST['name'],
7 'body' => $_POST['body'],
8 'post_id' => $_POST['id'],
9 'ip' => $_SERVER['REMOTE_ADDR'],
10 );
11
12 $comment_id = $comments_gateway->insert($input);
13 ?>

重复的 SQL 字符串呢?

在这个过程中,我们可能会遇到的一件事是,在整个遗留应用程序中,查询字符串中存在大量重复,或者重复时会发生变化。

例如,我们可能会在遗留应用程序的其他位置发现一个与注释相关的查询,如下所示:

1 <?php
2 $stm = "SELECT * FROM comments WHERE post_id = $post_id LIMIT 10";
3 ?>

查询字符串与本章开头的示例代码相同,只是附带了一个LIMIT子句。我们应该为这个查询创建一个全新的方法,还是修改一个现有的方法?

这是一种需要专业判断和熟悉代码库的事情。在这种情况下,修改似乎是合理的,但在其他情况下,差异可能很大,足以保证采用全新的方法。

如果我们选择修改CommentsGateway中已有的方法,我们可能会重写selectAllByPostId()以包含可选的LIMIT

1 <?php
2 public function selectAllByPostId($post_id, $limit = null)
3 {
4 $stm = "SELECT * FROM comments WHERE post_id = :post_id";
5 if ($limit) {
6 $stm .= " LIMIT " . (int) $limit;
7 }
8 $bind = array('post_id' => $post_id);
9 return $this->db->query($stm, $bind);
10 }
11 ?>

现在我们已经修改了应用程序类,我们需要运行现有的测试。如果他们失败了,我们高兴!我们发现我们的更改有缺陷,测试阻止了该缺陷进入生产。如果他们通过了,我们会感到高兴,因为事情仍然按照改变前的方式运作。

最后,在现有测试通过后,我们修改了CommentsGatewayTest,以便它检查新的LIMIT功能是否正常工作。该测试仍然不完善,但它抓住了要点:

tests/classes/Domain/Comments/CommentsGatewayTest.php
1 <?php
2 public function testSelectAllByPostId()
3 {
4 // a range of known IDs in the comments table
5 $post_id = mt_rand(1,100);
6
7 // get the comment rows
8 $rows = $this->gateway->selectAllByPostId($post_id);
9
10 // make sure they all match the post_id
11 foreach ($rows as $row) {
12 $this->assertEquals($post_id, $row['post_id']);
13 }
14
15 // test with a limit
16 $limit = 10;
17 $rows = $this->gateway->selectAllByPostId($post_id, $limit);
18 $this->assertTrue(count($rows) <= $limit);
19 }
20 }
21 ?>

我们再次运行测试,以确保新的LIMIT功能正常工作,并优化代码和测试,直到通过测试。

然后我们继续用对Gateway的调用、抽查、提交等来替换原始的嵌入式 SQL 代码。

我们在这方面需要谨慎。在看到查询的一个变体之后,我们将能够想象该查询的许多其他可能变体。由此产生的诱惑将是在我们实际遇到之前,先发制人地修改我们的Gateway方法,以解释想象中的变化。除非我们在遗留代码库中实际看到了特定的变体,否则我们应该限制自己为该变体编写代码。我们现在不想超出代码库的实际需求太多。我们的目标是在可见的道路上小步前进,而不是在想象的迷雾中大步前进。

复杂的查询字符串呢?

到目前为止,示例都是相对简单的查询字符串。这些简单的例子有助于保持流程清晰。然而,我们可能会在遗留代码库中看到非常复杂的查询。这些可能包括以片段构建的查询,使用多个条件语句修改片段的构建方式,并在查询中使用许多不同的参数。以下是取自附录 a典型遗留页面脚本的一个复杂查询示例:

1 <?php
2 // ...
3 define("SEARCHNUM", 10);
4 // ...
5 $page = ($page) ? $page : 0;
6
7 if (!empty($p) && $p!="all" && $p!="none") {
8 $where = "`foo` LIKE '%$p%'";
9 } else {
10 $where = "1";
11 }
12
13 if ($p=="hand") {
14 $where = "`foo` LIKE '%type1%'"
15 . " OR `foo` LIKE '%type2%'"
16 . " OR `foo` LIKE '%type3%'";
17 }
18
19 $where .= " AND `bar`='1'";
20 if ($s) {
21 $s = str_replace(" ", "%", $s);
22 $s = str_replace("'", "", $s);
23 $s = str_replace(";", "", $s);
24 $where .= " AND (`baz` LIKE '%$s%')";
25 $orderby = "ORDER BY `baz` ASC";
26 } elseif ($letter!="none" && $letter) {
27 $where .= " AND (`baz` LIKE '$letter%'"
28 . " OR `baz` LIKE 'The $letter%')";
29 $orderby = "ORDER BY `baz` ASC";
30 } else {
31 $orderby = "ORDER BY `item_date` DESC";
32 }
33 $query = mysql_query(
34 "SELECT * FROM `items` WHERE $where $orderby
35 LIMIT $page,".SEARCHNUM;
36 );
37 ?>

对于这类复杂的安排,我们需要在提取Gateway的相关查询构建逻辑时非常注意细节。主要考虑的是确定在查询构建逻辑中使用哪些变量,并将这些变量设置为新的Gateway方法的参数。然后,我们可以将查询构建逻辑移动到我们的Gateway

将嵌入式 SQL 相关逻辑提取到Gateway方法的第一步如下:

1 <?php
2 namespace Domain\Items;
3
4 class ItemsGateway
5 {
6 protected $mysql_link;
7
8 public function __construct($mysql_link)
9 {
10 $this->mysql_link = $mysql_link;
11 }
12
13 public function selectAll(
14 $p = null,
15 $s = null,
16 $letter = null,
17 $page = 0,
18 $searchnum = 10
19 ) {
20 if (!empty($p) && $p!="all" && $p!="none") {
21 $where = "`foo` LIKE '%$p%'";
22 } else {
23 $where = "1";
24 }
25
26 if ($p=="hand") {
Extract SQL Statements To Gateways 84
27 $where = "`foo` LIKE '%type1%'"
28 . " OR `foo` LIKE '%type2%'"
29 . " OR `foo` LIKE '%type3%'";
30 }
31
32 $where .= " AND `bar`='1'";
33 if ($s) {
34 $s = str_replace(" ", "%", $s);
35 $s = str_replace("'", "", $s);
36 $s = str_replace(";", "", $s);
37 $where .= " AND (`baz` LIKE '%$s%')";
38 $orderby = "ORDER BY `baz` ASC";
39 } elseif ($letter!="none" && $letter) {
40 $where .= " AND (`baz` LIKE '$letter%'"
41 . " OR `baz` LIKE 'The $letter%')";
42 $orderby = "ORDER BY `baz` ASC";
43 } else {
44 $orderby = "ORDER BY `item_date` DESC";
45 }
46
47 $stm = "SELECT *
48 FROM `items`
49 WHERE $where
50 $orderby
51 LIMIT $page, $searchnum";
52
53 return mysql_query($stm, $this->mysql_link);
54 }
55 }
56 ?>

尽管我们已经删除了一些依赖项(例如,mysql_connect()链接标识符上的隐式全局依赖项),但是第一次传递仍然存在很多问题。除此之外,它仍然容易受到 SQL 注入的攻击。我们需要对查询中使用的每个参数使用mysql_real_escape_string(),并将LIMIT值转换为整数。

一旦我们完成提取及其相关测试,我们将原始代码更改为如下内容:

1 <?php
2 // ...
3 define("SEARCHNUM", 10);
4 // ...
5 $page = ($page) ? $page : 0;
6 $mysql_link = mysql_connect($db_host, $db_user, $db_pass);
7 $items_gateway = new \Domain\Items\ItemsGateway($mysql_link);
8 $query = $items_gateway->selectAll($p, $s, $letter, $page, SEARCHNUM);
9 ?>

非网关类内部的查询如何?

本章中的示例显示了嵌入在页面脚本中的 SQL 查询字符串。我们很可能也会在非网关类中找到嵌入的查询字符串。

在这些情况下,我们遵循与页面脚本相同的过程。另外一个问题是,我们必须将Gateway依赖项传递给类。例如,假设我们有一个Foo类,它使用doSomething()方法检索注释:

1 <?php
2 class Foo
3 {
4 protected $db;
5
6 public function __construct(Db $db)
7 {
8 $this->db = $db;
9 }
10
11 public function doSomething($post_id)
12 {
13 $stm = "SELECT * FROM comments WHERE post_id = $post_id";
14 $rows = $this->db->query($stm);
15 foreach ($rows as $row) {
16 // do something with each row
17 }
18 return $rows;
19 }
20 }
21 ?>

我们提取 SQL 查询字符串及其相关逻辑,就像提取页面脚本一样。然后我们修改类Foo类,将Gateway作为依赖项,而不是Db对象,并根据需要使用Gateway

1 <?php
2 use Domain\Comments\CommentsGateway;
3
4 class Foo
5 {
6 protected $comments_gateway;
7
8 public function __construct(CommentsGateway $comments_gateway)
9 {
10 $this->comments_gateway = $comments_gateway;
11 }
12
13 public function doSomething($post_id)
14 {
15 $rows = $this->comments_gateway->selectAllByPostId($post_id);
16 foreach ($rows as $row) {
17 // do something with each row
18 }
19 return $rows;
20 }
21 }
22 ?>

我们可以从基本网关类扩展吗?

如果我们有许多Gateway类都具有类似的功能,那么将这些功能中的一些收集到AbstractGateway中可能是合理的。例如,如果它们都需要一个Db连接,并且都有类似的select*()方法,我们可以执行如下操作:

classes/AbstractGateway.php
1 <?php
2 abstract class AbstractGateway
3 {
4 protected $table;
5
6 protected $primary_key;
7
8 public function __construct(Db $db)
9 {
10 $this->db = $db;
11 }
12
13 public function selectOneByPrimaryKey($primary_val)
14 {
15 $stm = "SELECT * FROM {$this->table} "
16 .= "WHERE {$this->primary_key} = :primary_val";
17 $bind = array('primary_val' => $primary_val);
18 return $this->db->query($stm, $bind);
19 }
20 }
21 ?>

然后,我们可以从该基础AbstractGateway扩展类,并调整特定表的扩展属性:

1 <?php
2 namespace Domain\Items;
3
4 class ItemsGateway extends \AbstractGateway
5 {
6 protected $table = 'items';
7 protected $primary_key = 'item_id';
8 }
9 ?>

然后,基本的selectOneByPrimaryKey()方法可以处理范围广泛的Gateway类。我们仍然可以根据需要在特定的Gateway类上添加其他具体方法。

对这种方法要谨慎。我们应该只提取我们已经提取的行为中已经存在的功能。抵制先发制人地创建我们在遗留代码库中实际没有看到的功能的诱惑。

多个查询和复杂的结果结构如何?

本章中的示例展示了针对单个表的单个查询。我们可能会遇到这样的逻辑:对几个不同的表使用多个查询,然后将结果合并到一个复杂的域实体或集合中。以下是一个例子:

1 <?php
2 // build a structure of posts with author and statistics data,
3 // with all comments on each post.
4 $page = (int) $_GET['page'];
5 $limit = 10;
6 $offset = $page * $limit; // a zero-based paging system
7 $stm = "SELECT *
8 FROM posts
9 LEFT JOIN authors ON authors.id = posts.author_id
10 LEFT JOIN stats ON stats.post_id = posts.id
11 LIMIT {$limit} OFFSET {$offset}"
12 $posts = $db->query($stm);
13
14 foreach ($posts as &$post) {
15 $stm = "SELECT * FROM comments WHERE post_id = {$post['id']}";
16 $post['comments'] = $db->query($stm);
17 }
18 ?>

这个例子展示了一个典型的 N+1 问题,其中为一个主集合的每个成员发出一个查询。第一次查询获取博客帖子之后,将再进行 10 次查询,每个博客帖子一次,以获取评论。因此,查询总数为 10,加上初始查询的 1。对于 50 个员额,总共将有 51 个查询。这是遗留应用程序中性能拖累的典型来源。关于 N+1 问题的扩展讨论和解决方案请参见 PHP(中的解决 N+1 问题 https://leanpub.com/sn1php

第一个问题是确定如何将查询拆分为Gateway方法。一些查询必须同时进行,而其他查询可以分开。在这种情况下,第一个和第二个查询可以分为不同的Gateway类和方法。

下一个问题是确定哪个Gateway类应该接收提取的逻辑。当涉及多个表时,有时很难确定这一点,因此我们必须选择哪个是查询的主要主题。上面的第一个查询引用了 post、authors 和 stats,但从逻辑上看似乎很清楚,我们主要对 post 感兴趣。

因此,我们可以将第一个查询提取到一个PostsGateway。我们希望尽可能少地修改查询本身,因此我们保留了连接等:

1 <?php
2 namespace Domain\Posts;
3
4 class PostsGateway
5 {
6 protected $db;
7
8 public function __construct(Db $db)
9 {
10 $this->db = $db;
11 }
12
13 public function selectAllWithAuthorsAndStats($limit = null, $offset = null)
14 {
15 $limit = (int) $limit;
https://leanpub.com/sn1php
16 $offset = (int) $offset;
17 $stm = "SELECT *
18 FROM posts
19 LEFT JOIN authors ON authors.id = posts.author_id
20 LEFT JOIN stats ON stats.post_id = posts.id
21 LIMIT {$limit} OFFSET {$offset}"
22 return $this->db->query($stm);
23 }
24 }
25 ?>

完成后,我们继续基于第一个查询编写新功能的测试。我们修改代码并进行测试,直到测试通过。

第二个查询与注释相关,与前面的示例相同。

完成提取及其相关测试后,我们可以修改页面脚本,如下所示:

1 <?php
2 $db = new Database($db_host, $db_user, $db_pass);
3 $posts_gateway = new \Domain\Posts\PostsGateway($db);
4 $comments_gateway = new \Domain\Comments\CommentsGateway($db);
5
6 // build a structure of posts with author and statistics data,
7 // with all comments on each post.
8 $page = (int) $_GET['page'];
9 $limit = 10;
10 $offset = $page * $limit; // a zero-based paging system
11 $posts = $posts_gateway->selectAllWithAuthorsAndStats($limit, $offset);
12
13 foreach ($posts as &$post) {
14 $post['comments'] = $comments_gateway->selectAllByPostId($post['id']);
15 }
16 ?>

如果没有数据库类怎么办?

许多遗留代码库没有数据库访问层。相反,这些遗留应用程序直接在其页面脚本中使用mysql扩展。对mysql函数的调用分散在整个代码库中,并不是集中在一个类中。

如果我们可以升级到PDO,我们应该。但是,由于各种原因,可能无法从mysql升级。PDO的工作方式与mysql不完全相同,从mysql习惯用语转变为PDO习惯用语可能太难一步到位。此时的迁移也可能使测试比我们希望的更困难。

另一方面,我们可以将mysql调用移动到Gateway类中。这样做一开始似乎是合理的。然而,mysql扩展内置了一点全局状态。任何需要链路标识符(即服务器连接)的mysql功能在未传递链路标识符时自动使用最新的连接资源。这与依赖注入的原则背道而驰,因为如果我们能够帮助它,我们宁愿不依赖于全球国家。

因此,与其直接迁移到 PDO,也不要让msyql函数调用保持原样,我建议我们将mysql调用封装在一个类中,该类代理mysql函数的方法调用。然后我们可以使用类方法而不是mysql函数。类本身可以包含链接标识符,并将其传递给每个方法调用。这将为我们提供一个数据库访问层,我们的Gateway对象可以使用该层,而不会太大地改变mysql惯用用法。

此类包装器的一个操作示例实现是MysqlDatabase类。当我们创建一个MysqlDatabase实例时,它保留了连接信息,但实际上并没有连接到服务器。只有当我们调用实际需要服务器连接的方法时,它才会连接。这种延迟加载方法帮助我们减少资源使用。此外,MysqlDatabase类显式添加了 link identifier 参数,这在相关的mysql函数中是可选的,因此我们永远不会依赖于mysql扩展的隐式全局状态。

mysql函数调用替换为MysqlDatabase调用:

  1. 在整个代码库中搜索函数调用的mysql_前缀。
  2. 在每个文件中,都有带有mysql_函数前缀的函数调用。。。
    • 创建或注入MysqlDatabase的实例。
    • MysqlDatabase对象变量和单个箭头运算符(->替换每个mysql_函数前缀。如果我们坚持风格,我们还可以将剩余的方法名部分从snake_case()转换为camelCase()
  3. 抽查、提交、推送并通知 QA。
  4. 继续搜索函数调用的mysql_前缀,直到它们都被替换为MysqlDatabase方法调用。

例如,假设我们有这样一段遗留代码:

Using mysql functions
1 <?php
2 mysql_connect($db_host, $db_user, $db_pass);
3 mysql_select_db('my_database');
4 $result = mysql_query('SELECT * FROM table_name LIMIT 10');
5 while ($row = mysql_fetch_assoc($result)) {
6 // do something with each row
7 }
8 ?>

使用上述过程,我们可以将代码转换为使用MysqlDatabase对象:

使用 MysqlDatabase 类

1 <?php
2 $db = new \Mlaphp\MysqlDatabase($db_host, $db_user, $db_pass);
3 $db->select_db('my_database'); // or $db->selectDb('my_database')
4 $result = $db->query('SELECT * FROM table_name LIMIT 10');
5 while ($row = $db->fetch_assoc($result)) {
6 // do something with each row
7 }
8 ?>

反过来,可以使用注入的MysqlDatabase对象将该代码提取到Gateway类中。

对于我们的页面脚本,最好在现有的设置文件中创建一个MysqlDatabase实例并使用它,而不是在每个页面脚本中单独创建一个实例。实现的延迟连接特性意味着,如果我们从不调用数据库,就永远不会进行连接,因此我们不需要担心不必要的资源使用。现有的遗留代码库将帮助我们确定这是否是一种合理的方法。

一旦我们的Gateway类使用注入的MysqlDatabase对象,我们就可以开始计划从包装的mysql函数迁移到具有不同习惯用法的PDO。因为数据库访问逻辑现在由Gateway对象包装,所以迁移和测试将比替换遍布整个代码库的mysql调用更容易。

回顾和下一步

完成此步骤后,所有 SQL 语句都将位于Gateway类中,而不再位于页面脚本或其他非网关类中。我们还将对Gateway课程进行测试。

从现在开始,任何时候我们需要向数据库添加新调用,我们将只在Gateway类中这样做。任何时候我们需要获取或保存数据,我们都会使用Gateway方法,而不是编写嵌入式 SQL。这使我们能够清楚地分离数据库交互与未来的模型层和实体对象之间的关系。

现在我们已经将数据库交互分离到它们自己的层中,我们将检查整个遗留应用程序中对Gateway对象的所有调用。我们将检查页面脚本和其他类如何操作返回的结果,并开始提取定义模型层的行为。