各位好,我是你们的老朋友,一个在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 不行吗?‘用户密码错误’、‘服务器挂了’、‘数据库连不上’,这不都是文案吗?”
不行!绝对不行!
为什么?因为:
- 国际化:你要把API卖给全世界?中文的“用户不存在”翻译成英文就是“User does not exist”,前端改起来很累。
- 统计与分析:前端报错
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_FOUND 比 2 好懂多了吧?即便你忘了这是第几行定义的,你也知道这是跟用户相关的错误。
第三章:代码实现——从理论到实践
好了,概念都清楚了。现在我们要动手写代码。怎么写?如果你在每个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)中,都有中间件机制。我们需要写一个中间件,专门用来捕获所有未处理的异常。
逻辑是这样的:
- 尝试捕获异常。
- 如果是
ApiException,直接把它的code和msg拿出来,包装成JSON返回。 - 如果是系统异常(比如
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不找茬,发际线不后移!
下课!