教你更优雅地写 API 之「规范响应数据」

图片

前言

在推出 lumen-api-starter 以后,收到了不少的关注和反馈,先在此感谢各位朋友萌 🤟

关于 lumen-api-starter 的介绍可以参考上一篇是时候使用 Lumen 8 + API Resource 开发项目了!。本篇是在前一篇的基础上,重新整理并独立出了 package,可以同时支持最新版本 Laravel 和 Lumen 项目。

Package 地址:laravel-response
Laravel 版本 Api 开发初始化项目:laravel-api-starter
Lumen 版本 Api 开发初始化项目:lumen-api-starter

回到正题, 在用 Laravel 或 Lumen 写 API 项目前,通常需要先定义一些项目规范,来让后续的开发体验更舒适,包含有:

  • 规范统一响应数据结构:成功操作、失败操作以及异常操作响应
  • 使用枚举来管理项目中的常量,减少 bug,提高扩展性
  • 更有效地记录日志,来提高线上排查问题效率
  • 其他。..(规划中)

更新记录

实现过程

RESTful 服务最佳实践 :如何去设计 Http 状态码以及数据返回格式。

思路

  • 尽可能地遵循 Laravel 思维进行扩展,符合一定规范
  • 尽量少的依赖安装,最好是 0 依赖,不额外增加负担
  • 尽量完善的单元测试,保证代码质量(关于使用示例可以跳过下面的介绍直接查看 github.com/Jiannei/laravel-respons... 测试用例)
  • 实现需要简洁,使用需要优雅

功能

  • 统一的数据响应格式,固定包含:codestatusdatamessageerror
  • 内置 Http 标准状态码支持,同时支持扩展 ResponseCodeEnum 来根据不同业务模块定义响应码
  • 响应码 code 对应描述信息 message 支持本地化,支持配置多语言
  • 合理地返回 Http 状态码
  • 根据 debug 开关,合理返回异常信息、验证异常信息等
  • 支持格式化 Laravel 的 Api ResourceApi Resource CollectionPaginator(简单分页)、LengthAwarePaginator(普通分页)、Eloquent\ModelEloquent\Collection,以及简单的 arraystring等格式数据返回
  • 分页数据格式化后的结果与使用 league/fractal (DingoApi 使用该扩展进行数据转换)的 transformer 转换后的格式保持一致,也就是说,可以顺滑地从 Laravel Api Resource 切换到 league/fractal

规范

  • 合适的 Http 状态码,可以让客户端 / 浏览器更好地理解 Http 响应

教你更优雅地写 API 之规范响应数据

  • 格式固定
{
    "status": "success",// 描述 HTTP 响应结果:HTTP 状态响应码在 500-599 之间为”fail”,在 400-499 之间为”error”,其它均为”success”
    "code": 200,// 包含一个整数类型的 HTTP 响应状态码,也可以是业务描述操作码,比如 200001 表示注册成功
    "message": "操作成功",// 多语言的响应描述
    "data": {// 实际的响应数据
        "nickname": "Joaquin Ondricka",
        "email": "lowe.chaim@example.org"
    },
    "error": {}// 异常时的调试信息
}

需求

在不使用任何 package 的情况下,在 Laravel 中响应 API Json 格式数据,通常是下面这个样子:

return response()->json($data, $status, $headers, $options);

但实际开发场景,会有很多种数据返回需求:

  • 更多时候只是简单的成功和失败响应,所以需要有快捷的 successfail 格式化方法
  • 成功响应可能包含有:User::all()User::first()UserResourceUserCollectionUser::paginate()User::simplePaginate()Collection和普通的 Array等,希望这些不同类型的数据都能格式化成统一的结构
  • 失败的响应,通常就是根据不同的业务场景,返回不同的错误码和错误描述
  • 异常响应,对于表单验证、Http 等异常情况,能够针对是否开启 debug 有不同响应,并且格式与前面统一

定义业务操作码

<?php
namespace App\Repositories\Enums;

use Jiannei\Enum\Laravel\Repositories\Enums\ResponseCodeEnum as BaseResponseCodeEnum;

class ResponseCodeEnum extends BaseResponseCodeEnum
{
    // 业务操作正确码:1xx、2xx、3xx 开头,后拼接 3 位
    // 200 + 001 => 200001,也就是有 001 ~ 999 个编号可以用来表示业务成功的情况,当然你可以根据实际需求继续增加位数,但必须要求是 200 开头
    // 举个栗子:你可以定义 001 ~ 099 表示系统状态;100 ~ 199 表示授权业务;200 ~ 299 表示用户业务。..
    const SERVICE_REGISTER_SUCCESS = 200101;
    const SERVICE_LOGIN_SUCCESS = 200102;

    // 客户端错误码:400 ~ 499 开头,后拼接 3 位
    const CLIENT_PARAMETER_ERROR = 400001;
    const CLIENT_CREATED_ERROR = 400002;
    const CLIENT_DELETED_ERROR = 400003;

    const CLIENT_VALIDATION_ERROR = 422001; // 表单验证错误

    // 服务端操作错误码:500 ~ 599 开头,后拼接 3 位
    const SYSTEM_ERROR = 500001;
    const SYSTEM_UNAVAILABLE = 500002;
    const SYSTEM_CACHE_CONFIG_ERROR = 500003;
    const SYSTEM_CACHE_MISSED_ERROR = 500004;
    const SYSTEM_CONFIG_ERROR = 500005;

    // 业务操作错误码(外部服务或内部服务调用。..)
    const SERVICE_REGISTER_ERROR = 500101;
    const SERVICE_LOGIN_ERROR = 500102;
}

本地化操作码描述

<?php
// resources/lang/zh-CN/enums.php
use App\Repositories\Enums\ResponseCodeEnum;

return [
    // 响应状态码
    ResponseCodeEnum::class => [
        // 成功
        ResponseCodeEnum::HTTP_OK => '操作成功', // 自定义 HTTP 状态码返回消息
        ResponseCodeEnum::HTTP_INTERNAL_SERVER_ERROR => '操作失败', // 自定义 HTTP 状态码返回消息
        ResponseCodeEnum::HTTP_UNAUTHORIZED => '授权失败',

        // 业务操作成功
        ResponseCodeEnum::SERVICE_REGISTER_SUCCESS => '注册成功',
        ResponseCodeEnum::SERVICE_LOGIN_SUCCESS => '登录成功',

        // 客户端错误
        ResponseCodeEnum::CLIENT_PARAMETER_ERROR => '参数错误',
        ResponseCodeEnum::CLIENT_CREATED_ERROR => '数据已存在',
        ResponseCodeEnum::CLIENT_DELETED_ERROR => '数据不存在',
        ResponseCodeEnum::CLIENT_VALIDATION_ERROR => '表单验证错误',

        // 服务端错误
        ResponseCodeEnum::SYSTEM_ERROR => '服务器错误',
        ResponseCodeEnum::SYSTEM_UNAVAILABLE => '服务器正在维护,暂不可用',
        ResponseCodeEnum::SYSTEM_CACHE_CONFIG_ERROR => '缓存配置错误',
        ResponseCodeEnum::SYSTEM_CACHE_MISSED_ERROR => '缓存未命中',
        ResponseCodeEnum::SYSTEM_CONFIG_ERROR => '系统配置错误',

        // 业务操作失败:授权业务
        ResponseCodeEnum::SERVICE_REGISTER_ERROR => '注册失败',
        ResponseCodeEnum::SERVICE_LOGIN_ERROR => '登录失败',
    ],
];

使用示例

成功响应

  • 示例代码
<?php
public function index()
{
    $users = User::all();

    return Response::success(new UserCollection($users));
}

public function paginate()
{
    $users = User::paginate(5);

    return Response::success(new UserCollection($users));
}

public function simplePaginate()
{
    $users = User::simplePaginate(5);

    return Response::success(new UserCollection($users));
}

public function item()
{
    $user = User::first();

    return Response::success(new UserResource($user));
}

public function array()
{
    return Response::success([
        'name' => 'Jiannel',
        'email' => 'longjian.huang@foxmail.com'
    ],'', ResponseCodeEnum::SERVICE_REGISTER_SUCCESS);
}
  • 返回全部数据
{
    "status": "success",
    "code": 200,
    "message": "操作成功",
    "data": [
        {
            "nickname": "Joaquin Ondricka",
            "email": "lowe.chaim@example.org"
        },
        {
            "nickname": "Jermain D'Amore",
            "email": "reanna.marks@example.com"
        },
        {
            "nickname": "Erich Moore",
            "email": "ernestine.koch@example.org"
        }
    ],
    "error": {}
}
  • 分页数据
{
    "status": "success",
    "code": 200,
    "message": "操作成功",
    "data": {
        "data": [
            {
                "nickname": "Joaquin Ondricka",
                "email": "lowe.chaim@example.org"
            },
            {
                "nickname": "Jermain D'Amore",
                "email": "reanna.marks@example.com"
            },
            {
                "nickname": "Erich Moore",
                "email": "ernestine.koch@example.org"
            },
            {
                "nickname": "Eva Quitzon",
                "email": "rgottlieb@example.net"
            },
            {
                "nickname": "Miss Gail Mitchell",
                "email": "kassandra.lueilwitz@example.net"
            }
        ],
        "meta": {
            "pagination": {
                "count": 5,
                "per_page": 5,
                "current_page": 1,
                "total": 12,
                "total_pages": 3,
                "links": {
                    "previous": null,
                    "next": "http://laravel-api.test/api/users/paginate?page=2"
                }
            }
        }
    },
    "error": {}
}
  • 返回简单分页数据
{
    "status": "success",
    "code": 200,
    "message": "操作成功",
    "data": {
        "data": [
            {
                "nickname": "Joaquin Ondricka",
                "email": "lowe.chaim@example.org"
            },
            {
                "nickname": "Jermain D'Amore",
                "email": "reanna.marks@example.com"
            },
            {
                "nickname": "Erich Moore",
                "email": "ernestine.koch@example.org"
            },
            {
                "nickname": "Eva Quitzon",
                "email": "rgottlieb@example.net"
            },
            {
                "nickname": "Miss Gail Mitchell",
                "email": "kassandra.lueilwitz@example.net"
            }
        ],
        "meta": {
            "pagination": {
                "count": 5,
                "per_page": 5,
                "current_page": 1,
                "links": {
                    "previous": null,
                    "next": "http://laravel-api.test/api/users/simple-paginate?page=2"
                }
            }
        }
    },
    "error": {}
}
  • 返回单条数据
{
    "status": "success",
    "code": 200,
    "message": "操作成功",
    "data": {
        "nickname": "Joaquin Ondricka",
        "email": "lowe.chaim@example.org"
    },
    "error": {}
}

其他快捷方法

Response::accepted();
Response::created();
Response::noContent();

失败响应

不指定 meesage

public function fail()
{
    Response::fail();// 不需要加 return
}
  • 未配置多语言响应描述,返回数据
{
    "status": "fail",
    "code": 500,
    "message": "Http internal server error",
    "data": {},
    "error": {}
}
  • 配置多语言描述后,返回数据
{
    "status": "fail",
    "code": 500,
    "message": "操作失败",
    "data": {},
    "error": {}
}

指定 message

public function fail()
{
    Response::fail('error');// 不需要加 return
}

返回数据

{
    "status": "fail",
    "code": 500,
    "message": "error",
    "data": {},
    "error": {}
}

指定 code

public function fail()
{
    Response::fail('',ResponseCodeEnum::SERVICE_LOGIN_ERROR);
}

返回数据

{
    "status": "fail",
    "code": 500102,
    "message": "登录失败",
    "data": {},
    "error": {}
}

其他快捷方法

Response::errorBadRequest();
Response::errorUnauthorized();
Response::errorForbidden();
Response::errorNotFound();
Response::errorMethodNotAllowed();
Response::errorInternal();

异常响应

对于异常的数据格式化,需额外在 app/Exceptions/Handler.php 中 引入 use Jiannei\Response\Laravel\Support\Traits\ExceptionTrait; 引入以后,对于 ajax 请求产生的异常都会进行格式化数据返回。

(Lumen 中为达到同样效果,还需在 app/Http/Controllers/Controller.php 中引入 ExceptionTrait

  • 表单验证异常
{
    "status": "error",
    "code": 422,
    "message": "验证失败",
    "data": {},
    "error": {
        "email": [
            "The email field is required."
        ]
    }
}
  • Controller 以外抛出异常返回

可以直接使用 abort 辅助函数直接抛出 HttpException 异常

abort(ResponseCodeEnum::SERVICE_LOGIN_ERROR);

// 返回数据

{
    "status": "fail",
    "code": 500102,
    "message": "登录失败",
    "data": {},
    "error": {}
}
  • 其他异常

开启 debug

{
    "status": "error",
    "code": 404,
    "message": "Http not found",
    "data": {},
    "error": {
        "message": "",
        "exception": "Symfony\\Component\\HttpKernel\\Exception\\NotFoundHttpException",
        "file": "/home/vagrant/code/laravel-api-starter/vendor/laravel/framework/src/Illuminate/Routing/AbstractRouteCollection.php",
        "line": 43,
        "trace": [
            {
                "file": "/home/vagrant/code/laravel-api-starter/vendor/laravel/framework/src/Illuminate/Routing/RouteCollection.php",
                "line": 162,
                "function": "handleMatchedRoute",
                "class": "Illuminate\\Routing\\AbstractRouteCollection",
                "type": "->"
            },
            {
                "file": "/home/vagrant/code/laravel-api-starter/vendor/laravel/framework/src/Illuminate/Routing/Router.php",
                "line": 646,
                "function": "match",
                "class": "Illuminate\\Routing\\RouteCollection",
                "type": "->"
            },
            ...
        ]
    }
}

关闭 debug

{
    "status": "error",
    "code": 404,
    "message": "Http not found",
    "data": {},
    "error": {}
}

One more thing?

回顾一下,这些封装全部都是基于 response()->json() ,即返回的是 JsonResponse 对象,所以我们依旧可以继续链式调用该对象上的方法。

// 设置 HTTP 响应码
return Response::success(new UserResource($user))->setStatusCode(ResponseCodeEnum::HTTP_CREATED);

其他

依照惯例,如果对您的日常工作有所帮助或启发,欢迎三连 star + fork + follow

如果有任何批评建议,通过邮箱(longjian.huang@foxmail.com)的方式可以联系到我。

总之,欢迎各路英雄好汉。

QQ 群:1105120693

本作品采用《CC 协议》,转载必须注明作者和本文链接
Peace & Love ❤️
本帖由系统于 4周前 自动加精
Jiannei
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 5

真的很不错,楼主很用心,学习了!

4周前 评论
Jiannei

@dongzhiyu @terranc @温故知新 感谢支持 :heart:

4周前 评论

错误码的规范我看了 相对来说, 如果按照楼主的规范去指定就意味着无法急速定位到错误,并且每次都要改动两个文件, 这好吗? 这不好, 写代码要以和为贵, laravel原本就有exception 创建对应的exception 出来 在你的Handle文件内直接开始注解, 这样练死劲不好用, 注解格式 /**

  • @Message('用户不存在')
  • const USER_NOT_FOUND = '000000001';

注解完了还不直接用错误返回示例直接返回出去了 这样岂不是一次只需要改动一个文件,并且全局唯一

但是不得不说 楼主这死劲也还不错,但是要以和为贵,不要窝里斗. PS: Exception 分模块 并不是唯一的Exception 模块需要互相保证错误码唯一

2周前 评论
laogou 1周前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!