做 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() 里针对不同错误码做小动作,比如记录日志,发送告警短信,或者做链路追踪。
这套方案不仅适用新项目,老项目也可以逐步改造。每个接口返回格式统一后,维护起来真的会上瘾。不信你也试试。

