API 设计指南

API管理.png

博主有话说:早在 2012 年的时候,就开始接触 API 设计方面的工作。当时没有任何设计的经验。于是,团队就以支付宝的接口文档做出了一套自己的规范。形成了当时我们的第一版 API 设计的处女作。

本篇文章会从各个角度剖析 API 的设计规范。当然,也会为了易编程、易维护、性能等方面违背一丢丢前沿公司定义的公开的标准规范。

该文章转载于阮一峰的博客:http://www.ruanyifeng.com/blog/2014/05/restful_api.html 在它的基础上增加了一些自己的实战经验进行对比理解。

一、协议

API 与用户的通过协议,总是使用 HTTPS 协议。

二、域名

应该尽量将 API 部署在专用的域名之下。

> https://api.example.com

如果确定 API 很简单,不会有进一步扩展,可以考虑放在主域名下。

> https://example.com/api/

三、API 版本

API 版本区分有三种方式。

1)URL 中携带

> https://api.example.com/v1/

这种方式携带版本号,有个不太方便的地方: 版本号是 x.x.x 表示法。这种做法就会显得很难爱。

2)HTTP header 头信息携带

这种做法不如放入 URL 方便和直观。

3)请求参数携带

在请求参数中携带这种方式。支付宝有部分接口就是这样设计。本人也比较喜欢这种用法。

因为本人更倾向于喜欢第三种。所以,就不存在 x.x.x 这种表示法很奇怪的情况。如下:

{
    ......
    "method": "user.login",
    "username": "13888888888",
    "password": "123456",
    "v": "1.0.0",
    ......
}

x.x.x 表示法,我们通过用第一位表示大版本,第二位表示小版本,第三位表示对小版本的 BUG 修复。比如:1.0.0 发现有一个 BUG,我们及时修复并上线了。就变成了 1.0.1。如果 1.0.0 之上有功能的小修改,我们就变成了 1.1.0。当然,这样的划分并不是绝对。只要能够准确知道每个版本的差异即可。

四、接口请求路径(Endpoint)

路径又称"终点"(endpoint),表示 API 的具体网址。

1)RESTful 风格的请求路径

在 RESTful 架构中,每个网址代表一种资源(resource)。所以,网址中不能有动词,只能有名词,而且所用的名词往往与数据库的表格名对应。一般来说,数据库中的表都是同种记录的"集合"(collection),所以 API 中的名词也应该使用复数。

举例来说,有一个 API 提供动物园(zoo)的信息,还包括各种动物和雇员的信息,则它的路径应该设计成下面这样。

注意:如果你用的框架支持 RESTful 风格,且选择了 RESTful 规范,请尽量按照以上建议设计接口的请求路径。

2)请求参数的请求路径

在第三小节的第 3 点当中,我们推荐了版本号放入参数的方式。所以,我们的请求路径,也采用参数来设置的方式。如下所示:

{
    "method": "user.login",
    "username": "13888888888",
    "password": "123456",
    "type": "pwd",
    "device_token": "AhIWfj5ulQTChf8DsDti5ezNB8_1nEezN7eMIv6OfebA",
    "v": "1.0.0",
    "appid": "test_appid",
    "timestamp": 1523499419518,
    "unique_id": "b91f5a1e50d6a0fff36dda5a1bb08d76",
    "app_v": "4.0.0",
    "longitude": "113.945999",
    "latitude": "22.547822",
    "channel": "huawei",
    "platform": "4"
}

通过上述的 JSON 请求参数可知道,我们在其中有两个关键的值。

v = 1.0.0, method = user.login

可以很清晰知道我们向接口地址 https://api.example.com 请求了接口版本号为 1.0.0 的用户登录接口 user.login。

在服务器端代码里面我们可以通过这两个关键值的信息调用对应接口来处理具体的业务逻辑。

五、HTTP 动词

如果你采用了参数来传递接口版本号和接口路径。那么本小节你可以不用关心。本小节主要是对 RESTful 风格做进一步的请求说明。

对于资源的具体操作类型,由 HTTP 动词表示。

常用的 HTTP 动词有下面一个(括号里是对应的 SQL 命令)。

  • GET(SELECT):从服务器取出资源(一项或多项)。
  • POST(CREATE):在服务器新建一个资源。
  • PUT(UPDATE):在服务器更新资源(客户端提供改变后的完整资源)。
  • PATCH(UPDATE):在服务器更新资源(客户端提供改变的属性)。
  • DELETE(DELETE):从服务器删除资源。

还有两个不常用的 HTTP 动词。

  • HEAD:获取资源的元数据。
  • OPTIONS:获取信息,关于资源的哪些属性是客户端可以改变的。

下面是一些例子。

  • GET /zoos:列出所有动物园
  • POST /zoos:新建一个动物园
  • GET /zoos/ID:获取某个指定动物园的信息
  • PUT /zoos/ID:更新某个指定动物园的信息(提供该动物园的全部信息)
  • PATCH /zoos/ID:更新某个指定动物园的信息(提供该动物园的部分信息)
  • DELETE /zoos/ID:删除某个动物园
  • GET /zoos/ID/animals:列出某个指定动物园的所有动物
  • DELETE /zoos/ID/animals/ID:删除某个指定动物园的指定动物

六、过滤信息(Filtering)

以下只适用 RESTFul 风格的接口设计。

如果记录数量很多,服务器不可能都将它们返回用户。API 应用提供参数,过滤返回结果。

下面是一些常见的参数。

  • ?limit=10:指定返回记录的数量
  • ?offset=10:指定返回记录的开始位置。
  • ?page=2&per_page=100:指定第几页,以及每页的记录数。
  • ?sortby=name&order=asc:指定返回结果按照哪个属性排序,以及排序顺序。
  • ?animal_type_id=1:指定筛选条件

参数的设计允许存在冗余,即允许 API 路径和 URL 参数偶尔有重复。比如:GET /zoo/ID/animals 与 GET /animals?zoo_id=ID 的含义是相同的。

七、状态码(Status Codes)

服务器向用户返回的状态码和提示信息,常见的有以下一些(方括号中是该状态码对应的HTTP动词)。

  • 200 OK - [GET]:服务器成功返回用户请求的数据,该操作是幂等的(Idempotent)。
  • 201 CREATED - [POST/PUT/PATCH]:用户新建或修改数据成功。
  • 202 Accepted - [*]:表示一个请求已经进入后台排队(异步任务)
  • 204 NO CONTENT - [DELETE]:用户删除数据成功。
  • 400 INVALID REQUEST - [POST/PUT/PATCH]:用户发出的请求有错误,服务器没有进行新建或修改数据的操作,该操作是幂等的。
  • 401 Unauthorized - [*]:表示用户没有权限(令牌、用户名、密码错误)。
  • 403 Forbidden - [*] 表示用户得到授权(与401错误相对),但是访问是被禁止的。
  • 404 NOT FOUND - [*]:用户发出的请求针对的是不存在的记录,服务器没有进行操作,该操作是幂等的。
  • 406 Not Acceptable - [GET]:用户请求的格式不可得(比如用户请求JSON格式,但是只有XML格式)。
  • 410 Gone -[GET]:用户请求的资源被永久删除,且不会再得到的。
  • 422 Unprocesable entity - [POST/PUT/PATCH] 当创建一个对象时,发生一个验证错误。
  • 500 INTERNAL SERVER ERROR - [*]:服务器发生错误,用户将无法判断发出的请求是否成功。

状态码的完全列表参见这里

以上是 HTTP 的状态码。在 RESTFul 的时候,可以有很好的参照作用。但是,我采用的是返回参数当中用指定的参数来记录状态码。比如:200,代表请求成功,其他值代表具体失败的原因。如下所示:

{
    "code": "502",
    "msg": "您的密码被修改,请重新登录"
}

换句话说,我在设计接口的时候,只会存在两种 HTTP Status 状态: 200、500。

200 代表服务器正常接收请求并处理完成。500 代表服务器代码出现致使错误。这里的 200 与 500 并不是返回的数据里面的 code 值。而是 https status 值。

八、错误处理

错误处理的话,采用第七小节当中的方式即可。通过 msg 参数返回。

{
    "code": "403",
    "msg": "您没有权限访问该资源"
}

九、返回结果

返回结果个人觉得只要能被使用的人正确理解并使用就好。提供几中数据返回示例:

1)无分页列表数据

{
    "code": 1000200,
    "msg": "success",
    "data": {
        "list": [
            {
                "uaddressid": 974,
                "userid": 3549232,
                "username": "张三",
                "phone": "18575202691",
                "regionid": 410403,
                "region_name_path": "河南省平顶山市卫东八区",
                "address": "同乐街x院",
                "is_default": 0
            },
            {
                "uaddressid": 975,
                "userid": 3549232,
                "username": "张三",
                "phone": "18575202691",
                "regionid": 410403,
                "region_name_path": "河南省平顶山市卫东八区",
                "address": "同乐街x院",
                "is_default": 0
            }
        ]
    }
}

2)有分页列表数据

{
    "code": 1000200,
    "msg": "success",
    "data": {
        "list": [
            {
                "uaddressid": 974,
                "userid": 3549232,
                "username": "张三",
                "phone": "18575202691",
                "regionid": 410403,
                "region_name_path": "河南省平顶山市卫东八区",
                "address": "同乐街x院",
                "is_default": 0
            },
            {
                "uaddressid": 975,
                "userid": 3549232,
                "username": "张三",
                "phone": "18575202691",
                "regionid": 410403,
                "region_name_path": "河南省平顶山市卫东八区",
                "address": "同乐街x院",
                "is_default": 0
            }
        ],
        "total": 100,
        "page": 1,
        "is_next": 1,
        "count": 2
    }
}

3)详情

{
    "code": 1000200,
    "msg": "success",
    "data": {
        "uaddressid": 974,
        "userid": 3549232,
        "username": "张三",
        "phone": "18575202691",
        "regionid": 410403,
        "region_name_path": "河南省平顶山市卫东八区",
        "address": "同乐街x院",
        "is_default": 0
    }
}

十、接口的安全

接口的设计不仅要满足需求,还必须尽可能的安全。我们需要以可能的增加被攻击的难度。

1)HTTPS

这个我们在第一小节的时候,就已经强调了。接口必须使用 HTTPS。

2)请求过期验证

我们会要求请求的参数当中包含一个时间戳。这个时间戳是请求的时候把当时的系统时间戳。接口收获到这个时间戳,然后,读取接口所在系统的时间戳两相判断差值是否大于 60 秒。当然,此值多少根据实际情况权衡。超过这个时间就认为超时。如果该接口是提供给客户端 APP 使用。那么此值可能就不可信。因为,客户端所在的手机系统可能时区有误。那么,就有可能导致永远是超时的情况。

伪代码:

// 过期验证

if (($timestamp - $_REQUEST['timestamp']) > 60) {
    throw new \Exception(401, 'Expired request');
}

代码中的 $timestamp 接口传递过来的时间戳。

3)签名验证

签名验证通常涉及三种加密:1)对称加密、2)非对称加密、3)验签。

这里我们不做细节讲解。后面的小节我们会详细讲解各种算法。

4)重放攻击/重发攻击

所谓重放攻击,是指别有用心的恶意用户把一个请求抓取之后,不断以该数据包请求服务器。在一些不严谨的代码逻辑中,有可能会出现数据的恶意破坏。

要解决这个问题很简单。只需要把每次请求的请求接口路径、请求时间戳、签名、设备唯一号(如果有的话)进行拼接之后 MD5 生成一个此请求的唯一字符串。然后,在 Redis 缓存当中记录这个请求。如果下次有同样的请求过来,通过判断 Redis 缓存是否存在即可有效判断。

伪代码:

/**
 * 重放攻击判断
 */
$key = md5("{$apiName-$timestamp-$sign-$uniqueid}");
if (redis->exists(key)) {
    throw new \Exception(401, 'Repeated request');
}

十一、接口的具体设计

我在设计接口的时候,采用了工厂模式。每个接口类当作具体的产品。每个接口都继承一个公共的抽象类。在抽象类当中,我们定义了各种各样的验证。具体参与如下代码:

// 接口工厂类 ApiFactory.php

namespace apis;
use common\YCore;

class ApiFactory
{
    /**
     * 根据接口名称返回接口对象。
     * 
     * -- 1、接口名称转类名称规则:user.login = UserLoginApi
     * -- 2、当method参数为空的时候,要抛出异常给调用的人捕获处理。
     *
     * @param array $apiData 请求来的所有参数。
     * @throws Exception
     * @return \apis\BaseApi
     */
    public static function factory(&$apiData)
    {   
        // 接口请求路径/接口名称。
        if (!isset($apiData['method']) || strlen($apiData['method']) === 0) {
            YCore::exception(500, 'method does not exist');
        }

        // 接口版本号。
        if (!isset($apiData['v']) || strlen($apiData['v']) === 0) {
            YCore::exception(500, 'version number is wrong');
        }

        // 将 method 参数转换为实际的接口类名称。
        $apiName   = $apiData['method'];
        $params    = explode('.', $apiName);
        $classname = '';
        foreach ($params as $param) {
            $classname .= ucfirst($param);
        }
        $version   = str_replace('.', '', $apiData['v']);
        $classname = "apis\\v{$version}\\{$classname}Api";

        if (strlen($apiName) && class_exists($classname)) {
            return new classname($apiData);
        } else {
            YCore::exception(500, 'Interface does not exist');
        }
    }
}

// 接口抽象基类 BaseApi.php

<?php
/**
 * 所有API接口基类。
 * @author fingerQin
 */

namespace apis;

use common\YCore;
use services\ApiService;

abstract class BaseApi
{
    /**
     * 应用ID。
     * 
     * @var int
     */
    protected $appid;

    /**
     * 请求参数。
     *
     * @var array
     */
    protected $params  = [];

    /**
     * 结果。
     *
     * @var array
     */
    protected $result  = [];

    /**
     * 构造方法。
     * 
     * @param array  $data      所有请求过来的参数。
     * @param string $apiType   API 接口类型。
     * 
     * -- 1、合并提交的参数。
     * -- 2、调用权限判断。
     * -- 3、签名验证。
     * -- 4、参数格式判断。
     * -- 5、运行接口逻辑。
     */
    public function __construct(&data, $apiType = self::API_TYPE_APP)
    {
        $this->apiType   = $apiType;
        $this->timestamp = $_SERVER['REQUEST_TIME'];
        $this->params    = $data;
        $this->checksignature();
        $this->runService();
    }


    /**
     * 业务逻辑。
     *
     * -- 每个接口都必须实现此方法,此方法实现具体的业务。
     *
     * @return void
     */
    abstract protected function runService();

    /**
     * 验证码请求签名。
     *
     * @return boolean
     */
    protected function checksignature()
    {
        $sign      = $this->getString('sign');
        $appInfo   = $this->getAppInfo();
        $appKey    = $appInfo['app_key'];
        $str       = $this->params['oriJson'] . $appKey;
        $rightSign = strtoupper(md5($str));
        
        // 开发环境不验证签名。方便开发。
        if (YCore::appconfig('env.name') != 'dev') {
            if (strlen($sign) === 0 || sign != $rightSign) {
                YCore::exception(502, 'Signature error');
            }
        }
        $this->appid = $appInfo['aappid'];
        return true;
    }

    /**
     * 获取当前请求的应用 KEY。
     *
     * -- 实际的时候,我们分配的不是应用的自增id编号,而是英文名称。
     *
     * @return string
     */
    protected function getAppInfo()
    {
        return ApiService::getAppDetail($this->getString('appid'));
    }

    /**
     * 数据返回格式统一组装方法。
     *
     * @param  int     $code   错误码,必须是int类型。
     * @param  string  $msg    提示信息。
     * @param  array   $data   数据。
     * @return void
     */
    public function render($code, $msg, $data = null)
    {
        if (!is_int($code)) {
            YCore::exception(500, 'BaseApi render method of code parameter must be an integer');
        }

        $this->result = [
            'code' => $code,
            'msg'  => $msg
        ];

        if ($code == 200 && ! is_null($data)) {
            $data = empty($data) ? (object)[] : $data;
            $this->result['data'] = $data;
        }
    }

    /**
     * 响应结果。
     *
     * @return string
     */
    public function renderJson()
    {
        return json_encode($this->result, JSON_UNESCAPED_UNICODE);
    }
}

// 具体接口类 UserLoginApi.php

/**
 * 用户登录接口。
 * @author winerQin
 * @version 1.0.0
 */

namespace apis\v100;
use apis\BaseApi;
use services\UserService;
use common\YUrl;

class UserLoginApi extends BaseApi
{
    /**
     * 逻辑处理。
     * 
     * @see Api::runService()
     * @return void
     */
    protected function runService()
    {
        $mobile   = trim($this->getString('mobile'));
        $password = $this->getString('password');
        $result   = UserService::login($mobile, $password);
        $this->render(200, 'success', $result);
    }
}

在我们的用户登录接口当中,我们做了参数的接收,然后,调用 UserService::login 方法实现了登录操作。也就是说,任何接口都必须继承 BaseApi 基类,并实现其中的 runService() 抽象方法。在这个方法中调用业务类当中的方法完成具体的操作。

注意:如果你在实现接口的时候,是通过框架自身的 MVC 机制提供的 URL 地址,那么每个接口的版本号对应一个模块(module),每个接口对应一个控制器(Controller)下的一个操作(Action)。每个控制器必须继承一个接口通用的公共控制器。在这个公共控制器里面实现其我们刚刚上述的 BaseApi 的功能即可。

一个接口对应一个接口文件还是对应一个控制器的一个操作。这样都有好处:

1)版本接口兼容性:各种版本之间,每个接口有不同的差异之时。可以在接口里面做差异化处理。这样可以处理多个版本的兼容性。

2)接口特殊性处理:当需要对某个接口做特殊处理(权限调整)时,就不会影响其他接口。毕竟,并不是每个接口权限都一样。

总而言之,言而总之。每个接口必须有单独的接口文件或操作方法。

本文借鉴了阮一峰的《RESTful API 设计指南》,因为,我个人觉得应用程序 API 设计并不仅仅只有 RESTful 一种风格。所以,结合自身的实战经验,在本文中融入了自己的一些见解。

博主 2011 年创建了一个《PHP 初学者官方群》,目前群成员 500 人左右。群号:168159147。为了防止广告,设置为付费入群。欢迎大家加入讨论技术!

标签: 无

发表评论: