写在前面
统一响应结构,的确很有用。一种是官方默认的响应返回,比如自定义的 Request 的 validate。一种是自定义结构的。想要好的体验,就得两种统一处理起来。
工作中使用 Laravel 开发 API 项目已经有些年头了,发现每次启动新的 Api 项目的时都会在 Laravel 基础上进行一些预处理,包括针对 API 项目的结构设计,统一响应结构的封装,异常的捕获处理以及授权模块的配置等。总是在做一些重复的工作,那索性将这些常用的基础封装做成一个「启动模板」好了。
项目地址:戳这儿
更新内容
- 感谢 [@渔郎](https://learnku.com/users/19607) 纠正改进文章内容(2020-06-28)
- 实现了根据实际业务场景自定义响应码和多语言的响应描述,目录结构有参考 [@mingzaily](https://learnku.com/users/34707) 同学的实现(2020-06-05)
- 更好的支持 Api Resource:使用
with()
和$additonal
附加额外数据,响应返回前使用withResponse()
对响应进行处理(2020-06-04) - 补充响应成功时的使用示例(2020-06-03)
- 规范 Reponse 中的 message 提示(2020-06-02)
- 升级支持 Laravel 8 (2020-10-10)
为什么是 Lumen ?
现如今,中大型项目中通常使用前后端分离方式开发,前后端分别通过不同的代码仓库各自维护着项目代码,Laravel 只负责项目中的 API 部分,提供 API 给前端调用。这种场景下,使用 Laravel 进行开发 API 稍微显得有点“臃肿”了。
相比之下,Lumen 针对项目中的 API 开发场景,精简了Laravel 中的很多部分,更适合 API 开发。有了 Laravel 使用经验,切换到 Lumen 也较为容易。
概览
- 适配 Laravel 7 中新增的 HttpClient 客户端(已升级到 Laravel 8)
- 使用 Laravel 原生的 Api Resource
- 规范统一的响应结构
- 使用 Jwt-auth 方式授权
- 支持日志记录到 MongoDB
- 合理有效地『Repository & Service』架构设计(😏)
规范的响应结构
摘选自:RESTful 服务最佳实践
code——包含一个整数类型的HTTP响应状态码。
status——包含文本:"success","fail"或"error"。HTTP状态响应码在500-599之间为"fail",在400-499之间为"error",其它均为"success"(例如:响应状态码为1XX、2XX和3XX)。
message——当状态值为"fail"和"error"时有效,用于显示错误信息。参照国际化(il8n)标准,它可以包含信息号或者编码,可以只包含其中一个,或者同时包含并用分隔符隔开。
data——包含响应的body。当状态值为"fail"或"error"时,data仅包含错误原因或异常名称。
说明
整体响应结构设计参考如上,相对严格地遵守了 RESTful 设计准则,返回合理的 HTTP 状态码。
考虑到业务通常需要返回不同的“业务描述处理结果”,在所有响应结构中都支持传入符合业务场景的message
。
- data:
- 查询单条数据时直接返回对象结构,减少数据层级;
- 查询列表数据时返回数组结构;
- 创建或更新成功,返回修改后的数据;(也可以不返回数据直接返回空对象)
- 删除成功时返回空对象
- status:
- error, 客户端(前端)出错,HTTP 状态响应码在400-599之间。如,传入错误参数,访问不存在的数据资源等
- fail,服务端(后端)出错,HTTP 状态响应码在500-599之间。如,代码语法错误,空对象调用函数,连接数据库失败,undefined index等
- success, HTTP 响应状态码为1XX、2XX和3XX,用来表示业务处理成功。
- message: 描述执行的请求操作处理的结果;也可以支持国际化,根据实际业务需求来切换。
- code: HTTP 响应状态码;可以根据实际业务需求,调整成业务操作码
代码实现
<?php
namespace App\Http;
use Illuminate\Http\Resources\Json\JsonResource;
use \Illuminate\Http\Response as HttpResponse;
class Response
{
public function errorNotFound($message = 'Not Found')
{
$this->fail($message, HttpResponse::HTTP_NOT_FOUND);
}
/**
* @param string $message
* @param int $code
* @param null $data
* @param array $header
* @param int $options
* @throws \Illuminate\Http\Exceptions\HttpResponseException
*/
public function fail(string $message = '', int $code = HttpResponse::HTTP_INTERNAL_SERVER_ERROR, $data = null, array $header = [], int $options = 0)
{
$status = ($code >= 400 && $code <= 499) ? 'error' : 'fail';
$message = (!$message && isset(HttpResponse::$statusTexts[$code])) ? HttpResponse::$statusTexts[$code] : 'Service error';
response()->json([
'status' => $status,
'code' => $code,
'message' => $message,// 错误描述
'data' => (object) $data,// 错误详情
], $code, $header, $options)->throwResponse();
}
public function errorBadRequest($message = 'Bad Request')
{
$this->fail($message, HttpResponse::HTTP_BAD_REQUEST);
}
public function errorForbidden($message = 'Forbidden')
{
$this->fail($message, HttpResponse::HTTP_FORBIDDEN);
}
public function errorInternal($message = 'Internal Error')
{
$this->fail($message, HttpResponse::HTTP_INTERNAL_SERVER_ERROR);
}
public function errorUnauthorized($message = 'Unauthorized')
{
$this->fail($message, HttpResponse::HTTP_UNAUTHORIZED);
}
public function errorMethodNotAllowed($message = 'Method Not Allowed')
{
$this->fail($message, HttpResponse::HTTP_METHOD_NOT_ALLOWED);
}
public function accepted($message = 'Accepted')
{
return $this->success(null, $message, HttpResponse::HTTP_ACCEPTED);
}
/**
* @param JsonResource|array|null $data
* @param string $message
* @param int $code
* @param array $headers
* @param int $option
* @return \Illuminate\Http\JsonResponse|JsonResource
*/
public function success($data, string $message = '', $code = HttpResponse::HTTP_OK, array $headers = [], $option = 0)
{
$message = (!$message && isset(HttpResponse::$statusTexts[$code])) ? HttpResponse::$statusTexts[$code] : 'OK';
$additionalData = [
'status' => 'success',
'code' => $code,
'message' => $message
];
if ($data instanceof JsonResource) {
return $data->additional($additionalData);
}
return response()->json(array_merge($additionalData, ['data' => $data ?: (object) $data]), $code, $headers, $option);
}
/**
* @param JsonResource|array|null $data
* @param string $message
* @param string $location
* @return \Illuminate\Http\JsonResponse|JsonResource
*/
public function created($data = null, $message = 'Created', string $location = '')
{
$response = $this->success($data, $message, HttpResponse::HTTP_CREATED);
if ($location) {
$response->header('Location', $location);
}
return $response;
}
public function noContent($message = 'No content')
{
return $this->success(null, $message, HttpResponse::HTTP_NO_CONTENT);
}
}
使用
在需要进行 HTTP 响应的地方使用 \\App\\Traits\\Helpers
对\\App\\Http\\Response
中封装的响应方法进行调用。
通常使用是在 Controller 层中根据业务处理的结果进行响应,所以在 \\App\\Http\\Controllers
基类中已经引入了 Helpers
trait,可以直接在 Controller 中进行如下调用:
// 操作成功情况
$this->response->success($data,$message);
$this->response->success(new UserCollection($resource), '成功');// 返回 API Resouce Collection
$this->response->success(new UserResource($user), '成功');// 返回 API Resouce
$user = ["name"=>"nickname","email"=>"longjian.huang@foxmail.com"];
$this->response->success($user, '成功');// 返回普通数组
$this->response->created($data,$message);
$this->response->accepted($message);
$this->response->noContent($message);
// 操作失败或异常情况
$this->response->fail($message);
$this->response->errorNotFound();
$this->response->errorBadRequest();
$this->response->errorForbidden();
$this->response->errorInternal();
$this->response->errorUnauthorized();
$this->response->errorMethodNotAllowed();
操作成功时的响应结构
- 返回单条数据
{
"data": {
"nickname": "Jiannei",
"email": "longjian.huang@foxmail.com"
},
"status": "success",
"code": 200,
"message": "成功"
}
- 返回列表数据
{
"data": [
{
"nickname": "Jiannei",
"email": "longjian.huang@foxmail.com"
},
{
"nickname": "Qian",
"email": "1234567891@foxmail.com"
},
{
"nickname": "Turbo",
"email": "123456789@foxmail.com"
}
// ...
],
"links": {
"first": "http://lumen-api.test/users?page=1",
"last": null,
"prev": null,
"next": null
},
"meta": {
"current_page": 1,
"from": 1,
"path": "http://lumen-api.test/users",
"per_page": 15,
"to": 13
},
"status": "success",
"code": 200,
"message": "成功"
}
操作失败时的响应结构
{
"status": "fail",
"code": 500,
"message": "Service error",
"data": {}
}
异常捕获时的响应结构
整体格式与业务操作成功和业务操作失败时的一致,相比失败时,data 部分会增加额外的异常信息展示,方便项目开发阶段进行快速地问题定位。
- 自定义实现了
ValidationException
的响应结构
{
"status": "error",
"code": 422,
"message": "Validation error",
"data": {
"email": [
"The email has already been taken."
],
"password": [
"The password field is required."
]
}
}
NotFoundException
异常捕获的响应结构
关闭 debug 时:
{
"status": "error",
"code": 404,
"message": "Service error",
"data": {
"message": "No query results for model [App\\Models\\User] 19"
}
}
开启 debug 时:
{
"status": "error",
"code": 404,
"message": "Service error",
"data": {
"message": "No query results for model [App\\Models\\User] 19",
"exception": "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException",
"file": "/var/www/lumen-api-starter/vendor/laravel/lumen-framework/src/Exceptions/Handler.php",
"line": 107,
"trace": [
{
"file": "/var/www/lumen-api-starter/app/Exceptions/Handler.php",
"line": 55,
"function": "render",
"class": "Laravel\\Lumen\\Exceptions\\Handler",
"type": "->"
},
{
"file": "/var/www/lumen-api-starter/vendor/laravel/lumen-framework/src/Routing/Pipeline.php",
"line": 72,
"function": "render",
"class": "App\\Exceptions\\Handler",
"type": "->"
},
{
"file": "/var/www/lumen-api-starter/vendor/laravel/lumen-framework/src/Routing/Pipeline.php",
"line": 50,
"function": "handleException",
"class": "Laravel\\Lumen\\Routing\\Pipeline",
"type": "->"
}
// ...
]
}
}
- 其他类型异常捕获时的响应结构
{
"status": "fail",
"code": 500,
"message": "syntax error, unexpected '$user' (T_VARIABLE)",
"data": {
"message": "syntax error, unexpected '$user' (T_VARIABLE)",
"exception": "ParseError",
"file": "/var/www/lumen-api-starter/app/Http/Controllers/UsersController.php",
"line": 34,
"trace": [
{
"file": "/var/www/lumen-api-starter/vendor/composer/ClassLoader.php",
"line": 322,
"function": "Composer\\Autoload\\includeFile"
},
{
"function": "loadClass",
"class": "Composer\\Autoload\\ClassLoader",
"type": "->"
},
{
"function": "spl_autoload_call"
}
// ...
]
}
}
特别说明:使用 Postman 等 Api 测试工具的时候,需要添加 X-Requested-With:XMLHttpRequest
或者Accept:application/json
header 信息来表明是 Api 请求,否则在异常捕获到后返回的可能不是预期的 JSON 格式响应。
丰富的日志模式支持
- 支持记录日志(包括业务错误记录的日志和捕获的异常信息等)到 MongoDB,方便线上问题的排查
- 记录到 MongoDB 的日志,支持以每日、每月以及每年按表进行拆分
- 支持记录 sql 语句
Repository & Service 模式架构
使用了andersao/l5-repository 进行项目结构设计,补充添加了 Service 层。
职责说明
待补充。
规范
命名规范:待补充
使用规范:待补充
Packages
- guzzlehttp/guzzle (可选,需要使用 Laravel 7 新增的 HttpClient 时安装)
- jenssegers/mongodb (可选,需要使用记录日志到 MongoDB 时安装)
- tymon/jwt-auth (默认支持 JWT 授权)
- prettus/l5-repository (默认使用 Repository 模式)
- league/fractal (可选,需要用到 transformer 时安装)
其他
依照惯例,如对您的日常工作有所帮助或启发,欢迎单击三连 star + fork + follow
。
如果有任何批评建议,通过邮箱(longjian.huang@foxmial.com)的方式(如果我每天坚持看邮件的话)可以联系到我。
总之,欢迎各路英雄好汉。
参考
- RESTful API 最佳实践(感谢 [@liuqing_hu](https://learnku.com/users/17343) 提供了非常详细的思维导图:+1:)
- RESTful 服务最佳实践
- DingoApi
最近比较忙,后续文档还在整理中,包含「处理 Api 异常」、「处理好项目中的日志记录」、「规范项目中的常量使用」和「有效地使用 Repository & Service」等,各位好汉和进群讨论交流。