做后端接口联调的时候,最怕的不是接口报错,而是报错信息没法看。比如你用 Hyperf 起了一个接口服务,前端同学调用时抛了个业务异常,结果响应里返回的是一大段带堆栈的 HTML,里面有服务器的绝对路径、框架内部调用链,甚至连环境变量在某些配置下都可能被带出来。前端没法解析,运维觉得不安全,你自己排查问题还得从一堆乱码里翻关键信息。这个场景我碰到过好几次,最后都是从 Hyperf 的自定义异常处理器上手,把整个异常出口统一收口成一套可预期的 JSON 结构。这篇文章就把这套方案完整拆开,从调度原理到落地代码,再到实际项目里踩过的坑,一次性说清楚。
1. 异常处理器在 Hyperf 里的调度逻辑,先搞清楚机制再动手
1.1 默认异常输出的痛点究竟在哪
先说 Hyperf 默认的异常处理行为。框架本身自带一个 Hyperf\ExceptionHandler\Handler\ErrorExceptionHandler 之类的兜底逻辑,在没有显式注册自定义处理器的情况下,用户请求触发的未捕获异常最终会走框架内置的响应逻辑,把异常信息直接渲染到响应体里。
这样带来的问题有三个最突出:
一个是不符合接口协议。现在前后端分离,接口返回几乎都是 JSON 结构。但默认异常输出是 HTML 文本,前端拿到之后要么直接白屏,要么只能把 message 字段粗暴截取,根本没办法统一弹 toast、跳登录、刷 token。
另一个是信息泄漏风险。默认输出里可能包含异常类名、代码文件路径、甚至 SQL 片段,生产环境如果把这些原样返回给客户端,等于把服务器的内部结构暴露给攻击者。
还有一个是无法区分异常类型。参数校验异常、业务规则异常、数据库异常、第三方接口超时,这些在业务层面是完全不同的错误,前端需要根据不同的错误类型做不同交互。但默认异常处理器不会帮你做语义化包装,所有异常几乎都长一个样。
所以做 Hyperf 项目的第一件事,我建议就是把异常出口接管过来。自定义异常处理器解决的正是这几个核心问题:统一响应结构、过滤敏感信息、按异常类型做差异化处理。
1.2 调度链与处理器生命周期
Hyperf 对异常处理器的调度,并不是像 PHP-FPM 时代那样靠 try/catch 层层手写,而是由框架的 ExceptionHandlerDispatcher 统一分发。
当一个异常没有被业务代码捕获时,框架会拿到当前 HTTP 请求对应的 ResponseInterface 对象,然后读取 config/autoload/exceptions.php 里配置的处理器列表,逐个执行。
每个处理器都继承抽象类 Hyperf\ExceptionHandler\ExceptionHandler,需要实现两个方法:
php复制abstract public function handle(Throwable $throwable, ResponseInterface $response);
abstract public function isValid(Throwable $throwable): bool;
调度过程中,框架会先调用处理器的 isValid() 判断当前异常是否归它管。如果返回 false,就跳过;如果返回 true,就调用 handle() 方法,并将返回的响应对象继续往后传递。
这里有一个容易被忽略的关键点:处理器不是一锤子买卖。如果处理器的 stopPropagation() 方法返回的是 false(默认就是 false),那么即使当前处理器已经处理过这个异常,框架还会继续调用后面的处理器。
这就形成了一个类似洋葱的链路:
text复制HttpExceptionHandler → ValidationExceptionHandler → BusinessExceptionHandler → AppExceptionHandler
每个处理器都可以对响应做二次修正、补充日志、追加请求 ID,只要它不主动停止传播。如果你想表示“这个异常我已经处理完了,后面的处理器不用再执行”,就在 handle() 里调用 $this->stopPropagation()。
这种设计带来的直接好处是,自定义异常处理器可以做到职责分离:路由找不到的 404 归 HTTP 异常处理器,参数校验失败归校验异常处理器,业务状态码归业务异常处理器,剩下的未知异常全部交给兜底异常处理器。每个处理器只管自己那一类,逻辑不耦合。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一次性把自定义处理器的骨架跑通:HTTP JSON 方案
2.1 目录结构与 Handler 最小实现
我在新项目里的习惯是,在 app/Exception/Handler 目录下面放所有自定义处理器,和 app/Exception 下面的业务异常类放在同一个大目录里,方便统一管理。
先看一个最基础的自定义异常处理器实现,它解决的是“所有没有被其他处理器处理的异常,最后都返回一段统一 JSON”这个问题。
php复制<?php
declare(strict_types=1);
namespace App\Exception\Handler;
use Hyperf\ExceptionHandler\ExceptionHandler;
use Hyperf\HttpMessage\Stream\SwooleStream;
use Psr\Http\Message\ResponseInterface;
use Throwable;
class AppExceptionHandler extends ExceptionHandler
{
public function handle(Throwable $throwable, ResponseInterface $response): ResponseInterface
{
$this->stopPropagation();
$code = $throwable->getCode() > 0 ? (int) $throwable->getCode() : 500;
$httpStatus = $code >= 400 && $code < 600 ? $code : 500;
$data = json_encode([
'code' => $httpStatus,
'message' => $throwable->getMessage() ?: 'Internal Server Error',
'data' => null,
], JSON_UNESCAPED_UNICODE);
return $response
->withStatus($httpStatus)
->withHeader('Content-Type', 'application/json')
->withBody(new SwooleStream($data));
}
public function isValid(Throwable $throwable): bool
{
return true;
}
}
代码非常短,但每一个点都有讲究。
isValid() 永远返回 true 意味着这是一个兜底处理器,前面所有处理器都不接手的异常,最终会落到这里。所以在注册配置里,这个类必须放在处理器列表的最后。
handle() 方法接收的 $response,是当前 HTTP 请求线程的 PSR-7 响应对象。注意它不是你直接 new 出来的响应,而是从请求上下文里一路带过来的。直接对它调用 withStatus()、withHeader()、withBody(),会返回一个修改后的新响应对象,所以一定要把最终结果 return 出去。
withBody() 需要传入一个 Stream 对象。Hyperf 里常用 Hyperf\HttpMessage\Stream\SwooleStream,它本质上是把字符串包装成可读流。直接把 JSON 字符串塞进去,框架在发送响应时就能正常把它输出给客户端。
2.2 注册配置与验证
处理器写好了,必须注册到 config/autoload/exceptions.php 里才会生效。
如果没有这个文件,先发布配置:
bash复制php bin/hyperf.php vendor:publish hyperf/exception-handler
然后编辑 config/autoload/exceptions.php:
php复制<?php
declare(strict_types=1);
return [
'handler' => [
'http' => [
Hyperf\HttpServer\Exception\Handler\HttpExceptionHandler::class,
App\Exception\Handler\AppExceptionHandler::class,
],
'jsonrpc' => [
// 如果用到 JSON-RPC 服务,可以单独配置
],
],
'dontReport' => [
// 不需要记录日志的异常类
],
];
这里的 http 键名很重要。它对应 Hyperf\HttpServer\Server 的异常处理场景,Web 请求、接口请求都走这一组。jsonrpc、grpc、amqp 等场景需要分别配置对应的处理器。
注册后如果是 Swoole 常驻内存模式,记得重启服务:
bash复制php bin/hyperf.php start
不像 PHP-FPM 每次请求重新加载,Hyperf 的进程常驻,配置文件是启动时读取的。
我用 curl 验证一下:
bash复制curl -i http://127.0.0.1:9501/api/order/detail
如果接口里故意抛了一个 RuntimeException('测试异常'),正常会得到这样的响应头:
text复制HTTP/1.1 500 Internal Server Error
Content-Type: application/json
响应体:
json复制{
"code": 500,
"message": "测试异常",
"data": null
}
到这里,最基础的自定义异常处理器就已经跑通了。
2.3 如何设计一个合理的响应结构
很多团队的自定义异常处理器,只是把默认的 HTML 换成了 JSON,编码层面看着没问题,但仔细想想还是有隐患。
比如有的团队会把 HTTP 状态码和业务错误码混在一个字段里。HTTP 状态码是传输层语义,业务错误码是应用层语义,两者应该分开。
我的建议是采用下面这种结构:
json复制{
"code": 400001,
"message": "订单状态不允许取消",
"data": null,
"trace_id": "7f2c1a3b-5d4e-4f8a-9c6b-1a2b3c4d5e6f"
}
code 是业务错误码,是给前端做逻辑判断用的。message 是给用户或者前端提示用的,必须是可读的中文或英文。data 在异常场景下通常为 null,但统一保留字段有助于前端类型处理。trace_id 是用来串联日志的,接口报错之后,前端把 trace_id 给后端,后端直接去日志系统里查这个 ID,定位速度能快好几倍。
HTTP 状态码则单独通过 withStatus() 设置,核心原则是:4xx 表示客户端问题,5xx 表示服务端问题。参数校验失败返回 400,未登录返回 401,没有权限返回 403,资源不存在返回 404。至于业务层面的错误码,比如“订单已付款不能退款”,HTTP 状态码通常返回 200 或者 422,业务 code 返回 400020,两者不冲突。
这里也有一个反面案例。有的团队把所有业务失败都返回 HTTP 200,body 里用 code 区分成功失败。这种做法对前端相对友好,但会让监控系统失效,因为 Nginx 日志、网关监控都看不到 5xx 状态,服务健康度评估就成了睁眼瞎。我自己的习惯是:能用 HTTP 语义表达的尽量用 HTTP 语义,业务个性化错误码放进 body 的 code 字段,两者各司其职。
3. 进阶实践:业务异常 + 多处理器链 + 日志上报
3.1 自定义业务异常类
如果只是把兜底异常改成 JSON 输出,那这套方案还停留在“能跑”阶段。实际业务开发中,绝大部分需要主动抛出的异常是业务异常,比如“库存不足”“订单状态错误”“用户余额不够”。
这些异常不应该走兜底处理器,因为它们代表的是可预期的业务规则失败,而不是系统故障。按我的做法,会在 app/Exception 目录下建一个自定义业务异常类。
php复制<?php
declare(strict_types=1);
namespace App\Exception;
use Hyperf\Server\Exception\ServerException;
use Throwable;
class BusinessException extends ServerException
{
public function __construct(
int $code = 0,
string $message = '',
?Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
}
代码不复杂,关键在于构造函数里 $message 和 $code 的传参顺序。在业务代码里调用时,我习惯把业务错误码放在第一位,可读性更强:
php复制throw new BusinessException(400020, '订单状态不允许取消');
你可以根据团队规范调整。只要保证子类构造函数能正确透传给父类即可。
有了业务异常类,另外一个配套动作是维护一套错误码常量,最好是单独一个类来管理:
php复制<?php
declare(strict_types=1);
namespace App\Constants;
class ErrorCode
{
public const SUCCESS = 0;
public const SERVER_ERROR = 500000;
public const INVALID_PARAMS = 400000;
public const ORDER_NOT_FOUND = 400010;
public const ORDER_STATUS_INVALID = 400020;
public const INSUFFICIENT_BALANCE = 400030;
}
错误码的规划建议有规律可循。比如 400 开头表示客户端参数/状态错误,后面三位表示具体的业务模块加业务细分。这样看错误码基本能猜到是哪个环节出的问题,排查效率高很多。
3.2 用多处理器构建分类处理的链路
有了 BusinessException,就可以写一个专属的业务异常处理器:
php复制<?php
declare(strict_types=1);
namespace App\Exception\Handler;
use App\Constants\ErrorCode;
use App\Exception\BusinessException;
use Hyperf\ExceptionHandler\ExceptionHandler;
use Hyperf\HttpMessage\Stream\SwooleStream;
use Psr\Http\Message\ResponseInterface;
use Throwable;
class BusinessExceptionHandler extends ExceptionHandler
{
public function handle(Throwable $throwable, ResponseInterface $response): ResponseInterface
{
$this->stopPropagation();
/** @var BusinessException $throwable */
$code = (int) $throwable->getCode();
$message = $throwable->getMessage();
if ($code === 0 || $code === ErrorCode::SERVER_ERROR) {
$httpStatus = 500;
} elseif ($code >= 400000 && $code < 500000) {
$httpStatus = 400;
} else {
$httpStatus = 200;
}
$data = json_encode([
'code' => $code,
'message' => $message,
'data' => null,
], JSON_UNESCAPED_UNICODE);
return $response
->withStatus($httpStatus)
->withHeader('Content-Type', 'application/json')
->withBody(new SwooleStream($data));
}
public function isValid(Throwable $throwable): bool
{
return $throwable instanceof BusinessException;
}
}
这个处理器在 isValid() 里精确判断异常类型,只处理 BusinessException,其他异常一律放行,交给后面的处理器。
配合兜底处理器,注册配置可以调整成这样:
php复制return [
'handler' => [
'http' => [
Hyperf\HttpServer\Exception\Handler\HttpExceptionHandler::class,
App\Exception\Handler\BusinessExceptionHandler::class,
App\Exception\Handler\AppExceptionHandler::class,
],
],
];
注意顺序:HttpExceptionHandler 处理框架级的路由异常,放在最前;BusinessExceptionHandler 处理业务异常,放在中间;AppExceptionHandler 作为兜底放在最后。
如果 AppExceptionHandler 放在 BusinessExceptionHandler 前面,那由于 AppExceptionHandler 的 isValid() 返回 true,业务异常会被它直接拦截,BusinessExceptionHandler 永远没有机会执行。这是新手最容易踩的顺序坑。
3.3 异常日志上报与 dontReport 配置
自定义异常处理器除了要返回给客户端一个友好的响应,还要帮服务端把异常记录下来。否则客户端看到“系统繁忙”,你连系统为什么繁忙都不知道。
Hyperf 默认会对未捕获异常做日志上报,但如果没有做配置,框架对所有异常一视同仁地记录。举个例子,像“订单状态不允许取消”这种业务规则失败,本身属于正常的业务流程分支,每天可能发生几百次,如果每次都在日志里打一条 Error,日志系统很快就会被无效报错刷屏,真正重要的系统级异常反而被淹没了。
所以 config/autoload/exceptions.php 里的 dontReport 配置就派上用场了:
php复制'dontReport' => [
App\Exception\BusinessException::class,
],
声明在 dontReport 里的异常类,框架在调度时就不会触发默认的日志记录逻辑。这样业务异常只负责返回给前端一个明确的业务错误,不污染日志。
但是要注意,如果你在 BusinessExceptionHandler 里主动调用了日志组件去写日志,那 dontReport 就失效了,因为日志是你自己打的。所以我的建议是:业务异常不主动记录日志,系统未知异常一定要记录。这个“记录”的动作放在兜底的 AppExceptionHandler 里做。
一个更完整的兜底处理器可以这样写:
php复制<?php
declare(strict_types=1);
namespace App\Exception\Handler;
use Hyperf\Context\ApplicationContext;
use Hyperf\ExceptionHandler\ExceptionHandler;
use Hyperf\HttpMessage\Stream\SwooleStream;
use Hyperf\Logger\LoggerFactory;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use Throwable;
class AppExceptionHandler extends ExceptionHandler
{
protected LoggerInterface $logger;
public function __construct(LoggerFactory $loggerFactory)
{
$this->logger = $loggerFactory->get('exception', 'default');
}
public function handle(Throwable $throwable, ResponseInterface $response): ResponseInterface
{
$this->stopPropagation();
$this->logger->error($throwable->getMessage(), [
'exception' => $throwable,
'trace' => $throwable->getTraceAsString(),
]);
// 判断环境,决定是否暴露堆栈
$isDebug = env('APP_ENV', 'production') !== 'production';
$message = $isDebug
? $throwable->getMessage()
: 'Internal Server Error';
$data = json_encode([
'code' => 500,
'message' => $message,
'data' => null,
], JSON_UNESCAPED_UNICODE);
return $response
->withStatus(500)
->withHeader('Content-Type', 'application/json')
->withBody(new SwooleStream($data));
}
public function isValid(Throwable $throwable): bool
{
return true;
}
}
这里的 LoggerFactory 是 Hyperf 对 Monolog 的封装,构造函数里注入之后就能按 channel 取日志器。我用 exception 作为 channel 名,对应 config/autoload/logger.php 里的一个 channel,方便在日志系统里单独筛选。
3.4 环境隔离与敏感信息保护
上面代码里已经出现了一个关键处理:根据不同环境决定是否把异常消息暴露给客户端。
开发环境我们希望看到尽可能多的信息,最好连堆栈都打出来。但生产环境绝对不行,一旦把某个 SQL 异常的原生 message 返回给用户,轻则泄露表结构,重则被拿去拼接攻击 payload。
更稳妥的做法是,生产环境不仅不暴露异常详情,还要把日志的上下文打全。日志里包含异常类、文件、行号、调用栈,甚至当前请求的 URI、请求参数。这样即使客户端只看到“Internal Server Error”,你依然能通过 trace_id 找到具体的错误链路。
有人会问,能不能根据 APP_DEBUG 或者 APP_ENV 动态切换模板?完全可以。Hyperf 的 env() 函数在启动时读取 .env 配置,你在自定义异常处理器里直接调用即可:
php复制$isDebug = env('APP_DEBUG', false);
不过我个人不太建议在控制器或者业务代码里到处判环境,但在异常处理器里判是合理的,因为异常处理本身就属于框架边缘层,环境差异化在这里表现得很自然。
4. 实际项目里最容易踩的坑
4.1 注册顺序配置错误导致处理器不生效
这是最高频的问题。现象是:我在控制器里抛了 BusinessException,结果响应不是预期的 JSON,而是返回了 500 和原文堆栈。
排查思路很简单:看一下 BusinessExceptionHandler 在 config/autoload/exceptions.php 里是否排在了 AppExceptionHandler 的后面。如果是,那 AppExceptionHandler 的 isValid() 返回 true,它会第一时间接住异常,并且 stopPropagation() 之后,后面的 BusinessExceptionHandler 根本没机会执行。
处理器的执行顺序等于配置数组的顺序,前面的优先。兜底处理器一定要放最后。
4.2 handle 返回后响应状态码不对
有段时间我发现自定义异常处理器写了 withStatus(400),但客户端收到的还是 200。后来排查发现,我在处理完异常之后忘了 return $response,导致 PSR-7 的不可变特性生效——withStatus() 返回的是新对象,原来的 $response 根本没变,框架拿到的还是旧响应。
PSR-7 里所有的 with* 方法都是不可变操作,不会修改原对象,而是返回一个副本。所以新手写异常处理器时最常见的 bug 就是只调用了 withStatus() 却没有把返回值重新赋值或返回。
4.3 把 Throwable 直接 json_encode 导致响应为空
有人图省事,想直接 json_encode($throwable),结果发现返回的是 {},原因是异常对象里的属性大多是 protected 或 private,json_encode 默认只能序列化 public 属性。而 Throwable 的内部实现并没有公开这些属性。
正确做法是先手动提取需要暴露的字段,组装成数组后再 json_encode。另外还有一类隐藏问题:$throwable->getMessage() 如果包含非法 UTF-8 字符(比如从二进制协议里取出来的字符串),json_encode 会返回 false,导致响应体为空。处理方案是加 JSON_INVALID_UTF8_SUBSTITUTE 标志:
php复制json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
4.4 配置文件或代理缓存没刷新
Hyperf 是常驻内存框架,配置在启动时加载。如果你用的是 Swoole 模式,改了 config/autoload/exceptions.php 之后不重启服务,新配置不会生效。这一点和传统 PHP-FPM 差异很大。
另外 Hyperf 有注解扫描和代理类缓存的机制,如果你是通过注解方式注册的处理器(部分版本支持 DI 注解),修改完之后有时候需要清理 runtime 目录下的缓存:
bash复制rm -rf runtime/container
再重启服务。生产环境发布时,我一般会加一步 php bin/hyperf.php di:init-proxy,确保代理缓存是最新的。
4.5 处理器内部再抛异常导致死循环
handle() 方法里千万要小心,如果处理异常的过程中又抛了一个新异常,而这个新异常没有被 catch 住,框架会再次进入异常分发流程。如果兜底处理器的 isValid() 又是返回 true,就会形成递归,严重时直接打满 CPU。
最常见的触发场景是在 handle() 里调用一个外部 API 或者数据库查询,而这次查询恰好抛了异常。所以异常处理器里不要写重逻辑,优先级最高的是“稳定返回”,就算拿不到额外信息,也要保证能返回一个固定格式的响应。需要记录的信息尽量放在 try/catch 里面,或者使用不会抛异常的日志方法。
4.6 dontReport 配置没生效
有些朋友把 BusinessException::class 加进了 dontReport,但发现业务异常还是会出现在错误日志里。原因前面提过,大概率是在业务异常处理器里主动调用了 logger->error()。
dontReport 只控制框架的默认上报行为,你代码里手动写的日志当然不受它管控。想不记录业务异常,就不要在处理器里手动记录业务异常;想在日志里留下业务异常的痕迹但又不想要 Error 级别,可以降级为 info 级别,用不同的日志通道或标记区分。
4.7 不同场景的处理器没有分开配置
config/autoload/exceptions.php 里是分场景配置的,http 场景下的处理器只对 HTTP Server 生效。
如果项目里同时暴露了 JSON-RPC 服务或者 AMQP 消费者,这些场景的异常处理需要单独配置。我就犯过这样的错:JSON-RPC 服务抛了异常,客户端收到的错误结构和 HTTP 服务完全不一致,排查半天才发现是 jsonrpc 场景下没有注册对应的业务异常处理器。
建议为不同场景各写一套独立的异常响应结构,虽然大多数代码可以复用,但不要试图用一份配置覆盖所有 Server 类型。
5. 让这套方案在团队里真正落地的小建议
到这里,自定义异常处理器的主体方案已经完整了。最后分享几个我实践下来觉得对团队协作特别有帮助的小细节。
一个是在 BusinessExceptionHandler 的响应里加一个可选的 error_code 含义映射,比如附带 error_key 字段,便于前端 switch。如果前端团队习惯使用字符串状态标识而不是数字,可以通过错误码常量类再维护一组字符串映射,虽然会增加一点工作量,但前后端联调时的沟通成本会降低不少。
另一个是把 trace_id 做成中间件。在请求进入时生成一个 Hyperf\Context\Context 上下文变量,写入日志的公共字段,同时放在异常响应里返回。客户端把 trace_id 反馈给你,你用一条命令就能在日志平台里捞到完整的请求链路。这个能力一旦养成习惯,线上问题的平均定位时间能下降一多半。
最后是错误码文档自动化。既然错误码都收口在常量类里了,可以写一个简单的命令行脚本扫描 ErrorCode 类的常量,生成 Markdown 文档输出到项目中,再配合接口平台同步。这样每次新增业务错误码就不会出现“前端不知道这个 code 是什么意思”的尴尬情况。
根据我个人经验,Hyperf 自定义异常处理器这件事,真正花时间的不是写那几十行 handler 代码,而是把异常分类、错误码规划、日志上报这些工程规范想清楚。框架给你的是一个高度灵活的调度链,你在这条链上放哪些处理器、让谁先处理、谁负责兜底,决定了整个服务的异常行为是否可预期。只要链路设计合理,后续不管加多少种业务异常,都只是新增一个处理器和一组错误码的事。
