如何统一返回 JSON 格式错误给前端?

解读

国内一线/二线公司面试时,这道题表面问“格式”,实则考察三层能力:

  1. 工程化意识:能否把“错误”从分散的 echodie 中收拢到一处,让前端拿到稳定结构。
  2. 安全与规范:是否知道隐藏敏感信息、遵守 HTTP 状态码语义、符合国内等保/密评要求。
  3. 性能与可维护:是否理解 PHP 生命周期,能在最合适的钩子点拦截错误,避免重复封装、重复序列化。

一句话:面试官想听“你怎么用 PHP 机制把错误变成前端可识别的 JSON,同时保证高内聚、低耦合、易扩展”。

知识点

  1. PHP 错误层级:Error、Exception、Throwable,以及 set_error_handler / set_exception_handler / register_shutdown_function 三者区别。
  2. 自动加载与中间件:Composer PSR-4、PSR-15 中间件规范,Laravel/Symfony 异常渲染管道。
  3. 输出协议:国内主流 {code: number, msg: string, data: mixed, traceId?: string},以及 HTTP 状态码映射规则。
  4. 安全合规:生产环境屏蔽 filelinetrace,记录 requestId 方便链路追踪;符合《GB/T 35273 个人信息安全技术规范》。
  5. 性能细节:header('Content-Type:application/json;charset=utf-8') 必须在任何输出之前;使用 JSON_THROW_ON_ERROR 防止中文转义异常;OPcache 下避免在错误处理器里做重 IO。

答案

给出一个“公司可直接落地”的最小闭环方案,分三步:拦截 → 标准化 → 输出。

步骤 1:定义统一结构体

final class ApiResult
{
    public static function fail(int $code, string $msg, $data = null): array
    {
        return [
            'code' => $code,
            'msg'  => $msg,
            'data' => $data,
            'traceId' => $_SERVER['HTTP_X_TRACE_ID'] ?? uniqid('api-', true),
        ];
    }
}

步骤 2:注册全局处理器(原生 PHP 项目放在 public/index.php 顶部;Laravel 放在 app/Exceptions/Handler.php;Symfony 放在 EventListener/ExceptionListener.php)

// 统一异常
set_exception_handler(function (Throwable $e) {
    $code = $e->getCode() >= 400 && $e->getCode() < 600 ? $e->getCode() : 500;
    $msg  = env('APP_DEBUG') ? $e->getMessage() : '系统繁忙';
    $data = env('APP_DEBUG') ? ['trace' => $e->getTraceAsString()] : null;

    http_response_code($code);
    header('Content-Type:application/json;charset=utf-8');
    echo json_encode(ApiResult::fail($code, $msg, $data), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    exit;
});

// 统一致命错误
register_shutdown_function(function () {
    $error = error_get_last();
    if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
        // 记录日志,返回 500
        call_user_func('exception_handler', new ErrorException(
            $error['message'], 500, $error['type'], $error['file'], $error['line']
        ));
    }
});

步骤 3:业务层只管抛异常,不用关心格式

if (!($user = User::find($id))) {
    throw new BusinessException('用户不存在', 404);
}

结果:
前端收到的永远是

{"code":404,"msg":"用户不存在","data":null,"traceId":"api-664f9a8e9a3e27.53893432"}

且响应头 Content-Type: application/json; charset=utf-8,状态码 404。

拓展思考

  1. 多业务域错误码冲突:建议采用“领域+序号”方式,如 100001~199999 用户域,200001~299999 订单域,避免和 HTTP 状态码混淆。
  2. 国际化:在 ApiResult::fail 里注入 Lang::get($msg),根据 Accept-Language 自动返回中英文,出海项目必备。
  3. 链路追踪:把 traceId 写入响应头 X-Trace-Id,同时写入日志,接入阿里云 SLS、腾讯云 CLS,方便压测时快速定位。
  4. 错误分级告警:通过 Monolog\Handler\FingersCrossedHandler 把 5xx 错误聚合后推送到企业微信/飞书,减少告警风暴。
  5. 性能极限优化:在 Swoole/FPM 协程场景下,异常处理器里避免使用 file_get_contents 等阻塞 IO,可改用 Swoole\Coroutine\System::writeFile 异步写日志,防止错误雪崩。