AOP思想-API自定义全局异常处理

基于AOP面向切面编程思想, 接管框架中的异常类, 做到API自定义全局异常处理

我们接着重构tp6参数校验层的项目进行下面的代码:
在全局异常处理之前,我们先来实现一下 Banner 控制器的功能:
我们在 controller 的同级目录下新建一个 model 文件夹。在其下面新建一个 Banner 类。
(这里为了简洁,我们的控制器类和业务类都叫做 Banner,我们通过所属在不同的文件夹下进行区分即可)

<?php

namespace app\api\model;

class Banner{
  public static function getBannerById($id){
    //TODO: 根据 Banner ID 号,获取 Banner 信息
    return 'banner info';
  }
}

接着我们进行编辑控制器的类。由于控制器和业务类重名,所以需要在引入的时候注意:

<?php

namespace app\api\controller\v1;

use app\api\validate\IDMustBePositiveInt;
use app\api\model\Banner as BannerModel;

class Banner{
  public function getBanner($id){
    (new IDMustBePositiveInt())->goCheck();
    
    $banner = BannerModel::getBannerByID($id);
    return $banner;
  }
}

现在我们来假设这一种情况,客户端传来了 id 为 50,由于 50 是正整数,所以通过了参数校验,但我们的数据库中没有 id 号为 50 的 banner,这时候我们就需要进行相应的异常处理。
为了进行演示我们在 model\Banner 中加入以下错误的代码:

try{
  1/0;
}
catch(Exception $e){
  throw $e;
}

现在我们在业务类中抛出了异常,假如我们控制器中不做处理,那么就会抛给全局异常处理器处理,并返回以下 html 页面:
普通报错

这个页面适合后端调试,但是不适合给客户端来看,尤其是作为接口的返回来说。
有的人可能会说,返回 html 是因为开启了 tp5 调试模式,那么我们将 config.php 中的 'app_debug' 的值改为 false,又会返回一个这样的页面:
系统报错
这个页面当然也不适合API返回给客户端

那么我们在设计接口的时候该如何向客户端返回错误信息呢?
基于 RESTFul 规范,我们需要定义一个统一的错误返回消息。
我们改写一下控制器中的代码:

class Banner{
  public function getBanner($id){
    (new IDMustBePositiveInt())->goCheck();

    try{
      $banner = BannerModel::getBannerByID($id);
    } 
    catch(Exception $e)
    {
      $err = [
        'error_code' => 10001,
        'msg' => $e->getMessage()
      ];
      return json($err,400); // 注意不能直接返回数组,而应该用json包一下
    }

    return $banner;

  }
}

我们再在 Postman 里看一下返回结果:
接管后报错

客户端可以处理的返回结果

400状态码

这样我们这种直白的方法就写出来了,但我们反思一下,如果每一个控制器我们都要这样繁琐地处理异常,那么我们今后编写代码的思路一定难以保证十分流畅,而是会在这些异常的处理上耗费大量精力。而且这个仅仅是一个示例,实际上我们很多情况下是不可预知是否会有异常的,可能还会返回 tp5 自己的错误的网页,对于我们 API 来说是不合适的。

现在我们花了大量的篇幅展示了一种错误的、复用性差的直白写法,比起直接展示最终的结果,演示这些错误的写法我认为也是很有必要的,因为这是我们一步一步思路的体现。重构代码不是一蹴而就的,期间代码的写法也会越来越抽象,所以我们需要静下心来,不断地完善。

我们先来梳理一下异常的分类:
异常分类
tp5 有一个全局异常处理类,如果我们想自己实现上面的分类,需要覆盖和重写默认的全局异常处理类。
我们现在在 api 模块的同级下新建一个 lib 文件夹,再新建一个 exception 文件夹。
(我们想让这个 exception 里的类是一个通用的,可以供很多模块使用的一个类库。)
新建 ExceptionHandler 的 php class,并继承 Handle 类。

<?php 
namespace app\lib\exception;

use think\exception\Handle;

class ExceptionHandler extends Handle {
  public function render(Exception $e){ // 重写render方法
    return json('~~~~~~~~~');
  }
}

我们现在来验证一下是否会通过我们重写的 ExceptionHandler 的 render 方法中的格式呈现异常。
在此之前,我们先去掉(上)篇中的控制器自己处理的过程,将控制器还原为:

public function getBanner($id){
  (new IDMustBePositiveInt())->goCheck();
  $banner = BannerModel::getBannerByID($id);
  return $banner;
}

并且重新指定 tp5 的全局异常处理类:
在 config.php 文件中的 'exception_handle'字段输入我们自定义的处理器的命名空间:
app\lib\exception\ExceptionHandler
用 postman 运行后就可以看到 render 返回的值了。
测试

现在我们继续来写 render 方法来区分前面提到的两种异常:
其中有一种异常需要向客户端返回具体信息,我们需要新建一个 BaseException 类:

<?php
namespace app\lib\exception;

class BaseException {
  public $code = 400; // HTTP 状态码 404,200...
  public $msg = '参数错误';  // 错误信息具体
  public $errorCode = 10000;  // 自定义错误码
}

这里的我们可以随便写,因为子类错误会将其覆盖。
我们新建一个 BannerMissException 的 php 类,继承 BaseException。
比如说:

<?php
namespace app\lib\exception;

class BannerMissException extends BaseException
{
    public $code = 404;
    public $msg = '请求Banner不存在';
    public $errorCode = 40000;
}

所以只要是继承于 BaseException 的异常类都是我们自定义的类,且需要返回给客户端信息。
我们这样修改 render 方法:

private $code;
private $msg;
private $errorCode;
// 还需要返回客户端当前请求的URL地址
public function render(Exception $e){
  if($e instanceof BaseException){
    $this->code = $e->code;
    $this->msg = $e->msg;
    $this->errorCode = $e->errorCode;
  } else {
    $this->code = 500;
    $this->msg = '服务器内部异常';
    $this->errorCode = 999; 
  }
  $request = Request::instance();

  $result=[
    'msg' => $this->msg,
    'error_code' => $this->errorCode,
    'request_url' => $request->url()
  ];
  
  return json($result,$this->code);
}

我们现在运行后就会发现:
json报错

不会报出 Division by 0 错误

我们下面来测试一下 BannerMissException,
将 model\Banner 中的 1/0 注释掉,改为:

class Banner
{
    public static function getBannerByID($id)
    {
        return null;
    }
}

然后控制器里检测一下拿到的 $banner 是否为空,因为 RESTFull 规则中规定获取的值为空也是一种获取不到资源的异常,所以我们如下编辑控制器的代码:

class Banner
{
    public function getBanner($id)
    {
        (new IDMustBePositiveInt())->goCheck();

        $banner = BannerModule::getBannerByID($id);
        if (!$banner) {
            throw new BannerMissException();
        }
        return $banner;
    }
}

查看返回结果:
自定义内容报错

tp5 会有一个自动写日志的机制,在 runtime 文件夹下的 log 文件夹下有写好的日志,但自动写日志并不是很好,因为对于开发环境,这样意义不大,而且 tp5 的日志格式不能变更、内容太多,而且有的日志是由于用户操作失误发生,我们并不需要进行记录。
要想变更日志记录的位置,我们需要首先看一下 tp5 是如何配置日志写入的:
在 config.php 中可以看到:

'log'=> [
        // 日志记录方式,内置 file socket 支持扩展
        'type'  => 'File',
        // 日志保存目录
        'path'  => LOG_PATH,
        // 日志记录级别
        'level' => [],
]

那么 LOG_PATH 这个常量配置在哪里呢?
我们先看一下入口文件 index.php 发现除了里面 define 了一个 APP_PATH 之外,还加载了一个引导文件 start.php,打开之后发现又加载了 base.php,继续打开,发现了配置常量的位置。
下面我们来看一下如何修改,因为 base.php 中

defined('LOG_PATH') or define('LOG_PATH', RUNTIME_PATH . 'log' . DS);
会先判断是否定义了,如果提前定义过了,这里就不会定义。
而提前定义在 index.php 中 APP_PATH 的定义就是一个很好的范例。
我们仿照 APP_PATH 在 index.php 中定义。

define('APP_PATH', DIR . '/../application/');
define('LOG_PATH', DIR . '/../log/');
现在运行后就会发现新建了一个 log 文件夹,并且内部记录了日志。
下面我们要关闭 tp5 默认的日志记录行为, 来自定义我们自己的记录。
将 config.php 的 type 改为 test:

'log'=> [
        // 日志记录方式,内置 file socket 支持扩展
        'type'  => 'test',
        // 日志保存目录
        'path'  => LOG_PATH,
        // 日志记录级别
        'level' => [],
]

运行后发现不再记录日志。
我们接下来在 ExceptionHandler 中添加一个方法来记录日志:

private function recordErrorlog(Exception $e){
  Log::init([
            'type' => 'File',
            'path' => LOG_PATH,
            'level' => ['error']
  ]);
  Log::record($e->getMessage(),'error');
}

并且在 render 方法中服务器内部错误的位置调用该方法
$this->rerecordErrorlog($e)
即可看到服务器内部异常时会记录日志。

下面为大家展示可用于生产环境的BaseExcepiton和ExceptionHandle

ExceptionHandle
<?php

namespace app;

use app\lib\BaseException;
use app\lib\Exceptions\ServiceException;
use app\lib\services\Log;
use think\facade\Env;
use think\Response;
use think\facade\Request;
use think\exception\Handle;
use think\db\exception\DataNotFoundException;
use think\db\exception\ModelNotFoundException;
use think\exception\HttpException;
use think\exception\HttpResponseException;
use think\exception\ValidateException;

use Throwable;

/**
 * 应用异常处理类
 */
class ExceptionHandle extends Handle
{
    /**
     * http状态码
     * @var int
     */
    protected $code;

    /**
     * 错误信息
     * @var string
     */
    protected $msg;

    /**
     * 错误码
     * @var mixed|int
     */
    protected $errorCode;

    /**
     * 错误文件
     * @var mixed
     */
    protected $errorFile;

    /**
     * 日志记录提示语
     * @var mixed
     */
    protected $logMsg;

    /**
     * 日志记录错误文件
     * @var
     */
    protected $logErrorFile = null;

    /**
     * 日志通道
     * @var string
     */
    protected $logChannel = 'file';

    /**
     * 日志记录等级
     * @var string
     */
    protected $logStatus = 'error';

    /**
     * 错误路径
     * @var string|mixed
     */
    protected $errorPath;

    /**
     * 不需要记录信息(日志)的异常类列表
     * @var array
     */
    protected $ignoreReport = [
        HttpException::class,
        HttpResponseException::class,
        ModelNotFoundException::class,
        DataNotFoundException::class,
        ValidateException::class,
        BaseException::class,
    ];

    /**
     * 记录异常信息(包括日志或者其它方式记录)
     *
     * @access public
     * @param Throwable $exception
     * @return void
     */
    public function report(Throwable $exception): void
    {
        // 使用内置的方式记录异常日志
        parent::report($exception);
    }

    /**
     * Render an exception into an HTTP response.
     *
     * @access public
     * @param \think\Request $request
     * @param Throwable $e
     * @return Response
     * @throws ServiceException
     */
    public function render($request, Throwable $e): Response
    {
        // 添加自定义异常处理机制
        if ($e instanceof BaseException) {
            //如果是自定义的异常,状态统一是200,请求成功,业务失败
            $this->code = 200;
            $this->msg = $e->msg;
            $this->errorCode = $e->errorCode;
            $this->logStatus = 'debug';
            $this->logMsg = $e->msg;
        } else {
            //如果是系统异常,修改抛出机制为json
            //获取http状态码
            if ($e instanceof HttpException) {
                $this->code = $e->getStatusCode();
            } else {
                $this->code = 500;
            }
            if (Env::get('app_debug')) {
                $this->msg = $e->getMessage();
                $this->errorCode = 404010;
                $this->errorFile = basename($e->getFile()) . ' line ' . $e->getLine();
                //return parent::render($request, $e);
            } else {
                $this->msg = '网络出错啦,请稍后重试~';
                $this->errorCode = 500010;
            }

            $this->logMsg = $e->getMessage();
            $this->logErrorFile = basename($e->getFile()) . ' line ' . $e->getLine();
        }

        $this->errorPath = $request->url();
        $this->logChannel = $e->logChannel ?? $this->logChannel;

        $result = [
            'msg' => $this->logMsg,
            'errorFile' => $this->logErrorFile,
            'error_code' => $this->errorCode,
            'error_path' => $this->errorPath,
            'header_ak' => $request->header('access-key'),
        ];
        $this->logRecord($result);
        // 其他错误交给系统处理
        $result['msg'] = $this->msg;
        $result['errorFile'] = $this->errorFile;
        //报错信息不返回客户端应用标识
        unset($result['header_ak']);
        //return parent::render($request, $e);
        return json($result, $this->code);
    }

    /**
     * @title  日志记录
     * @param array $result 日志信息
     * @throws ServiceException
     * @author  Coder
     * @date   2019年11月25日 14:49
     */
    private function logRecord(array $result): void
    {
        if (empty($this->errorFile)) {
            unset($result['errorFile']);
        }
        if ($this->errorCode == 500010) {
            $result['error_file'] = $this->logErrorFile;
        }
        $result['request_url'] = Request::url(true);
        $result['request_param'] = Request::param();
        (new Log())->setChannel($this->logChannel)->record($result, $this->logStatus);
    }

}
BaseException
<?php
// +----------------------------------------------------------------------
// |[ 文档说明: 异常类基类]
// +----------------------------------------------------------------------

namespace app\lib;

use RuntimeException;

class BaseException extends RuntimeException
{

    /**
     * HTTP 状态码 404,200
     * @var int|mixed
     */
    public $code = 400;

    /**
     * 错误具体信息
     * @var string
     */
    public $msg;

    /**
     * 自定义的错误码
     * @var string|int
     */
    public $errorCode;

    /**
     * 默认错误码
     * @var int
     */
    private $defaultErrorCode = 100000;

    /**
     * 默认错误信息
     * @var string
     */
    private $defaultMsg = '参数错误';

    /**
     * 默认记录日志通道
     * @var mixed|string
     */
    public $logChannel = 'file';

    /**
     * 数据
     * @var array|mixed
     */
    public $data = [];

    /**
     * 全局异常码配置文件名称
     * @var string
     */
    private $exceptionCodeConfig = 'exceptionCode';
    private $configExt = '.php';

    /**
     * BaseException constructor.
     * @param array $params
     */
    public function __construct($params = [])
    {
        if (array_key_exists('code', $params)) {
            $this->code = $params['code'];
        }

        if (array_key_exists('data', $params)) {
            $this->data = $params['data'];
        }

        if (array_key_exists('logChannel', $params)) {
            $this->logChannel = $params['logChannel'];
        }

        //读取全局异常码配置
        $this->getExceptionCode($params);
    }

    /**
     * @title  获取异常码
     * @param array $params
     * @return void
     */
    public function getExceptionCode(array $params): void
    {
        $user = $this->getExceptionUserCode($params);
        if ($user['status']) {
            $errorCode = $user['errorCode'];
            $msg = $user['msg'];
        } else {
            $config = $this->getExceptionConfigCode($params);
            $errorCode = $config['errorCode'];
            $msg = $config['msg'];
        }
        $this->errorCode = $errorCode;
        $this->msg = $msg;
    }

    /**
     * @title  获取用户自定义异常码
     * @param array $params
     * @return array
     */
    public function getExceptionUserCode(array $params): array
    {
        $userExist = false;
        $errorCode = $params['errorCode'] ?? ($this->errorCode ?? null);
        $msg = $params['msg'] ?? ($errorCode == $this->errorCode ? $this->msg : null);
        if (!empty($msg)) $userExist = true;
        return ['status' => $userExist, 'errorCode' => $errorCode, 'msg' => $msg];
    }

    /**
     * @title  获取配置文件异常码
     * @param array $params
     * @return array
     */
    public function getExceptionConfigCode(array $params): array
    {
        $Code = $params['errorCode'] ?? ($this->errorCode ?? null);
        if (file_exists(app()->getConfigPath() . $this->exceptionCodeConfig . $this->configExt)) {
            $thisClass = get_class($this);//获取子类名称
            $thisException = trim(strrchr($thisClass, '\\'), '\\');
            $configCode = config($this->exceptionCodeConfig . '.' . $thisException);
            $errorCode = empty($Code) ? (key($configCode) ?? null) : $Code;
            $msg = empty($Code) ? (current($configCode) ?? null) : ($configCode[$Code] ?? null);
            if (empty($msg)) {
                $miss = explode(":", config($this->exceptionCodeConfig . '.' . 'miss'));
                if (!empty($miss)) {
                    $errorCode = $miss[0];
                    $msg = $miss[1];
                }
            }
        }
        return ['errorCode' => $errorCode ?? $this->defaultErrorCode, 'msg' => $msg ?? $this->defaultMsg];
    }


}

添加新评论