使用Guzzle并发请求接口

最近在重构官网,为了支持SEO,整个项目的结构从采用AngularJs的单页面应用结构回归到PHP服务端渲染。问题来了:之前统一调用接口可以保证三端(APP,PC和移动web端)数据同步,现在如果是单独为PC端写一套业务逻辑,需要花费大量的时间精力,且后期维护可能比较困难(需要维护两处)。因此为了最大程度上保证数据同步,决定在通过服务器中转请求原始数据接口,然后渲染页面。经过筛选最终选定了Guzzle框架,随之而来的便是网络请求效率的问题~

<!--more-->

这篇文章不是Guzzle的教程,虽然我也是看着文档刚折腾的。

1. 模拟环境

由于首页需要展示很多模块的数据,这意味着需要发送多个请求(大概估算了一下有18个接口)。在开发初期便发现首页打开速度非常慢,当时还以为只是接口效率的问题。后来才发现原因是:Guzzle的默认网络请求是串行的*,这意味着首页打开时间是大于等于等于所有接口请求消耗时间总和的,这是一件完全不能容忍的事情(你能忍受首页打开时间超过8秒?)。

1.1. 接口

下面是为了测试并解决这个问题所搭建的一个接口环境,采用的是node的restify框架。

let Restify = require('restify');
let plugins = require('restify-plugins');
let server = Restify.createServer();

server.use(plugins.acceptParser(server.acceptable));
server.use(plugins.authorizationParser());
server.use(plugins.queryParser());
server.use(plugins.bodyParser({mapParams: true}));


// 等待一段时间后发送响应,模拟接口耗时,根据需要可创建多个接口
server.get("test_2000",(req, res, next)=>{
    setTimeout(function () {
        res.send("hello after 2s");
    }, 2000);
});

server.get("test_1000",(req, res, next)=>{
    setTimeout(function () {
        res.send("hello after 1s");
    }, 1000);
});

// 请求路径 如 localhost:9999/test_100
server.listen(9999, function() {
    console.log('%s listening at %s', server.name, server.url);
});

这里的test_1000接口会延迟1s响应,test_2000会延迟2s响应,由于均为本地环境,且接口没有什么复杂的逻辑操作,因此实际的网络请求耗时可估算为代码中指定的延迟时间。

1.2. 请求

接下来在PHP总进行测试,这里使用了LaravelHTTPModel是对Guzzle进行的封装,后面我们会重写这个基类


namespace App\ApiModel;
use GuzzleHttp\Promise;

class TestModel extends HTTPModel
{

    public function test_2000(){
        $url = "/test_2000";

        $data = $this->get($url, []);
        return $data;
    }

    public function test_1000(){
        $url = "/test_1000";

        $data = $this->get($url, []);
        return $data;
    }
}

这里是控制器,代码就不贴全了,根据前面的接口,我们估算控制器从接受到浏览器的请求,到返回响应大概需要3s的时间

public function index(Request $request){
    $data1 = TestModel::getInstance()->test_1000();
    $data2 = TestModel::getInstance()->test_2000();

    echo TestModel::getCostTime(); 

    dd($data1);
    dd($data2);
}

实际输出为3042ms,刷新页面重复几次测试,尽管每次的结果并不是固定的,但也可以初步认为推断没有错误:整个页面的耗时是由于串行的请求接口造成的。

1.3. 小结

由于平常基本是写Node和前端的接口,在使用PHP的时候下意识的就以为都是异步接口,导致页面打开速度太慢。既然找到了原因,那么就该解决这个问题了。

2. Guzzle并发请求

实际上文档中已经提到了通过异步请求和Promise来实现并发请求,这个确实是文档的时候疏忽了(现在起码有二十多个控制器要修改~)

2.1. 并发请求

TestModel增加一个测试并发请求的方法

public function test_concurrent(){
    $url_1 = "/test_1000";
    $url_2 = "/test_2000";

    $promises = [
        $this->getAsync($url_1, []),
        $this->getAsync($url_2, []),
    ];

    // 这里会将请求挂起并等待请求请求结果,对于长期使用异步的前端来讲可能有点陌生,理解成async 和 await即可
    $results = Promise\unwrap($promises);

    foreach ($results as $res){
        $data[] = $this->getResult($res);
    }

    return $data;

}

同上在控制器中进行测试,理论上响应的时间是由耗时最长的那个接口所决定的,这里应该是2s

public function concurrent(){
    $data3 = TestModel::getInstance()->test_concurrent();
    echo TestModel::getCostTime();

    dd($data3);
}

得到的结果是2044ms,与估算基本相同。当然这里没有考虑接口服务器限制最大并发请求数量的情况,这个后面再提。

2.2. 思考

从上面的两个测试可以看出,确实是由于串行的请求导致了响应时间过慢,而当前的解决办法就是将串行请求,比如由于业务逻辑的不同,页面上的某个数据需要请求多个接口,我们可以在Model中进行异步请求,最终合并结果并返回数据。

但是,考虑另外一个问题:一个页面可能需要调用多个接口,这些接口由不同的Model抽象,这意味着不同Model接口的调用仍旧是串行的,而我们最终需要的效果应当是将某个控制器方法中的所有请求都进行并发处理,这样才能最大程度上减少接口调用所带来的网络延迟。

也就是说,我们需要实现的是:先由不同的模型准备接口请求,然后再统一发起请求。下面我们尝试着对Guzzle进行封装并实现这个需求。

3. 封装Guzzle

虽然具体的方向已经确定了,但还有很多细节需要整理:

  • 请求由不同的模型生成,最后统一发送,这里可以使用单例实现。
  • 需要保证模型在控制器中的调用习惯(即同步代码),这是因为需要根据返回的数据处理部分业务逻辑,可以使用闭包和数据引用传递实现

3.1. 初步实现

首先实现请求基类

use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Promise;
class HTTPModel
{
    protected $hostname = "localhost:9999";

    protected $client;
    protected static $count = 0;
    protected static $start_at;
    // 用来保存异步请求
    protected static $asyncRequest = [];

    private static $instances = [];

    // 实例化guzzle client对象
    private function __construct()
    {
        // 统计请求耗时
        self::$start_at = self::getMicrTime();

        $this->client = new Client([
            'base_uri' => "http://{$this->hostname}/index.php",
            'timeout'  => 3.0,
        ]);
    }

    // 单例    
    public final static function getInstance()
    {
        $name = get_called_class();
        if (!isset(self::$instances[$name])) {
            self::$instances[$name] = new static();
        }
        return self::$instances[$name];
    }

    // 异步请求
    public function getAsync($url, $opts, $callback){
        self::$count++;
        $async = $this->client->getAsync($url, $opts);

        // 这里只是简单处理将请求和回调函数关联在一起
        $obj = new \stdClass();
        $obj->request = $async;
        $obj->callback = $callback;

        // 将请求保存起来
        self::$asyncRequest[] = $obj;

        return $async;
    }

    // 统一发送请求
    public function startAsync(){

        $asyncRequest = self::$asyncRequest;
        foreach ($asyncRequest as $request){
            $promises[] = $request->request;
        }

        // 发送请求,等待响应 
        $results = Promise\unwrap($promises);

        foreach ($results as $index=>$res){
            $data = $this->getResult($res);
            $callback = $asyncRequest[$index]->callback;
            // 执行回调函数
            $callback($data);
        }

        // 清空请求队列
        self::$asyncRequest = [];
    }

    // 还有一些其他的工具方法,暂时不列出来了
}

然后我们还是定义一个TestModel,用于接口测试,由于在模型类中需要处理数据,因此在这里声明回调函数用于处理响应结果,其中$result是控制器中的引用变量,用于在控制器中获取结果

class TestModel extends HTTPModel
{
    public function test_2000_c(&$result){
        $url = "/test_2000";

        return $this->getAsync($url, [], function($data)use(&$result){
            $result = $data;
        });
    }

    public function test_1000_c(&$result){
        $url = "/test_1000";
        return $this->getAsync($url, [], function($data)use(&$result){
            $result = $data;
        });
    }
}

最后在控制器中调用

class TestController extends Controller
{
     public function async(){

        // 预先定义引用变量
        $data['data1'] = [];
        $data['data2'] = [];

        // 生成请求    
        TestModel::getInstance()->test_1000_c($data['data1']);
        TestModel::getInstance()->test_2000_c($data['data2']);

        // 发送并发请求
        TestModel::getInstance()->startAsync();

         // 查看结果
        // echo TestModel::getCostTime(); // 2026ms
        // var_dump($data1); // hello after 1s
        // var_dump($data2); // hello after 2s

        // 输出数据
        return view("test",$data);
    }
}

这样貌似就达到了我们的目的,其中核心的思想是通过单例来维护所有模型需要调用的接口请求(这样就不用纠结于到底是哪个模型需要调用接口),然后手动触发网络请求。 很明显,这比串行调用接口的效率要高很多,尤其是需要调用很多接口的页面(比如首页)。而在控制器中,对数据的处理还基本维持了串行调用的同步习惯(只是需要手动调用startAsync而已)。

4. 总结

我对PHP并不是很了解,实际上这只是针对于三端数据统一所做的尝试,使用服务器中转调用第三方接口肯定会影响效率,后面这里的接口可能会被重构掉。事实上我当我把之前的串行代码替换之后,提高的速度也并不是那么明显,貌似真正的性能瓶颈在数据库那里,这完完全全是后端的事儿了~