codecamp

保持的力量:接口开发最佳实践

神啊,求你赐给我平静的心,去接受我无法改变的事;赐给我勇气,去做我能改变的事;赐给我智慧,去分辨两者的不同。 --平静之祷

1.30.1 论保持的力量

追到一个心仪的女生不难,难于如何保持和培养一份真挚的感情;获得一时的财富也不难,难于如何长久保持收益;创业的公司很容易博得一时媒体的关注以及某次天使的投资,但难于如何排除各种障碍、充分利用各方资源发展成中企业及至上市公司。

同样,提供一时的接口很容易,但当我们需要不断为接口提供升级,以及当我们维护提供一整套接口时,面临的困难和问题会越来越大。
所以,这是一场持久的战役。需要我们用稳重的心态、专业的能力在背后持久支撑、推动。

值得庆幸的是,这些都是问题而不是限制,都是可以被解决的。
以下是结合 @郭了个浩浩 同学提供的apigee.web_api.pdf文档,以及我们多年来的项目实际开发经验为新手提供的一些建议,对老同学相信也会有所帮助。
每个建议通常会包括三部分: 现在主流的做法、PhalApi的做法以及项目的选取。

1.30.2 最佳实践建议

为了大家查阅和翻看,这里先罗列本章的全部建议:

  • (1)接口风格和协议的选择 - HTTP
  • (2)接口域名 - 使用api单独域名
  • (3)异常处理 - 200/400/500三大接口结果状态码
  • (4)对外的命名规则 - 使用小写加下划线
  • (5)对内的命名规则 - 使用驼峰法和遵循PEAR命名
  • (6)安全与验证 - 使用接口签名和token登录态双重机制
  • (7)返回结果格式 - JSON
  • (8)URL规则与路由映射 - 统一service接口服务,可一个文件一个接口
  • (9)SDK包 - 给客户端自由的调用空间和自由
  • (10)接口文档 - 使用markdown快速编写
  • (11)测试驱动开发 - 坚持单元测试

1.30.3 建议细说

(1)接口风格和协议的选择 - HTTP

目前,后台接口开发可以用RESTFull风格,也可以用Web Service;可以用SOAP协议、RPC协议,也可以用HTTP协议;可以用短链接,也可以使用长链接。如果我们希望继续进行划分,还可以分为同步或异步、单个或批量、是否有SDK包、内部接口还是开放接口平台等。

主流的做法

现在看来,大部分大型的企业以及大多数的小公司使用的都是HTTP协议下的接口开发,部分使用RESTFull,但Web Service较少。如:

PhalApi的做法

我们选取了HTTP的协议,在于其无论是客户端接入、开发调试,还是部署构建上都很容易实现,而且也符合主流,因为大家都比较熟悉。
这一点是非常重要的:因为简单,后台接口开发的同学才会更容易上手;因为容易,客户端接入才会更加无压力而不用担心处处受挫。

项目的选取

根据项目不同的项目背景和需求,可以选择你合适的风格或者协议。但是即使出于安全、性能或者其他技术或非技术的原因而不采用HTTP协议的情况下,你也可以在PhalApi原有的接口开发实现时,轻松扩展你需要的协议。如使用SOAP,PHPRpc或者swoole下的TCP协议。其中,部分协议已有扩展类库提供支持。

(2)接口域名 - 使用api单独域名

首先,有一点是可以肯定的。
接口系统应该有自己单独的域名,而不应该附属于网站或者管理后台。

主流的做法

显然,主流做法也是这样做的。如:

项目的选取

如果可以,尽量让接口系统使用独立的域名,并且使用api作为一级域名。如:

//你的网站为:
http://www.demo.com

//则对应的接口为:
http://api.demo.com

(3)异常处理 - 200/400/500三大接口结果状态码

对于接口的异常处理,在使用HTTP协议下,可以通过HTTP本身的响应状态码来进行区分。在非HTTP协议并有SDK包的情况下,异常的处理手段则会更为多样。

主流的做法

优酷接口采用了HTTP响应状态码加结果返回的形式,如:

Request URL:https://openapi.youku.com/v2/videos/show_basic.json
Request Method:GET
Status Code:400 Bad Request

{"error":{"code":1004,"type":"SystemException","description":"Client id null"}}

新浪微博也一样:

Request URL:https://api.weibo.com/2/statuses/mentions/ids.json
Request Method:GET
Status Code:403 Forbidden

{"error":"auth by Null spi!","error_code":21301,"request":"/2/statuses/mentions/ids.json"}

微信接口则采用了统一200的形式,如:

Request URL:https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=
Request Method:GET
Status Code:200 OK

{"errcode":41002,"errmsg":"appid missing"}

PhalApi的做法

为了与HTTP保持一致性,同时降低不必要的复杂性,我们采用了200/400/500三大接口结果状态码。

注意,这里所说的三大状态码,是指接口返回结果中的状态码,而不是HTTP的响应状态。
也就是说接口全部的结果返回都应该是200,除非接口服务有内部未捕获的异常,即:

Status Code:200 OK

返回结果状态码刚是以下几种:

//正常返回
{
    "ret": 200,
    "data": {
        //...
    },
    "msg": ""
}

//客户端非法请求
{
    "ret": 400,
    "data": [],
    "msg": "非法请求:接口服务Default.Test不存在"
}

//服务端内部错误
{
    "ret": 500,
    "data": [],
    "msg": "服务器运行错误: can not connect to database db_demo"
}

####项目的选取
你可以根据你的需要,扩展400和500这两系列的错误,如401表示登录失败等。  
此外,在data里面,你也可以添加一个code来表示业务级的操作码,以及客户端根据不同的业务场景做出不同和反应、交互或引导提示。

(4)对外的命名规则 - 使用小写加下划线

对外的命名,是指外部看得到的命名,如接口参数的名字,接口返回的结果节点名字,以及数据库的表名、字段名。

主流的做法

新浪微博采用了小写加下划线的做法,如:

//URL
https://c.api.weibo.com/2/friendships/followers/trend_count.json

//请求参数
source
access_token

//返回结果
{
    "uid": 10438,
    "result": [            
        {
            "days": "2012-04-04",
            "follower_count_online":"15",  //粉丝数 
            "active_follower":"14", //活跃粉丝数
            "loyal_follower":"0"   //互动粉丝数            
        },
        ....
    ]
}

Amazon采用了首字母大写且无下划线的做法,如:

//Responses
HTTP/1.1 200 OK
Date: Wed, 25 Nov 2009 12:00:00 GMT
Connection: close
Server: AmazonS3

<?xml version="1.0" encoding="UTF-8"?>
<BucketLoggingStatus xmlns="http://doc.s3.amazonaws.com/2006-03-01">
  <LoggingEnabled>
    <TargetBucket>mybucketlogs</TargetBucket>
    <TargetPrefix>mybucket-access_log-/</TargetPrefix>
    <TargetGrants>
      <Grant>
        <Grantee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:type="AmazonCustomerByEmail">
          <EmailAddress>user@company.com</EmailAddress>
        </Grantee>
        <Permission>READ</Permission>
      </Grant>
    </TargetGrants>
  </LoggingEnabled>
</BucketLoggingStatus>

PhalApi的做法

我们提倡使用全部小写加下划线的命名,因为这样更符合客户端的使用,如:接口参数:

//正确的
&user_id=888

//错误的
&userId=888

返回字段:

//正确的
"device_type": "cube",

//错误的
"deviceType": "cube",

数据库字段:

//正确的
`user_id` bigint(20) DEFAULT '0' COMMENT '创建者的用户ID',

//错误的
`userId` bigint(20) DEFAULT '0' COMMENT '创建者的用户ID',

项目的选取

不管是使用全部小写,还是全部大写,项目都应该保持一致的命名风格,而不是混合凌乱的风格。

(5)对内的命名规则 - 使用驼峰法和遵循PEAR命名

与对外命名对应的则是对内的命名规则,这里又回归到了老生常谈的PHP代码风格。
这里不作过多的说明,只是稍作提及。

PhalApi的做法

我们建议使用PEAR包的命名风格,和驼峰法,如下为一个接口示例:

$ vim ./Api/Default.php 
<?php
/**
 * 默认接口服务类
 *
 * @author: dogstar <chanzonghuang@gmail.com> 2014-10-04
 */

class Api_Default extends PhalApi_Api {

    public function getRules() {
        return array(
            'index' => array(
                'username'  => array('name' => 'username', 'default' => 'PHPer', ),
            ),
        );
    }

    public function index() {
        return array(
            'title' => 'Default Api',
            'content' => T('Hello {name}, Welcome to use PhalApi!', array('name' => $this->username)),
            'version' => PHALAPI_VERSION,
            'time' => $_SERVER['REQUEST_TIME'],
        );
    }
}

项目的选取

你可以选择你喜欢的风格,但团队应该保持一致。

即便你不喜欢PhalApi约定的PEAR命名,你也可以自行实现内部的类加载机制。
当有多个项目或者多个模块并存时,可以添加模块名前缀来作区分,如:

$ tree
.
├── Demo
│   └── Api
│       └── DUser.php
├── MyApp
│   └── Api
│       └── MUser.php
└── Task
    └── Api
        └── TUser.php

$ head */*/*
==> Demo/Api/DUser.php <==
<?php
class Api_DUser extends PhalApi_Api {
}

==> MyApp/Api/MUser.php <==
<?php
class Api_MUser extends PhalApi_Api {
}

==> Task/Api/TUser.php <==
<?php
class Api_TUser extends PhalApi_Api {
}

其它的Domain层和Model层等也类似,这样可以避免类名冲突,或者IDE开发环境下的混淆。

(6)安全与验证 - 使用接口签名和token登录态双重机制

既然采用HTTP协议,那么安全方面就需要接口自身进行保证。

所幸,现在可用的加密手段有多种选择。
对于接口签名,我们可以使用非对称的验签方式,如md5;也可以用对称的方式,如RSA。
最后,为每一个接入的客户端分配app_key和app_secrect即可。

当然,更好的安全是接口系统再提供登录态的验证,即通常所说的token。这两者的相合,会为接口增加更好的安全保障。

主流的做法

  • 七牛云存储,采用Access Key/Secret Key,并且在需要时添加相应的凭证
  • 微信公众号,采用由AppID(应用ID)和AppSecret(应用密钥)生成的ACCESS_TOKEN
  • 优酷开放平台,采用应用Key client_id

PhalApi的做法

我们不提供具体的接口签名方案,是因为把这种决策移交给项目应用本身进行定制。
而定制也是非常简单的,只需要简单的两步即可:

  • 1、实现过滤器接口 PhalApi_Filter::check()
  • 2、注册过滤器服务 DI()->filter

对于token,虽然框架没有提供内置的实现,但可以从PhalApi的扩展类库寻找这种支持,这一点已经User扩展类库支持。

项目的选取

正如PhalApi提供的自由空间,项目可以自行实现接口签名,和根据需要是否采用User扩展类库,或者自行实现token的处理。

(7)返回结果格式 - JSON

[1.14.1 统一返回的格式]一节中,已经对JSON的返回格式作了说明,这里不再赘述,也只是稍作提及。

主流的做法

目前采用了JSON的格式返回的有:

  • 新浪微博
  • 优酷开放平台
  • 腾讯开放平台
  • 微信接口

采用了XML格式返回的有:

  • Amazon

PhalApi的做法

我们默认采了JSON的格式返回。

项目的选取

项目可以轻松扩展成其他格式的返回。

(8)URL规则与路由映射 - 统一service接口服务,可一个文件一个接口

先从项目内部的文件划分说起,通常最为常见的情况是,很多开发人员都喜欢把很多很多很多接口都塞到一个接口文件里面。

这样的文件,通常会有2K到3K左右。
我觉得这是一种极端,而且是一种不好的极端。因为文件过大的话,会带来很多问题。

但与之对立的有另一种做法,即一个文件,一个接口。
这一点,在我之前就职的一家出名的游戏公司中得到了广泛的认可和遵循。如:

//?service=UserInfo.Go
<?php
class Api_UserInfo extends PhalApi_Api {

     public function go() {
           //TODO
     }
}

//?service=GroupInfo.Go
<?php
class Api_GroupInfo extends PhalApi_Api {

     public function go() {
           //TODO
     }
}

虽然也是一种极端,但却很好地做到了接口隔离,即不用担心修改此接口的实现而影响到其他接口服务。

最后,我们再来聊URL规则,就更顺畅了。如果我们采用一个文件对应一个接口,则我们可以省略Action(全部都为go()方法),简写成:?service=XXX。
再进一步,我们可以利用接口服务器(如Nginx)的规则Rewrite来提供更好的URL规则,同时尽量隐藏我们的接口内部实现细节,如:

//原始地
http://api.demo.com/?service=UserInfo.Go

//简化地
http://api.demo.com/?service=UserInfo

//再进一步
http://api.demo.com/UserInfo

//或者
http://api.demo.com/UserInfo.json

还有一点需要关注的就是接口的版本,当有v1,v2,v3等不同的版本时,我们也需要在接口URL中体现这些版本的不同。

主流的做法

PhalApi的做法

目前而言,PhalApi在URL规则和路由这块还比较欠缺,没有像其他网站一样提供强大的路由支持。
但我们在代码实现的层面,可以提供不同的入口,以开放给不同的终端(内部的或者外部的), 以及不同的版本支持。如:

$ tree Public/
Public/
├── v1
│   └── index.php
├── v2
│   └── index.php
└── v3
    └── index.php

3 directories, 3 files

则对应的版本URL则可以为:

//v1版本
http://api.demo.com/v1/?service=Default.Index

//v2版本
http://api.demo.com/v2/?service=Default.Index

//v3版本
http://api.demo.com/v3/?service=Default.Index

项目的选取

项目可以结合不同的入口,以及接口服务器的URL规则Rewrite作一些自定的URL路由。

(9)SDK包 - 给客户端自由的调用空间和自由

目前移动开发主要有iOS、Android、Windowns Phone、网站等不同的终端,各种终端又有不同的语言,如果我们需要提供SDK包,不仅仅需要考虑到纵向的版本升级,还需要维护横向的多样性。
而且,如果我们使用的是HTTP协议,则不必要担心这些维护的成本,同时给客户端提供一个自由的空间进行调用 -- 即客户端可以自己编写本身的接口客户端。

主流的做法

很多国内的开放平台接口都是不提供SDK包的,但有些安全度高的则会,如支持宝。

以下是一些提供了SDK的平台 :

PhalApi的做法

我们暂时没有提供SDK包,但对于PHP,有一个简单的客户端类,可见: [1.13]-统一的接口请求方式

项目的选取

出于公司产品簇的项目考虑,项目可以内部提供SDK给同类的客户端使用,如分为iOS版的客户端SDK,以及Android版的客户端SDK。

小故事:与SDK包的一个真实的痛苦经历

有一点是非常重要的,千万不要让不懂PHP语言的人去开发提供PHP的SDK包,更不要使用所谓的工具自动转换生成SDK包代码。
在我曾经做过的一个项目中,因为需要接入一个接口系统,而这个接口是由专业的JAVA团队维护的,但他们对PHP语言则是非常薄弱,以致他们使用了工具来生成PHP语言的SDK包。

这就导致了我在接入一个简单的接口时,却开发联调耗费了两天、测试联调时耗费了在接口调用超时问题排查上。
而最后找到的原因却是因为app_key不对而导致服务端异常,而在SDK包却隐藏了这一异常错误信息,反而给出了time out超时的提示,严重误导了排查的方向!
而当我尝试深入去调试SDK时,得到却又是既没有code又没有message的异常!最让人难以忍受的是,他们提供的SDK包竟然和JAVA的企业系统一样复杂的结构(正如他们是使用工具来生成转换的)!

想象一下,PHP代码下有\com\sina\webo\sdk\Constants.php这样类似JAVA的文件结构,PHP的同学会作何感想?用JAVA的世界的方式来开发PHP,显然是走不通的啊!
而执意要走的话,到最后就是各种接入的痛苦,稍微按奈不住的同学难免就会因为情绪问题而大开争论了。而这一切,只是因为非PHP人员使用了自动生成工具。
我觉得,这是一种不负责任的做法,希望大家不要效仿。

(10)接口文档 - 使用markdown快速编写

(场外音:通过沐浴法理清了头绪,继续回来执笔编写)。
就我个人经历而言,markdown就是一个开始你会拒绝,接着你会越来越喜欢,到最后会爱不释手的一个工具。

如果你或者你的团队还在使用邮件或者work文档来传递共享接口文档,那就太不应该了;如果你正在使用某个WIKI系统进行文档的维护但却不喜欢它的编辑或者展示方式时,你可以尝试使用一下markdown。
正如你现在正在查看的文档也是通过markdown编写的。

主流的做法

作为开放接口平台,文档肯定是以网站的形式提供。但很多时候,对于我们内部的接口或者小项目来说,显然这样的成本太大了。
接口,从简单开始。
我们理应一直坚持这一点,所以文档也是一样,我们应该寻求一种在内部快速共享最新接口文档的途径。如:

  • 1、使用内部WIKI
  • 2、使用开源中国或者其他站点的WIKI(这时可以通过在线编辑或者GIT更新)

项目的选取

你可以根据项目的需要,或者公司以往的做法,但至少不要再使用邮件或者word文档。

(11)测试驱动开发 - 坚持单元测试

单元测试,在PhalApi里面不只一次提到了,这里再次进行说明,是希望能引起大家的关注,去尝试体验一下。

我们都知道,在开发一个新功能时、新接口时,修复一个BUG或者作一些大的调整或者重构工作,我们是毫无压力的,而且这时的成本很低,仅在于开发人员本身的时间和精力的消耗。
当提测后进入测试阶段,测试人员发现一个BUG后,有些团队会以禅道或者Bugzilla或其他方式来纪录和追踪BUG。这时我们开发会觉得一个这么小的问题还需要去纪录、去登记很不值得。然后,我们应当注意到这时修复一个BUG会涉及到测试人员资源的开销。
当进入了回归测试阶段,特别是多系统交互、跨团队合作时,一个BUG就会从一个人传到另一个人,从这个团队流到那个团队,这时成本就会逐渐增大。

最后,上线后,当一个奇怪的问题出现后,我们需要定位原因就更加困难重重了。
我曾经就经历这样一番:有用户发现游戏的道具减少了。我们一开始以为是某些运营配置、或者数据以及用户的等级限制所引发的,但在排除了各种业务的问题后,到最后却发现是PHP中array使用“+”运算而引发的血案!
在正常情况下,我们都知道array_merge()函数对于数值的下标则会追加并重新生成下标序列,即会合并;而数组+则会去掉相同下标的元素。

但实际情况下,线上BUG所产生的影响不在于排查和修复的时间成本,而在于在这段时间内所损失的金额、数据等成本。

当然,从测试的角度上看,测试并不能保证我们的系统没有BUG,只能说暂时未发现BUG。
单元测试也一样,作为开发人员,我们应当在最低成本的时期就及时发现我们直觉觉得可能会出现的问题并进行修复。
对我们亲手所编写的代码负责,并且用客观的方式来证明我们的代码目前未发现问题,而不是主观认为“我写的代码没有问题”。更不应该一次又一次地犯下各种低级或者重复的错误,而让团队其他成员对我们丧失信任。

PhalApi一直很注重单元测试,也很注重自动化,为了减轻大家重复编写单元测试骨架代码的痛苦,我们提供了一个可以生成单元测试代码的脚本。
假设我们有这么一个类:

<?php

class Api_Default extends PhalApi_Api {

        public function index() {
             //TODO
        }
}

那么,我们可以这样生成测试代码:

$ cd .//Demo/Tests
$ phalapi-buildtest ../Api/Default.php Api_Default ./test_env.php 
<?php
/**
 * PhpUnderControl_ApiDefault_Test
 *
 * 针对 ../Api/Default.php Api_Default 类的PHPUnit单元测试
 *
 * @author: dogstar 20150514
 */

require_once dirname(__FILE__) . '/test_env.php';

if (!class_exists('Api_Default')) {
    require dirname(__FILE__) . '/../Api/Default.php';
}

class PhpUnderControl_ApiDefault_Test extends PHPUnit_Framework_TestCase
{
    public $apiDefault;

    protected function setUp()
    {
        parent::setUp();

        $this->apiDefault = new Api_Default();
    }

    protected function tearDown()
    {
    }

    /**
     * @group testGetRules
     */ 
    public function testGetRules()
    {
        $rs = $this->apiDefault->getRules();
    }

    /**
     * @group testIndex
     */ 
    public function testIndex()
    {
        $rs = $this->apiDefault->index();
    }

}

温馨提示:

  1. 可以先执行:ln -s /path/to/PhalApi/phalapi-buildtest /usr/bin/phalapi-buildtest
  2. test_env.php为测试环境初始化文件,可以在里面引用init.php文件,并作一些调整
  3. 输出的测试代码可以重定向到./Demo/Tests/Api/Api_Default_Test.php,让测试代码与产品代码对齐

最后,我们就可以这样执行单元测试了:

$ phpunit ./Api_Default_Test.php 

开放与封闭:多入口和统一初始化
新型计划任务:以接口形式实现的计划任务
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

八、视频教程

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }