PHP 的面向方面程式設計

oschina發表於2013-07-01

  面向方面程式設計(AOP)對於PHP來說是一個新的概念。現在PHP對於 AOP 並沒有官方支援,但有很多擴充套件和庫實現了這個特性。本課中,我們將使用 Go! PHP library 來學習 PHP 如何進行 AOP 開發,或者在需要的時候,可以回來看一眼。

 AOP簡史

Aspect-Oriented programming is like a new gadget for geeks.

  面向方面程式設計的思想在二十世紀90年代中期,於施樂帕洛阿爾託研究中心(PARC)成型。同很多有趣的新技術一樣,由於缺少明確的定義,起初 AOP 備受爭議。因此相關小組決定將未完成的想法公之於眾,以便接受廣大社群的反饋。關鍵問題在於“關注點分離(Separation of Concerns)”的概念。AOP 是一種可以分離關注的可行系方案。

  AOP 於90年代末趨於成熟,標識為施樂 AspectJ 的釋出,IBM 緊隨其後,於2001年釋出了 Hyper/J。現在,AOP是一種對於常用程式語言來說都是一種成熟的技術。

 基本詞彙

  AOP 的核心就是“方面”,但在我們定義「方面『aspect』」之前,我們需要先討論兩個術語;「切點 point-cut』 」和「通知advise』」。切點代表我們程式碼中的一個時間點,特指執行我們程式碼的某個時間。在切點執行程式碼被稱為通知,結合一個活多個切點及通知的即為方面

  通常,每個類都會有一個核心的行為或關注點,但有時,類可能存在次要的行為。例如,類可能會呼叫一個日誌記錄器或是通知一個觀察員。因為類中的這些功能是次要的,其行為通常都是相同的。這種行為被稱為“交叉關注點”;使用 AOP 可以避免。

 PHP的各種AOP工具

  Chris Peters 已經討論過在PHP中實現 AOP 的Flow 框架Lithium 框架也提供了對AOP的實現。

  另一個框架採用了不同的方法,建立了一個 C/C++ 編寫的PHP擴充套件,在PHP直譯器的層級上宣示著它的魔力。名為AOP PHP Extension,我會在後續文章中討論它。

  但正如我之前所言,本文將檢閱Go! AOP-PHP 庫。

 安裝並配置 Go!

  Go! 庫並未擴充套件;它完全由PHP編寫,併為PHP5.4或更高版本使用。作為一個純PHP庫,它部署簡易,即使是在不允許編譯安裝你自己的PHP擴充套件的受限及共享主機環境,也可以輕易安裝。

  使用 Composer 安裝 Go!

  Composer 是安裝 PHP 包的首選方法。如果你沒有使用過 Composer,你可以在Go! GitHub repository下載。

  首先,將下面幾行加入你的 composer.json 檔案。

{
    "require": {
        "lisachenko/go-aop-php": "*"
    }
}

  之後,使用 Composer 安裝 go-aop-php。在終端中執行下面命令:

$ cd /your/project/folder
$ php composer.phar update lisachenko/go-aop-php

  Composer 將會在之後數秒中內安裝引用的包以及需求。如果成功,你將看到類似下面的輸出:

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! 庫及其需求就安裝在這。

$ 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

  整合到我們的專案

  我們需要建立一個呼叫,介於路由/應用程式的入口點。自動裝彈機的然後自動包括類。開始吧!引用作為一個切面核心。

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() 方法。如下:

// [...]
class ApplicationAspectKernel extends AspectKernel {

	// [...]

	protected function getApplicationLoaderPath() {
		return __DIR__ . DIRECTORY_SEPARATOR . 'autoload.php';
	}

}

  別擔心 autoload.php 就是這樣。我們將會填寫被省略的片段。

  當我們第一次安裝 Go語言!和達到這一點我的過程中,我覺得需要執行一些程式碼。所以開始構建一個小應用程式。

 建立一個簡單的日誌記錄器

  我們的「方面」為一個簡單的日誌記錄器,但在繼續我們應用的主要部分之前,有些程式碼需要看一下。

  建立一個最小的應用

  我們的小應用是一個電子經紀人,能夠購買和出售股票。

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 檔案。並新增下面的程式碼:

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.

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 為引用自動載入器 。

require_once '../autoload.php';

class BrokerTest extends PHPUnit_Framework_TestCase {
	// [...]
}

  執行 BrokerTest,驗證程式碼執行情況。

  連線到應用方面核心

  我們最後的一件事是配置Go!.為此,我們需要連線所有的元件讓們能和諧工作。首先,建立一個php檔案AspectKernelLoader.php,其程式碼如下:

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
));

我們需要連線所有的元件讓們能和諧工作!

  這個檔案位於前端控制器和自動載入器之間。他使用AOP框架初始化並在需要時呼叫autoload.php

  第一行,我明確地載入AspectKernel.php和ApplicationAspectKernel.php,因為,要記住,在這個點我們還沒有自動載入器。

  接下來的程式碼段,我們呼叫ApplicationAspectKernel物件init()方法,並且給他傳遞了一個數列引數:

  • autoload 定義了初始化AOP類庫的路徑。根據你實際的目錄機構調整為相應的值。
  • appDir 引用了應用的目錄
  • cacheDir 指出了快取目錄(本例中中我們忽略快取)。
  • includePaths 對aspects的一個過濾器。我想看到所有特定的目錄,所以設定了一個空陣列,以便看到所有的值。
  • debug  提供了額外的除錯資訊,這對開發非常有用,但是對已經要部屬的應用設定為false。

  為了最後實現各個不同部分的連線,找出你工程中autoload.php自動載入所有的引用並且用AspectKernelLoader.php替換他們。在我們簡單的例子中,僅僅test檔案需要修改:

require_once '../AspectKernelLoader.php';

class BrokerTest extends PHPUnit_Framework_TestCase {

// [...]

}

  對大一點的工程,你會發現使用bootstrap.php作為單元測試但是非常有用;用require_once()做為autoload.php,或者我們的AspectKernelLoader.php應該在那載入。

  記錄Broker的方法

  建立BrokerAspect.php檔案,程式碼如下:

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的匹配邏輯。

* @Before("execution(public Broker->*(*))")
  • @Before 給出合適應用建議. 可能的引數有@Before,@After,@Around和@After執行緒.
  • "execution(public Broker->*(*))" 給執行一個類所有的公共方法指出了匹配規則,可以用任意數量的引數呼叫Broker,語法是:                 
[operation - execution/access]([method/attribute type - public/protected] [class]->[method/attribute]([params])

   請注意匹配機制不可否認有點笨拙。你在規則的每一部分僅可以使用一個星號‘*‘。例如public Broker->匹配一個叫做Broker的類;public Bro*->匹配以Bro開頭的任何類;public *ker->匹配任何ker結尾的類。

public *rok*->將匹配不到任何東西;你不能在同一個匹配中使用超過一個的星號。

   緊接著匹配程式的函式會在有時間發生時呼叫。在本例中的方法將會在每一個Broker公共方法呼叫之前執行。其引數$invocation(型別為MethodInvocation)子自動傳遞到我們的方法的。這個物件提供了多種方式獲取呼叫方法的資訊。在第一個例子中,我們使用他獲取了方法的名字,並且輸出。

  註冊切面

  僅僅定義一個切面是不夠的;我們需要把它註冊到AOP架構裡。否則,它不會生效。編輯ApplicationAspectKernel.php同時在容器上的configureAop()方法裡呼叫registerAspect():

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());
    }
}

  執行測試和檢查輸出。你會看到類似下面的東西:

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上時都會執行。

  查詢引數和匹配@After

  讓我們加入另外的方法到BrokerAspect。

// [...]
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匹配器)。染汙我們加入另外一行來輸出用來呼叫方法的引數。我們的測試現在輸出:

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增加另一個方法,修改現有的程式碼:

 //[...]
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;
	}

}

僅僅定義一個aspect是不夠的;我們需要將它註冊到AOP基礎設施。

  這個新的程式碼把引數資訊移動到@Before方法。我們也增加了另一個特殊的@Around匹配器方法。這很整潔,因為原始的匹配方法呼叫被包裹於aroundMethodExecution()函式之內,有效的限制了原始的呼叫。在advise裡,我們要呼叫$invocation->proceed(),以便執行原始的呼叫。如果你不這麼做,原始的呼叫將不會發生。

  這種包裝也允許我們操作返回值。advise返回的就是原始呼叫返回的。在我們的案例中,我們沒有修改任何東西,輸出應該看起來像這樣:

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。返回到測試類,寫如下的測試:

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));
	}

}

  這會失敗:

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()方法:

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的購買價格。

 // [...]
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的話就提供一個折扣。測試現在通過了。

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)

  匹配異常

  我們現在可以在一個方法的開始和執行之後、繞過時,執行附加程式。但當方法丟擲異常時又如何呢?

  新增一個測試方法來購買大量微軟的股票:

function testBuyTooMuch() {
	$broker = new Broker('Finch', '2');
	$broker->buy('MS', 10000, 8);
}

  現在,建立一個異常類。我們需要它是因為內建的異常類不能被 Go!AOP 或 PHPUnit 捕捉.

class SpentTooMuchException extends Exception {

	public function __construct($message) {
		parent::__construct($message);
	}

}

  修改經紀人類,對大值丟擲異常:

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;
	}

	// [...]

}

  執行測試,確保它們產生失敗訊息:

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.

  現在,期待異常(在測試中),確保它們通過:

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;

 // [...]
Use Go\Lang\Annotation\AfterThrowing;

class BrokerAspect implements Aspect {

	// [...]

	/**
	 * @param MethodInvocation $invocation Invocation
	 * @AfterThrowing("execution(public Broker->buy(*))")
	 */
	public function afterExceptionMethodExecution(MethodInvocation $invocation) {
		echo 'An exception has happened';
	}

}

  @AfterThrowing匹配器抑制丟擲的異常,並允許你去採取自己的行動。在我們的程式碼中,我們簡單的顯示一個資訊,但你可以做任何你的應用程式需要的事情。

 最後的思考

這就是為什麼我建議你小心使用“方面”。

  面向方面程式設計就像給怪人們的新玩意兒;您可以立即看到其巨大的潛力。方面允許我們在我們的系統的不同部分引入額外的程式碼,而無需修改原始程式碼。當你需要實現一些通過緊耦合引用和方法呼叫會汙染你的方法和類的模組時,這會非常有用。

  然而,這種靈活性,是有代價的:陰暗朦朧。有沒有辦法告訴如果一方面表的方法只是在尋找方法或類。例如,在我們的Broker類中執行方法時沒有跡象表明發生任何事情。這就是為什麼我建議你小心使用“方面”的原因。

  我們使用“方面”來給一個特定的經紀人提供折扣是誤用的一個例子。不要在一個真實的專案中這樣做。經紀人的折扣與經紀人相關;所以,在Broker類中保持這個邏輯。“方面”應該只執行不直接關係到物件主要行為的任務。

  樂在其中吧!

  英文原文:Aspect-Oriented Programming in PHP with Go!

相關文章