我所理解的接口设计

前言

自己做接口开发的时间也算不短了(三年),想写这篇文章其实差不多已经有一年多的时间了。我将从下面的方向来对我所理解的接口设计做个总结:

接口参数定义 -> 接口版本化的问题 -> 接口的安全性 -> 接口的代码设计 -> 接口的可读性 -> 接口文档 -> 我遇到的坑

接口参数定义

接口设计中往可以抽象出一些新的公共参数,从事了近三年的接口开发工作中,我目前能想到了一些较为常见的公共接口参数如下:

公共参数 含意 定义该参数的意义
timestamp 毫秒级时间戳 1.客户端的请求时间标示 2.后端可以做请求过期验证 3.该参数参与签名算法增加签名的唯一性
app_key 签名公钥 签名算法的公钥,后端通过公钥可以得到对应的私钥
sign 接口签名 通过请求的参数和定义好的签名算法生成接口签名,作用防止中间人篡改请求参数
did 设备ID 设备的唯一标示,生成规则例如android的mac地址的md5和ios曾今udid(目前无法获取)的md5, 1:数据收集 2.便于问题追踪 3.消息推送标示

接口版本化的问题

接口设计中有个算是历史上的难题 -> 接口版本化。曾经也去调研了很多关于接口版本化的资料和设计,最后我得到的结论大致如下:

  • 接口的版本区分为
    • 大版本
      • 原则:大版本的数量最多控制到5个以内(我个人跟倾向于3个),超过版本限制的版本提示升级到新版本
      • 方案:
        • uri携带版本号,例如:v1/user/get
        • 请求参数,例如:user/get?v=1.0
    • 小版本
      • 原则:自己把控吧?
      • 方案:
        • uri携带版本号,例如:v1/user/get_01
        • 请求参数,小数点右边就是小版本,例如:user/get?v=1.1

接口的安全性

接口的设计肯定绕不开安全这两个字,为了达到尽可能的安全,我们需要尽可能的增加被攻击的难度,以下是我了解和使用到的一些常见的手段去增加接口的安全性(https这里就不讨论了):

过期验证/签名验证/重放攻击/限流/转义

伪代码如下:

// 过期验证
if (microtime(true)*1000 - $_REQUEST['timestamp'] > 5000) {
    throw new \Exception(401, 'Expired request');
}
// 签名验证(公钥校验省略)
$params = ksort($_REQUEST);
unset($params['sign']);
$sign = md5(sha1(implode('-', $params) . $_REQUEST['app_key']));
if ($sign !== $_REQUEST['sign']) {
    throw new \Exception(401, 'Invalid sign');
}
/**
 * 重放攻击
 * @params noise string 随机字符串或随机正整数,与 Timestamp 联合起来, 用于防止重放攻击 例如腾讯云是6位随机正整数
 */
$key = md5("{$_REQUEST['REQUEST_URI']}-{$_REQUEST['timestamp']}-{$_REQUEST['noise']}-{$_REQUEST['did']}");
if ($redisInstance->exists($key)) {
    throw new \Exception(401, 'Repeated request');
}
// 限流
$key = md5("{$_REQUEST['REQUEST_URI']}-{$_REQUEST['REMOTE_ADDR']}-{$_REQUEST['did']}");
if ($redisInstance->get($key) > 60) {
    throw new \Exception(401, 'Request limit');
}
$redisInstance->incre($key);
// 转义
$username = htmlspecialchars($_REQUEST['username']);

接口的代码设计 -> 解耦业务 即插即用

这个过程的关键字:抽象成类 前置中间件 注入

接着就是我们代码设计的层面了,如何抽象公共的部分与业务代码解耦。

一般写法, 定义个全局函数,然后每个接口开始时调用该函数:

// 全局定义一个函数
function check () {
    // 校验公共参数
    # code ...
    // 校验签名
    # code ...
    // 校验频率
    # code ...
    // 等等...
}

二般写法, 定义个父类方法,然后每个接口类继承该接口,构造函数调用改方法,其实和上面的换汤不换药:

// 父类方法

class father
{
    public function __construct()
    {
        $this->check();
    }

    public function check () {
        // 校验公共参数
        # code ...
        // 校验签名
        # code ...
        // 校验频率
        # code ...
        // 等等...
    }
}

重点来了,我提倡的第三般写法,对象链和前置中间件:

# Check文件

<?php
namespace App\Extro;

use Illuminate\Http\Request;
abstract class Check
{
    /**
     * 下一个check实体
     * @var object
     */
    private $nextCheckInstance;

    /**
     * 校验方法
     *
     * @param Request $request 请求对象
     */
    abstract public function operate(Request $request);

    /**
     * 设置责任链上的下一个对象
     *
     * @param Check $check
     * @return Check
     */
    public function setNext(Check $check)
    {
        $this->nextCheckInstance = $check;
        return $check;
    }

    /**
     * @return object
     */
    public function getNextCheckInstance(): object
    {
        return $this->nextCheckInstance;
    }

    /**
     * 启动
     *
     * @param Request $request 请求对象
     */
    public function start(Request $request)
    {
        $this->operate($request);
        // 调用下一个对象
        if (!empty($this->nextCheckInstance)) {
            $this->nextCheckInstance->start($request);
        }
    }

}

# 中间件类
<?php
namespace App\Extro;

use Illuminate\Http\Request;

class FrontMiddleware
{
    public function run(Request $request)
    {
        $checkParams = new ParamsCheck();
        $checkSign = new SignCheck();
        $checkLogin = new LoginCheck();
        $checkParams->setNext($checkSign)->setNext($checkLogin)->setNext(new TestCheck());

        $checkParams->start($request);
    }
}

# 或者是下面的写法
<?php
namespace App\Extro;

use Illuminate\Http\Request;

class FrontMiddleware
{
    /**
     * 抽象接口Check类的具体类
     * 若要新增加新的过滤,那么只需要在$next数组中添加新的Check类的具体类即可.
     * @var array
     */
    protected $next = [
        ParamsCheck::class,
        SignCheck::class,
        LoginCheck::class,
        TestCheck::class
    ];

    /**
     * 入口函数
     * @param Request $request
     */
    public function run(Request $request)
    {
        if (empty($this->next)) {
            return;
        }
        $check = array_shift($this->next);
        /**@var Check $check */
        $check = new $check;
        $this->create($check);
        $check->start($request);
    }

    /**
     * 设置职责连
     * @param Check $check
     */
    protected function create(Check $check)
    {
        foreach ($this->next as $value) {
            /*$check = $check->setNext(new $value);*/ # 可以用这句话代替下面的三句话
            $nextCheck = new $value;
            call_user_func_array([$check, 'setNext'], [$nextCheck]);
            $check = $nextCheck;
        }

    }
}

# 登录检测类
<?php

namespace App\Extro;

use Illuminate\Http\Request;

class LoginCheck extends Check
{
    public function operate(Request $request)
    {
        print_r("LoginCheck<br />");
    }

}
# 参数检测类
<?php
namespace App\Extro;

use Illuminate\Http\Request;

class ParamsCheck extends Check
{
    public function operate(Request $request)
    {
        print_r("ParamsCheck<br />");
    }

}
# 签名检测类
<?php
namespace App\Extro;

use Illuminate\Http\Request;

class SignCheck extends Check
{
    public function operate(Request $request)
    {
        print_r("SignCheck<br />");
    }

}

使用职责链的方式,很巧妙的利用每个子类都有的$nextCheckInstance属性,保存下次要执行的类,所以采用递归调用时,只是调用了具体类的$nextCheckInstance

SignCheck {#171 ▼
  -nextCheckInstance: LoginCheck {#177 ▼
    -nextCheckInstance: TestCheck {#178 ▼
      -nextCheckInstance: null
    }
  }
}

接口的可读性

关于可读性的不得不提到的就是RESTFUL,这里我就不讨论RESTFUL,大家可以自行补充相关知识。关于接口设计可读性的我的一些思考:

  • url
    • 非RESTFUL: 资源/资源/操作(动词), 例如 content/article/get -> 获取内容资源下的一篇文章资源
    • RESTFUL: 资源/资源/资源, 例如 get content/article/1 -> 获取内容资源下文章ID为1的文章资源
  • method
    • 非RESTFUL: get便于查nginx日志,上传资源post, 没啥硬性要求
    • RESTFUL: 符合RESTFUL的思想
  • request params: 个人更青睐于下划线命名,适当的单词缩写
  • response params: 响应的code要符合http status
    • 200 -> 正常
    • 400 -> 缺少公共必传参数或者业务必传参数
    • 401 -> 接口校验失败 例如签名
    • 403 -> 没有该接口的访问权限
    • 499 -> 上游服务响应时间超过接口设置的超时时间
    • 500 -> 代码错误
    • 501 -> 不支持的接口method
    • 502 -> 上游服务返回的数据格式不正确
    • 503 -> 上游服务超时
    • 504 -> 上游服务不可用
// 响应的格式
{
    "code": 200,
    "msg": "ok",
    "data": {

    }
}

接口文档

好的接口文档就是生产力, swagger + api blueprint 自行google吧?

我遇到的坑

这里遇到的一个比较大的坑就是http协议历史遗留的bug:

不区分url里的空格 和加号➕

带来的问题就是urldecode会把参数里的+号转为空格,所以这种场景的就得使用rawurldecode防止+转成空格。比如做接口的参数校验的时候~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,928评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,192评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,468评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,186评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,295评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,374评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,403评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,186评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,610评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,906评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,075评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,755评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,393评论 3 320
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,079评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,313评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,934评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,963评论 2 351

推荐阅读更多精彩内容