SQL

第23章 非礼勿视

在获得所有证据之前就下结论,是一个重大错误。 -- 夏洛特•福尔摩斯

“我在你们的产品中找到了另一个Bug。”电话那头说。

20世纪90年代,当我还是一个SQL RDBMS的技术支持时,接到这个电话。我们有一个客户以总是提交一些荒谬的报告而出名。几乎所有他报告的问题最终都证明是他自己犯的小错误,而不是Bug。

“Davis先生,早上好。我们很荣幸能为你解决你所发现的问题。”我回答道:“你能告诉我发生了什么吗?”

“我向数据库发起了一个查询请求,可是什么都没返回。”Davis先生很尖刻地说:“但我确信数据库里有数据,我用一个测试脚本验证过。”

“你的查询有问题吗?”我问道:“API返回任何错误信息了吗?”

Davis回应道:“为什么我要关心API函数的返回值?这个函数就应该执行我的SQL查询。如果它返回错误,就说明你的产品有Bug。如果你的产品没有Bug,就不会返回错误。我的工作不是解决你的Bug。”

我目瞪口呆,但我必须让事实说话才能说服他。“好吧,我们来测试一下。将你的代码中的SQL查询语句复制粘贴到查询工具中,然后执行一下。返回什么?”我等待他回答。

“在SELECT附近有语法错误。”他停顿了一下,然后说:“你可以结束这个问题了。”然后他直接挂掉了电话。

Davis先生是一家航空管制公司的开发人员,编写一些软件来记录国际航班的飞行数据。我们每周都要接到几个他的电话。

23.1 目标:写更少的代码

每个人都想写出优雅的代码。也就是说,我们想要用更少的代码来做更酷的事情。同时,我们所做的事情越酷,我们所要写的代码就越少,也越优雅。但如果我们不能让工作变得更酷,至少我们还有足够的理由来改进代码:用更少的代码来提高代码的优雅程度。

这仅仅只是表面上的理由,还有很多别的更靠谱的写出清晰代码的理由。

  • 我们可以更快地写完一个程序的代码。
  • 我们有更少的代码需要测试、写文档或审查。
  • 更少的代码意味着更少的Bug。

因此对于一个程序员来说,删除任何他们能删除的代码是他们的本能,尤其是那些不能让工作看起来更酷的代码。

23.2 反模式:无米之炊

“非礼勿视”对于开发人员来说通常有两种形式:第一,忽略数据库API的返回值;第二,和程序代码混在一起阅读那些分散的SQL语句片段。在这两种情况下,开发人员都会错过那些对他们修复错误非常有帮助的信息。

23.2.1 没有诊断的诊断

See-No-Evil/anti/no-check.php
      <?php
 ①    $pdo = new PDO("mysql:dbname=test;host=db.example.com", "dbuser", "dbpassword");
      $sql = "SELECT bug_id, summary, date_reported FROM Bugs WHERE assigned_to = ? AND status = ?";
 ②    $stmt = $dbh->prepare($sql);
 ③    $stmt->execute(array(1, "OPEN"));
 ④    $bug = $stmt->fetch();

以上的代码非常简洁,但代码中有几处函数返回的状态值可能会引起问题,但如果你忽略了返回值,就永远不会知道怎么回事。

数据库API最有可能返回的错误就是在你建立和数据库之间的链接的时候,比如在代码①处。你可能不小心打错了数据库的名字、服务器的地址、用户名密码,或者数据库服务本身挂了。在实例化一个PDO链接时,会抛出一个异常,可能会直接终止这个范例脚本的执行。

代码②处调用的prepare()函数,可能会由于打字错误、不匹配的括号或者拼错了列名导致的语法错误而返回false。代码③处,$stmt 对象的成员函数execute()会产生一个致命错误,因为false不是一个合法的对象。

PHP Fatal error: Call to a member function execute() on a non-object

函数execute()也会失败。比如,如果这条查询语句违反了某一个约束,或者超出了访问权限设定,这个函数也会返回false。

在代码④处,对函数fetch()的调用,在有任何错误产生时,都会返回false,比如无法链接到RDBMS。

像Davis先生这样的程序员并不少见。他们可能会觉得对返回值进行检查或者对异常进行处理对于他们的代码来说没有任何意义,因为他们假设这些可能出错的情况是不可能发生的。同时,这些额外的代码都是重复的,并且让程序看起来很丑陋,而且难以阅读。它们的确一点也不酷。

但用户看不见代码,他们只能看见输出。当一个致命错误没有被处理时,用户就只能看到一个白屏(如图23-1),或者是一个不完整的异常信息。当发生这种情况时,程序代码简短和清晰对用户来说,一点安慰作用都没有。

图23-1 PHP执行中的一个致命错误,导致白屏

23.2.2 字里行间

另一个常见的导致“非礼勿视”这个反模式的坏习惯是,盯着创建SQL查询字符串的代码调试。这么做对于调试来说比较困难,因为用程序逻辑、串连接和应用变量的额外内容建立之后,很难想象出最终拼出来的SQL查询语句到底是什么。这么调试代码就如同不看盒子上的图片直接开始玩拼图一样。

举个简单的例子,让我们看一个我经常听程序员问起的问题。下面的这段代码在断定用户查询一个特定的Bug而不是一个Bug集合后,会将WHERE子句连接到这个查询语句的后面,从而为其加上了条件查询的逻辑。

See-No-Evil/anti/white-space.php
<?php 
$sql = "SELECT * FROM Bugs"; if ($bug_id) {
      $sql .= "WHERE bug_id = " . intval($bug_id);
 }
 $stmt = $pdo->prepare($sql);

为什么这个简单的查询会返回错误?如果你看了连接后的$sql变量里的值,真相就会浮出水面:

See-No-Evil/anti/white-space.sql
SELECT * FROM BugsWHERE bug_id = 1234

在Bugs和WHERE之间没有空格,造成了查询语句的语法错误。这条语句的意思变成了查询一张叫做BugsWHERE的表,但随后又跟着一串有语法错误的表达式。这段程序代码在连接两个字符串时没有加空格。

开发人员浪费了无数的时间和精力来调试这样简单的问题。通常只要看一眼创建完的SQL语句就能找到问题的症结,但开发人员更喜欢分析创建SQL语句的代码逻辑。

23.3 如何识别反模式

你可能认为,人工定位这些遗漏的错误处理代码很困难,你不见得需要这么做。很多现代的IDE都会在检测到没有处理返回值或者忽略了一个需要检测的异常1时,突出显示相应的代码。同时,你还可以根据下面的这些描述,推断出有人使用了“非礼勿视”这个反模式。

1 需要检测的异常指的是在函数签名中附带的异常定义,因此使用者可以知道这个函数有可能会返回特定类型的异常。

  • “我的程序在我执行查询之后就崩溃了。”通常程序崩溃是因为查询失败,然后又试图以一种非法的方式使用返回的结果,诸如调用一个非对象的方法,或者引用一个空指针。
  • “你能帮我找一下我的SQL查询中的错误吗?这是我的代码……”首先,看一下SQL语句,而不是生成它的代码。
  • “我不会让错误处理弄乱了我的代码结构的。”一些计算机科学家推测在一个稳固的程序中,至少有50%的代码是用来进行错误处理的。这看上去似乎很多,但想想在一个错误处理中所要包含的所有步骤:检查、分类、报告以及补救。对于所有的程序来说,实现错误处理都是很重要的。

23.4 合理使用反模式

当你真的不需要为错误负责的时候,的确可以忽略错误处理。比如,close()函数会关闭一个到数据库的链接,然后返回一个状态。但如果你的程序已经运行完成并准备退出了,那所有的链接资源无论如何都会被清空的,也就不需要关心close()的返回值了。

在面向对象的语言中,异常允许你跟踪一个错误而不用处理它。任何调用你所提供的接口的代码,才是需要为这个异常负责的代码。因此,你可以直接将一个异常抛出并返回到调用栈的上一层。

23.5 解决方案:优雅地从错误中恢复

所有喜欢跳舞的人都知道,跳错舞步是不可避免的。优雅的秘诀就是弄明白怎么挽回。给自己一个了解错误产生原因的机会,然后就可以快速响应,在任何人注意到你出丑之前,神不知鬼不觉地回到应有的节奏上。

23.5.1 保持节奏

检查数据库API调用的返回状态和异常,是确保你不会跳错舞步的最佳方式。如下的代码对每一个会产生错误的调用都做了检查:

See-No-Evil/soln/check.php
      <?php
       try {
           $pdo = new PDO("mysql:dbname=test;host=localhost", "dbuser", "dbpassword");
  ①    } catch (PDOException $e) {
           report_error($e->getMessage());
           return;
       }
       $sql = "SELECT bug_id, summary, date_reported FROM Bugs WHERE assigned_to = ? AND status = ?";
  ②    if (($stmt = $pdo->prepare($sql)) === false) {
           $error = $pdo->errorInfo();
           report_error($error[2]);
           return;
       }
  ③    if ($stmt->execute(array(1, "OPEN")) === false) {
           $error = $stmt->errorInfo();
           report_error($error[2]);
           return;
       }
  ④    if (($bug = $stmt->fetch()) === false) {
           $error = $stmt->errorInfo();
           report_error($error[2]);
           return;
       }

代码①处在数据库链接失败时,捕获了其抛出的异常。当有问题的时候,其他的函数就会返回false。

在检查了代码②、③和④之后,你可以从数据库链接对象或者查询语句对象那里获得更多的错误信息。

23.5.2 回溯你的脚步

使用实际的SQL查询语句来进行调试也是很重要的,不能仅仅只是分析产生SQL查询语句的代码。很多简单的错误,诸如拼写错误、引号、括号不全都是非常明显的,即使它们在程序代码中让人费解、困惑。

  • 使用一个变量来记录生成的SQL查询语句,而不是在调用API方法,传递参数的时候临时生成。这样做让你有机会在使用生成的SQL语句之前对其进行检查。
  • 选择一个不是程序输出的地方输出SQL语句,比如日志文件、IDE调试控制窗口,或者能显示调试输出的浏览器扩展2。2 Firebug(http://getfirebug.com)就是个不错的扩展。
  • 别将SQL语句当做HTML注释一起输出在页面中。因为任何人都能看页面源码。阅读这些SQL语句会让黑客了解很多关于数据库架构的信息。

使用一个对象关系映射(ORM)框架来透明地构建和执行SQL查询会使得调试更复杂。如果你不能获得SQL查询的上下文,又怎么观察并且调试问题呢?有些ORM通过将生成的SQL语句写入日志来解决这一矛盾。

最后一点,大多数数据库都提供了它们自己的日志机制,日志是记录在数据库服务器端而非程序客户端的。如果你不能在程序中记录SQL请求,仍可以在数据库服务器端监控这些请求的执行情况。

发现并解决代码中的问题已经很困难了,就别再盲目地干了。

下一节:人们讨厌改变。他们总是会说:“我们一向如此。”而我尝试改变这一切,这就是为什么我墙上的钟是逆时针的。 -- 葛丽丝•霍普少将