面向方面编程(AOP)对于PHP来说是一个新的概念。现在PHP对于 AOP 并没有官方支持,但有很多扩展和库实现了这个特性。本课中,我们将使用 Go! PHP library 来学习 PHP 如何进行 AOP 开发,或者在需要的时候,可以回来看一眼。
<p>Aspect-Oriented programming is like a new gadget for geeks.</p>
面向方面编程的思想在二十世纪90年代中期,于施乐帕洛阿尔托研究中心(PARC)成型。同很多有趣的新技术一样,由于缺少明确的定义,起初 AOP 备受争议。因此相关小组决定将未完成的想法公之于众,以便接受广大社区的反馈。关键问题在于“关注点分离(Separation of Concerns)”的概念。AOP 是一种可以分离关注的可行系方案。
AOP 于90年代末趋于成熟,标识为施乐 AspectJ 的发布,IBM 紧随其后,于2001年发布了 Hyper/J。现在,AOP是一种对于常用编程语言来说都是一种成熟的技术。
AOP 的核心就是“方面”,但在我们定义「方面『aspect』」之前,我们需要先讨论两个术语;「切点『 point-cut』 」和「通知『advise』」。切点代表我们代码中的一个时间点,特指运行我们代码的某个时间。在切点运行代码被称为通知,结合一个活多个切点及通知的即为方面。
通常,每个类都会有一个核心的行为或关注点,但有时,类可能存在次要的行为。例如,类可能会调用一个日志记录器或是通知一个观察员。因为类中的这些功能是次要的,其行为通常都是相同的。这种行为被称为“交叉关注点”;使用 AOP 可以避免。
Chris Peters 已经讨论过在PHP中实现 AOP 的Flow 框架。 Lithium 框架也提供了对AOP的实现。
另一个框架采用了不同的方法,创建了一个 C/C++ 编写的PHP扩展,在PHP解释器的层级上宣示着它的魔力。名为AOP PHP Extension,我会在后续文章中讨论它。
但正如我之前所言,本文将检阅Go! AOP-PHP 库。
Go! 库并未扩展;它完全由PHP编写,并为PHP5.4或更高版本使用。作为一个纯PHP库,它部署简易,即使是在不允许编译安装你自己的PHP扩展的受限及共享主机环境,也可以轻易安装。
Composer 是安装 PHP 包的首选方法。如果你没有使用过 Composer,你可以在Go! GitHub repository下载。
首先,将下面几行加入你的 composer.json 文件。
1 2 3 4 5 |
{
"require" : {
"lisachenko/go-aop-php" : "*"
}
}
|
之后,使用 Composer 安装 go-aop-php。在终端中运行下面命令:
1 2 |
$ cd /your/project/folder
$ php composer.phar update lisachenko /go-aop-php
|
Composer 将会在之后数秒中内安装引用的包以及需求。如果成功,你将看到类似下面的输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
Loading composer repositories with package information
Updating dependencies
- Installing doctrine /common (2.3.0)
Downloading: 100%
- Installing andrewsville /php-token-reflection (1.3.1)
Downloading: 100%
- Installing lisachenko /go-aop-php (0.1.1)
Downloading: 100%
Writing lock file
Generating autoload files
|
在安装完成后,你可以在你的代码目录中发现名为 vendor 的文件夹。Go! 库及其需求就安装在这。
1 2 3 4 5 6 7 8 9 10 11 |
$ ls -l . /vendor
total 20
drwxr-xr-x 3 csaba csaba 4096 Feb 2 12:16 andrewsville
-rw-r--r-- 1 csaba csaba 182 Feb 2 12:18 autoload.php
drwxr-xr-x 2 csaba csaba 4096 Feb 2 12:16 composer
drwxr-xr-x 3 csaba csaba 4096 Feb 2 12:16 doctrine
drwxr-xr-x 3 csaba csaba 4096 Feb 2 12:16 lisachenko
$ ls -l . /vendor/lisachenko/
total 4
drwxr-xr-x 5 csaba csaba 4096 Feb 2 12:16 go-aop-php
|
我们需要创建一个调用,介于路由/应用程序的入口点。自动装弹机的然后自动包括类。开始吧!引用作为一个切面内核。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
use Go\Core\AspectKernel;
use Go\Core\AspectContainer;
class ApplicationAspectKernel extends AspectKernel {
protected function configureAop(AspectContainer $container ) {
}
protected function getApplicationLoaderPath() {
}
}
|
现在,AOP是一种在通用编程语言中相当成熟的技术。
例如,我创建了一个目录,调用应用程序,然后添加一个类文件: ApplicationAspectKernel.php 。
我们开始切面扩展!AcpectKernel 类提供了基础的方法用于完切面内核的工作。有两个方法,我们必须知道:configureAop()用于注册页面特征,和 getApplicationLoaderPath() 返回自动加载程序的全路径。
现在,一个简单的建立一个空的 autoload.php 文件在你的程序目录。和改变 getApplicationLoaderPath() 方法。如下:
1 2 3 4 5 6 7 8 9 10 |
// [...]
class ApplicationAspectKernel extends AspectKernel {
// [...]
protected function getApplicationLoaderPath() {
return __DIR__ . DIRECTORY_SEPARATOR . 'autoload.php' ;
}
}
|
别担心 autoload.php 就是这样。我们将会填写被省略的片段。
当我们第一次安装 Go语言!和达到这一点我的过程中,我觉得需要运行一些代码。所以开始构建一个小应用程序。
我们的「方面」为一个简单的日志记录器,但在继续我们应用的主要部分之前,有些代码需要看一下。
我们的小应用是一个电子经纪人,能够购买和出售股票。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class Broker {
private $name ;
private $id ;
function __construct( $name , $id ) {
$this ->name = $name ;
$this ->id = $id ;
}
function buy( $symbol , $volume , $price ) {
return $volume * $price ;
}
function sell( $symbol , $volume , $price ) {
return $volume * $price ;
}
}
|
这些代码非常简单,Broker 类拥有两个私有字段,储存经纪人的名称和 ID。
这个类同时提供了两个方法,buy() 和 sell(),分别用于收购和出售股票。每个方法接受三个参数:股票标识、股票数量、每股价格。sell() 方法出售股票,并计算总收益。相应的,buy()方法购买股票并计算总支出。
通过PHPUnit 测试程序,我们可以很容易的考验我们经纪人。在应用目录内创建一个子目录,名为 Test,并在其中添加 BrokerTest.php 文件。并添加下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
require_once '../Broker.php' ;
class BrokerTest extends PHPUnit_Framework_TestCase {
function testBrokerCanBuyShares() {
$broker = new Broker( 'John' , '1' );
$this ->assertEquals(500, $broker ->buy( 'GOOGL' , 100, 5));
}
function testBrokerCanSellShares() {
$broker = new Broker( 'John' , '1' );
$this ->assertEquals(500, $broker ->sell( 'YAHOO' , 50, 10));
}
}
|
这个检验程序检查经纪人方法的返回值。我们可以运行这个检查程序检验我们的代码,至少是不是语法正确。
让我们创建一个自动加载器,在应用需要的时候加载类。这是一个简单的加载器,基于PSR-0 autoloader.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
ini_set ( 'display_errors' , true);
spl_autoload_register( function ( $originalClassName ) {
$className = ltrim( $originalClassName , '\\' );
$fileName = '' ;
$namespace = '' ;
if ( $lastNsPos = strripos ( $className , '\\' )) {
$namespace = substr ( $className , 0, $lastNsPos );
$className = substr ( $className , $lastNsPos + 1);
$fileName = str_replace ( '\\' , DIRECTORY_SEPARATOR, $namespace ) . DIRECTORY_SEPARATOR;
}
$fileName .= str_replace ( '_' , DIRECTORY_SEPARATOR, $className ) . '.php' ;
$resolvedFileName = stream_resolve_include_path( $fileName );
if ( $resolvedFileName ) {
require_once $resolvedFileName ;
}
return (bool) $resolvedFileName ;
});
|
这就是我们 autoload.php 文件中的全部内容。现在,变更 BrokerTest.php, 改引用Broker.php 为引用自动加载器 。
1 2 3 4 5 |
require_once '../autoload.php' ;
class BrokerTest extends PHPUnit_Framework_TestCase {
// [...]
}
|
运行 BrokerTest,验证代码运行情况。
我们最后的一件事是配置Go!.为此,我们需要连接所有的组件让们能和谐工作。首先,创建一个php文件AspectKernelLoader.php,其代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
include __DIR__ . '/../vendor/lisachenko/go-aop-php/src/Go/Core/AspectKernel.php' ;
include 'ApplicationAspectKernel.php' ;
ApplicationAspectKernel::getInstance()->init( array (
'autoload' => array (
'Go' => realpath (__DIR__ . '/../vendor/lisachenko/go-aop-php/src/' ),
'TokenReflection' => realpath (__DIR__ . '/../vendor/andrewsville/php-token-reflection/' ),
'Doctrine\\Common' => realpath (__DIR__ . '/../vendor/doctrine/common/lib/' )
),
'appDir' => __DIR__ . '/../Application' ,
'cacheDir' => null,
'includePaths' => array (),
'debug' => true
));
|
<p>我们需要连接所有的组件让们能和谐工作!</p>
这个文件位于前端控制器和自动加载器之间。他使用AOP框架初始化并在需要时调用autoload.php
第一行,我明确地载入AspectKernel.php和ApplicationAspectKernel.php,因为,要记住,在这个点我们还没有自动加载器。
接下来的代码段,我们调用ApplicationAspectKernel对象init()方法,并且给他传递了一个数列参数:
为了最后实现各个不同部分的连接,找出你工程中autoload.php自动加载所有的引用并且用AspectKernelLoader.php替换他们。在我们简单的例子中,仅仅test文件需要修改:
1 2 3 4 5 6 7 |
require_once '../AspectKernelLoader.php' ;
class BrokerTest extends PHPUnit_Framework_TestCase {
// [...]
}
|
对大一点的工程,你会发现使用bootstrap.php作为单元测试但是非常有用;用require_once()做为autoload.php,或者我们的AspectKernelLoader.php应该在那载入。
创建BrokerAspect.php文件,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
use Go\Aop\Aspect;
use Go\Aop\Intercept\FieldAccess;
use Go\Aop\Intercept\MethodInvocation;
use Go\Lang\Annotation\After;
use Go\Lang\Annotation\Before;
use Go\Lang\Annotation\Around;
use Go\Lang\Annotation\Pointcut;
use Go\Lang\Annotation\DeclareParents;
class BrokerAspect implements Aspect {
/**
* @param MethodInvocation $invocation Invocation
* @Before("execution(public Broker->*(*))") // This is our PointCut
*/
public function beforeMethodExecution(MethodInvocation $invocation ) {
echo "Entering method " . $invocation ->getMethod()->getName() . "()\n" ;
}
}
|
我们在程序开始指定一些有对AOP框架有用的语句。接着,我们创建了自己的方面类叫BrokerAspect,用它实现Aspect。接着,我们指定了我们aspect的匹配逻辑。
1 |
* @Before( "execution(public Broker->*(*))" )
|
1 |
[operation - execution/access]([method/attribute type - public / protected ] [ class ]->[method/attribute]([params])
|
请注意匹配机制不可否认有点笨拙。你在规则的每一部分仅可以使用一个星号‘*‘。例如public Broker->匹配一个叫做Broker的类;public Bro*->匹配以Bro开头的任何类;public *ker->匹配任何ker结尾的类。
<p>public *rok*->将匹配不到任何东西;你不能在同一个匹配中使用超过一个的星号。</p>
紧接着匹配程序的函数会在有时间发生时调用。在本例中的方法将会在每一个Broker公共方法调用之前执行。其参数$invocation(类型为MethodInvocation)子自动传递到我们的方法的。这个对象提供了多种方式获取调用方法的信息。在第一个例子中,我们使用他获取了方法的名字,并且输出。
仅仅定义一个切面是不够的;我们需要把它注册到AOP架构里。否则,它不会生效。编辑ApplicationAspectKernel.php同时在容器上的configureAop()方法里调用registerAspect():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
use Go\Core\AspectKernel;
use Go\Core\AspectContainer;
class ApplicationAspectKernel extends AspectKernel
{
protected function getApplicationLoaderPath()
{
return __DIR__ . DIRECTORY_SEPARATOR . 'autoload.php' ;
}
protected function configureAop(AspectContainer $container )
{
$container ->registerAspect( new BrokerAspect());
}
}
|
运行测试和检查输出。你会看到类似下面的东西:
1 2 3 4 5 6 7 8 9 |
PHPUnit 3.6.11 by Sebastian Bergmann.
.Entering method __construct()
Entering method buy()
.Entering method __construct()
Entering method sell()
Time: 0 seconds, Memory: 5.50Mb
OK (2 tests, 2 assertions)
|
就这样我们已设法让代码无论什么时候发生在broker上时都会执行。
让我们加入另外的方法到BrokerAspect。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// [...]
class BrokerAspect implements Aspect {
// [...]
/**
* @param MethodInvocation $invocation Invocation
* @After("execution(public Broker->*(*))")
*/
public function afterMethodExecution(MethodInvocation $invocation ) {
echo "Finished executing method " . $invocation ->getMethod()->getName() . "()\n" ;
echo "with parameters: " . implode( ', ' , $invocation ->getArguments()) . ".\n\n" ;
}
}
|
这个方法在一个公共方法执行后运行(注意@After匹配器)。染污我们加入另外一行来输出用来调用方法的参数。我们的测试现在输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
PHPUnit 3.6.11 by Sebastian Bergmann.
.Entering method __construct()
Finished executing method __construct()
with parameters: John, 1.
Entering method buy()
Finished executing method buy()
with parameters: GOOGL, 100, 5.
.Entering method __construct()
Finished executing method __construct()
with parameters: John, 1.
Entering method sell()
Finished executing method sell()
with parameters: YAHOO, 50, 10.
Time: 0 seconds, Memory: 5.50Mb
OK (2 tests, 2 assertions)
|
目前为止,我们学习了在一个方法执行的之前和之后,怎样运行额外的代码。当这个漂亮的实现后,如果我们无法看到方法返回了什么的话,它还不是非常有用。我们给aspect增加另一个方法,修改现有的代码:
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 |
//[...]
class BrokerAspect implements Aspect {
/**
* @param MethodInvocation $invocation Invocation
* @Before("execution(public Broker->*(*))")
*/
public function beforeMethodExecution(MethodInvocation $invocation ) {
echo "Entering method " . $invocation ->getMethod()->getName() . "()\n" ;
echo "with parameters: " . implode( ', ' , $invocation ->getArguments()) . ".\n" ;
}
/**
* @param MethodInvocation $invocation Invocation
* @After("execution(public Broker->*(*))")
*/
public function afterMethodExecution(MethodInvocation $invocation ) {
echo "Finished executing method " . $invocation ->getMethod()->getName() . "()\n\n" ;
}
/**
* @param MethodInvocation $invocation Invocation
* @Around("execution(public Broker->*(*))")
*/
public function aroundMethodExecution(MethodInvocation $invocation ) {
$returned = $invocation ->proceed();
echo "method returned: " . $returned . "\n" ;
return $returned ;
}
}
|
<p>仅仅定义一个aspect是不够的;我们需要将它注册到AOP基础设施。</p>
这个新的代码把参数信息移动到@Before方法。我们也增加了另一个特殊的@Around匹配器方法。这很整洁,因为原始的匹配方法调用被包裹于aroundMethodExecution()函数之内,有效的限制了原始的调用。在advise里,我们要调用$invocation->proceed(),以便执行原始的调用。如果你不这么做,原始的调用将不会发生。
这种包装也允许我们操作返回值。advise返回的就是原始调用返回的。在我们的案例中,我们没有修改任何东西,输出应该看起来像这样:
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 |
PHPUnit 3.6.11 by Sebastian Bergmann.
.Entering method __construct()
with parameters: John, 1.
method returned:
Finished executing method __construct()
Entering method buy()
with parameters: GOOGL, 100, 5.
method returned: 500
Finished executing method buy()
.Entering method __construct()
with parameters: John, 1.
method returned:
Finished executing method __construct()
Entering method sell()
with parameters: YAHOO, 50, 10.
method returned: 500
Finished executing method sell()
Time: 0 seconds, Memory: 5.75Mb
OK (2 tests, 2 assertions)
|
我们增加一点变化,赋以一个具体的broker一个discount。返回到测试类,写如下的测试:
1 2 3 4 5 6 7 8 9 10 11 12 |
require_once '../AspectKernelLoader.php' ;
class BrokerTest extends PHPUnit_Framework_TestCase {
// [...]
function testBrokerWithId2WillHaveADiscountOnBuyingShares() {
$broker = new Broker( 'Finch' , '2' );
$this ->assertEquals(80, $broker ->buy( 'MS' , 10, 10));
}
}
|
这会失败:
1 2 3 4 5 6 7 8 9 10 11 12 |
Time: 0 seconds, Memory: 6.00Mb
There was 1 failure:
1) BrokerTest::testBrokerWithId2WillHaveADiscountOnBuyingShares
Failed asserting that 100 matches expected 80.
/home/csaba/Personal/Programming/NetTuts/Aspect Oriented Programming in PHP /Source/Application/Test/BrokerTest .php:19
/usr/bin/phpunit :46
FAILURES!
Tests: 3, Assertions: 3, Failures: 1.
|
下一步,我们需要修改broker以便提供它的ID。只要像下面所示实现agetId()方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class Broker {
private $name ;
private $id ;
function __construct( $name , $id ) {
$this ->name = $name ;
$this ->id = $id ;
}
function getId() {
return $this ->id;
}
// [...]
}
|
现在,修改aspect以调整具有ID值为2的broker的购买价格。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// [...]
class BrokerAspect implements Aspect {
// [...]
/**
* @param MethodInvocation $invocation Invocation
* @Around("execution(public Broker->buy(*))")
*/
public function aroundMethodExecution(MethodInvocation $invocation ) {
$returned = $invocation ->proceed();
$broker = $invocation ->getThis();
if ( $broker ->getId() == 2) return $returned * 0.80;
return $returned ;
}
}
|
无需增加新的方法,只要修改aroundMethodExecution()函数。现在它正好匹配方法,称作‘buy‘,并触发了$invocation->getThis()。这有效的返回了原始的Broker对象,以便我们可以执行它的代码。于是我们做到了!我们向broker要它的ID,如果ID等于2的话就提供一个折扣。测试现在通过了。
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 |
PHPUnit 3.6.11 by Sebastian Bergmann.
.Entering method __construct()
with parameters: John, 1.
Finished executing method __construct()
Entering method buy()
with parameters: GOOGL, 100, 5.
Entering method getId()
with parameters: .
Finished executing method getId()
Finished executing method buy()
.Entering method __construct()
with parameters: John, 1.
Finished executing method __construct()
Entering method sell()
with parameters: YAHOO, 50, 10.
Finished executing method sell()
.Entering method __construct()
with parameters: Finch, 2.
Finished executing method __construct()
Entering method buy()
with parameters: MS, 10, 10.
Entering method getId()
with parameters: .
Finished executing method getId()
Finished executing method buy()
Time: 0 seconds, Memory: 5.75Mb
OK (3 tests, 3 assertions)
|
我们现在可以在一个方法的开始和执行之后、绕过时,执行附加程序。但当方法抛出异常时又如何呢?
添加一个测试方法来购买大量微软的股票:
1 2 3 4 |
function testBuyTooMuch() {
$broker = new Broker( 'Finch' , '2' );
$broker ->buy( 'MS' , 10000, 8);
}
|
现在,创建一个异常类。我们需要它是因为内建的异常类不能被 Go!AOP 或 PHPUnit 捕捉.
1 2 3 4 5 6 7 |
class SpentTooMuchException extends Exception {
public function __construct( $message ) {
parent::__construct( $message );
}
}
|
修改经纪人类,对大值抛出异常:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Broker {
// [...]
function buy( $symbol , $volume , $price ) {
$value = $volume * $price ;
if ( $value > 1000)
throw new SpentTooMuchException(sprintf( 'You are not allowed to spend that much (%s)' , $value ));
return $value ;
}
// [...]
}
|
运行测试,确保它们产生失败消息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
Time: 0 seconds, Memory: 6.00Mb
There was 1 error:
1) BrokerTest::testBuyTooMuch
Exception: You are not allowed to spend that much (80000)
/home/csaba/Personal/Programming/NetTuts/Aspect Oriented Programming in PHP /Source/Application/Broker .php:20
// [...]
/home/csaba/Personal/Programming/NetTuts/Aspect Oriented Programming in PHP /Source/Application/Broker .php:47
/home/csaba/Personal/Programming/NetTuts/Aspect Oriented Programming in PHP /Source/Application/Test/BrokerTest .php:24
/usr/bin/phpunit :46
FAILURES!
Tests: 4, Assertions: 3, Errors: 1.
|
现在,期待异常(在测试中),确保它们通过:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class BrokerTest extends PHPUnit_Framework_TestCase {
// [...]
/**
* @expectedException SpentTooMuchException
*/
function testBuyTooMuch() {
$broker = new Broker( 'Finch' , '2' );
$broker ->buy( 'MS' , 10000, 8);
}
}
|
在我们的“方面”中建立一个新方法来匹配@AfterThrowing,别忘记指定 Use Go\Lang\Annotation\AfterThrowing;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// [...]
Use Go\Lang\Annotation\AfterThrowing;
class BrokerAspect implements Aspect {
// [...]
/**
* @param MethodInvocation $invocation Invocation
* @AfterThrowing("execution(public Broker->buy(*))")
|