教你更优雅地写 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,提高扩展性
  • 更有效地记录日志,来提高线上排查问题效率
  • 其他。..(规划中)

更新记录

  • 2021-04-16:支持配置异常或操作失败时返回的状态码(比如,response.php 中的 error_code 配置成 200),默认依旧遵守较为严格的 http 状态码返回
  • 2021-03-20:移除 laravel-enum 依赖,可以独立使用
  • 2021-02-04:支持修改分页数据中的 data 字段名
  • 2020-12-22:laravel-response 中移除对 laravel-enum 的依赖,更加独立简洁

实现过程

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": {}
}
  • 分页数据(支持修改内层 data 字段名称)
{
    "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": {}
}
  • 返回简单分页数据(支持修改内层 data 字段名称)
{
    "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 协议》,转载必须注明作者和本文链接
本帖由系统于 3年前 自动加精
Jianne
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 31

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

3年前 评论
Jianne

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

3年前 评论
陈先生

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

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

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

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

3年前 评论
laogou 3年前
Jianne

@xiaochen52113 没明白你说的什么,简单回复:

  • ResponseCodeEnum的目的是为了规范返回给前端的操作码,前端同学可以很容易地根据操作码来提示操作信息;
  • 另一方面,项目业务到达一定规模,复杂度提高,团队成员可以很快地通过ResponseCodeEnum文件了解项目的接口概况,而不是每个文件都可能包含有个人风格的操作码定义
  • 另外,PhpStorm 对于定义的常量和方法可以很简单地反向找到使用到的地方

file

3年前 评论

群主能不能建个群呐,其实还有其他的响应格式需求啊! 比如说 API响应 token,还要代cookie的要求,建议建设一个群!好好研究研究!哈哈

3年前 评论
Jianne

@dongzhiyu QQ 群:1105120693

3年前 评论
陈先生

@Jiannei 我给的方案也是如此 不过精简一些 因为我希望在任何地方来抛出异常, 所以我定义了一套 exception来作为错误返回的行为 错误码唯一不唯一的问题已经没关系了 但是我模块化的时候 每个模块都是单独的exception 并且相应的错误文字是可以随时改动 更多的是通过异常来停止 然后响应数据, laravle本身的handle可以实现这个需求,加了注解,可以用更好的方案, 本身错误码就是const 出来的 一定是支持phpstorm等ide追踪的

3年前 评论
朕略显ぼうっと萌

你data里面为啥还要再套一层data,你这样前端用的时候得 result.data.data.data,不会被前端打死嘛 :joy:

3年前 评论
labu 3年前
Jianne

@朕略显ぼうっと萌 分页结构已支持修改内层 data 字段名,或许会改成 result.data.data.list或者result.data.data.rows ? :ok_hand:

3年前 评论

我自己新建一个项目(laravel 8),安装步骤引入这个包,但是异常返回的数据格式就是不行。
配置步骤如下:

#安装
composer require jiannei/laravel-enum -vvv
composer require jiannei/laravel-response -vvv
#发布文件
php artisan vendor:publish --provider="Jiannei\Response\Laravel\Providers\LaravelServiceProvider"
#格式化异常响应
在 app/Exceptions/Handler.php 中 引入 use Jiannei\Response\Laravel\Support\Traits\ExceptionTrait;

请问还需要配置什么地方吗?

3年前 评论
Jianne (楼主) 3年前
w_y_h 2年前

想统一返回200状态码,如何修改最快呢

3年前 评论
lyle1995 (作者) 3年前
Jianne (楼主) 3年前

要正常显示自定义操作码需要的配置

config/response.php

//  自定义操作码
//  https://github.com/Jiannei/laravel-response#%E8%87%AA%E5%AE%9A%E4%B9%89%E6%93%8D%E4%BD%9C%E7%A0%81
'enum' => \App\Repositories\Enums\ResponseCodeEnum::class,
2年前 评论
Jianne

@Adminwj 提供下版本号和复现步骤 :sweat_smile:

2年前 评论
Jianne

近期为了兼容 laravel 5.*、laravel 6 、laravel 7 和 laravel 8 各版本,扩展发版较为频繁,原先在 Laravel 8 或 Lumen 8 中使用了该扩展的同学,更新版本时请先 composer remove 后再安装最新版,如有造成升级困扰,恳以谅解:neutral_face:

较大版本发布说明: github.com/Jiannei/laravel-respons...

就这么多,下次有好的想法再聊 :relaxed:

2年前 评论
dongzhiyu 2年前

abort无法抛出自定义的状态吗?InvalidArgumentException The HTTP status code "500102" is not valid.

2年前 评论
chengjiabing 2年前
Jianne (楼主) 1年前

请问怎么配置多语言响应描述?

1年前 评论
Jianne (楼主) 1年前

其它地方蛮统一的,这个data太不统一了,而且还嵌套 :joy:

1年前 评论

没太明白多语言是如何搞的

1年前 评论

不太习惯http和业务自定义的码混合的情况;而且业务的码一般只需定义错误码就好了, 正确的时候,错误码约定为0即可

1年前 评论

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