API 设计指南

2018-09-06  本文已影响29人  fingerQin

早在 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 命令)。

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

下面是一些例子。

六、过滤信息(Filtering)

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

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

下面是一些常见的参数。

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

七、状态码(Status Codes)

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

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

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

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

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

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

八、错误处理

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

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

九、返回结果

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

1)无分页列表数据

{
    "code": 200,
    "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": 200,
    "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": 200,
    "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 一种风格。所以,结合自身的实战经验,在本文中融入了自己的一些见解。

上一篇 下一篇

猜你喜欢

热点阅读