Feb 01, 2012
Matthew Weier O'Phinney,资深的PHP工程师。目前在Zend Technologies工作,是Zend Framework的Leader。下面的这篇文章翻译自作者的博客上的一篇文章:Using the ZF2 EventManager。
今年早些时候,我写了一篇关于Aspects, Intercepting Filters, Signal Slots, 和 Events的文章。在这篇文章中我对这三种比较相似的处理异步编程和交叉应用的策略做了比较。
我为了写那篇文章而做的研究后来被应用到了Zend Framework 2中的“SignalSlot”设计中,还有全新的“EventManager”的重构中工作中。现在在这篇文章将做进一步探讨。
目录
Assumptions(前提)
Terminology(术语)
Getting Started(开始)
EventCollection vs EventManager(EventCollection 和 EventManager)
Global Static Listeners(全局静态监听器)
Listener Aggregates(监听器聚合)
Introspecting Results(内省结果)
Short Circuiting Listener Execution(短路监听器的执行)
Keeping it in Order(按顺序执行)
Custom Event Objects(自定义事件对象)
Putting it Together: A Simple Caching Example(综合:一个简单的缓存例子)
Fin
Updates
前提
你需要先安装Zend Framework 2,下面任意一种方式都可以:
从开发快照下载(在写这篇文章时ZF2博客上有最新的下载链接)
从ZF2的GIT仓库克隆
术语
Event Manager,事件管理器,是一个对象,它为某一个或多个事件聚合监听器,并且事件也是由它来触发(trigger)的。
Listener,监听器,是当一个事件(Event)发生时会运行的回调。
Event,事件,就是一个动作。
开始
先从最简单的例子着手,这其中包括:
一个EventManager实例
一个或者多个Listener;一个或者多个Event
触发trigger()一个事件
那么,开始吧:
use Zend\EventManager\EventManager;
$events = new EventManager();
$events->attach('do', function($e) {
$event = $e->getName();
$params = $e->getParams();
printf(
'Handled event "%s", with parameters %s',
$event,
json_encode($params)
);
});
$params = array('foo' => 'bar', 'baz' => 'bat');
$events->trigger('do', null, $params);
上面的代码将会输出:
Handled event "do", with parameters {"foo":"bar","baz":"bat"}
很简单吧。
注意:在整篇文章中,我将使用匿名函数作为监听器。实际上,任何有效的PHP回调类型都可以作为监听器使用,比如PHP方法名,静态类的方法,对象的方法还有匿名函数。我在这里使用匿名函数的原因是书写和表达起来比较简单清晰。
你可能会注意到一个问题:上面代码中第二个参数“null”是什么?
一般情况下,你会在类的内部构建EventManager,这样它就可以在类成员方法中触发行为。trigger()方法的第二个参数就是”上下文“或者叫做”目标“。所以,在类的内部的话这个参数就应该是当前对象的实例。这样事件监听器就可以调用当前对象。这在实际开发中会很有用。
use Zend\EventManager\EventCollection, Zend\EventManager\EventManager;
class Example
{
protected $events;
public function setEventManager(EventCollection $events)
{
$this->events = $events;
}
public function events()
{
if (!$this->events) {
$this->setEventManager(new EventManager(
array(__CLASS__, get_called_class())
);
}
return $this->events;
}
public function do($foo, $baz)
{
$params = compact('foo', 'baz');
$this->events()->trigger(__FUNCTION__, $this, $params);
}
}
$example = new Example();
$example->events()->attach('do', function($e) {
$event = $e->getName();
$target = get_class($e->getTarget()); // "Example"
$params = $e->getParams();
printf(
'Handled event "%s" on target "%s", with parameters %s',
$event,
$target,
json_encode($params)
);
});
$example->do('bar', 'bat');
上面的这例子和第一个几乎一样。主要的不同就是我们现在使用第二个参数来传递上下文到监听器中去。监听器可以使用$e->getTarget()来获取上下文参数。
如果你认真阅读,那么你可能会有下面的疑问:
这个 EventCollection 是什么?
在实例化 EventManager 时为什么要这样传递参数呢?
为了回答这两个问题,我们就需要进一步向下深入了。
EventCollection 和 EventManager
在ZF2中,我们遵守着一个叫里氏代换原则的设计原则。通俗地解释就是任何可能被替换的类都必须有强接口定义,以便用户可以使用其他的实现方式而无需关心其内部运作。
所以,我们定义了一个接口,也就是 EventCollection。它描述了一个对象,这个对象可以为事件聚合监听器并能触发事件。EventManager是我们自己提供的一个实现。
全局静态监听器
EventManger的实现(implementation)需要遵守的一项就是提供StaticEventCollection接口。这个接口不仅可以将监听器绑定到事件上,还能绑定到有着特定上下文或目标的事件上。EventManager在通知监听器的同时还可以 StaticEventController 获取相关的监听器并向也他们发送通知。
具体是怎么运行的呢?
在应用层,你应先获取到StaticEventManager的一个实例,然后给它绑定事件。
use Zend\EventManager\StaticEventManager;
$events = StaticEventManager::getInstance();
$events->attach('Example', 'do', function($e) {
$event = $e->getName();
$target = get_class($e->getTarget()); // "Example"
$params = $e->getParams();
printf(
'Handled event "%s" on target "%s", with parameters %s',
$event,
$target,
json_encode($params)
);
});
你应该会注意到这和先前的例子几乎一样。唯一的不同是有个新的参数"Example"。这行代码相当于说:"监听目标'Example'的'do'事件,一有通知就执行回调方法"。
这也正是EventManager在执行构造函数的时候所做的事情。构造函数接受一个字符串作为上下文或者目标的名字,也可以是字符串组成的数组。如果是数组,则数组中包含的任何一个目标上所绑定的所有监听器均会被通知。直接绑定到EventManager的监听器会先于静态绑定的监听器执行。
现在,回到例子中。我们假设上面的静态监听器已经注册好了,Example类也定义好了。那么执行下面的代码:
$example = new Example();
$example->do('bar', 'bat');
不出意外的话,我们可以得到下面的输出:
Handled event "do" on target "Example", with parameters {"foo":"bar","baz":"bat"}
现在,这样扩展Example类:
class SubExample extends Example
{}
在构造EventManager时的一个需要注意的事情是,我们同时使用了CLASS和get_called_class()来定义监听。那么,当调用do()时,SubExample也会触发我们静态绑定的事件!反过来也意味着,如果需要的话,我们可以将事件绑定到指定的子类(如SubExample),而对其父类(如这里的Example)的监听器不起作用。
还有,作为上下文或目标的名字不一定非要是类名,可以是任何有意义的名字。举个例子,你可以定义很多类,这些类可以响应“log”或“cache”事件,其中任何一个事件发生时,这些监听器就可以接到通知。
任何时候,如果你不想某个类内的EventManager通知静态绑定的监听器,你可以简单地传送一个NULL给setStaticConnections()方法。
$events->setStaticConnections(null);
这样即可。同理,当你又想启用了,就传一个StaticEventManager的实例即可:
$events->setStaticConnections(StaticEventManager::getInstance());
监听器聚合
很多时候,你需要由一个类来监听多个事件,或者在一个事件上绑定一个或者多个监听器。这很简单,你可以直接实现HandlerAggregate接口。这个接口定义了两个方法,attach(EventCollection $events)和 detach(EventCollection $events)。一般情况下,你只需要将一个EventManager的实例传递给这两个方法,然后由你所具体实现的类来决定做什么。
下面是一个例子:
use Zend\EventManager\Event,
Zend\EventManager\EventCollection,
Zend\EventManager\HandlerAggregate,
Zend\Log\Logger;
class LogEvents implements HandlerAggregate
{
protected $handlers = array();
protected $log;
public function __construct(Logger $log)
{
$this->log = $log;
}
public function attach(EventCollection $events)
{
$this->handlers[] = $events->attach('do', array($this, 'log'));
$this->handlers[] = $events->attach('doSomethingElse', array($this, 'log'));
}
public function detach(EventCollection $events)
{
foreach ($this->handlers as $key => $handler) {
$events->detach($handler);
unset($this->handlers[$key];
}
$this->handlers = array();
}
public function log(Event $e)
{
$event = $e->getName();
$params = $e->getParams();
$log->info(sprintf('%s: %s', $event, json_encode($params)));
}
}
接下来就可以用下面的代码来进行绑定操作了:
$doLog = new LogEvents($logger);
$events->attachAggregate($doLog);
然后当某个事件被触发的时候,相应的监听器就可以被通知到。这样,你就可以拥有可记录状态的事件监听器了。
你应该会注意到detach()方法的实现。就像attach()一样,它接受一个EventManager作为参数,然后调用了已经聚合的每个事件句柄的detach方法。这不是错误,因为EventManager::attach()会返回一个对象,这个对象代表一个监听器,也就是我们在聚合器的attach()方法中所聚合的那些。
内省结果
有时,你想要知道监听器返回了什么。需要注意的是,一个事件可能有多个监听器;而无论有多少监听器,返回结果必须在接口上保持一致。
在EventManager的实现中,默认会返回一个ResponseCollection对象。这个类继承自PHP的SplStack,允许你遍历返回值,反向的(这一点你应该会感兴趣)。这个类也要实现下面两个方法:
first() 检索接收到的返回结果的第一项
last() 检索接收到的返回结果的最后一项
contains($values) 允许你在所有结果中检查是否有指定的那一项,然后返回一个boolean值。true代表找到,false相反。
一般来说,你不需要担心事件的返回值,就像触发事件的对象不需要去检查绑定了哪些监听器一样。但是,某些时候你确实需短路执行来获取你感兴趣的返回值。
短路监听器的执行
有时你可能需要将执行过程短路,比如你需要获取特定的运行结果,或者在某个监听器发现错误时进行处理,或者更快地获取一些数据。
在例子中,缓存机制就是一个需要添加EventManager的场景。你可以方法执行的初期触发一个事件,这个事件返回检索到的缓存,并触发下一个事件来分发缓存内容。
EventManager组件提供了两种处理方法,第一种是给trigger()传递一个回调作为最后一个参数;如果回调返回true,则中止执行过程。
下面是一个例子:
public function someExpensiveCall($criteria1, $criteria2)
{
$params = compact('criteria1', 'criteria2');
$results = $this->events()->trigger(__FUNCTION__, $this, $params, function ($r) {
return ($r instanceof SomeResultClass);
});
if ($results->stopped()) {
return $results->last();
}
// ... do some work ...
}
在上面的范例中,我们取得了满足要求的数据,所以中止了执行并直接返回这个数据。
第二种方法是,在监听器内中止执行:直接操作事件对象。这种情况下,监听器应调用事件的stopPropagation(true)方法,然后EventManager就可以返回结果并且不再通知其他相关监听器。
$events->attach('do', function ($e) {
$e->stopPropagation();
return new SomeResultClass();
});
但是这样又会产生歧义,因为你可能不能确定最后得到的结果是否满足期望。所以,我的建议是使用第一种方法或者另选其他的方法。
按顺序执行
某些场景下,你可能会很在意监听器的执行顺序。举个例子,你需要尽早执行日志记录,以便当短路发生时你已记录下来;或者实现一个缓存机制,你需要在命中缓存尽早返回数据,而将数据的缓存起来要放到数据生成的后面。
每一个EventManager::attach() 和 StaticEventManager::attach()还可以再接收一个参数,叫权重(priority)。默认权重为1,按照绑定的顺序执行。高权重的先于低权重的执行。
举个例子吧:
$priority = 100;
$events->attach('Example', 'do', function($e) {
$event = $e->getName();
$target = get_class($e->getTarget()); // "Example"
$params = $e->getParams();
printf(
'Handled event "%s" on target "%s", with parameters %s',
$event,
$target,
json_encode($params)
);
}, $priority);
上面的代码中设置了一个高权重,意味着这个监听器会优先执行。如果将$priority改为-100,则执行权重降低也就是后执行。
当你不能确切知道所有绑定的监听器时,只能靠自己的推断来进行权重的设置。我的建议是能不设置权重时就不设置。
自定义事件对象
希望有人能注意到一个事情:“事件对象(Event object)是在哪里,什么时候创建的呢?”。在上面的所有示例中,它是基于传递给trigger()的参数来创建的,这些参数有事件名,目标和配置参数。有时,你也许想控制这个对象。
举个例子,我们在开发ZF2的MVC层时需要为核心MVC组件添加事件的感知能力。于是出现了类似下面这种不太和谐的代码:
$routeMatch = $e->getParam('route-match', false);
if (!$routeMatch) {
// Oh noes! we cannot do our work! whatever shall we do?!?!?!
}
这样就有了几个问题。首先,字符串形式的参数名容易出错——比如在设置或者检索参数时的拼写错误,而且这种错误很难排查。其次,如何写文档也是个问题——我们如何描述传递给事件的参数呢?再次,一个副作用,就是我们无法使用IDE或编辑器的拼写提示了——它们无法识别字符串参数作为关键词。
类似地,我们发现在触发一个事件时,关于如何表达一个方法的计算结果我们老是在做hack。举个例子:
// 在方法中:
$params['__RESULT'] = $computedResult;
$events->trigger(__FUNCTION__ . '.post', $this, $params);
// 在监听器里:
$result = $e->getParam('__RESULT__');
if (!$result) {
// Oh noes! we cannot do our work! whatever shall we do?!?!?!
}
当然,这个键名有点特殊,但是确实有很多类似的问题存在。
所以,解决方法就是创建自定义事件。举个例子,我们在ZF2的MVC层创建了一个自定义的类MvcEvent。这个事件组成一个路由,路由匹配对象,请求和响应对象,返回一个结果对象。最后我们写出了下面的代码:
$response = $e->getResponse();
$result = $e->getResult();
if (is_string($result)) {
$content = $view->render('layout.phtml', array('content' => $result));
$response->setContent($content);
}
我们如何使用这个自定义的事件对象呢?很简单:trigger()方法接受的事件名,目标或配置参数都可以用对象来代替。
$event = new CustomEvent();
$event->setSomeKey($value);
// Injected with event name and target:
$events->trigger('foo', $this, $event);
// Injected with event name:
$event->setTarget($this);
$events->trigger('foo', $event);
// Fully encapsulates all necessary properties:
$event->setName('foo');
$event->setTarget($this);
$events->trigger($event);
// Passing a callback following the event object works for
// short-circuiting, too.
$results = $events->trigger('foo', $this, $event, $callback);
对于与域相关的对象系统,这确实是很强大的技术,值得去尝试。
综合:一个简单的缓存示例
在前一部分中,我指出短路是一种处理缓存机制的方法。现在让我们创建一个完整的例子:
首先,让我们定义一个方法并在这个方法中使用缓存。你应该注意到,在大部分例子中我试用了__FUNCTION__作为事件名;这是很好的应用实践,可以简化触发事件的创建,也有利于保证事件名的唯一性(他们的上下问通常是触发器所在的类)。当然,在缓存这种场景中,可以统一要触发的事件名。所以,我建议使用语义化的事件名:do.pre,do.post,do.error等等。在这个例子中我也会这么做。
另外,你将会注意到我传递给事件的$params参数通常是方法所接收的参数列表。这是因为这些一般不在对象中保存。另外,还要保证监听器和调用方法的上下文一致。但这引发了一个有趣的问题:我们如何命名方法的返回结果?我统一用__RESULT__,以双下划线开头结尾的变量一般是系统保留的。如果你有更好的建议,我很愿意听!
下面是这个方法的代码:
public function someExpensiveCall($criteria1, $criteria2)
{
$params = compact('criteria1', 'criteria2');
$results = $this->events()->trigger(__FUNCTION__ . '.pre', $this, $params, function ($r) {
return ($r instanceof SomeResultClass);
});
if ($results->stopped()) {
return $results->last();
}
// ... do some work ...
$params['__RESULT__'] = $calculatedResult;
$this->events()->trigger(__FUNCTION__ . '.post', $this, $params);
return $calculatedResult;
}
现在来添加一些缓存监听器。我们需要给每一个someExpensiveCall.pre和someExpensiveCall.post绑定监听器。在之前的例子中,如果命中缓存,则返回缓存数据然后继续运行。缓存数据的运行放在后面。
先假定$cache已经定义好了,并且遵循Zend_Cache范式。我们需要在缓存命中之后尽快将其返回,而保存缓存数据的操作要尽可能放到后面(因为可能会还有其他监听器存在)。所以,我们设置someExpensiveCall.pre的权重为100,而someExpensiveCall.post的是-100.
$events->attach('someExpensiveCall.pre', function($e) use ($cache) {
$params = $e->getParams();
$key = md5(json_encode($params));
$hit = $cache->load($key);
return $hit;
}, 100);
$events->attach('someExpensiveCall.post', function($e) use ($cache) {
$params = $e->getParams();
$result = $params['__RESULT__'];
unset($params['__RESULT__']);
$key = md5(json_encode($params));
$cache->save($result, $key);
}, -100);
注意,你也可以使用HandlerAggregate,这样可以将$cache作为一个属性使用而不是传递给匿名函数。
当然,我们本来可以只简单地给对象添加缓存功能,但是现在的机制可以允许将同样的处理机制绑定到多个事件中,或者绑定多个监听器到一个事件(比如参数验证器,日志和缓存管理)。所以,如果你按照事件驱动的思路去设计一个对象,你可以获得更强大的灵活性和扩展性而不需要开发者真的去继承一个类——只需要加几个监听器即可。
补充
EventManager是Zend Framework中一个强大的工具,并已经在新的MVC原型中试用了。这让我们写出更好的构造器,而这在1.x版本中却不容易。举个例子,通过从MVC中将功能点正确地相互分隔,我可以轻易实现ViewRenderer的切换机制。我预计我们会更频繁地在ZF2中使用这种技术。
目前还有很多不完善的地方,比如短路的样板代码过于累赘。我们可能还要添加一些东西,比如全局事件——但是大致的基础代码现在已经固定和成熟了。尝试一下吧,看看你可以做出什么!
更新
2011-10-06:移除了有关triggerUntil()的内容,这个机制已经被整合到trigger()中了。添加了“自定义事件类”这一部分。