基于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);
}
我们现在运行后就会发现:
不会报出 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];
}
}