EventDispatcher组件

3.3 version
维护中的版本

我们的框架始终缺乏一个优秀框架的关键特性:扩展性。可扩展意味着开发者应该可以轻易打入(hook)框架的生命周期来调整请求被处理的方式。

我们讨论的是何种hook方式?例如,认证或缓存。为了灵活,这种“打入”必须是“即插即用”的(plug-and-play)。你注册到程序中的hooks是不能等同于另外一个依赖于你的特定需求的(hook)。很多软件有相似概念,比如Drupal或WordPress。在一些语言中,甚至有诸如Python的WSGI和Ruby的Rack等等一些标准。

由于在PHP中无有标准,我们将使用一个广为人知的设计模式,即Mediator(中介者),来允许任何各类的行为被附着到我们的框架之中。Symfony的EventDispatcher组件实现了这种模式的一个轻量化版本:

1
$  composer require symfony/event-dispatcher

它是如何工作的?dispatcher(派遣器)是事件派遣系统的中心对象(central object),它通知那些“针对某个事件”的监听器。换一种表达方式:你的代码派遣一个事件到dispatcher,dispatcher通知所有已经注册到该事件的监听,然后每个监听对这个事件去做它们希望的任何事。

作为例子,我们创建一个监听来透明地添加Google Analytics代码(谷歌统计代码)到所有响应中。

要让它工作,框架必须派遣一个事件,在响应实例被返回之前:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// example.com/src/Simplex/Framework.php
namespace Simplex;
 
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Matcher\UrlMatcherInterface;
 
class Framework
{
    private $dispatcher;
    private $matcher;
    private $controllerResolver;
    private $argumentResolver;
 
    public function __construct(EventDispatcher $dispatcher, UrlMatcherInterface $matcher, ControllerResolverInterface $controllerResolver, ArgumentResolverInterface $argumentResolver)
    {
        $this->dispatcher = $dispatcher;
        $this->matcher = $matcher;
        $this->controllerResolver = $controllerResolver;
        $this->argumentResolver = $argumentResolver;
    }
 
    public function handle(Request $request)
    {
        $this->matcher->getContext()->fromRequest($request);
 
        try {
            $request->attributes->add($this->matcher->match($request->getPathInfo()));
 
            $controller = $this->controllerResolver->getController($request);
            $arguments = $this->argumentResolver->getArguments($request, $controller);
 
            $response = call_user_func_array($controller, $arguments);
        } catch (ResourceNotFoundException $e) {
            $response = new Response('Not Found', 404);
        } catch (\Exception $e) {
            $response = new Response('An error occurred', 500);
        }
 
        // dispatch a response event / 派遣事件
        $this->dispatcher->dispatch('response', new ResponseEvent($response, $request));
 
        return $response;
    }
}

框架每处理一次请求,一个ResponseEvent事件就被派遣一次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// example.com/src/Simplex/ResponseEvent.php
namespace Simplex;
 
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\EventDispatcher\Event;
 
class ResponseEvent extends Event
{
    private $request;
    private $response;
 
    public function __construct(Response $response, Request $request)
    {
        $this->response = $response;
        $this->request = $request;
    }
 
    public function getResponse()
    {
        return $this->response;
    }
 
    public function getRequest()
    {
        return $this->request;
    }
}

最后一步是在前端控制器中创建dispatcher,然后注册一个监听到response事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';
 
// ...
 
use Symfony\Component\EventDispatcher\EventDispatcher;
 
$dispatcher = new EventDispatcher();
$dispatcher->addListener('response', function (Simplex\ResponseEvent $event) {
    $response = $event->getResponse();
 
    if ($response->isRedirection()
        || ($response->headers->has('Content-Type') && false === strpos($response->headers->get('Content-Type'), 'html'))
        || 'html' !== $event->getRequest()->getRequestFormat()
    ) {
        return;
    }
 
    $response->setContent($response->getContent().'GA CODE');
});
 
// 官方文档在下面这行少了一个参数,即第四个argumentResolver,请大家注意
$framework = new Simplex\Framework($dispatcher, $matcher, $resolver);
$response = $framework->handle($request);
 
$response->send();

此处的监听只用作概念证明,你应该在body标签之前添加Google统计代码。

如你所见,addListener()关联了一个指向了“命名事件”(response)的有效PHP回调。事件名称必须和使用在dispatch()方法中的那个一样。

在监听内部,我们仅在响应“没有被重定向”、请求的格式(format)是HTML并且响应的content-type也是HTML的时候(这些条件演示了在操作你代码中的请求和响应数据时是何其容易),添加了Google统计代码。

到目前为止一切还好,但让我们对同一事件添加另一个监听。说我们想要设置响应的Content-Length,如果它未被设置的话:

1
2
3
4
5
6
7
8
$dispatcher->addListener('response', function (Simplex\ResponseEvent $event) {
    $response = $event->getResponse();
    $headers = $response->headers;
 
    if (!$headers->has('Content-Length') && !$headers->has('Transfer-Encoding')) {
        $headers->set('Content-Length', strlen($response->getContent()));
    }
});

当创建你的框架时,要考虑优先级(例如对内部监听器的某些数字进行保留),同时全面文档化。

通过转移谷歌统计代码到它自己的类,我们略微重构一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// example.com/src/Simplex/GoogleListener.php
namespace Simplex;
 
class GoogleListener
{
    public function onResponse(ResponseEvent $event)
    {
        $response = $event->getResponse();
 
        if ($response->isRedirection()
            || ($response->headers->has('Content-Type') && false === strpos($response->headers->get('Content-Type'), 'html'))
            || 'html' !== $event->getRequest()->getRequestFormat()
        ) {
            return;
        }
 
        $response->setContent($response->getContent().'GA CODE');
    }
}

然后在另一个监听中做同样的事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// example.com/src/Simplex/ContentLengthListener.php
namespace Simplex;
 
class ContentLengthListener
{
    public function onResponse(ResponseEvent $event)
    {
        $response = $event->getResponse();
        $headers = $response->headers;
 
        if (!$headers->has('Content-Length') && !$headers->has('Transfer-Encoding')) {
            $headers->set('Content-Length', strlen($response->getContent()));
        }
    }
}

我们的前端控制器应该是下面这样:

1
2
3
$dispatcher = new EventDispatcher();
$dispatcher->addListener('response', array(new Simplex\ContentLengthListener(), 'onResponse'), -255);
$dispatcher->addListener('response', array(new Simplex\GoogleListener(), 'onResponse'));

就算类中的代码已经被漂亮地“打了包”,还是有一点问题:(监听器的)优先级信息被写死在前端控制器中了,而没有在监听器内部进行。在每套程序中,你不得不记起要设置合适的优先级。更甚者,监听方法的名字也被暴露在这里,这意味着重构监听器时将会改变所有依赖此监听的程序。当然,有一个解决方案,就是使用订阅器而非监听:

一个订阅器(subscriber)知晓它感兴趣的所有事件,并通过getSubscriberEvents()方法将此信息传至dispatcher(派遣器):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// example.com/src/Simplex/GoogleListener.php
namespace Simplex;
 
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
class GoogleListener implements EventSubscriberInterface
{
    // ...
 
    public static function getSubscribedEvents()
    {
        return array('response' => 'onResponse');
    }
}

这里是全新版本的ContentLengthListener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// example.com/src/Simplex/ContentLengthListener.php
namespace Simplex;
 
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
class ContentLengthListener implements EventSubscriberInterface
{
    // ...
 
    public static function getSubscribedEvents()
    {
        return array('response' => array('onResponse', -255));
    }
}

一个单一的订阅器即可托管你想要“随需针对任意数量事件进行监听”的全部Listener。

为了让你的框架真正灵活,应毫不犹豫地添加更多事件;为了让它更震撼人心,应添加更多的监听。再一次,本系列文章非是要创建一个“通用框架”,而是一个“量体裁衣”满足你需要的框架。可以在你认为舒适的地方停下来,另行选择时间再从那里重新介入。

本文,包括例程代码在内,采用的是 Creative Commons BY-SA 3.0 创作共用授权。

登录symfonychina 发表评论或留下问题(我们会尽量回复)