PHP高可用接口服务系统之保持对客户端的友好性

本文主要内容是关注如何保持对客户端的友好性,例如通过SDK包快速帮助客户端开发人员请求接口服务;通过约定成俗的返回结构和格式,让接口结果不言而喻,减少认知成本和缩短接口语义上的差别和鸿沟;通过接口文档,不管是自动生成的还是手工编写的,都致力于帮助客户端更全面的理解接口,明白正确使用接口下的上下文场景信息。
“客户是上帝”,这是商业的第一准则。同样道理,如果开发和提供的接口,没有客户端使用,或者客户端难以理解,难以使用的话,我们的接口系统也会黯然失色。作为后端开发人员,我们应该拉进与客户端开发人员之间的距离,用心倾听他们的诉求,并致力保持和不断提升对客户端的友好性。

1 客户端SDK包的设计

ASQL接口结构化查询语言

自从结识了DSL领域特定语言后,我就开始对它产生了深厚的兴趣。在为我的开源框架PhalApi设计客户端SDK包时,为了给客户端统一接口请求调用的规范性、流畅性和简单易懂,为此我使用了内部领域特定语言: 接口查询语言 (Api Structured Query Language) 。
在请求PhalApi框架下的接口服务时,从外部DSL的角度来看待接口查询的操作,可以描述成:

create

withHost host
withFilter filter
withParser parser
withService service
withParams paramName1 paramValue1
withParams paramName2 paramValue2
withParams ... ...
withTimeout timeout

request

根据此设计理念,各客户端语言都可以实现此接口请求的操作。以JAVA版本为例,其调用示例是:

PhalApiClientResponse response = PhalApiClient.create()
       .withHost("http://demo.phalapi.net/")
       .withService("Default.Index")          //接口服务
       .withParams("username", "dogstar")     //接口参数
       .withTimeout(3000)                     //接口超时
       .request();

这是我在设计客户端SDK包时,结合领域特定语言的一点心得体会与实际应用。这样做的目的,主要是为了统一客户端对服务端接口服务请求、调用和查询等操作,类似SQL数据库结构查询语言那样。
我们为客户端提供的称之为SDK包,而我们服务端系统需要调用外部第三方接口系统时,在系统与系统之间起通讯连接作用的则称为连接器。连接器是一个富有启发式的概念,后面有机会再来探讨。

记一次SDK包性能优化的亲身经历

这里,重点分享一个我与SDK包性能优化的亲身经历。这段经历,除了有不少技术收获外,还教会了我们一定要用工具和事实来证明自己的推断,并敢于质疑现有的解决方案,哪怕它是已经广为流行的开源框架。
故事是这样的,曾经我在某大型企业负责维护核心系统时,需要对接底层接口系统。我们的系统是用PHP开发的接口系统,并且是通过Thrift软件框架接入底层接口。Apache Thrift是一个开源项目,能进行跨语言的服务开发,但它有一个明显的性能问题,甚至制约了我们业务系统的正常响应。详细的故事细节,以及解决方案、经验总结,下面将慢慢道来。
自从我们的业务接口系统对接底层接口系统后,接口响应的时间从原来平均20毫秒上升到了平均40毫秒,上涨了100%!更令人担忧的是,个别接口的响应时间已经上涨到600多毫秒,而原来不到200毫秒。为此,找出性能瓶颈区,并优化之,成了当务之急。
为了方便对比此Thrift SDK包的性能情况,我们针对同一个接口服务,进行了两种情况的性能对比,即:完全不使用Thrift SDK包、和使用Thrift SDK包。同时,为了排除其他因素的干扰,在进行性能分析的过程中,全程穿透缓存直接访问底层接口获取数据,并关闭全部的日志和埋点纪录。以下是性能对比的重要信息。

表1 对Thrift SDK包性能分析对比

 

#case 1

 

#case 2

 

分类

 

不使用Thrift SDK

 

使用Thrift SDK包

 

总响应时间

 

138 毫秒

 

822 毫秒

 

总内存消耗

 

 

3.7 M

 

 

10.6 M

 

 

函数调用总次数

 

 

1,110 次

 

 

70,888 次

 

;
让人惊讶的 ,在使用Thrift SDK包时,函数调用总次数高达7万多次!根据Xhprof给出的信息,我们可以得到Thrift内部的执行路径如下:

1.    Osp\Thrift\Protocol\TBinaryProtocol::readString
2.    Osp\Thrift\Protocol\TBinaryProtocol::readI32
3.    Osp\Thrift\Transport\TTransport::readAll
4.    Osp\Thrift\Transport\TFramedTransport::read
5.    Osp\Thrift\StringFunc\Core::substr
6.    substr()
7.    Osp\Thrift\StringFunc\Core::strlen
8.    Osp\Thrift\Factory\TStringFuncFactory::create
9.    Osp\Thrift\ClassLoader\ThriftClassLoader::loadClass

就本次性能分析而言,Excl. Wall Time最高的前10个函数操作里面,有近9个函数都是属于Thrift SDK包的。我们来看下Top 5 的函数,及其消耗的时间和耗时占比情况。

表8-2 Excl. Wall Time Top 5 函数

序号

执行的函数

执行时间(单位:毫秒)

耗时占比

Top 1

Osp\Thrift\Factory\TStringFuncFactory::create

85,593

10.4%

Top 2

 

Osp\Thrift\StringFunc\Core::strlen

77,266

9.4%

Top 3

Osp\Thrift\Transport\TFramedTransport::read

75,508

9.2%

Top 4

Osp\Thrift\ClassLoader\ThriftClassLoader::loadClass

66,148

8.0%

Top 5

Osp\Thrift\Transport\TTransport::readAll

50,542

6.1%

而Top 5全部的消耗时间加起来占整体的43.1%,几乎占据了一半,使人不禁再一次想起那次话:

往往20%的代码,占用程序的80%的时间。

使用Thrift SDK包时,执行时间耗时最长的,莫过于是Osp\Thrift\Factory\TStringFuncFactory::create,约执行了86毫秒,被调用了17,681次!

图 关于TStringFuncFactory::create的分析报告

为什么一个看似正常的工厂创建方法,却被调用了17,681次呢?是有Bug,还是有死循环,还是其他原因?带着这样的疑问,我们继续追查,终于发现了一个很隐蔽、很微妙的问题。
绝大多数的情况下,以下这样的写法都是没有问题的:

$bufLength = TStringFuncFactory::create()->strlen($this->buf_);

而且我们也确实看不出有什么问题。这是一个很好的封装,也使用了工厂方法模式,可以说这是一行很合格且写法清晰的代码。再来看下TStringFuncFactory::create()内部的实现,也是使用了单例模式。

    public static function create() {
        if(!self::$_instance) {
            self::_setInstance();
        }
        return self::$_instance;
    }

所以,当我们看到了Osp\Thrift\Transport\TFramedTransport::read()这样的类函数实现时也就习以为常了。

  public function read($len) {
    if (!$this->read_) {
      return $this->transport_->read($len);
    }
    if (TStringFuncFactory::create()->strlen($this->rBuf_) === 0) {
      $this->readFrame();
    }
    // Just return full buff
    if ($len >= TStringFuncFactory::create()->strlen($this->rBuf_)) {
      $out = $this->rBuf_;
      $this->rBuf_ = null;
      return $out;
    }
    // Return TStringFuncFactory::create()->substr
    $out = TStringFuncFactory::create()->substr($this->rBuf_, 0, $len);
    $this->rBuf_ = TStringFuncFactory::create()->substr($this->rBuf_, $len);
    return $out;
  }

从实现、封装到调用,一切都似乎合情、合理、合法。但是,一切不考虑场景上下文的代码,都是耍流氓,都是不负责任的。
回顾TStringFuncFactory::create()被调用的场景,我们从图中,可以看出TFramedTransport::read()竟然调用了11,700次!为了重申调用次数的恐怖,我们把图片再次转换成文字版的报表,并稍加调整。

11,700 Osp\Thrift\Transport\TFramedTransport::read
 5,852 Osp\Thrift\Transport\TTransport::readAll
    94 Osp\Thrift\Transport\TFramedTransport::writ

任何程序员都不应该让这样如此疯狂的事情发生,更不应该让它一直这样疯狂。想要考究问题的原因所在,既然TFramedTransport::read()本身没问题,可以再回溯到上一层的调用。

图 函数Osp\Thrift\Transport\TTransport::readAll的调用情况

很明显,应该是上层循环调用而导致的。看一下TTransport::readAll()的源代码即可证实。

  public function readAll($len) {
    $data = '';
    $got = 0;
    while (($got = TStringFuncFactory::create()->strlen($data)) < $len) {
      $data .= $this->read($len - $got);
    }
    return $data;
  }

此时,已经逐步可以发现一些端倪,找到问题所在。在疯狂循环调用下,细小代码也能产生巨大影响。把上面的代码,转化一下,可简化成:

while (($got = TStringFuncFactory::create()->strlen($data)) < $len) {
    if (TStringFuncFactory::create()->strlen($this->rBuf_) === 0) {
        // ...
    }
    if ($len >= TStringFuncFactory::create()->strlen($this->rBuf_)) {
        // ...
    }
    $out = TStringFuncFactory::create()->substr($this->rBuf_, 0, $len);
    $rBuf_ = TStringFuncFactory::create()->substr($this->rBuf_, $len);
}

显而易见,稍微计算一下便可知道,上面的程序,跑一次就会调用5次TStringFuncFactory::create();跑10次,就会调用50次;跑3K次,就会调用15K次!TFramedTransport::read与TStringFuncFactory::create之间的调用比例是1:5。
发现了问题所在,改进也就很容易了。只需要把频繁重复不必要的工厂创建改为临时变量即可。这里,我们使用TTransport::$core_来存放。Osp\Thrift\Transport\TTransport改动如下:

abstract class TTransport {
  protected $core_;

  public function __construct() {
      $this->core_ = TStringFuncFactory::create();
  }
  // ...

然后,其子类需要调用父类这些构造方法,以免遗漏对$core_的初始化。如在TFramedTransport中,调用为:

  public function __construct($transport=null, $read=true, $write=true) {
    $this->transport_ = $transport;
    $this->read_ = $read;
    $this->write_ = $write;

    parent::__construct();
  }

其他子类类似,这里不再赘述。优化后,重新使用xhprof进行分析,可以看到这样令人欣慰的报告。调用次数从17K降到了只有125次,三个级别的差距!
为了有一个更形象的对比,用米作为单位,试这样粗算对比前后优化的效果:

对比

函数调用次数

等效的长度(单位:米)

优化前

17,681

相当于田径场跑44圈

优化后

125

相当于百米冲刺的距离

需要注意的是,实际的优化效果与接口返回的报文长度成正比。因为报文长度越大,差距越明显。
通过这次的深入研究和分析,可以得出以下总结:

    • 在使用Thrift SDK包时,函数调用次数高达7万多
    • 在加载过多PHP文件而频繁使用file_exists()函数时,会影响性能
    • 应该尽量避免使用Thrift SDK包,可改用PHP扩展的调用方式以提高性能
    • Java所擅长做的事情,在PHP并非如此,当把复杂的Java操作在PHP重复白盒实现时尤为如此
    • Thrift SDK包所消耗的时间以及调用的次数,由调用的OSP接口次数以及返回的数据量决定;数据量越大,函数调用次数越多,耗时越长

如果说Java自动生成PHP代码是高效率的,那么任何自动化的东西都是在以损坏长远利益为代价以换取短期的方便。因为Java语言所擅长的,PHP并不擅长,每个语言都有其特性和适用的场景。生硬的代码自动生成最多只能解决功能实现这一点,但并不完美。而且,当涉及报文传送与解析这些更底层的技术实现时,应考虑使用更底层的处理方式,如使用PHP扩展或者用面向过程的函数处理,而不是层层再封装的面向对象技术。在实时要求高的场景,本身就是慢的PHP,再加上如些之重的负荷,可谓真的是步履维艰。
后话,关于这个Thrift SDK的性能问题,我们反馈给了底层接口开发团队,他们提供了基于HTTP协议的接口调用方式。同时,我们也把这个缺陷报告给了Thrift开源团队,但后来的结果没进一步跟进。

巧用代理轻松使用SDK包

很多时候,都需要使用第三方提供的SDK包或类库,然而当第三方出现版本升级或者调整,或修复某些Bug又不能保证接口兼容性时,就需要我们“客户端”作出相应的调整——修改全部的客户调用代码。这种情况,按领域驱动设计一书的说法,称之为“供应商锁定”。又或者,我们需要对某些操作进行控制时,例如追加日记的写入,如果不加节制地使用日记,也会造成高风险和高维护成本的窘境。
关于这一点,我深有体会。
对于某个网站的图片域名,在最初的时候,是根据算法随机从多个图片域名中获取一个然后返回给前台进行展示,目的是为了进行流量分流。后来为了更好地进行控制流量,能手工干涉负载均衡,对算法进行了改进,引进了权重概念。同时在使用方式上也追加了一个全局参数,用于传递各图片域名的权重配置。
因为涉及使用的地方有多处,因此需要同时对多个地方的代码进行修改,特别当这种调用分散在计划任务、前端业务多个模块时更给维护带来了更高的成本。如果早一点可以使用代理模式对获取图片域名进行封装,当SDK包发生改变时,只需要修改一处即可。尽早地遵循“规则有且只有一处”和DRY这两个原则,将会让项目受益匪浅。
还有一种情况是,有些SDK设计得不够友好,导致每次客户端在使用时都需要人工生重复写好几行甚至一堆代码,才能把SDK正常运转起来。以曾经配置中心的SDK包使用为例,获取一个配置项和使用的代码片段如下:

$cfgLoader = new CustomConfigCenter();
$openCartApiCC = $cfgLoader->getCustomConfigByKey('www.exmaples.com', 'RWA_isAllowComment');
$isAllowComment = ($isAllowComment === '1' || $isAllowComment === false ) ? true : false,  // 是否允许评论

看似简单的配置获取动作,实际上需要编写三行代码,这无形中增大了企业级系统开发人员的认识压力。除此之外,这短短几行的代码,背后的意义也十分丰富,言下之意,如果稍有差错或使用不当,就会容易引发缺陷和故障。让我们一起来解读这三行代码。
第一行代码,创建了一个配置中心SDK的外观入口类,这是很平常的一行代码。但是,这样硬编程的创建类实例的方式,存在僵硬性,不方便日后的升级和维护,这是必然的。再者,不方便单元测试,因为不能进行模块和替换,只能硬着头皮使用真实的配置项。而真实的配置项怎么调整、修改和自动临时管理呢?这又是一个问题。最后,多次创建相同的类实例,重复完成类似的初始化工作,也会存在一定的性能问题。
第二行代码,是根据客户端自己配置的配置项名称,如这里的RWAisAllowComment,读取对应的配置值。这里有两个隐性的规则:首先,每次读取配置时都需要重复指定当前域名,因为每个域名、每个渠道、每个平台的配置是分开管理的;其次,配置项的名称都约定要有统一的前缀,如这里的“RWA
”,并且最为重要的一点是,配置中心限制了每个配置项的名称不得超过32个字符。一旦配置项名称长度超过32个字符,配置中心将会进行截取处理,至于是前截取还是后截取不确定,从而引发了不确定性,即会导致潜在的Bug。
第三行代码,是将配置的序列化值再解析还原,与预期的配置值进行匹配和转换。注意到一点,每次在判断和解析读取到手配置时,都需要对异常的情况做出兼容处理。例如这里当获取不到配置项时,不管是因为没有配置,还是因为配置错误,还是因为系统异常,默认为false,即不允许发表评论。
为了简化客户端开发的使用,应对可能发生的变化,并避免隐藏的BUG出来惹祸,可以使用代理模式对配置中心的操作进行封装处理,然后提供一个简单、友好、连新手使用也不会出错的外观接口。添加合适的代理后,原来客户端读取配置的代码,便可以简化为一行,代码量更少,且更容易理解。

$cfgLoader = CustomConfigCenterProxy::create()->getBool('isAllowComment', true);

在代理类CustomConfigCenterProxy里,可以添加以下额外的控制:

    • 1、自动填充默认统一的域名、渠道或平台名称,例如:www.exmaples.com
    • 2、自动追加配置项名称前缀,例如:RWA_
    • 3、自动检测配置项名称的长度,如果长度过长或者包含非法字符,就及时通过合适的方式告知和提醒开发人员
    • 4、自动进行类型的转换和配置值的结果解析
    • 5、增强容错性和兼容性,对于异常的情况能返回默认值
    • 6、统一内部使用的接口,便于统一维护、升级和管理,例如对于重复配置的获取的缓存机制

如果细细想来,对于任何一个功能点,或者某个场景,我们都能不断完善,提出更有建设性的改进方案。
最后,关于代理,还可以应用在日志纪录的控制之上。
在项目和系统中,需要对某些异常的情况进行纪录,以便监控和排查。例如调用第三方接口进行支付却失败时、当计划任务生成的缓存数据有问题时、当管理员录入不该录入的规则时等等。但用户访问量庞大、计划任务数量众多,这时若发生异常,将会导致瞬间有庞大的日志数量涌入。骤然增加了服务器的负载和压力。所以经验做法是,只需要纪录前面N条日记、或者一定概率随机纪录、抑或是每间隔一定周期纪录一次。显然,很多日志纪录的功能类暂时还不支持这么灵活的写入控制,不管是统一的日记组件还是自己编写的日记类。这时,我们可以利用代理模式,进行自定义的控制,例如只写入前N条日记,实现的参考代码是:

<?php
/**
 * 日志代理类
 *
 * - 只纪录前面 MAX_LOG_TIMES 条
 */
class LogProxy {
    const MAX_LOG_TIMES = 3;        // 允许纪录的最大日志数量
    protected $_hasLogTimes = 0;    // 当前已纪录的日志数量

    public function log($type, $msg) {
        if ($this->_shouldLog()) {
            // 进行具体的日志纪录
            ……
        }
    }

    public function _shouldLog() {
        if ($this->_hasLogTimes >= self::MAX_LOG_TIMES) {
            return false;
        }

        $this->_hasLogTimes ++;
        return true;
    }
}

代理在代码、项目和系统的应用,可以范围更广。这里重点提及代理对于接口客户端SDK包的再封装,避免陷入“供应商锁定”。同时,也可以考虑应用在其他第三方SDK包的代理,以及内部组件的代理。

2 返回结构与格式

对于接口返回的格式,在某种程度上要结合使用的通讯协作来考虑。但从本质上讲,我觉得接口返回的格式要符合两个特性:可序列化,和可扩展性。可序列化是指能把多种基本类型、多层嵌套结构转换成串联的字符串或者字节流,并且能够无损还原,进行反序列化。可扩展性是指新增一个字段,或新添加一层嵌套,不会影响既有的传输、解析。

JSON返回格式

很明显,符合上面这两个特性,同时也是我们使用最多的就是:JSON和XML。而JSON又会比XML更为简洁,故而受到了更为广泛的使用。这里主要推荐的也是JSON的返回格式。除此之外,还有谷歌提供的Protobuffer,也是一个不错的选择。
至于返回结构,按照不成文的规定,一般在顶层会有三个固定字段。它们分别是:

    • code 状态码,用于表示当前请求是否成功,非成功时的错误码
    • data 数据,成功时提供给客户端的业务数据,以及必要的信息
    • msg 提示信息,通常是错误时才会提供此信息,表示错误提示信息

举个例子,我们请求登录接口进行账号登录,成功情况下可以返回:

{
    "code": 200,
    "data": {
        "is_login": true,
        "user_id": 888
    },
    "msg": ""
}

如果登录失败,那么状态码code就不再是200,而是参考HTTP状态码的用法,返回4xx,表示非法请求,并返回相应的错误提示信息。例如:

{
    "code": 400,
    "data": 
    },
    "msg": "账号不存在或密码错误"
}

这里,由于未登录成功,所以data字段不会提供用户的ID,但这时的返回,即失败时的data返回,其设计、定义和标准,有待进一步探讨。下面来看一个真实的故事。

论按国际惯例编程的重要性

在生命中,总会有一些看起来平常却又不平常的日子让你印象颇为深刻,记忆犹新,除了因为那天发生的事情外,更多在于所发生的事情促使了你更深层次的思考。而我记得的一个不平常日子是2016年7月中下旬的一个周末,那天很炎热,炎热得容易让人躁动。
当时我负责开发的某个H5页面发生了线上故障,那时我正在佛山接受公司培训,前端开发同学在深圳陪小孩放松周末,产品同学在广州拍婚纱照,正在感受人生最重要、最美妙的时刻,更为窘迫的是其他相关backup人员也一时没法上线支持,导致了线上故障排查和解决都有很大的阻碍。
坐在培训现场的最前面座位上,讲师就在旁边,手机频频响起监控中心的来电,我知道客服部门的负责人已经着紧,监控中心已经开始介入,故障事态已经开始扩大。而我们技术人员仍然一脸茫然:之前都好端端的,页面怎么就突然打不开了呢?
由于出门在外,没有上网办公和远程协助的条件,相关项目干系人员无法操作和排查。这时已越来越多的人被卷了进来,原来只有几个人的微信群慢慢变成了几十个人,包括但不限于客服人员、技术人员、中间层开发、产品同学、测试、PMO、监控中心,就差帅帅的保安哥哥还没加入进来。但依然毫无头绪,炎热,躁动不安……
有时感觉我们技术开发人员就像侦探一样,非常善于从细小的线索发现问题的本质。这些疑似破案关键的线索可能是一条log,可能是一个字符,抑或是一个看不见的特殊编码的字符。这一次,我们历经波折,通过backup同学的支援,终于初步定位到了引发故障发生的原因,因为我们看到了这样不寻常的接口返回:

{
    "data": [
        {
            "id": "1103",
            "content": "如何注册",
            "answer": "进入“我的”进入“个人中心”-“注册”输入相关信息提交即可。"
        },
        null,
        {
            "id": "1105",
            "content": "如何登录",
            "answer": "进入“我的”进入“个人中心”-“登录”输入相关信息提交即可。"
        }
    ]
}

注意到,data字段里的第二个元素是null。通常情况下,也就是按照国际惯例,接口正常返回是的data节点是一个数组,数组中各个元素都是一个对象。如果某个元素不存在,如上面的id=1104找不到的话,即缺失1104时的返回应该是直接把此元素从集合里剔除,应该返回:

{
    "data": [
        {
            "id": "1103",
            "content": "如何注册",
            "answer": "进入“我的”进入“个人中心”-“注册”输入相关信息提交即可。"
        },
        {
            "id": "1105",
            "content": "如何登录",
            "answer": "进入“我的”进入“个人中心”-“登录”输入相关信息提交即可。"
        }
    ]
}

倘若全部元素都不存在,那么接口应该返回一个为空数组的data,即:

{
    "data": []
}

然而,这时在线上环境,在接口返回的结果中,有一个值为null的元素存在data数组中!正是这个特殊的返回,导致了页面的山崩溃。看似一个简单的小问题,却因未能在前端开发团队、前台开发团队、以及中间层接口系统开发团队达到共同的认识,引发了一个线上故障。
实际上,在此次故障发生之前,我们就已经发现中间层的接口系统会在全部data元素都不存在时返回null的情况,即:一个data元素都没有时,期望返回空数组。 然而,实际却返回了null,这是不符合我们的期望。

{
    "data": null   // 不符合期望的返回
}

为了保护脆弱的前端Javascript代码在遇到null对象进行forEach时不报错,我们在自己的PHP接口层做了以下兼容处理:

    public function retrieveQaListBy($qsId) {
        $qaList = array();
        $apiQaList = Factory::create('ConsultQuestionModel')->detailsGet($qsId);
        if (is_array($apiQaList)) {
            //优化格式:接口找不到时返回null,确实返回数组时才返回
            $qaList = $apiQaList;
        }
        return $qaList;
    }

但我们还是遗漏了本次故障中个别data元素为null的异常情况,这种情况下,最终会导致前端在循环遍历时出现致命错误:TypeError: item is null,最后导致页面无法显示。
当时对应的JS前端模板渲染代码如下:

<% helpList.forEach(function(item) {%>
    <li>
        <div data-id="<%= item.id %>" class="m-slide-li j-slide-li"><%= item.content %></div>
        <div class="m-online-con j-online-con"><%= item.answer %>
            <div data-id="<%= item.id %>" class="m-arrow-mod j-service-online">在线客服</div>
        </div>
    </li>
<% }); %>

其实这一次的问题原因很简单,大家会觉得没必要把这么简单的事情如此详细的纪录下来。然而,正是这些简单的事情,给我带来了更深层次的思考。如果在软件开发过程中,只是按照自己想当然而没有经过沟通确认,最后会很容易(而且往往会)因小问题而引发线上故障,就像这一次。实际上,软件开发就是一个需要高智力、频繁沟通和密切协作的过程。与此同时,对于接口返回的字段、规范,乃至细微的类型,都马虎不得。我已经不止一次看到,因为接口返回的结果与预期的结果和格式不一致,而导致前端页面或移动应用解析错误,引发页面访问失败或者应用崩溃。
我把对于约定成俗的接口返回结果、格式和规范,通俗地称之为国际惯例编程中的一部分。只有当我们都对行业内的规范和标准有着共同的认识并严格遵守或自然而然地习以为常时,我们软件开发工程师之间的协助和沟通才会更为顺畅。一旦我们技术人员达成同一战线后,随之而来的将是系统与系统之间,客户端与服务端之间的顺畅对接。

3 接口文档

软件开发是一个需要高智力,频繁沟通和密切雷傲的过程。我一直都非常认同这一说法。
敏捷开发,相比于传统的瀑布流开发,更能拥抱变化,并受到了广大开发团队、众多组织的推崇。在敏捷软件开发宣言中,有一个价值观是:工作的软件高于详尽的文档。但是,大家一定要注意、留心、明白,这一价值观并不是指不需要文档,不是指可以不写文档,更不是否定文档的存在价值。最后,这也不应该是我们不编写文档的挡箭牌。
尤其在接口服务系统开发过程中,接口文档更显得重要。接口文档能在客户端开发人员和服务端开发人员之间建立起接口签名、规范和协助的方式,让项目干系人能基于契约进行开发。在开发完成后,测试工程师也能根据接口文档进行黑盒测试、功能测试以及系统层面的测试。接口文档是跨时空、跨地域、跨部门沟通与协助的必不可少的重要参考资料。
那我们可以通过哪些方式来提供或编写接口文档呢?既能减少自身对文档维护的工作量,又能保持知识的更新与同步,帮助客户端开发人员以及其他项目干系人快速了解接口相关的信息。

使用开源类库自动生成在线文档

首先,比较快捷的方式是,采用合适的开源项目,根据编写的代码注释自动生成对应的文档。比较经典的是PHPDoc,全称是phpDocumentor。它的使用非常方便,主要遵循phpdoc的注释规范,然后再简单地执行下phpdoc命令,就能生成漂亮的文档。这部分的使用不难,具体可以参考开源项目官方文档的说明与介绍。

打造自己的在线文档生成器

其次,也可以尝试打造自己的在线文档生成器。虽然这一部分会消耗一些时间,并需要投资一点精力,但我觉得一旦研发成功,其收益是明显且长远的。这里,我特别想分享一下PhalApi 2.x 是如何自动生成在线文档和离线文档的,便于给大家作个参考,抛个敲门砖。
前面有介绍到,PhalApi 2.x的接口参数规则配置与实现,这里结合参数规则配置以及PHP代码注释这两大部分源材料,通过对于PHP源代码的转换以及通过反射机制对注释进行解析这两大技术,并遵循既定的约束、约定和契约,我们就能生成得到富有活力和参考意义的文档。一旦在线文档生成完毕后,再保存为离线文档,就非常容易了。
下面,先一步一步学习,如何生成在线文档。
先就前面的用户接口服务App.User.Login的Api接口类代码补充必要的注释,主要补充接口类的说明,接口服务的名称、描述以及返回参数。而接口参数部分,则已经由参数规则提供。追加注释后的完整接口类App\Ap\User代码如下:

<?php
namespace App\Api;

use PhalApi\Api;

/**
 * 用户模块接口服务
 */
class User extends Api {
    public function getRules() {
        return array(
            'login' => array(
                'username' => array('name' => 'username', 'require' => true, 'min' => 1, 'max' => 50, 'desc' => '用户名'),
                'password' => array('name' => 'password', 'require' => true, 'min' => 6, 'max' => 20, 'desc' => '密码'),
            ),
        );
    }
    /**
     * 登录接口
     * @desc 根据账号和密码进行登录操作
     * @return boolean is_login 是否登录成功
     * @return int user_id 用户ID
     */
    public function login() {
        $username = $this->username;   // 账号参数
        $password = $this->password;   // 密码参数
        // 更多其他操作……

        return array('is_login' => true, 'user_id' => 8);
    }
}

这就是我们生成在线接口文档的原材料,经过一系列的深加工,就可以产出一份简洁、实用的接口文档。文档可分为三部分,即由概要描述、接口参数、返回结果这三部分构成。最终生成的文档,效果类似如下:

图 用户登录接口在线文档

首先,对于接口概要描述,对应的注释部分是:

<?php
namespace App\Api;

/**
 * 用户模块接口服务
 */
class User extends Api {
    /**
     * 登录接口
     * @desc 根据账号和密码进行登录操作
     */
    public function login() {
    }
}

其中,需要解析以及处理的部分涉及:

    • 接口服务类的类名,并转换成对应的面向客户端的接口服务名称
    • 接口类方法的注释,以及@desc
      注解对应的接口功能描述

结合PHP的反射机制,即ReflectionClass类反射和ReflectionMethod方法反射,可以编写文档生成器的源代码。先根据类名,需要注意的是此时的类名是带命名空间的,进行类反射,然后类注释和父类的注释, 以做备用。这里要留意兼顾多级继承的情况。

        // 整合需要的类注释,包括父类注释
        $rClass = new \ReflectionClass($className);
        $classDocComment = $rClass->getDocComment();
        while ($parent = $rClass->getParentClass()) {
            if ($parent->getName() == '\\PhalApi\\Api') {
                break;
            }
            $classDocComment = $parent->getDocComment() . "\n" . $classDocComment;
            $rClass = $parent;
        }

接下来是对类方法进行反射,这一环节是生成接口文档的重点部分。首先,需要提取的是接口功能描述与@desc
注解。

        // 方法注释
        $rMethod = new \ReflectionMethod($className, $action);
        $docCommentArr = explode("\n", $needClassDocComment . "\n" . $rMethod->getDocComment());

        foreach ($docCommentArr as $comment) {
            $comment = trim($comment);

            //标题描述
            if (empty($description) && strpos($comment, '@') === FALSE && strpos($comment, '/') === FALSE) {
                $description = substr($comment, strpos($comment, '*') + 1);
                continue;
            }

            //@desc注释
            $pos = stripos($comment, '@desc');
            if ($pos !== FALSE) {
                $descComment = substr($comment, $pos + 5);
                continue;
            }
        }

根据类名和方法名,创建一个ReflectionMethod的类对象,然后使用ReflectionMethod::getDocComment()就可以获取当前类方法的函数注释。这时注释是一段文本内容,也就是我们前面看到的:

    /**
     * 登录接口
     * @desc 根据账号和密码进行登录操作
     */

注意这里的换行,不同操作系统下的换行符号是不一样的。将文本段落注释按行切分后,就可以逐行解析,分别找出标题描述和@desc
注解。必要的数据提取完毕后,就可以通过模板视图渲染出来,呈现给客户端开发人员查看。
其次,是对接口参数规则的解析和转换。这部分的原材料是PHP的数组配置,而不是注释。这部分比较简单,又可分为两个子流程。第一个子流程是获取当前接口服务的参数规则配置,只需要使用封装好的工厂方法动态创建接口类实例,然后调用统一的接口PhalApi\Api::getApiRules()即可。getApiRules()是一个多合一版本的操作,它可以把全局的接口参数规则,以及当前的具体规则,乃至白名单下个别接口参数的动态调整,全部自动合并成一套规则。考虑到可能会存在创建接口类实例失败的情况,因此要添加异常捕捉和处理,例如当接口服务不存在时,也能正常显示接口文档,而不是直接500,提高在线文档的友好性。

        try {
            $pai = ApiFactory::generateService(FALSE);
            $rules = $pai->getApiRules();
        } catch (Exception $ex){
            $service .= ' - ' . $ex->getMessage();
            include dirname(__FILE__) . '/api_desc_tpl.php';
            return;
        }

第二个子流程是将最终的接口参数规则解析转换成HTML格式的文档,本质上是把参数数组转换成表格的展示形式。这一环节的难点,是在于怎么和开发人员共同约定一套接口参数规则标准,达成开发共识。这就是为什么你会发现,我们需要对参数类型、默认值、是否为必传参数、最大值、最小值、范围、数据源、参数说明等进行解析。定义或制定的规则,要及时体现和同步到在线接口文档,避免引起歧义。

$typeMaps = array(
    'string' => '字符串',
    'int' => '整型',
    ……
);

foreach ($rules as $key => $rule) {
    $name = $rule['name'];
    $type = isset($typeMaps[$rule['type']]) ? $typeMaps[$rule['type']] : $rule['type'];
    $require = isset($rule['require']) && $rule['require'] ? '<font color="red">必须</font>' : '可选';
    $default = isset($rule['default']) ? $rule['default'] : '';
    ……

    $other = array();
    if (isset($rule['min'])) {
        $other[] = '最小:' . $rule['min'];
    }
    ……
    $other = implode(';', $other);

    $desc = isset($rule['desc']) ? trim($rule['desc']) : '';

    echo "<tr><td>$name</td><td>$type</td><td>$require</td><td>$default</td><td>$other</td><td>$desc</td></tr>\n";
}

最后,也是比较重要的部分,是对接口返回的结果、字段和结构的说明。输入物或者原材料主要是全部@return
注释。

    /**
     * @return boolean is_login 是否登录成功
     * @return int user_id 用户ID
     */
    public function login() {
    }

继续前面类方法注释的解析,在循环体内,我们可以追加对@return
注释的提取和处理。@return
注释是有一定格式要求的,需要使用空格,分别把返回字段的类型、返回字段的名称以及返回字段的说明这三个元素依次串联起来,但同步又要做好兼容处理,即便开发人员少填了后面的元素也不影响文档的生成和展示,做到有多少就播报多少,珍惜并充分利用开发人员的每一个注释的输入。

        foreach ($docCommentArr as $comment) {
            $comment = trim($comment);
            ……
            //@return注释
            $pos = stripos($comment, '@return');
            if ($pos === FALSE) {
                continue;
            }

            $returnCommentArr = explode(' ', substr($comment, $pos + 8));
            //将数组中的空值过滤掉,同时将需要展示的值返回
            $returnCommentArr = array_values(array_filter($returnCommentArr));
            if (count($returnCommentArr) < 2) {
                continue;
            }
            if (!isset($returnCommentArr[2])) {
                $returnCommentArr[2] = '';  //可选的字段说明
            } else {
                //兼容处理有空格的注释
                $returnCommentArr[2] = implode(' ', array_slice($returnCommentArr, 2));
            }
        }

一旦返回结果的素材提炼就绪,就可以使用数组的循环,结合HTML表格的标签,生成对应的接口返回结果的文档片段。

/**
 * 返回结果
 */
echo <<<EOT
            <h3><i class="sign out alternate icon"></i>返回结果</h3>
            <table class="ui green celled striped table" >
                <thead>
                    <tr><th>返回字段</th><th>类型</th><th>说明</th></tr>
                </thead>
                <tbody>
EOT;

foreach ($returns as $item) {
    $name = $item[1];
    $type = isset($typeMaps[$item[0]]) ? $typeMaps[$item[0]] : $item[0];
    $detail = $item[2];

    echo "<tr><td>$name</td><td>$type</td><td>$detail</td></tr>";
}

echo <<<EOT
            </tbody>
        </table>
EOT;

到这里,我们就基本完成了具体某个接口服务的在线接口文档的生成工作。但仅有此还不足够,我们还需要提供一个索引页,即接口列表文档,以方便开发人员及时查看、查找和查阅。实现的思路是扫描应用项目下的全部接口类文件,然后对每个接口类使用ReflectionClass进行反射解析,最后再对接口类的全部接口服务方法使用Reflectionmethod进行类函数注释的提取。简单来说,就是三层嵌套循环,依次是应用接口类目录、接口类文件、接口类函数。最后,我们就看到在线接口列表文档。

图 接口列表文档

完成了在线接口文档的生成后,接下来再看下如何生成离线接口文档。对于在线版,访问的链接是:

http://localhost/public/docs.php

而离线版则需要在命令终端通过CLI模式执行相同的PHP文件,从而在public对外访8问目录下生成全部的接口文档,包括列表页和详情页。执行的方式和执行结果类似如下:

$ php ./public/docs.php

Usage:

生成展开版:  php ./public/docs.php expand
生成折叠版:  php ./public/docs.php fold

脚本执行完毕!离线文档保存路径为:/path/to/phalapi/public/docs

成功执行完毕后,根据结果提示,查看./public/docs目录,可以看到共生成了10个文件,其中入口文件为./public/docs/index.html,对应访问的链接是:http://localhost/public/docs/index.html。

$ tree ./public/docs
./public/docs
├── App.Examples_CURD.Delete.html
├── App.Examples_CURD.Get.html
├── App.Examples_CURD.GetList.html
├── App.Examples_CURD.Insert.html
├── App.Examples_CURD.Update.html
├── App.Examples_QrCode.Png.html
├── App.Examples_Upload.Go.html
├── App.Site.Index.html
├── App.User.Login.html
└── index.html

0 directories, 10 files

这里又有几个有趣的点,值得分享一下的。因为在线版和离线版都是同一个PHP文件处理的,以在线版为主,兼顾离线版。当需要生成离线版时,则需要应用ob_start()和ob_get_clean()来收集中间的结果输出,最后再保存到对应的HTML文件。同时,要注意跳转链接的切换,以及根目录的调整。关键的PHP代码如下:

<?php
$env && ob_start ();

 // 省略,在线文档的生成……

if ($env){
    $string = ob_get_clean();
    \PhalApi\Helper\saveHtml ($webRoot, 'index', $string);
    exit(0);
}

但在生成离线版时还有一个特别的诉求,那就是要同时提前生成全部接口详情文档,即要嵌套生成。此部分的关键核心代码片段如下,也分为三层嵌套循环。

foreach ($allApiS as $namespace => $subAllApiS) {       // 循环多个接口项目
    foreach ($subAllApiS as $key => $item) {            // 循环全部接口类
        foreach ($item['methods'] as $mKey => $mItem) { // 循环接口类的类方法
            if ($env){
                ob_start();

                // 换一种更优雅的方式
                \PhalApi\DI()->request = new \PhalApi\Request(array('service' => $mItem['service']));
                $apiDesc = new \PhalApi\Helper\ApiDesc($projectName);
                $apiDesc->render();

                $string = ob_get_clean();
                \PhalApi\Helper\saveHtml($webRoot, $mItem['service'], $string);
                $link = $mItem['service'] . '.html';
            }else{
                // 在线版的生成 ……
            }
        }
    }
}

这里遵循了信息专家原刚,把接口服务的注释放在了离它最近的接口类方法附近。同时能对注释做到一举多用,将深藏在代码背后的注释也能通过文档的形式展示出来,而不仅仅是代码的自我备注。对于根据注释生成文档,就如同根据产品代码自动生成单元测试代码一样,都是我们开发人员平时考虑较少的领域,不仅接触少,使用少,甚至研究也少。这又是一个有趣的点。除此之外,我还发现,大部分程序员对于命令行的使用,以及PHP命令行应用的开发也关注得很少,几乎认为命令行都是远古时代的产物。其实,这种想法是不对的,反而越古老、越底层的,越纯真、越动人。

人工用心编写优质的接口文档

最后,除了可以用开源框架或者自己研发的文档生成器外,还可以回归到文档编写的纯真时代,采用人工编写的方式。手动编写接口文档,会更贴切,更亲近,而不会像自动生成的文档那般冰冷生硬。同时格式也更为灵活,能够表达的意思和信息更为丰富。可问题在于,不少技术人员,一碰到文档编写就头疼,对于接口文档的编写也如此。
实际上,手动编写接口文档并不难,只要遵循一定的接口文档模板或格式即可。以下是我根据多年开发经验整理的接口文档模板,可作参考。

表8-3 接口文档参考模板

序号

项目

说明

1

接口链接

接口调用的链接,或者接口服务的唯一标识

2

功能说明

对接口服务实现的主要功能进行扼要说明,以及注意事项

3

接口参数

将接口需要的全部参数,包括每个参数的说明,汇总到一个表格

4

返回字段

对于接口返回的每个字段进行说明,包括每个字段的类型、枚举值等,同样以表格形式汇总

5

成例

提供一个接口请求调用与结果返回的示例,便于通过示例快速学习如何使用接口

6

错误码

提供一个错误码列表,汇总可能出现的错误情况,以及每个错误码对应的异常情况

7

备注

可以补充参考资料、依赖的底层接口、内部逻辑说明或业务流程图等信息

比较推荐的方式是,在WIKI上,通过可视化的富文本编辑器编写接口文档。或者,如果使用的是Git代码仓库,在GitLab上都会有配套的文档编写,这时使用的是Markdown。值得一提的是,如果你还没接触过或熟悉使用Markdown,建议现在就学习一下。本书在图灵社区上也是使用Markdown格式来编写的,PhalApi全部的官方文档用的也是Markdown,编写好后,可以在线转换成HTML格式的文档,在Sublime等文档编辑器内也有支持Markdown转换的插件或功能,甚至还可以通过代码来解析生成HTML,非常方便、简单、通用。就有点类似编写跨平台的代码一样,一处编写,到处查看。
但不推荐的方式是,还是使用本地Word文档的方式来编写、保存和传递接口文档,这样会缺少版本变更的追踪,同步不及时且难以共享。现在是共享时代,到处是共享单车,共享充电宝,共享雨伞,共享出行……为什么处于科技最前沿的我们没有共享接口文档呢?当然,一些企业内部、敏感或者保密性较高的接口文档,是可以保持传统路线来维护的。

发表评论