上个月,在PHPAdvent期间,gwoo写了一篇关于面向方面设计或更广为人知的面向方面编程(AOP)的有趣帖子。这篇文章让我开始思考,并重新审视我对AOP、拦截过滤器和信号槽的了解——特别是,我看到了它们的用例、当前PHP产品的状态以及未来可能在哪里。
但首先,一些背景知识可能是有序的,因为这是一篇术语繁多的文章。
面向切面编程
我是在2006年通过DmitrySheiko于2006年4月发表的php|architect文章首次接触到AOP的。该文章详细介绍了在您可能希望挂钩功能的方法中的不同位置添加调用——例如,记录、缓存等.在此基础上展开,我考虑了其他可能性:操纵传入参数、验证、ACL检查、实施直写缓存策略等等。典型的、天真的实现会导致大量样板代码:
interface Listener { public function notify($signal, $argv = null); } class Foo { protected $listeners; public function attach($signal, Listener $listener) { $this->listeners[$signal][] = $listener; } public function doSomething($arg1, $arg2) { foreach ($this->listeners as $listener) { $listener->notify('preDoSomething', func_get_args()); } // do some work foreach ($this->listeners as $listener) { $listener->notify('postDoSomething', $result); } } }
这篇文章没有详细介绍如何使过滤器短路或处理它们的返回值,并且方面处理本身也没有完全详细说明。因此,代码开始增加,尤其是在许多类和/或方法实现该功能的情况下。
拦截过滤器
与AOP类似的概念是拦截过滤器。与AOP一样,其思想是将日志记录、调试等横切关注点与组件公开的实际逻辑分开。不同之处在于,典型的拦截过滤器是独立于语言的,在给定的框架中有一个标准的实现,并且可以重复使用。gwoo在他的帖子中使用的方法属于此类。
Lithium,一个PHP5.3框架和gwoo文章的参考,有一个非常有趣的方法。他们建议代码主体通过闭包简单地成为过滤器之一,而不是在代码主体内显式调用过滤器:
Dispatcher::applyFilter('run', function ($self, $params, $chain) { // do something... return $chain->next($self, $params, $chain); });
在Lithium中,每个过滤器负责调用下一个过滤器(每个过滤器接收链作为其第三个也是最后一个参数);一旦不调用next()
,执行就会停止,并返回结果(或者至少我是这样阅读源代码的)。您可以在每个过滤器中要执行的代码之前或之后调用链;放置将决定它是前置过滤器还是后置过滤器。该方法解决了我之前概述的一些问题,即方法的标准化和短路执行的能力。
上面的例子定义了一个过滤器,它会在Dispatcher
类的run()
方法被执行时运行。$self
通常是对象实例,$params
是传递给方法的参数数组,$chain
如上所述。该方法本身将执行任何过滤器——通常是这样的:
use lithium/core/Object as BaseObject; class Foo extends BaseObject { public function doSomething($with, $these, $args) { $params = compact('with', 'these', 'args'); return $this->_filter(__METHOD__, $params, function ($self, $params) { // do the actual work here return $result; }); } }
(_filter()
方法在lithium\core\Object
中定义,基本上将本地静态过滤器链传递给Lithium的Filter
用于执行的类。applyFilter()
来自上一个示例,在命名方法下向链中静态添加一个回调。)
这个解决方案很优雅——但我看到了一些限制:
-
首先,我不是特别喜欢通过单个类上的静态方法实现的过滤功能;它引入了一个硬编码的、隐藏的依赖关系。这意味着如果不扩展使用过滤器的类,您就无法提供备用过滤功能,如果您希望提供兼容的API(例如,引入理解优先级的实现),也不能在不扩展基本过滤器实现的情况下提供。p>
此外,在Lithium中实现过滤的最简单方法是扩展
lithium\core\Object
类——我在文档的其他地方找不到展示如何编写的示例过滤器
在您自己的对象中实现。因此,现在编写过滤器的最简单方法是通过继承,在我看来,这似乎与过滤背后的整个基本原理适得其反。 -
其次,制作方法调用方法的主体闭包使得创建非公共辅助方法变得困难。在过滤器内部,您不再处于对象的范围内,失去了将对象的各种元数据和功能联系在一起的语义。(Lithium文档提供了如何完成此操作的说明,但它们需要额外的工作,并且需要对引用在PHP中的工作方式有敏锐的理解。)
-
第三,有时访问权限很有用所有过滤器的返回结果(不仅仅是最后执行的);您可能希望以某种方式聚合它们,或者根据各种返回来分支逻辑。
-
第四,有时在主代码中有多个调用点很有用。例如,对于许多缓存策略,您会首先检查是否有缓存命中,如果找到则立即返回;否则,您将执行代码,并在返回之前缓存结果。这在Lithium中可能是可能的,结构如下:
Filters::add('SomeClass::doSomething', function ($method, $self, $params) { if (null !== ($content = cache_hit($params))) { return $content; } $content = Filters::next($method, $self, $params); return $content; });
但是,如果您有多个这样的过滤器,那么顺序就变得至关重要,这会引入新的
另一个例子是外观方法,您可能希望在每个方法调用之前和之后引入过滤器:
public function doSomeWorkflow($message) { $this->somePrivateMethod($message); $this->nextPrivateMethod($message); $this->lastPrivateMethod($message); }
(我已经能听到Nate说“制作所有过滤器!”或“过滤每个方法!”——但这就是简单示例的问题——它们不能总是表达用例的细微差别。)
-
第五,能够附加不知道链的回调很有用。例如,您可能已经编写了在独立情况下运行良好的代码——例如,一个记录器——而您只是想将它添加到链中。在Lithium范例中,您需要将调用引入,而不是简单地使用现有方法:
// This: SomeClass::applyFilter('doSomething', function ($self, $params, $chain) use ($log) { $log->info($params['message']; $chain->next($self, $params, $chain); }); // VS: SomeClass::signals()->connect('doSomething', $log, 'info');
与此相关,我个人不喜欢聚合将参数过滤到一个关联数组中。我不喜欢必须测试参数是否存在,而更愿意PHP告诉我是否缺少必需的参数或是否有任何失败的类型提示。也就是说,这样做可以在过滤时提供一致的API。
总而言之,Lithium提供的方法非常好;它只是不完全符合我的口味或用例。
信号槽
有趣的是,我需要的功能与Lithium提供的功能相差无几——事实上,我认为Lithium的拦截过滤器实际上可能更类似于另一种模式,即信号槽。
使用信号槽,您的代码会发出信号(Lithium就是这样做的——它会发出被调用方法的名称);然后执行连接到信号的任何处理程序或插槽(锂中的过滤器)。
因此,您通常有某种信号“管理器”对象(Lithium中的Filters
类)聚合信号和附加槽;然后将该管理器组合到发射信号的对象中。对于那些熟悉JavaScript或其他事件驱动语言中的事件的人来说,这听起来应该很熟悉。
这种方法看起来像这样:
class Foo { protected $signals; public function signals(SignalSlot $signals = null) { if (null === $signals) { // No argument? make sure we have a signal manager if (null === $this->signals) { $this->signals = new Signals(); // SignalSlot implementation } } else { // Compose in an instance of a signal manager $this->signals = $signals; } return $this->signals; } public function doSomething($with, $these, $args) { $this->signals()->emit('doSomething.pre', $this, $with, $these, $args); // do some work $this->signals()->emit('doSomething.during', $this, $with, $these, $args); // do some more work // This time, pass the result $this->signals()->emit('doSomething.post', $this, $result, $with, $these, $args); return $result; } } $f = new Foo(); $f->signals()->connect('doSomething.pre', $log, 'info'); $f->signals()->connect('doSomething.during', $validator, 'isValid'); $f->signals()->connect('doSomething.post', $indexer, 'index');
基本上,SignalSlot
提供了一个对象,其中聚合了信号及其附加槽。这允许有多个信号的单个管理器(这类似于Lithium的Filters
类的工作方式),同时还提供了一种从单个过程发出多个信号的方法。此外,由于它只是一个对象,您可以将它组合成可以发出信号的类,而无需继承。
这是ZF2SignalSlot实现的基本方法,以及在Symfony2的EventDispatcher和ZetaComponents的SignalSlot组件中找到的方法。
Symfony2的事件调度器和ZF2的SignalSlot
组件都内置了短路功能,Symfony通过notifyUntil()
方法,而ZF2通过emitUntil
方法。对于ZF2,每次发出信号时,管理器都会返回一个ResponseCollection
,其中包含所有槽响应的集合。如果给定槽返回根据给定条件验证的响应,则调用emitUntil()
将短路remainingslots的执行;此时,集合被标记为“已停止”,您可以拉取“最后”响应并返回它:
$responses = $this->signals()->emitUntil(function($response) { return ($response instanceof SpecificResultType); }, 'doSomething.pre', $this, $with, $these, $args); if ($responses->stopped()) { return $responses->last(); }
这在发出信号的方法中引入了额外的代码——但符合没有给定槽需要知道链的标准。
SignalSlot方法实际上支持类似于Lithium中所示的范例。例如,我可以让我的方法体成为一个插槽:
class Foo { protected $handlers = array(); // ... skip signals composition ... public function doSomething($with, $these, $args) { $params = compact('with', 'these', 'args'); // connect() returns a signal handler (slot); store it so that we only // ever attach it once... if (isset($this->handlers[__FUNCTION__])) { $this->handlers[__FUNCTION__] = $this->signals()->connect(__FUNCTION__, function($self, $params) { // do the work here! }); } // Emit the signal, and return the last result return $this->signals()->emit(__FUNCTION__, $this, $params)->last(); } }
疑虑
使用信号槽和拦截过滤器并非没有问题,也不是任何给定的实现都是完美的。
- ZetaComponents在处理信号槽方面做得非常出色。但是,您不能短路执行,也不能内省返回值。它确实提供了ZF2和Symfony2(目前)都没有提供的两个功能:静态将插槽连接到信号的能力,允许您在没有现有实例的情况下进行连接,甚至不用关心什么对象可能会发出信号;以及向插槽添加优先级的能力,这允许您更改执行顺序。
- Lithium在提供良好标准方面做得很好(信号是方法名称;所有处理程序的参数都是可预测的),但在一些灵活性的代价(静态实现没有用于替代实现的接口;无法在不柯里化的情况下重用具有不同签名的现有方法和函数)。
- Symfony2在回调中提供了短路和灵活性,但要求您创建传递给事件调度程序的事件对象,使使用稍微更冗长,并且没有提供信号命名的标准化。
- ZF2的
SignalSlots
提供与Symfony2类似的优点(和缺点)’实现,提供信号管理器响应的标准化,允许注册类以信号处理程序自注册,但缺乏静态布线功能或优先级。
在更抽象的层面上,信号槽和拦截过滤器会导致学习和掌握使用它们的代码的困难:
信号是如何命名的?
您如何记录插槽/过滤器可用的参数?
- 使用IDE的人如何发现可用信号?以及预期的参数?
布线发生在哪里?
- 例如,如果任何布线是自动化的,这可能会导致调试更加困难。
- 如果手动完成,何时何地完成?
如果槽没有接收到它需要的参数,或者无法处理它接收到的参数,会发生什么?
简而言之,虽然它们解决了许多问题,但实施也引入了新的问题——尽管根据我的经验,任何扩展系统都是如此。
结论
我个人非常喜欢拦截过滤器和信号槽。我认为它们可以通过提供一种无需类扩展即可引入横切关注点的标准方法,从而使代码更易于扩展。他们还可以通过引入函数式编程范式使代码更具表现力——有时会以可读性为代价。
如果您之前没有研究过这些概念或组件,我强烈建议您这样做;我认为它们在下一代PHP框架中发挥着基础性作用。
注意事项
我不是专家,也不精通此处列出的所有框架,因此,某些信息可能不正确或不完整。我是ZF2当前SignalSlot实现的作者,并且仍在致力于改进它。
更新
- 2011-01-1011:35Z-05:00CalEvans找到了我引用的原始php|architect文章,我根据重读修改了一些评估它,以及与问题相关的内容。