PHP开发API接口时如何统一错误码和返回数据结构设计

各位好,我是你们的老朋友,一个在PHP这片代码荒原上摸爬滚打多年的“资深”打工人。

今天我们不聊高并发,不聊微服务架构,也不聊那些高大上的DDD(领域驱动设计)。今天我们聊点更接地气、更让人头秃,但又是每个PHP后端程序员必须面对的“灵魂拷问”——API接口的统一错误码和返回数据结构设计

为什么要聊这个?因为各位都知道,现在的API接口,就像是一群性格迥异的下属。有的干活利索,返回数据清清爽爽;有的就像个老油条,返回个500服务器错误,然后留下一行 undefined variable 的堆栈信息,让你和前端的小哥哥小姐姐们在电话里对线:“哥,这报错是啥意思啊?”“我也不知道啊,服务器炸了呗!”

别闹了!别让服务器炸了!

如果你想让前端开发人员在你面前永远是微笑的,如果你想让运维同学不把你当空气,如果你想让你的API看起来像个正经的国际大牌,那么,请系好安全带,我们开始今天的“API标准化改造”之旅。


第一章:那个让我们痛并快乐着的“数据结构”

在聊错误码之前,我们得先统一一下“包装盒”。你知道的,如果你把货物(数据)直接扔给客户,客户可能会投诉说包装太简陋。我们需要一个标准的JSON包装。

1.1 标准的“红绿灯”模式

在很长一段时间里,我们的返回结构是混乱的:

  • 有时候返回 ["a", "b", "c"](数组);
  • 有时候返回 {"code": 0, "data": ...}(对象);
  • 有时候成功就是 200,失败就是 500

这种混乱,比你的头发还要稀疏。

我们要建立一个铁律:所有接口,必须返回同一个结构。

1.2 JSON结构定义

这里我们要引入一个经典的“红绿灯”模式:

  • code: 错误码。0代表绿灯(成功),非0代表红灯(失败)。
  • msg: 状态信息。这是给前端同学看的,告诉他们“发生了什么”,或者是“祝你好运”。
  • data: 数据载荷。这是核心业务数据。

看看这个JSON长啥样:

// 成功的例子
{
    "code": 0,
    "msg": "操作成功",
    "data": {
        "user_id": 1001,
        "username": "张三",
        "vip_level": 5
    }
}

// 失败的例子
{
    "code": 10001,
    "msg": "用户不存在",
    "data": null
}

注意到了吗?code: 0 就像是公司的KPI达标了。一旦 code 不是0,前端开发人员拿到手的第一件事就是检查 msg,然后决定是弹窗提示用户,还是把用户踢回登录页。

1.3 关于 Data 的那些事儿

有时候,返回数据里可能还需要一些辅助字段。比如分页信息,或者请求的耗时。我们可以在 data 里再套一层,或者直接在顶层加个 meta 字段。

为了保持简洁,我们暂时约定 data 就是业务数据。如果不需要数据(比如删除成功),data 字段依然存在,值为 null 或者空对象 {},但千万别不返回这个字段,前端解析的时候会崩溃。


第二章:错误码的艺术——“上帝之手”的编码哲学

现在,我们已经有了盒子。接下来是装进去的东西——错误码。这部分是重头戏,也是最考验功力的地方。

2.1 为什么要设计错误码?

有人会说:“我直接返回 msg 不行吗?‘用户密码错误’、‘服务器挂了’、‘数据库连不上’,这不都是文案吗?”

不行!绝对不行!

为什么?因为:

  1. 国际化:你要把API卖给全世界?中文的“用户不存在”翻译成英文就是“User does not exist”,前端改起来很累。
  2. 统计与分析:前端报错 500,你以为是代码Bug,其实可能只是网络波动。有了错误码,你后台一统计,发现 10001 这种错误占比高达90%,你才能针对性地优化。

2.2 错误码的“阶级划分”

我们绝对不能使用 1, 2, 3, 4, 5 这种毫无意义的数字。这就像你去点菜,服务员问你:“要几号?”你说:“随便,就5号吧。”服务员一脸懵逼。

我们需要一个清晰的层级:

  • 1xxxxxx: 系统级错误

    • 这类错误通常意味着服务器挂了,或者环境配置错了。
    • 1000001: 服务器内部错误(万能兜底)
    • 1000002: Redis连接失败
    • 1000003: 数据库连接超时
  • 2xxxxxx: 业务级错误

    • 这类错误是正常的业务逻辑分支,比如用户没权限、参数不对。
    • 2000001: 用户名或密码错误
    • 2000002: 验证码错误
    • 2000003: 账户余额不足
  • 3xxxxxx: 客户端/调用方错误

    • 这类错误通常是请求参数不对,或者是前端传的非法数据。
    • 3000001: 参数缺失
    • 3000002: 参数格式错误(比如手机号写成了邮箱)

2.3 错误码的两种流派:数字与字符串

这里我们要做一个严肃的投票:

  • 流派A(数字党)20001。这是传统的RESTful风格,简单,快速。
  • 流派B(字符串党)USER_NOT_FOUND。这是Google风格,甚至有些大厂风格,可读性极强。

我的建议是:混合使用,但要有规则。

在内部开发中,我们可以用数字,为了快。
在对外API中,或者给前端看的文档中,我们可以用字符串。

但为了保证代码的一致性,我们这里统一使用 数字编码 + 枚举类 的方式。

class ErrorCode {
    // 系统错误 1xxxxxx
    const SYSTEM_ERROR = 1000001;
    const DB_ERROR = 1000002;

    // 业务错误 2xxxxxx
    const USER_NOT_FOUND = 2000001;
    const USER_PASSWORD_ERROR = 2000002;

    // 参数错误 3xxxxxx
    const PARAM_MISSING = 3000001;
    const PARAM_INVALID = 3000002;
}

你看,ErrorCode::USER_NOT_FOUND2 好懂多了吧?即便你忘了这是第几行定义的,你也知道这是跟用户相关的错误。


第三章:代码实现——从理论到实践

好了,概念都清楚了。现在我们要动手写代码。怎么写?如果你在每个Controller里都写 return ['code'=>0, 'msg'=>'...', 'data'=>$data],那你就是自己给自己挖坑。

3.1 基础响应类

我们首先需要一个基类,或者一个Trait,来帮我们生成这些JSON。假设我们使用的是现代PHP(PSR-7响应对象),我们可以这样封装:

<?php

namespace AppHttpResponse;

use PsrHttpMessageResponseInterface;
use SlimHttpResponse;
use SlimHttpServerRequest;

class JsonResponse
{
    /**
     * 成功响应
     * @param mixed $data 返回的数据
     * @param string $msg 提示信息
     * @param ResponseInterface $response
     * @return ResponseInterface
     */
    public static function success($data = null, string $msg = '操作成功', ResponseInterface $response = null): ResponseInterface
    {
        $payload = [
            'code' => 0,
            'msg'  => $msg,
            'data' => $data
        ];

        return self::json($payload, $response);
    }

    /**
     * 失败响应
     * @param int $code 错误码
     * @param string $msg 错误信息
     * @param ResponseInterface $response
     * @return ResponseInterface
     */
    public static function error(int $code, string $msg = '操作失败', ResponseInterface $response = null): ResponseInterface
    {
        $payload = [
            'code' => $code,
            'msg'  => $msg,
            'data' => null
        ];

        return self::json($payload, $response);
    }

    /**
     * 统一JSON输出
     */
    private static function json(array $payload, ResponseInterface $response = null): ResponseInterface
    {
        // 如果没传response对象(比如在控制台脚本里),就new一个临时的(这里简化处理,假设使用了依赖注入)
        // 实际项目中,$response 应该是从中间件或者构造函数注入进来的

        $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

        return $response
            ? $response->withJson($json, 200)
            : new Response(200, [], $json);
    }
}

3.2 业务代码里的应用

现在,我们在写业务逻辑的时候,就可以优雅地使用了。别再 die('出错了') 了,别再 return ['code'=>404, ...] 了。

看一个典型的登录接口:

public function login(ServerRequest $request, Response $response)
{
    $params = $request->getParsedBody();

    // 1. 校验参数
    if (empty($params['username']) || empty($params['password'])) {
        // 直接调用我们定义好的失败方法
        return JsonResponse::error(ErrorCode::PARAM_MISSING, '用户名和密码不能为空', $response);
    }

    // 2. 查询数据库
    $user = User::where('username', $params['username'])->first();

    // 3. 判断用户是否存在
    if (!$user) {
        return JsonResponse::error(ErrorCode::USER_NOT_FOUND, '用户名或密码错误', $response);
    }

    // 4. 验证密码(假设这里有个简单的验证)
    if ($user->password !== md5($params['password'])) {
        return JsonResponse::error(ErrorCode::USER_PASSWORD_ERROR, '用户名或密码错误', $response);
    }

    // 5. 返回Token
    $token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...';

    return JsonResponse::success([
        'token' => $token,
        'user_info' => [
            'id' => $user->id,
            'name' => $user->name
        ]
    ], '登录成功', $response);
}

看到了吗?这就是优雅。代码里只有业务逻辑,没有繁琐的JSON拼装。前端同学拿到这个JSON,一看 code: 0 就知道成功了,一看 code: 2000001 就知道是用户没找到,根本不需要去猜那个 msg 是不是真的。


第四章:异常处理——给接口穿上“防弹衣”

但是,各位朋友,现实是残酷的。你永远不知道下一行代码会不会抛出一个 PDOException 或者 Exception

如果你不处理异常,当数据库挂了,用户在手机上点个“支付”,后台直接返回一个 500 Internal Server Error,然后前端JS捕获到500,开始显示“服务器开小差了”,用户就会把你骂得狗血淋头。

我们需要一个全局的异常捕获器。

4.1 自定义异常类

首先,我们要定义一些自定义异常,用来区分不同的错误。

class ApiException extends Exception
{
    protected $code;
    protected $msg;

    public function __construct(int $code, string $msg = "", Throwable $previous = null)
    {
        $this->code = $code;
        $this->msg = $msg;
        parent::__construct($msg, $code, $previous);
    }

    // 获取错误码
    public function getErrorCode(): int
    {
        return $this->code;
    }

    // 获取错误信息
    public function getErrorMessage(): string
    {
        return $this->msg;
    }
}

4.2 全局异常处理器

在大多数PHP框架(如Laravel, Slim, Swoole)中,都有中间件机制。我们需要写一个中间件,专门用来捕获所有未处理的异常。

逻辑是这样的:

  1. 尝试捕获异常。
  2. 如果是 ApiException,直接把它的 codemsg 拿出来,包装成JSON返回。
  3. 如果是系统异常(比如 PDOException),先把错误记录到日志文件里(别直接给用户看),然后返回一个通用的系统错误码(1000001)。
use PsrHttpMessageResponseInterface as Response;
use PsrHttpMessageServerRequestInterface as Request;
use SlimHandlersErrorHandler;
use SlimViewsTwig;
use Exception;

class ApiExceptionHandler extends ErrorHandler
{
    protected function determineStatusCode(Request $request, Exception $exception): int
    {
        return 200; // 统一返回200,因为我们在body里放code,前端通过code判断状态
    }

    protected function writeToErrorLog(Request $request, Exception $exception, bool $displayErrorDetails): void
    {
        // 写入日志,别暴露堆栈给用户
        error_log("API Error: " . $exception->getMessage());
    }

    protected function produceResponse(Request $request, Exception $exception, bool $displayErrorDetails): Response
    {
        // 创建一个假的Response对象(简化版)
        $response = new SlimHttpResponse();

        // 1. 如果是业务异常,直接返回
        if ($exception instanceof ApiException) {
            return JsonResponse::error($exception->getErrorCode(), $exception->getErrorMessage(), $response);
        }

        // 2. 如果是系统异常,返回通用错误
        error_log("System Error: " . $exception->getMessage());

        return JsonResponse::error(ErrorCode::SYSTEM_ERROR, '系统繁忙,请稍后再试', $response);
    }
}

这样一来,你以后写代码就可以尽情地 throw new ApiException(ErrorCode::USER_NOT_FOUND, '用户不存在'); 了。哪怕你忘了捕获,中间件也会把它变成一个标准的JSON返回给前端,而不是一坨白屏。


第五章:进阶技巧——那些让你脱颖而出的设计

好了,基础打牢了。现在你是“资深专家”了,我们来聊聊一些更高级的玩法。

5.1 返回数据的“脱敏”处理

在实际业务中,我们的 data 里可能会包含用户的手机号、身份证号、银行卡号。如果你原封不动地返回给前端,万一接口被日志抓取了,或者被中间人抓包了,那可是天大的安全事故。

我们可以在响应类里加一个过滤器。

public static function success($data = null, string $msg = '操作成功', ResponseInterface $response = null, $isEncrypt = false): ResponseInterface
{
    // 如果开启了脱敏模式(或者在登录接口返回Token时)
    if ($isEncrypt) {
        $data = self::desensitize($data);
    }

    $payload = [
        'code' => 0,
        'msg'  => $msg,
        'data' => $data
    ];

    return self::json($payload, $response);
}

private static function desensitize($data)
{
    if (is_array($data)) {
        // 递归处理数组
        return array_map([self::class, 'desensitize'], $data);
    }

    // 简单的正则替换,手机号中间4位变*
    return preg_replace('/(d{3})d{4}(d{4})/', '$1****$2', $data);
}

5.2 多语言支持(I18N)

如果你的用户是老外,或者你需要多语言支持,不要在代码里写死 return JsonResponse::error(2000001, '用户不存在');

我们需要一个语言包管理器。

class ErrorCode {
    const USER_NOT_FOUND = 2000001;
}

class Lang {
    public static function get($code) {
        static $lang = [
            'zh-CN' => [
                ErrorCode::USER_NOT_FOUND => '用户不存在'
            ],
            'en-US' => [
                ErrorCode::USER_NOT_FOUND => 'User not found'
            ]
        ];

        $currentLang = 'zh-CN'; // 从Header或Cookie里获取

        return $lang[$currentLang][ErrorCode::USER_NOT_FOUND] ?? 'Unknown Error';
    }
}

然后在响应类里调用它。

return JsonResponse::error(
    ErrorCode::USER_NOT_FOUND, 
    Lang::get(ErrorCode::USER_NOT_FOUND), 
    $response
);

5.3 分页数据的统一格式

分页是最容易乱的地方。有时候前端要 total,有时候要 page。我们建议把分页信息封装在 data 里面,作为一个子对象。

{
    "code": 0,
    "msg": "成功",
    "data": {
        "list": [...],
        "pagination": {
            "total": 100,
            "page": 1,
            "page_size": 10,
            "total_page": 10
        }
    }
}

这样前端无论是调用第一页,还是调用最后一页,数据结构都是一致的,写 for 循环的时候心里才踏实。


第六章:避坑指南——千万别这么干

说了这么多,最后再给大家提个醒。设计API接口时,有几个大坑千万别踩。

6.1 不要把 HTTP 状态码 和 API 错误码 混为一谈

这是新手最容易犯的错误。
HTTP 200 只代表“请求发出了,服务器收到了,开始处理了”。
HTTP 500 代表“服务器挂了”。
如果你的业务逻辑是“账户余额不足”,不要返回 HTTP 402 Payment Required(这太晦涩了),也不要返回 HTTP 500。请务必返回 HTTP 200,并在 Body 里的 code 字段返回 2000003

记住:API 的业务逻辑是 HTTP 状态码的“天花板”。 只要服务器没死机,永远不要给 500。

6.2 避免嵌套过深

有的同学为了追求极致的JSON封装,搞了一层套一层的结构:

{
    "code": 0,
    "data": {
        "result": {
            "item": {
                "content": "..."
            }
        }
    }
}

前端拿到这个 JSON,写个 data.result.item.content 就晕了。API 的嵌套层级最好控制在 2-3 层以内,简单粗暴才是王道。

6.3 避免过度封装

不要为了封装而封装。如果一个接口只是返回一个简单的字符串,比如“注册成功”,就没必要把它包在 {code:0, msg:..., data:...} 里了,直接返回 {"success": true} 即可。


结语:代码是写给人看的,顺便给机器跑

好了,今天的讲座就到这里。

大家回去写代码的时候,回想一下那个“混乱”的过去。试着把你的 if/else 里的 die 换成 ApiResponse::error,试着把那些散落在各处的 return ['code'=>...] 换成统一的类方法。

统一的错误码和数据结构,就像是你给API穿上了西装,打了领带。这看起来可能有点累赘,有点不自由,但当你面对前端开发人员的抱怨时,你会发现,这身西装是那么的合身,那么的优雅。

API设计没有银弹,但统一的数据结构至少能让你少掉几根头发。

祝大家代码无Bug,API无异常,PM不找茬,发际线不后移!

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注