levacic/ exception-with-context
用于携带自身上下文的异常类通用接口
Requires
- php: ^7.0|^8.0
README
这个最小化包提供了一个单一的ExceptionWithContext
接口,该接口可以在客户端代码中实现,以便您的异常对象可以携带自己的上下文。
有关具体如何以及为什么想要这样做,请参阅“使用”部分。
要求
- PHP >= 7.0
安装
composer require levacic/exception-with-context
使用
为什么?
“为什么”显然取决于您通常如何使用异常,但此功能在记录异常时特别有用,以便在抛出异常时获得一些额外的信息。一般来说,如果您在2020年的PHP中记录,您很可能使用与PSR-3兼容的记录器 - 而这些记录器允许您记录附加的上下文数据,以及实际的日志消息。
此包以及一些手动或自动的连接(如以下所述)允许异常携带自己的上下文,这样您的记录逻辑既更清晰又更有信息量。
示例实现
使用此包时,您通常会实现一个异常类,例如这样
<?php declare(strict_types=1); namespace App\Exceptions; use Levacic\Exceptions\ExceptionWithContext; use RuntimeException; use Throwable; class UserNotActivated extends RuntimeException implements ExceptionWithContext { /** * The ID of the non-activated user. */ private int $userId; /** * @param int $userId The ID of the non-activated user. * @param Throwable|null $previous The previous exception. * @param int $code The internal exception code. */ public function __construct(int $userId, ?Throwable $previous = null, int $code = 0) { parent::__construct('The user has not been activated yet.', $code, $previous); $this->userId = $userId; } /** * @inheritDoc */ public function getContext(): array { return [ 'userId' => $this->userId, ]; } }
注意:我的首选约定是不要在异常类上使用
Exception
后缀,尽管在PHP社区中这样做很常见。原因是我个人认为这没有任何有用的信息 - 异常类通常位于某种Exceptions
命名空间中,当您在代码中处理它们时,通常是在throw
或catch
它们 - 因此很明显它们是什么。
就是这样。这个想法仅仅是传递额外的上下文信息 - 在这个虚构的例子中,是一个用户ID - 给异常,以便它可以在getContext()
方法实现中返回它。
如何将上下文传递给异常?这无关紧要!
此示例实现使用构造函数来实现,因此您可能会这样抛出它
if (!$user->isActivated()) { throw new UserNotActivated($user->id); }
您也可以使用setter方法,甚至是一个公共属性(尽管如果您更喜欢代码的不可变性,您可能不想这样做)。
并且所有这些方法都适用于您更喜欢使用异常工厂(无论是作为独立的类,还是异常类本身的静态创建方法)。
唯一重要的事情是
- 异常对象携带一些额外的上下文信息
- 这些额外的上下文信息通过一个
getContext()
方法公开,该方法返回一个信息数组
这使得无论在哪里/如何处理您的日志,都可以轻松地将此上下文与异常一起记录。
如何记录
记录是一个复杂的话题,它很大程度上取决于您对记录的一般方法(例如,您想要记录什么内容,何时/为什么,您将记录在哪里到等),以及您的应用程序(或框架)的记录架构 - 因此本节将稍微通用一些。
那么您是如何记录这些信息的呢?
假设您有一个PSR-3记录器实例,您可以这样做
$logger->error($exception->getMessage(), $exception->getContext());
当然,日志记录器需要配置一个处理程序和格式化器,以便能够处理和输出上下文,无论您将其记录到何处。据我所知,大多数常见的默认设置已经处理了这一点,所以大多数时候,这应该可以按原样工作。
您在哪里做这件事?您想要在的地方!
您肯定想要在应用程序的顶级异常处理程序中做这件事。
您还可以在任意一个catch
块中这样做,其中您可能已经有一种处理情况的方法,但仍然想要记录这个异常发生了,用于调试或一般日志记录目的。
例如,如果您的应用程序中有一个与外部API交互的服务类,您可能会有这样的代码
$response = makeRequestToExternalApi($someRequestData); if ($response->statusCode !== 200) { throw new InvalidResponseFromExternalApi( $someRequestData, $response->statusCode, $response->body, $response->headers, ); } return $response;
InvalidResponseFromExternalApi
异常的构造函数会接受这些参数,存储它们,并在getContext()
方法中优雅地格式化它们。
一些高级代码,例如位于应用程序HTTP层中的控制器,可能会这样做
try { $response = $apiService->getResponse(); } catch (InvalidResponseFromExternalApi $exception) { $logger->error($exception->getMessage(), $exception->getContext()); return new \Symfony\Component\HttpFoundation\Response('External API is currently unavailable.', 503); } return $response;
(或者您可以将它重新抛出为ServiceUnavailableHttpException
或适合您应用程序架构的任何其他异常)。
这样,您基本上会向客户端返回一个有用的消息,同时在内部记录发生了错误 - 并且这个日志消息将包括有用的上下文信息。
Laravel
如果您使用的是较新的Laravel版本,您可以在App\Exceptions\Handler
中重写Illuminate\Foundation\Exceptions\Handler::exceptionContext()
,它扩展了默认Laravel设置中的Laravel类
protected function exceptionContext(Throwable $e) { if ($e instanceof ExceptionWithContext) { return $e->getContext(); } return parent::exceptionContext($e); }
当Laravel记录错误时,会自动调用此方法。
因此,在应用程序代码中未捕获的任何具有上下文的异常都将与它们携带的上下文一起记录。
然而,这仅在捕获的异常是携带上下文的异常时才有效 - 但任何具有上下文的链式异常将忽略其上下文。如果您当然使用Monolog,可以通过自定义Monolog处理器实现更好的解决方案。
自定义Monolog处理器
您可以将处理器附加到Monolog,该处理器检查日志记录记录的上下文中是否设置了exception
键,如果是,则检查它是否是Throwable
,然后在那种情况下执行一些自定义逻辑。
为了实现我们想要的,我们基本上只需要遍历异常链(通过$exception->getPrevious()
),并对链中的每个异常检查它是否实现了ExceptionWithContext
,然后提取该数据。
事实上,我正在创建一个具有处理器的包,该处理器正好做这件事 - 一旦发布,我将在下面链接到它。
许可证
此软件包是开源软件,根据[MIT许可证][LICENSE]授权。