PHP高可用接口服务系统之可视化接口编程

如果仔细研究分析接口服务系统的开发和维护,再结合软件开发的经典流程,我们不难将接口编程切分为以下三大阶段:

    • 接口开发阶段
    • 接口上线阶段
    • 接口维护阶段

而可视化接口编程,是指借助其他可视化的方式和手段,将接口编程的细节、背后隐藏的秘密彰显在世人面前。本节着重探讨在接口开发、上线和维护这三大阶段,我们可以做哪些事情,才能最大限度保证接口的质量和稳定性,同时快速完成线上调试、问题排查和接口依赖关系的整理。

1 微服务,微测试

首先,要保证的是接口服务质量。想要保证接口服务质量,就要准确、全面、完整地验证接口服务肩负的职责、任务和规则。接口测试,可以分为黑盒接口测试和白盒接口测试。

黑盒测试:自动化接口测试

通常而言,黑盒接口测试由测试人员,即QA部门负责。他们会在拿到接口文档后,开始构建对接口的自动化测试体系。对于每个测试用例,都可以建立对应的一个接口测试用例。由点到面,组织成一个全方位的测试体系。
作为黑盒测试的辅助工具,我们还可以自己根据接口系统自身的特点,开发一个在线接口测试面板。这个测试面板,可以开放给后端开发人员自己使用,也可以提供给客户端开发人员进行调试使用。例如唯品会开放平台的在线测试工具。

图 在线测试工具

在某些开源框架中,也提供了现成的在线测试面板。例如在PhalApi 2.x,在生成接口文档的同时还提供了一个简易的请求模拟版块。通过表单参数录入的方式,然后只需轻轻点击一下“请求当前接口”,就能完成对接口的请求和测试。例如针对用户登录接口的测试效果截图如下:

图 请求模拟

如果没有搭建自己的在线测试工具,也缺少开源框架内置的测试面板的话,那么还有一个通用的解决方案,就是使用Postman工具完成对接口的请求、测试和管理工作。Postman工具更为专业,也更为实用、贴心。对于用户登录接口,采用Postman测试的效果如下:

图 使用Postman对接口进行测试

这些测试工具,都能非常方便地通过界面化的操作,完成对接口服务的请求的测试。当然,也可以在浏览器直接手动拼接参数来访问接口,但这一做法并不推荐。

白盒测试:内部单元测试

界面化的测试工具以及黑盒测试是很有帮助的,但作为一名专业的PHP开发工程师,使用更多的应该是白盒测试,和针对代码内部的单元测试。
在第5章的PHPUnit单元测试新解中,我们已经讨论了很多关于单元测试的内容。所以在这里,为了不重复讲述,转而关注如何在单元测试用例内模拟外部的接口请求,并进行执行、验证和测试。
继续前面用户登录的接口服务,在PhalApi 2.x 内可以使用phalapi-buildtest命令,一键生成单元测试代码骨架,稍微修改,完善登录服务的测试用例,最终单元测试的代码如下:

<?php
// ./tests/app/Api/User_Test.php 文件
namespace App;

use App\Api\User;
use PhalApi\Helper\TestRunner;

class PhpUnderControl_AppApiUser_Test extends \PHPUnit_Framework_TestCase
{
    public $appApiUser;

    protected function setUp()
    {
        parent::setUp();
        $this->appApiUser = new User();
    }

    public function testLogin()
    {
        //Step 1. 构建请求URL
        $url = 's=App.User.Login&username=dogstar&password=123456';

        //Step 2. 执行请求  
        $rs = TestRunner::go($url);

        //Step 3. 验证
        $this->assertTrue($rs['is_login']);
        $this->assertSame(8, $rs['user_id']);
    }
}

在testLogin()测试用例内,注意到,我们首先构建了一个请求URL,在这里可以包含请求接口时的GET参数。然后,执行TestRunner::go(),即可将前面模拟的接口请求链接交由封装好的调度器执行,此时第一个参数是待请求的接口链接,第二个参数是POST的参数,为空时可不传。最后,是验证环节。
执行以上测试,结果显示通过。

tests$ phpunit ./app/Api/User_Test.php 
PHPUnit 4.3.4 by Sebastian Bergmann.

Configuration read from /path/to/phalapi/tests/phpunit.xml

..

Time: 33 ms, Memory: 7.50Mb
OK (2 tests, 2 assertions)

这里顺便讲一下,我们的系统之所以吸引人,能产生价值,并不是因为当前使用的技术有多前沿,多深奥,或者多极客,而是在于我们所做的系统,开发的功能,能对客户端开发带来什么好处,解决了哪些痛点。哪怕只是很平常的一段代码,但它出生的志向远大,价值观正确,都会产生非凡的成绩。例如这里的TestRunner测试调度器。
如果揭开TestRunner的神秘面纱,我们可以看到,它的内部实现并不复杂,也没有高深的技术,有的只是很平实的一段代码。它所做的只是通过parse_str把GET参数解析提取出来,然后和待POST的参数进行合并,最后基于开源框架本身的设计,重新初始化请求服务,在完成接口服务实例的创建后,动态执行将要请求的接口服务,并返回结果。仅此而已。

<?php
// kernal/src/Helper/TestRunner.php 文件
namespace PhalApi\Helper;

use PhalApi\Exception;
use PhalApi\Request;
use PhalApi\ApiFactory;

class TestRunner {
    /**
     * @param string $url 请求的链接
     * @param array $param 额外POST的数据
     * @return array 接口的返回结果
     */
    public static function go($url, $params = array()) {
        parse_str($url, $urlParams);
        $params = array_merge($urlParams, $params);

        if (!isset($params['service']) && !isset($params['s'])) {
            throw new Exception(\PhalApi\T('miss service in url'));
        }
        \PhalApi\DI()->request = new Request($params);

        $apiObj = ApiFactory::generateService(true);
        $action = \PhalApi\DI()->request->getServiceAction();

        $rs = $apiObj->$action();

        return $rs;
    }
}

但是通过TestRunner,我们可以将接口请求的细节封装起来,对外提供一个友好的接口,帮助客户端开发优雅完成对接口请求的工作。

2 让天下没有难查的故障

阿里巴巴的使命是“让天下没有难做的生意”,坚持在B2B领域,为中小企业和创业者提供交易平台。在维护接口服务系统过程中,我们也可以树立“让天下没有难查的故障”这一目标,并坚持做到接口执行可视化,接口链路纪录化,接口逻辑路线化。通俗一点来说,就是尽量做到显而易见。
在本地开发环境,我们对接口服务系统的拥有充足的控制能力。当发现问题时,我们可以轻易伪造测试数据重现缺陷,也可以在IDE开发环境内进行断点调试,也可以直接修改PHP源代码进行二分法式的“走一步die一步”的单步调试,还可以在单元测试的测试用例内验证特定的逻辑和规则。在线下,我们拥有对数据、对代码、对环境的完全权限,但在线上呢?当生产环境的接口系统未按预期正常工作时,我们又该如何排查呢?
生产环境的服务器,掌握在运维部门的同学手上,我们开发人员是无法直接接触正式环境的服务器,在大型企业中更是如此。对于线上环境的数据,也不能为了临时的测试需要而轻易修改。接口服务系统的代码,更是要通过严格的开发、测试、code review,并通过申请和审批后方能发布到线上环境。不得不说,我们对于线上代码的控制能力更弱,因为那是产品代码,容不得半点儿戏。
要想做到“让天下没有难查的故障”,需要提前做一些准备。分别是:实时的全球追踪器、异常发生时和调试模式下的执行堆栈、离线的调用链路纪录。

实时的全球追踪器 即便是正常请求和响应的情况,对于接口服务,添加加一定的追踪器也是很有必要的。这些附带的追踪信息有助于我们即时了解接口服务的健康程度,以及帮助我们理解和解释藏在接口服务背后刚刚发生的事情。
例如,前文多次提到的全球追踪器,也可以应用在接口服务系统上。我很高兴自己曾经提出全球追踪器这一概念,能切实应用到实际企业级系统开发中,并得到其他开发人员的认可。事隔多年,我依然看到曾经自己在企业级系统中埋下全球追踪器的种子,至今还发挥着它的作用。通过全球追踪器提供的高度浓缩的信息,我们可以通过专业和角度来分析和理解它背后的含义,从而还原数据流经的节点的路线图。这是一棵单向有向图,它在开发人员的脑中动态地描绘了系统中的企业级数据的变化情况,并能帮助开发人员动态建模。

图 全球追踪器依然应用在实际场景中

但当接口服务系统的层级较多时,我们又要面临解决另一个新问题的困境。需要解决的难题是如何协调跨系统调用下业务数据缓存时间的一致性。当一次请求,依赖多个下层接口服务系统时,而这些依赖都是依次逐层嵌套依赖时,例如接口系统A依赖接口系统B,接口系统B依赖接口系统C,进而依赖接口系统D……由于各个系统使用各自的缓存策略,从而导致了在终端展示给用户的数据,其缓存时间最大值可达各系统缓存时间之和。这无疑会降低用户体验,因为数据更新太慢了,页面数据实效性太低,而且不一致。
这里提出的优化方案是,下游系统对数据的缓存时间,应在原来自身过期的时间基础上,优先以上游系统提供的业务缓存时间为本身缓存失效时间。简单来说,即统一以源头业务数据的业务缓存时间为准,以达到数据的一致性更新。同时,将此一致性的缓存通过特定的标识添加到全球追踪器上,近一步丰富它的语义。

异常发生时和调试模式下的执行堆栈

当发生故障时,当用户投诉时,我们应该具备根据提供的接口请求链接进行快速排查和定位。除了结合正常情况下的全球追踪器外,还可以开启调试模式下的堆栈信息查看。
让我们以一个具体的异常故障为例,演示如何实现对执行的堆栈信息的整理。回顾到用户登录接口服务,可以在中间故意制造一些麻烦,让接口服务触一些未期望的异常。如下:

<?php
namespace App\Api;

use PhalApi\Api;
/**
 * 用户模块接口服务
 */
class User extends Api {
    public function login() {
        throw new \Exception('手机快没电啦!');
        ……
    }
}

这时,再次请求用户登录接口服务App.User.Login,会出现500,接口返回一片空白,对于排查故障毫无帮助。但这一做法是正确的,因为我们不希望在发生异常情况下暴露任何错误信息给终端用户,这有利于保护服务端内部实现的细节和敏感信息。但如果开启或进入调试模式,我们就能在同样异常情况下,这时会完整的堆栈信息,类似如下:

{
    "ret": 0,
    "data": [
    ],
    "msg": "手机快没电啦!",
    "debug": {
        "exception": [
            {
                "function": "login",
                "class": "App\Api\User",
                "type": "->",
                "args": [
                ]
            },
            {
                "file": "/path/to/phalapi/vendor/phalapi/kernal/src/PhalApi.php",
                "line": 53,
                "function": "call_user_func",
                "args": [
                    [
                        {
                            "username": "dogstar",
                            "password": "123456"
                        },
                        "Login"
                    ]
                ]
            },
            ……
        ],
        "stack": [
            "[#0 - 0ms - PHALAPI_INIT]/path/to/phalapi/public/index.php(6)",
            "[#1 - 2.7ms - PHALAPI_RESPONSE]/path/to/phalapi/vendor/phalapi/kernal/src/PhalApi.php(46)",
            "[#2 - 39.6ms - PHALAPI_FINISH]/path/to/phalapi/vendor/phalapi/kernal/src/PhalApi.php(74)"
        ],
        "sqls": [
        ],
        "version": "2.2.3"
    }
}

在调试模式下,返回的状态码ret为0,表示当前是处理调试模式,除了正常情况下返回的状态码ret、数据data和提示信息msg外,额外返回的debug结构体。在debug结构体内,有三大部分的重要信息,一个是异常情况下的堆栈信息exception,一个是自定义埋点纪录信息stack,最后一个是本次接口服务过程中执行的全部SQL语句。有了这三部分信息,基本就可以全面剖析接口服务的全过程。
针对本次示例,从堆栈信息exception内可以看到异常是在App\Api\User::login()方法内抛出的,在这前面,是框架底层的call_user_func动态调度。从自定义埋点纪录信息stack内,可以得出整个接口服务响应时间约为39.6 毫秒。根据过往的经验,这部分信息非常有帮助,因为它可以帮助我们技术开发人员了解线上运行的性能情况和每个业务模块和环节的响应时间。例如,假设用户登录接口的内部实现修改为:

    public function login() {
        $username = $this->username;   // 账号参数
        $password = $this->password;   // 密码参数
        // 更多其他操作……

        \PhalApi\DI()->tracer->mark('开始写一本书');

        // 闭关一段时间……
        sleep(3);

        \PhalApi\DI()->tracer->mark('终于完成写书');

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

在用户登录过程中,我们插入了一段漫长的过程,长达3秒的睡眠,并在睡眠前面进行了自定义埋点,再后再来对比这时的stack。这时,用户登录接口共耗时了3029.8 毫秒,其中在开始写书与结束写书之间消耗了3000多毫秒。通过查看自定义埋点信息,不仅能快速找到性能瓶颈,还能清楚地定位到代码位置。

"stack": [
    "[#0 - 0ms - PHALAPI_INIT]/path/to/phalapi/public/index.php(6)",
    "[#1 - 6.9ms - PHALAPI_RESPONSE]/path/to/phalapi/vendor/phalapi/kernal/src/PhalApi.php(46)",
    "[#2 - 11.6ms - 开始写一本书]/path/to/phalapi/src/app/Api/User.php(29)",
    "[#3 - 3029.7ms - 终于完成写书]/path/to/phalapi/src/app/Api/User.php(34)",
    "[#4 - 3029.8ms - PHALAPI_FINISH]/path/to/phalapi/vendor/phalapi/kernal/src/PhalApi.php(74)"
],

最后debug结构体中的sql字段是表示当前执行的全部SQL语句,因为本次示例中不涉及任何数据库操作,因此为空。
至此,我们通过PhalApi 2.x 的示例,快速了解了对接口的调试和故障排查,但这里的重点是和读者分享在这背后实现的思路。实际上,PhalApi一开始是不提供在线调试功能的,因为作为其创始人,我觉得开发人员可以直接查看PHP自带的错误信息和日志也许会更为简单明了,并能减少开发人员的学习成本。因为PHP的错误日志是通用的,但开源框架对于错误信息的整理和展示,各有各的不同。直到有一次,我遇到了从深圳专程专程赶过来和我进行技术交流的一位技术总监,他建议我为PhalApi框架添加在线调试的能力,因为这样能极大提升框架对开发人员的友好性,哪怕是非常粗糙的可视化工作,也能明显地帮助开发人员快速定位问题。
先从框架的入口开始,在动态调度接口服务时,完善对异常捕捉和处理的机制。当出现不可控的异常时,在调试模式下才会追加调试信息exception到接口返回结果。如果是非调试模式,则直接继续抛出异常,升级为异常案例。而在这前后,即执行具体接口服务的前后,在框架层面自动添加自定义埋点,纪录开始响应和结束响应的埋点信息。

<?php
// ./kernal/src/PhalApi.php 文件
namespace PhalApi;

class PhalApi {
    public function response() {
        ……
        // 开始响应接口请求
        $di->tracer->mark('PHALAPI_RESPONSE');
        $rs = $di->response;
        try {
            // 接口调度与响应
            $api    = ApiFactory::generateService();
            $action = $di->request->getServiceAction();
            $data   = call_user_func(array($api, $action));

            $rs->setData($data);
        } catch (\Exception $ex) {
            // 不可控的异常
            if ($di->debug) {
                $rs->setDebug('exception', $ex->getTrace());
            } else {
                throw $ex;
            }
        }
        // 结束接口调度
        $di->tracer->mark('PHALAPI_FINISH');
        ……
    }
}

可以看到,这里的异常堆栈信息来源于异常类的Exception::getTrace()方法。至于自定义埋点信息,则需要自行纪录、计算和处理。回顾先前学习的知识,我们用到了debug_backtrace()函数来获取PHP代码执行的堆栈信息。如下面追踪器PhalApi\Helper\Tracer类的核心代码片段所示:

<?php
// ./kernal/src/Helper/Tracer.php 文件
namespace PhalApi\Helper;

class Tracer {
    public function mark($tag = NULL) {
        $backTrace = debug_backtrace();
        if (empty($this->timeline)) {
            array_shift($backTrace);
        }
        $this->timeline[] = array(
            'tag' => $tag,
            'file' => isset($backTrace[0]['file']) ? $backTrace[0]['file'] : '',
            'line' => isset($backTrace[0]['line']) ? $backTrace[0]['line'] : 0,
        );
    }
}

最后剩下SQL语句的纪录,则更为简单。只需要在追踪器类内新增一个纪录SQL语句的接口,然后开放给数据库类库回调即可。这就是为什么我们会在数据库外观入口类PhalApi\Database\NotORMDatabase内看到以下代码的原因。

// 调试模式与回调函数
$this->_notorms[$notormKey]->debug = $this->debug;
$this->_notorms[$notormKey]->debugTimer = array(\PhalApi\DI()->tracer, 'sql');

如果再深入到底层代码,深入到PhalApi 2.x 的NotORM扩展,就能找到最终执行SQL纪录调用的代码。

if($this->notORM->debug){
    $debugTrace['endTime'] = microtime(true);

    $sqlInfo = sprintf("[%s - %sms]%s",
        self::$queryTimes,
        round(($debugTrace['endTime'] - $debugTrace['startTime']) * 1000, 2),
        $debugTrace['sql']
    );

    if ($this->notORM->debugTimer && is_callable($this->notORM->debugTimer)) {
        call_user_func_array($this->notORM->debugTimer, array($sqlInfo));
    }
}

如果上面的代码片段不好理解,我们可以来看一个具体的例子。类似下面的例子,总共执行了3条SQL语句,分别消耗了0.82毫秒、0.34毫秒、3.07毫秒。明显地,最后的更新操作耗时相对较长。

"sqls": [
    "[1 - 0.82ms]SELECT * FROM counter_tbl WHERE counter_name = 'OKAYAPI' LIMIT 1;",
    "[2 - 0.34ms]SELECT * FROM counter_ext_tbl WHERE counter_name = 'OKAYAPI' LIMIT 1;",
    "[3 - 3.07ms]UPDATE counter_tbl SET `cur_counter_value` = cur_counter_value + 1, `update_time` = '2018-08-18' WHERE counter_name = 'OKAYAPI';"
],

这部分的调试能力非常强大,但也有其局限性,那就是我们首先要知道具体的接口请求链接,并且此异常的接口请求能够允许在线上环境重复多次执行,我们才能进行线上调试和故障排查。如果等排查的是用户支付接口,那么就不适宜频繁或多次调试,因为会损害用户的利益,产生期望以外的副作用。
那么如何弥补这一块的空缺呢?如果能防患于未然,在不确定接口服务何时出现问题、发生故障前,提前做好纪录呢?

离线的调用链路纪录

实时的全球追踪器,以及异常下的调试模式,这些都是实时性的,对现在正在发生的事情进行剖析。如果想对过去发生的事情进行回顾,则可以纪录离线的调用链路。
Java社区在这方面有很多现在的解决方案,可用于纪录一个接口请求背后的全部节点运行情况,包括请求时间、响应时间、请求参数、响应结果,并通过一个唯一的Trace ID 将整个链路的执行情况串联起来。最后,可以在可视化的平台进行搜索、查看。但PHP对于这一块还是比较欠缺的,需要自建,花一点时间。这部分可以结合前面介绍的日志服务组件,共同配合搭建。更推荐的做法时,在PHP层面通过SDK包客户端或扩展,接入到大数据平台。
此外,我们也可以提前准备整理可视化接口依赖,即根据自定义的接口关系,生成可视化依赖图表,从而可视化特定接口服务之间的依赖关系。相比于接口系统之间的关系依赖,此可视化接口依赖主要用于描述更细粒度的接口服务的关系依赖。由于这块尚未有较好的输出,暂且不展开分享。

发表评论