错误处理

无论您的应用程序设计得多么简单和直观,都会有用户使用不当或出现随机连接错误的情况,您的代码必须做好处理这些情况的准备,以便为用户提供尽可能好的体验。我们将这些情况称为异常:一种识别与我们预期不同的情况的语言元素。

try…​catch 块

只要您认为有必要,您的代码就可以手动抛出异常。例如,以 Unique trait 中的 setId 方法为例。多亏了类型提示,我们强制 ID 必须是数字,但也仅此而已。如果有人试图设置一个负数 ID,会发生什么情况呢?现在的代码允许这样做,但根据您的偏好,您希望避免这样做。这将是出现异常的好地方。让我们看看如何添加这种检查和相应的异常:

public function setId($id) {
    if ($id < 0) {
        throw new \Exception('Id cannot be negative.');
    }
    if (empty($id)) {
        $this->id = ++self::$lastId;
    } else {
        $this->id = $id;
        if ($id > self::$lastId) {
            self::$lastId = $id;
        }
    }
}

正如你所看到的,异常是类 exception 的对象。请记住在类名后加上反斜杠,除非你想在文件顶端包含使用 Exception;Exception 类的构造函数需要一些可选参数,其中第一个参数是异常信息。异常类的实例本身没有任何作用,它们必须被抛出才能被程序注意到。

让我们试着强制程序抛出这个异常。为此,让我们尝试创建一个 ID 为负数的客户。在 init.php 文件中添加以下内容:

$basic = new Basic(-1, "name", "surname", "email");

如果您现在在浏览器中进行尝试,PHP 会抛出一个致命的错误,说出现了一个未捕获的异常,这是预期的行为。对于 PHP 来说,异常是无法恢复的,因此它会停止执行。这并不理想,因为您希望只向用户显示一条错误信息,然后让他们再试一次。

您可以而且应该使用 try…​catch 块捕获异常。在 try 代码块中插入可能产生异常的代码,如果出现异常,PHP 就会跳转到 catch 代码块。让我们看看它是如何工作的:

public function setId(int $id) {
    try {
        if ($id < 0) {
            throw new Exception('Id cannot be negative.');
        }
        if (empty($id)) {
            $this->id = ++self::$lastId;
        } else {
            $this->id = $id;
            if ($id > self::$lastId) {
                self::$lastId = $id;
            }
        }
    } catch (Exception $e) {
        echo $e->getMessage();
    }
}

如果我们在浏览器中测试最后一段代码,就会看到 catch 代码块中打印的消息。在异常实例上调用 getMessage 方法,就会得到异常信息—​创建对象时的第一个参数。但请记住,构造函数的参数是可选的;因此,如果不确定异常是如何产生的,就不要过于依赖异常消息,因为它可能是空的。

请注意,抛出异常后,try 代码块中的其他代码都不会被执行;PHP 会直接进入 catch 代码块。此外,该代码块会得到一个参数,即抛出的异常。在这里,类型提示是强制性的—​你很快就会知道为什么。将参数命名为 $e 是一种广泛使用的惯例,尽管使用描述性不强的变量名并不是一种好的做法。

到目前为止,在这个示例中使用异常并没有任何真正的好处。一个简单的 if…​else 代码块就能完成同样的工作,不是吗?但异常的真正威力在于它可以跨方法传播。也就是说,setId 方法抛出的异常如果没有被捕获,就会传播到调用该方法的任何地方,从而让我们在那里捕获它。这一点非常有用,因为代码中的不同地方可能希望以不同的方式处理异常。要了解如何做到这一点,让我们移除 setId 中插入的 try…​catch,并在 init.php 文件中插入以下代码:

try {
    $basic = new Basic(-1, "name", "surname", "email");
} catch (Exception $e) {
    echo 'Something happened when creating the basic customer: ' . $e->getMessage();
}

前面的示例说明了捕获传播异常的作用:我们可以更具体地了解发生了什么,因为我们知道异常抛出时用户试图做什么。在本例中,我们知道用户正试图创建客户,但该异常可能是在试图更新现有客户的 ID 时抛出的,因此需要不同的错误信息。

finally 块

在处理异常时,还可以使用第三个代码块:finally 代码块。该代码块添加在 try…​catch 代码块之后,是可选的。事实上,catch 块也是可选的;限制条件是 try 之后必须有至少一个 finally 块。因此,你可能会遇到以下三种情况:

// scenario 1: the whole try-catch-finally
try {
    // code that might throw an exception
} catch (Exception $e) {
    // code that deals with the exception
} finally {
    // finally block
}

// scenario 2: try-finally without catch
try {
    // code that might throw an exception
} finally {
    // finally block
}

// scenario 3: try-catch without finally
try {
    // code that might throw an exception
} catch (Exception $e) {
    // code that deals with the exception
}

try 块或 catch 块全部执行完毕后,finally 块中的代码才会被执行。因此,如果没有异常,在 try 代码块中的所有代码执行完毕后,PHP 将执行 finally 代码块中的代码。另一方面,如果 try 代码块中出现异常,PHP 就会跳转到 catch 代码块,在执行完所有代码后,也会执行 finally 代码块。

为了测试此功能,让我们实现一个包含 try…​catch…​finally 块的函数,尝试用给定的 ID(通过参数)创建一个客户,并记录发生的所有操作。您可以将以下代码段添加到 init.php 文件中:

function createBasicCustomer($id)
{
    try {
        echo "\nTrying to create a new customer.\n";
        return new Basic($id, "name", "surname", "email");
    } catch (Exception $e) {
        echo "Something happened when creating the basic customer: " . $e->getMessage() . "\n";
    } finally {
        echo "End of function.\n";
    }
}

createBasicCustomer(1);
createBasicCustomer(-1);

如果您尝试这样做,您的浏览器将显示以下输出 - 请记住显示页面的源代码以查看其格式是否正确:

image 2023 11 02 15 52 54 661

结果可能与你的预期不同。第一次调用函数时,我们能够顺利创建对象,这意味着我们执行了返回语句。在一个正常的函数中,这应该是它的结束,但由于我们是在 try…​catch…​finally 块中,所以我们仍然需要执行 finally 代码!第二个示例看起来更直观,从 try 跳到 catch,然后再跳到 finally 块。

在处理昂贵的资源(如数据库连接)时,finally 块非常有用。在第 5 章 "使用数据库" 中,您将看到如何使用它们。根据连接的类型,使用后必须关闭连接,以便其他用户可以连接。无论函数是否抛出异常,finally 块都用于关闭这些连接。

捕获不同类型的异常

异常已经被证明是有用的,但还有一个重要功能需要展示:捕获不同类型的异常。正如你已经知道的,异常是类 Exception 的实例,与其他任何类一样,它们可以被扩展。从该类扩展的主要目的是创建不同类型的异常,但我们不会在内部添加任何逻辑—​当然,你也可以这样做。让我们创建一个从 Exception 扩展而来的类,用于识别与无效 ID 相关的异常。将这段代码放在 src/Exceptions/InvalidIdException.php 文件中:

<?php

namespace Bookstore\Exceptions;

use Exception;

class InvalidIdException extends Exception {
    public function __construct($message = null) {
        $message = $message ?: 'Invalid id provided.';
        parent::__construct($message);
    }
}

InvalidIdException 类从 Exception 类扩展而来,因此可以作为一个类被抛出。该类的构造函数包含一个可选参数 $message。下面两行代码很有意思:

  • ?: 运算符是条件运算符的简写版本,其工作原理如下:如果左边的表达式不为假,则返回左边的表达式,否则返回右边的表达式。在这里,我们希望使用用户提供的信息,或者在用户没有提供信息的情况下使用默认信息。有关更多信息和用法,请访问 PHP 文档 http://php.net/manual/en/language.operators.comparison.php

  • parent::__construct 将调用父类的构造函数,即异常类的构造函数。正如你已经知道的,该构造函数的第一个参数是异常信息。你可以说,既然我们是从 Exception 类扩展而来,就不需要调用任何函数,因为我们可以直接编辑该类的属性。避免这样做的原因是让父类管理自己的属性。设想一下,由于某种原因,在未来的 PHP 版本中,Exception 更改了消息的属性名称。如果直接修改,就必须在代码中修改,但如果使用构造函数,就不用担心。内部实现比外部接口更容易改变。

我们可以使用这个新的异常来代替通用的异常。将其替换为您的 Unique trait,如下所示:

throw new InvalidIdException('Id cannot be a negative number.');

您可以看到,我们仍在发送一条信息:这是因为我们希望更加具体。但是,如果不发送消息,异常也能正常工作。再试一次你的代码,你会发现没有任何变化。

现在设想一下,我们的数据库非常小,不能允许超过 50 个用户。我们可以创建一个新的异常来识别这种情况,比方说,src/ Exceptions/ExceededMaxAllowedException.php

<?php

namespace Bookstore\Exceptions;

use Exception;

class ExceededMaxAllowedException extends Exception {
    public function __construct($message = null) {
        $message = $message ?: 'Exceeded max allowed.';
        parent::__construct($message);
    }
}

让我们修改我们的 trait,以检查这种情况。在设置 ID 时,如果该 ID 大于 50,我们就可以认为用户数量已达到上限:

public function setId(int $id) {
    if ($id < 0) {
        throw new InvalidIdException('Id cannot be a negative number.');
    }
    if (empty($id)) {
        $this->id = ++self::$lastId;
    } else {
        $this->id = $id;
        if ($id > self::$lastId) {
            self::$lastId = $id;
        }
    }
    if ($this->id > 50) {
        throw new ExceededMaxAllowedException('Max number of users is 50.');
    }
}

现在,前面的函数会抛出两种不同的异常: InvalidIdExceptionExceededMaxAllowedException。在捕获它们时,您可能希望根据捕获的异常类型采取不同的行为方式。还记得必须在 catch 代码块中声明参数吗?您可以根据需要添加多个捕获块,并在每个捕获块中指定不同的异常类。代码可以如下所示

function createBasicCustomer(int $id)
{
    try {
        echo "\nTrying to create a new customer with id $id.\n";
        return new Basic($id, "name", "surname", "email");
    } catch (InvalidIdException $e) {
        echo "You cannot provide a negative id.\n";
    } catch (ExceededMaxAllowedException $e) {
        echo "No more customers are allowed.\n";
    } catch (Exception $e) {
        echo "Unknown exception: " . $e->getMessage();
    }
}

createBasicCustomer(1);
createBasicCustomer(-1);
createBasicCustomer(55);

如果您尝试此代码,您应该会看到以下输出:

image 2023 11 02 16 03 21 856

请注意,我们在这里捕获了三个异常:两个新异常和一个通用异常。这样做的原因是,可能会有其他代码抛出与我们定义的异常类型不同的异常,我们需要用通用异常类定义一个捕获块来捕获它,因为所有异常都将从通用异常类扩展而来。当然,这完全是可选的,如果不这样做,异常就会直接传播。

请注意 catch 块的顺序。PHP 会按照您定义的顺序使用捕获块。因此,如果您的第一个 catch 是针对异常的,那么其余的块将永远不会被执行,因为所有异常都是从该类扩展而来的。用下面的代码试试看:

try {
    echo "\nTrying to create a new customer with id $id.\n";
    return new Basic($id, "name", "surname", "email");
} catch (Exception $e) {
    echo 'Unknown exception: ' . $e->getMessage() . "\n";
} catch (InvalidIdException $e) {
    echo "You cannot provide a negative id.\n";
} catch (ExceededMaxAllowedException $e) {
    echo "No more customers are allowed.\n";
}

您从浏览器获得的结果将始终来自第一次捕获:

image 2023 11 02 16 04 52 541