ThinkPHP8 统一响应与异常处理封装:让 API 接口告别散乱的 json 返回

2026-08-10 0 193

做 API 开发已经有三年多,最难受的不是逻辑复杂,而是接口的返回格式五花八门。有的接口直接返回一堆数据,有的用 {"status":1},有的用 {code:200},还有的报错只给一个字符串。前端同事每次对接新接口都要问一遍字段含义,烦得很。

最近把公司的 PHP 项目框架升级到 ThinkPHP8,借着这次重构,我干脆把接口的响应格式和异常处理彻底统一了。不光是代码看着舒服,前端沟通成本也直线下降。

首先,设计一个统一的数据结构

不管成功还是失败,我们都返回一个固定形状的 JSON。

{
    "code": 0,       // 业务状态码,0表示成功,非0表示各种业务异常
    "message": "ok", // 对状态的描述
    "data": {}       // 业务数据,可能是对象或数组
}

这里的 code 使用 0 表示成功,而不是 200。因为 HTTP 状态码有它们自己的含义,业务状态码适合单独定义。比如 1001 表示参数错误,1002 表示未登录等。

有了这个格式,我做了两个东西:一个负责返回成功数据,另一个负责处理所有异常。

第二步:创建自定义业务异常类

app/exception 目录下新建一个 BusinessException.php,继承 TP8 的默认异常类。

<?php
declare(strict_types=1);

namespace appexception;

use Exception;

class BusinessException extends Exception
{
    // 业务错误码
    protected $errorCode;

    // 额外数据
    protected $errorData;

    public function __construct(string $message = '', int $errorCode = 0, $data = null)
    {
        parent::__construct($message);
        $this->errorCode = $errorCode;
        $this->errorData = $data;
    }

    public function getErrorCode()
    {
        return $this->errorCode;
    }

    public function getErrorData()
    {
        return $this->errorData;
    }
}

这样做的目的是,在业务代码里可以自由地抛出异常:

throw new BusinessException('用户不存在', 1003);

而不需要写一堆 return json(...)

第三步:重写全局异常处理类

TP8 自带一个 app/ExceptionHandle.php,专门用来处理所有抛出的异常。只要修改它的 render() 方法,就能控制异常返回的 JSON 格式。

我们把它改成这样:

<?php
declare(strict_types=1);

namespace app;

use appexceptionBusinessException;
use thinkdbexceptionDataNotFoundException;
use thinkdbexceptionModelNotFoundException;
use thinkexceptionValidateException;
use thinkResponse;
use Throwable;

class ExceptionHandle extends thinkexceptionHandle
{
    public function render($request, Throwable $e): Response
    {
        // 如果是在调试模式下,并且异常不是业务异常,则交给框架默认处理(显示详细错误)
        if (env('app_debug') && !($e instanceof BusinessException)) {
            return parent::render($request, $e);
        }

        // 业务异常
        if ($e instanceof BusinessException) {
            return json([
                'code'    => $e->getErrorCode() ?: 1,
                'message' => $e->getMessage(),
                'data'    => $e->getErrorData(),
            ]);
        }

        // 参数验证异常
        if ($e instanceof ValidateException) {
            return json([
                'code'    => 1001,
                'message' => $e->getMessage(),
                'data'    => [],
            ]);
        }

        // 数据库记录不存在
        if ($e instanceof ModelNotFoundException || $e instanceof DataNotFoundException) {
            return json([
                'code'    => 1004,
                'message' => '记录不存在',
                'data'    => [],
            ]);
        }

        // 其他异常,统一返回“系统错误”
        return json([
            'code'    => 500,
            'message' => '系统繁忙,请稍后再试',
            'data'    => [],
        ]);
    }
}

关键点有几个:

  • 当开发环境开启 app_debug 且不是业务异常时,直接交给父类,方便我们在开发时看到详细报错。
  • 业务异常返回它自己的错误码和提示。
  • 验证异常固定返回 1001,这个和前端约定好。
  • 模型找不到时返回 1004。
  • 最后兜底返回 500,避免暴露内部细节。

这样,不管代码里抛出什么异常,都会变成统一格式的 JSON。你可能会问,那些在控制器里自己写的 return json(['code'=>1]) 怎么办?慢慢把它们改成抛异常就行了。

第四步:封装成功响应

一个项目里的成功返回值,通常也不会只是 return json($data),需要加上 code 和 message。为了防止大家在每个控制器都写重复代码,我在 app/common.php 里写了一个公共函数:

<?php
// app/common.php 已经自动被加载

use thinkResponse;

if (!function_exists('success')) {
    function success($data = [], string $message = 'ok'): Response
    {
        return json([
            'code'    => 0,
            'message' => $message,
            'data'    => $data,
        ]);
    }
}

if (!function_exists('error')) {
    function error(string $message, int $code = 1, $data = []): Response
    {
        return json([
            'code'    => $code,
            'message' => $message,
            'data'    => $data,
        ]);
    }
}

有了这两个函数,控制器里成功就返回 success($list),错误也可以 error(),不过错误我更推荐抛异常,因为可以中断逻辑。

第五步:验证器异常彻底简化

TP8 的验证器通常在控制器中这样使用:

$validate = new UserValidate;
if (!$validate->check($data)) {
    return error($validate->getError(), 1001);
}

每个接口都这么写,依然很啰嗦。更好的方式是使用验证器在控制器自动验证,让验证失败直接抛出 ValidateException

在控制器里用 validate() 助手函数,并且我们会让它抛异常:

$data = $request->only(['name', 'email']);

validate(appvalidateUserValidate::class)
    ->check($data);  // 如果不给第二条参数,验证失败会抛异常

实际上 TP8 的 validate() 在验证失败时,如果只传入了一个参数,会直接抛 ValidateException。所以你在控制器只需要一行代码:

validate(UserValidate::class)->check($request->param());

你可能会问,check() 返回 false 或者 true,但这里它不会返回 false,因为异常已经被抛出了。而且这个异常正好被我们全局异常处理捕获,返回错误码 1001。

这样每个控制器不需要写 if 判断,代码一下子干净很多。

第六步:一个完整示例 —— 用户列表接口

下面是一个用户管理接口,包含搜索、分页、权限判断。你来看看和以前写法的区别。

<?php
declare(strict_types=1);

namespace appcontroller;

use thinkRequest;
use appmodelUser;
use appexceptionBusinessException;
use appvalidateUserQueryValidate;

class UserController
{
    public function index(Request $request)
    {
        // 参数验证(失败自动抛出异常)
        validate(UserQueryValidate::class)->check($request->get());

        $page     = $request->get('page', 1);
        $limit    = $request->get('limit', 20);
        $keyword  = $request->get('keyword', '');

        $query = User::where('status', 1);

        if (!empty($keyword)) {
            $query->where('name', 'like', "%{$keyword}%");
        }

        $list = $query->paginate([
            'list_rows' => $limit,
            'page'      => $page,
        ]);

        return success([
            'list'  => $list->items(),
            'total' => $list->total(),
            'page'  => $list->currentPage(),
            'pages' => $list->lastPage(),
        ]);
    }

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

        $user->delete();

        return success(null, '删除成功');
    }
}

这里我们只用了一个验证器来保证参数类型,其他逻辑非常简洁。特别是删除操作,不需要在控制器里返回错误,直接抛异常,全局处理。

对于验证器的定义,可以是这样的:

<?php
namespace appvalidate;

use thinkValidate;

class UserQueryValidate extends Validate
{
    protected $rule = [
        'page'   => 'integer|egt:1',
        'limit'  => 'integer|between:1,100',
        'keyword'=> 'max:50',
    ];

    protected $message = [
        'page.integer'   => '页码必须是整数',
        'limit.between'  => '每页数量在1-100之间',
        'keyword.max'    => '关键词太长',
    ];
}

page 传入 abc 时,验证异常会被全局处理,返回 code=1001,提示“页码必须是整数”。前端拿到的 JSON 始终如一的。

第七步:如何调试和记录异常

有时候线上环境我们不想暴露具体错误,但这会让排查问题变得困难。为此,可以在全局异常处理中添加日志记录。

修改 render() 方法,在返回之前写一条日志:

// 记录异常日志
$logData = [
    'url'    => $request->url(),
    'method' => $request->method(),
    'error'  => $e->getMessage(),
    'trace'  => $e->getTraceAsString(),
];
trace(json_encode($logData, JSON_UNESCAPED_UNICODE), 'error');

这样即使返回给用户的是“系统繁忙”,我们也能在日志里找到真实原因。

但要注意,开发环境如果 app_debug 为 true,我们直接使用父类 render 会打印错误详情,不需要再记录日志。只有在生产环境才需要记录日志并返回通用错误。

最终:外部调用效果

现在,当你请求一个不存在的记录时,API 返回:

{
    "code": 1004,
    "message": "记录不存在",
    "data": []
}

当你请求参数错误时:

{
    "code": 1001,
    "message": "页码必须是整数",
    "data": []
}

当你自己抛出一个库存不足的异常时:

throw new BusinessException('库存不足', 2001, ['stock' => 0]);

返回:

{
    "code": 2001,
    "message": "库存不足",
    "data": {"stock": 0}
}

前端只需要判断 code 是否等于 0,就能决定是否正常渲染数据。有了这个统一格式,接口文档也可以写得更规范。

总结:封装后的感觉

把所有接口的返回都变成统一格式,看起来只是一个小改动,但对前后端配合的影响特别大。前端同事再也不用为“到底返回几个 key”发愁。后端在写业务时,也完全不需要考虑如何拼接 JSON,只需要专注于业务逻辑,该抛异常就抛异常。

同时,异常处理类把所有可能出现错误的地方都汇聚到了一起,代码的坏味道少了很多。而且因为有了业务异常类,你还可以在 render() 里针对不同错误码做小动作,比如记录日志,发送告警短信,或者做链路追踪。

这套方案不仅适用新项目,老项目也可以逐步改造。每个接口返回格式统一后,维护起来真的会上瘾。不信你也试试。

ThinkPHP8 统一响应与异常处理封装:让 API 接口告别散乱的 json 返回
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP8 统一响应与异常处理封装:让 API 接口告别散乱的 json 返回 https://www.taomawang.com/server/thinkphp/2521.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务