如何组织 PHP 中的异常

1,534 阅读8分钟
原文链接: www.jianshu.com

Exception

本文的主题是怎么组织php的异常?在大型项目中异常往往被我们忽略,但是如果前期没有很好的规划好,越到项目后期,重构的成本会越大。

在实际工作中,对于错误的处理,我们一帮都是直接返回错误号,然后从最内层一层一层往外面传,最后将错误返回给用户,很少使用异常,可能是因为公司里最初写代码比较早,13,14年开始使用php,当时第一批使用者是从C转过来的,从而没有使用异常,导致现在都16年了,php都出7了,我们在实际代码中还是没有使用异常,我前不久在项目中引入了异常,但也是简单的使用try catch,没有很多的经验,网上搜索也只是简单的一些使用例子,没有说在大型项目中怎么去使用,最近也是在读The Clean Architecture in php,深知代码组织的重要性,如果前期没有很好的组织好,后期的维护,重构代价都会很大,今天看到两篇文章:

Structuring PHP Exceptions

A Crash Course of Changes to Exception Handling in PHP 7

所以就有了本文。写这篇文章的目的是探讨一些在实际中怎么使用异常的方式,也希望得到大家的反馈,大家平时在开发中是怎么使用异常的?如何组织的。

为什么还使用异常?

在讨论使用异常之前,我们得统一认识:使用异常对项目是有益的。我们看看没有异常的时候,我们的处理方式。

返回错误号

function foo($arrInput) {
    if ($arrInput['user_id']<0){
        return -1; // 参数错误
    }
    // something else
}

当程序遇到错误时返回一个错误码,使用这种方式的好处是:我们每次在调用完函数后,都会检查返回值,当出现错误的时候,马上进行处理。

但是坏处也很明显:错误的处理和正常的业务逻辑耦合在了一起,我们平时开发中一个很恼人的感触就是:写一个业务逻辑,可能异常错误处理就占了2/3的代码,愁人啊,于是有人就发明了异常

在php中对错误的处理有两种,一种是error和warnings,另一种是异常。

errors & warnings

php中的errors和warnings来源于过程式的代码,在过程式代码中,我们按照既定的步骤一步一步执行,此时如果出现了错误,我们必须要将程序的控制权接管过来,在PHP中是通过 set_error_handler 方法来设置处理函数的,但是这种方式没能提供一种有效的错误恢复手段,你可能除了打印下错误信息后,没有足够的错误发生时的上下文信息让你来恢复错误了。

exceptions

一般我们使用异常的代码如下:

try {
  find_slash(string);
} catch(AnException& e) {
    //Handle exception
}

这样做的好处是:程序逻辑和错误处理分离了。你可以看到函数是如何工作的,同时也可以看到失败时候是怎么处理的。另外,现在可以提供更多的异常发生的上下信息,帮助你从发生的异常中恢复出来。

举个例子:当从数据库中获取一条记录的时候发生了异常,我们可以根据异常的不同类型,采取不同的结果。如果异常时由于没有我们想要的id记录,我们可能返回一个NullObject 是更好的方式,但如果异常是由于数据库连接的断开,我们可能会继续抛出异常,让异常被更上层的函数看到,因为这个异常在此处我们已经没有能够恢复的方法了。

通过SPL来构建异常

Standard PHP Library (SPL) 标准库中提供了一些predefined set of exceptions,我们可以基于这些预定于的异常进行扩展,得到满足我们自己需求的代码。这样子做的好处是,我们能够很方便的捕获(catch)这些异常。

此处提供一个组织异常的方案:standard set of exception groupings 是一些预定义的异常,每次在使用的使用,通过composer引入。通过引入这一抽象层的目的是:让我能更好的区分想要捕获异常的粒度。

standard set of exception groupings 中的每个异常,都extend了SPL中的异常,而且实现了BrightNucleus\Exception\ExceptionInterface 接口,这么做可以方便我只捕获框架相关的异常,通过只捕获实现了接口的异常。

下面列举了捕获不同粒度的异常的方法:

  1. Catch all exceptions
    catch( Exception $exception ) {}
  2. Catch all exceptions thrown by a Bright Nucleus library
    catch( BrightNucleus\Exception\ExceptionInterface $exception ) {}
  3. Catch a specific SPL exception (BrightNucleus or not)
    catch( LogicException $exception )
  4. Catch a specific SPL exception thrown by a Bright Nucleus library
    catch( BrightNucleus\Exception\LogicException $exception ) {}

命名规范

目前命名的一个原则是:

  1. 该异常如果代表一个具体的错误,则使用一个过去时态的语句表明错误发生的原因
  2. 如果异常是一个基类,需要别的类进行扩展,则统一后缀Exception

看一个具体的例子:

假设我们有一个功能是从文件中读取内容,可能会有3种错误发生:

  1. 文件名不合法
  2. 文件不存在
  3. 文件不可读

此时会有3种错误:

FileNameWasNotValid extends InvalidArgumentException
FileWasNotFound extends InvalidArgumentException
FileWasNotReadable extends RuntimeException

此时具体的错误都是过去式的句子,而基类都是带有统一后缀的。

通过构造函数捕获异常逻辑

我们一般在实例化异常的时候,都是直接在使用的时候才去new出来,但是这种方式导致异常的代码可能会比正常的业务逻辑还负杂,非常不适合阅读,而且将相同的实例化逻辑放的到处都是,也不符合代码重用的原则,我们举个例子:

public function render( $view ) {

   if ( ! $this->views->has( $view ) ) {
      $message = sprintf(
         "The View "%s" does not exist.",
         json_encode( $view )
      );

      throw new ViewWasNotFound( $message );
   }

   echo $this->views->get( $view )->render();
}

上面的代码中异常的处理逻辑比正常的业务逻辑还多,我们重构下,将异常的构建封装起来:

class ViewWasNotFound extends InvalidArgumentException {

   public static function fromView( $view, $code = null, Exception $previous = null ) {
      $message = sprintf(
         "The View "%s" does not exist.",
         json_encode( $view )
      );

      return new static( $message, $code, $previous );
   }
}

我们可能会有多个构造函数,每个构造函数有不同的应用场景,此时我们再来写我们的render函数:

public function render( $view ) {

   if ( ! $this->views->has( $view ) ) {
      throw ViewWasNotFound::fromView( $view );
   }

   echo $this->views->get( $view )->render();
}

现在代码就非常简洁了。

异常捕获

问:我们需要捕获什么异常?

答:只捕获当前上下文下能够处理的异常。

如果当前操作返回NullObject也ok,那在最外层套一个catch( Exception $exception ) {}就完全ok。但是如果当前操作只有正确才能保证后续操作继续,那你可能就需要捕获那些你当前能恢复的异常,那些不能恢复的异常,则让它往更上层去。

在SPL中,我们定义了两大类异常:

  1. Logic exceptions

    逻辑异常是那些由于开发者的错误而导致的异常。你可能在请求一些不存在的值,或者调用传递的参数不对等等。这些异常在开发中都需要我们马上处理掉的。在理想情况下,这些逻辑异常在实际生产系统中是不应该出现的。

  2. runtime exception

    运行时异常是一些在开发中不能控制的异常,如:数据库链接的异常断开,文件的读写权限不对等等。这些错误是无法避免的,我们不可能开发一个没有错误的系统,我们能做得只是当这些错误发生的时候,尽快的去通知系统管理员,而不是代码出现fatal

这就是为什么我们在开发中需要在某一软件层捕获运行时错误,而对于逻辑错误,我们尽可能让它在开发时就让他们暴露出来,好让我们在开发时就解决它。

中心化的Error处理函数

我们将逻辑异常都pass through,没有去捕获,那么作为一个web应用,我们不能让用户无响应啊,因此我们需要通过一个中心化的处理函数来捕获所有我们没有处理的异常。

捕获后,我们一般的工作是:记录这些异常,记录调用栈,方便我们去分析解决这些问题。

对于这个工作,我推荐使用 BooBoo 来做。

总结

此处总结下我们的原则:

  • 对于运行时异常,我们尽量捕获然后进行处理,重要的上报错误,让管理员知道系统异常,而对于逻辑异常我们则是将其尽可能详细的记录下来,因为这些错误理论上是不应该出现在生产环境中。
  • 我们在捕获异常的时候,只捕获在该层级能处理的异常,对于不能处理的则让它到上一层上去。
  • 我们需要一个全局的异常处理函数,处理如返回html,json这种格式问题,以及处理错误信息的转换(隐藏系统内部错误信息),错误的记录,现场环境的保存等公共逻辑。

一个示例

讲了这么多,还是那句话

talk is cheap, show me the code

我们基于的一个基本代码是:

$user = $this->usersGateway->fetchOneById($userId);

if (!$user) {
    throw new Exception('User with the ID: ' . $userId . ' does not exist');
}

用户定义异常

上面针对找不到user的情况,我们只是简单的抛出了异常。但是上面的问题是:仅仅抛出异常不足以帮助我们定位问题,单一的异常类型,不能让我们针对不同的类型做出不同行为,因此解决方法是自定义异常。

class UserNotFoundException extends RuntimeException
{
}

//...

throw new UserNotFoundException('User with the ID: ' . $userId . ' does not exist');

格式化异常

现在我们已经有了异常类,并且异常的生成和异常消息都是异常类本身的职责,因此我们根据单一职责(SRP)将其组织到异常类中:

class UserNotFoundException extends RuntimeException
{
    public static function forUserId(string $userId) : self
    {
        return new self(sprintf(
            'User with the ID: %s does not exist',
            $userId
        ));
    }
}

在使用异常的地方我们简单的调用下面的代码:

throw UserNotFoundException::forUserId($userId);

聚合异常

根据单一职责(SRP)我们将相同异常放到一起,不同的功能拆分出来,看例子:

class UserException extends Exception
{
    public static function forEmptyEmail() : self
    {
        return new self("User's email must not be empty");
    }

    public static function forInvalidEmail(string $email) : self
    {
        return new self(sprintf(
            '%s is not a valid email address',
            $email
        ));
    }

    public static function forNonexistentUser(string $userId) : self
    {
        return new self(sprintf(
            'User with the ID: %s does not exist',
            $userId
        ));
    }
}

在上面的例子中,异常类UserException有两个功能,第一个负责User的验证异常,另一个则是没有用户的异常,因此我们应该拆分为两个:

class InvalidUserException extends DomainException
{
    public static function forEmptyEmail() : self
    {
        return new self("User's email address must not be empty");
    }

    public static function forInvalidEmail(string $email) : self
    {
        return new self(sprintf(
            '%s is not a valid email address',
            $email
        ));
    }
}
class UserNotFoundException extends RuntimeException
{
    public static function forUserId(string $userId) : self
    {
        return new self(sprintf(
            'User with the ID: %s does not exist',
            $userId
        ));
    }
}

此时我们就能针对不同的异常类采取不同的措施,可能我们会根据异常类返回合适的 HTTP status codes。

异常代码

异常的构造函数接受code作为第二个参数,所以我们可以通过不同的错误码来标志不同的错误。

class UserNotFoundException extends RuntimeException
{
    public static function forUserId(string $userId) : self
    {
        return new self(
            sprintf(
                'User with the ID: %s does not exist',
                $userId
            ), 
            ErrorCodes::ERROR_USER_NOT_FOUND
        );
    }
}

我们会将所有的错误码都放到一个文件中,方便管理。

组件级别的异常

当我们提供一个库给别人使用的时候,我们可能希望能够捕获我们库级别的异常,这通过一个模式Marker Interface可以实现:

namespace App\Domain\Exception;

interface ExceptionInterface
{
}

class UserNotFoundException extends RuntimeException impements ExceptionInterface
{
    public static function forUserId(string $userId) : self
    {
        return new self(
            sprintf(
                'User with the ID: %s does not exist',
                $userId
            ), 
            ErrorCodes::ERROR_USER_NOT_FOUND
        );
    }
}

我们通过在每个命名空间都声明一个ExceptionInterface类来实现,这样我们就可以通过代码try{}catch(ExceptionInterface $e){}来捕获所有本库的错误。

错误处理

上代码:

class UserController extends BaseController
{
    public function viewUserAction(RequestInterface $request)
    {
        try {
            $user = $this->userService->get($request->get('id'));

            return new JsonResponse($user->toArray()); 
        } catch (\Exception $ex) {
            return new JsonResponse([
                'error' => $ex->getCode(),
                'message' => $ex->getMessage(),
            ], 500);
        }
    }
}

上面的处理中,我们在controller中通过一个最外层的try{}catch{}捕获了所有异常,但是我们针对不同的需求可能会有不同的返回格式的要求,可能我们需要针对参数的不同返回html或者json格式,另外我们也不希望底层的错误信息,如:数据库连接失败,这样子的错误信息直接返回给调用方,那怎么解决呢?

这就要用到PHP的全局异常处理函数了,通过set_exception_handler来设置,另外推荐除了 BooBoo 另外一个开源库:Whoops,能很好的解决这个问题。

你的观点

相信你在实际工作中肯定也遇到过好多类似的困扰,你在实际工作中也有你自己的一套解决方案,期待你的分享,让更多的人知道好的优秀的方案,所以期待你在评论区写下你的方案。

这是 php异常系列 的第一篇,你的鼓励是我继续写下去的动力,期待我们共同进步。

参考文章

Structuring PHP Exceptions

A Crash Course of Changes to Exception Handling in PHP 7

Best practices for handling exceptional behavior