[PHP] 在 PHP5 中實作 AOP 的概念
這篇積在我電腦裡很久了,一直沒公開…這次趁著要幫我的 Library 加料,順便拿出來分享一下心得。
什麼是 AOP
AOP 全名為 Aspect-Oriented Programming ,基本的觀念可以參考良葛格的 AOP 入門:
這裡我簡單提一下 AOP 的基本想法:
假設當我們呼叫物件的某些方法 (或是業務流程) 之後,會想要把相關的資訊記錄到 log 檔裡,我們也許會這樣寫:
<?php /** * Test */ class Test { /** * 某個方法 */ public function doSomething() { // 建立 Log 物件 $logger = new Log(); // 寫入前置 Log $logger->save('before do something.'); // 主要的動作 // ... // 寫入 Log $logger->save('before do something.'); } }
可是如果今天這個記錄 log 的這個動作只是臨時的,或是在未來可能會需要再加入不同的動作時 (例如寄信) ,難道我們還要在原有方法的程式碼裡修修改改嗎?有沒有什麼方式能協助我們動態地把記錄的動作插在原有動作之後呢?
AOP 就是從這個角度所延伸出來的一種觀念,它能協助我們在不侵入原有類別程式碼的狀況下,動態地為類別方法新增額外的權責;簡單來說, AOP 主要的目的就是切入類別原有方法執行之前或之後,並安插我們想要執行的動作。
註: IT 界似乎很喜歡發明深奧的名詞來詮釋一個簡單的概念,然後像我這樣不學無術的開發者就常被唬得一楞一楞的。
AOP 和 Decorator
先介紹幾篇實作 AOP 的文章:
- AOP for jQuery
- Aspect Oriented Programming in PHP as a contrast to other languages.
- Bunny Aspects
- More on Aspect Oriented PHP
- 在PHP里利用魔术方法实现准AOP
- AOP在PHP中的实现方式
- Class: AOP Library for PHP
其實一開始我以為 AOP 和 Decorator 模式在 PHP 上的實作方式是差不多的,不過實際上還有是些許的差別。
一般在 Decorator 模式中,具體類別和 Wrapper 類別都會有個共同的祖先,亦即一個抽象類別或介面,因此所產生出來的物件對 Client 程式來說,其抽象型態可以說是一樣的。
但是在 AOP in PHP 中,我們必須透過一個代理類別來切入原有的類別方法裡,雖然這個代理類別也能夠提供原有類別中的所有方法,但是實際上它卻已經失去了與原有類別所擁有的抽象型態了。
用 PHP 實作 AOP
首先我們來看看還沒有切入任何事件的目標類別:
<?php /** * Test class * */ class TestClass { /** * Method 1 * * @param string $message */ public function method1($message) { echo "\n", __METHOD__, ":\n", $message, "\n"; } /** * Method 2 * * @return int */ public function method2() { echo "\n", __METHOD__, ":\n"; return rand(1, 10); } /** * Method 3 * * @throws Exception */ public function method3() { echo "\n", __METHOD__, ":\n"; throw new Exception('Test Exception.'); } }
這個類別提供了三個方法,其中 method1 和 method2 只是簡單的顯示資料而已,而 method3 則會丟出一個異常。
另外我們需要一個 Log 類別:
<?php /** * Log * */ class Log { /** * log message * * @param string $message */ public function save($message) { echo $message, "\n"; } }
這個 Log 類別只提供一個 save() 方法,以顯示 log 訊息。
現在我們要完成的目標如下:
-
在 method1 執行前呼叫 Log::save() 。
-
在 method2 執行後呼叫 Log::save() 。
-
在 method3 發生異常時呼叫 Log::save() 。
這裡我用很簡單的方式來做,那就是直接使用一個 Aspect 類別:
<?php /** * Aspect * */ class Aspect { /** * Name of target class * * @var string */ private $_className = null; /** * Target object * * @var object */ private $_target = null; /** * Event callback * * @var array */ private $_eventCallbacks = array(); /** * Add object * * @param object $target * @return Aspect */ public static function addObject($target) { return new Aspect($target); } /** * Contructor * * @param object $target */ public function __construct($target) { if (is_object($target)) { $this->_target = $target; $this->_className = get_class($this->_target); } } /** * Register event * * @param string $eventName * @param string $methodName * @param callback $callback */ private function _registerEvent($eventName, $methodName, $callback, $args) { if (!isset($this->_eventCallbacks[$methodName])) { $this->_eventCallbacks[$methodName] = array(); } if (!is_callable(array($this->_target, $methodName))) { throw new Exception(get_class($this->_target) . '::' . $methodName . ' is not exists.'); } if (is_callable($callback)) { $this->_eventCallbacks[$methodName][$eventName] = array($callback, $args); } else { $callbackName = Aspect::getCallbackName($callback); throw new Exception($callbackName . ' is not callable.'); } } /** * Register 'before' handler * * @param string $methodName * @param callback $callback */ public function before($methodName, $callback, $args = array()) { $this->_registerEvent('before', $methodName, $callback, (array) $args); } /** * Register 'after' handler * * @param string $methodName * @param callback $callback */ public function after($methodName, $callback, $args = array()) { $this->_registerEvent('after', $methodName, $callback, (array) $args); } /** * Register 'on catch exception' handler * * @param string $methodName * @param callback $callback */ public function onCatchException($methodName, $callback, $args = array()) { $this->_registerEvent('onCatchException', $methodName, $callback, (array) $args); } /** * Trigger event * * @param string $eventName */ private function _trigger($eventName, $methodName, $target) { if (isset($this->_eventCallbacks[$methodName][$eventName])) { list($callback, $args) = $this->_eventCallbacks[$methodName][$eventName]; $args[] = $target; call_user_func_array($callback, $args); } } /** * Execute method * * @param string $methodName * @param array $args * @return mixed */ public function __call($methodName, $args) { if (is_callable(array($this->_target, $methodName))) { try { $this->_trigger('before', $methodName, $this->_target); $result = call_user_func_array(array($this->_target, $methodName), $args); $this->_trigger('after', $methodName, $this->_target); return $result ? $result : null; } catch (Exception $e) { $this->_trigger('onCatchException', $methodName, $e); throw $e; } } else { throw new Exception("Call to undefined method {$this->_className}::$methodName."); } } /** * Get name of callback * * @param callback $callback * @return string */ public static function getCallbackName($callback) { $className = ''; $methodName = ''; if (is_array($callback) && 2 == count($callback)) { if (is_object($callback[0])) { $className = get_class($callback[0]); } else { $className = (string) $callback[0]; } $methodName = (string) $callback[1]; } elseif (is_string($callback)) { $methodName = $callback; } return $className . (($className) ? '::' : '') . $methodName; } }
這個類別有點小長,簡單說明如下:
-
我們利用 Aspect::addObject() 方法來指定要被切入的物件; addObject() 方法會回傳一個透明的 Aspect 物件。
-
利用 before 、 after 和 onCatchException 三個方法來指定切入的時機,它們會呼叫 _registerEvent() 方法來註冊要執行的回呼函式 (callback) 。
-
執行原來被切入物件的方法,這時會觸動 Aspect 的 __call() 方法,並在指定的切入時機呼叫 _trigger() 方法來執行我們所切入的回呼函式。
先來看看還沒有使用 AOP 前,我們對 TestClass 類別的測試:
<?php require_once 'TestClass.php'; $test = new TestClass(); /* @var $test TestClass */ echo "=======\n"; $test->method1('abc'); echo "=======\n"; echo $test->method2(), "\n"; echo "=======\n"; $test->method3(); echo "=======\n"; /* 執行結果: ======= TestClass::method1: abc ======= TestClass::method2: 2 ======= TestClass::method3: Exception: Test Exception. in TestClass.php on line 38 */
接下來我們利用 Aspect 類別來對 TestClass 物件的三個方法切入 Log::save() :
<?php require_once 'Aspect.php'; require_once 'TestClass.php'; require_once 'Log.php'; $test = Aspect::addObject(new TestClass()); $logger = new Log(); $test->before('method1', array($logger, 'save'), 'Log saved (method1).'); $test->after('method2', array($logger, 'save'), 'Log saved (method2).'); $test->onCatchException('method3', array($logger, 'save'), 'Log saved (method3).'); /* @var $test TestClass */ echo "=======\n"; $test->method1('abc'); echo "=======\n"; echo $test->method2(), "\n"; echo "=======\n"; $test->method3(); echo "=======\n"; /* 執行結果: ======= Log saved (method1). TestClass::method1: abc ======= TestClass::method2: Log saved (method2). 8 ======= TestClass::method3: Log saved (method3). Exception: Test Exception. in TestClass.php on line 38 */
結論
我們可以從範例看到, AOP 能幫我們在某類別的方法中插入一些額外的動作,同時又能不破壞原有類別的程式碼。而它與 Decorator 最大的不同是, Decorator 必須用很多小類別來完成相同的動作,但是 AOP 則透過 PHP 的動態特性解決了這個問題。
當然 AOP 也不是萬靈丹,像在本文的實作裡它就不能接觸目標類別的非公開屬性。而之前也跟 Mark 聊了一下,其實 AOP 偏向於程式的整體設計,所以這裡的範例尚不能用於實戰之中,僅僅只是我個人一個概念的實作而已。
供大家參考看看吧。也歡迎一起討論~
jaceju的note那一句真的是太有同感了,第一次看到AOP是在Java看到的。看完真的是一頭霧水。
但我後來覺得這種東西,先講結果(before, after的method),然後想一下究竟要怎麼寫才能達到這個結果。試著想出自己的做法,接著再去比對人家的想法,就會很清楚。
經由您這篇文章再連回我自己的文章看到您的留言, 可見你這篇真的積很久了….
Decorator pattern 必需要實作相同的界面, 來做到 provide new behaviour at runtime.
而 AOP 則是期望讓 advice 可以 pointcut (任何?)的 object 中, 即使雙方並未實作共同的界面. 且被切入對象並不需要預期未來會被切入擴充.
Log 是最常被舉的例中, LogAdvice 可以讓它切入任何您現存的 class 來提供需要 log 的 method.
您現在這樣的實作已經點出應用的可能性, 未來也許您可以在 Zend Framework 中提供了 container 的功能以及設定, 由 container 來取代 new XXX Object, 再配合設定檔自動切入 method , 這樣做到動態為 Controller action log 功能也是否錯.
再舉一個會員註冊的例子:
register form->register action->save to db.
這一原先預期的流程及功能, 我們能利用 after advice , 讓它自動發出會員註冊通知信, 而不用重新改變 register action.
~阿土伯
To racklin:
對呀,這篇就是那時對您說我想到的概念實作。真的積超久了,最近才翻出來寫完
目前還在思考怎麼將它繫結到 Framework 裡,又不要增加初學者學習上的複雜度。看來有空還得再去翻翻 Spring 的做法,看一下 container 到底是怎麼做的。
真的是學無止盡呀~
Zend Framework 我並沒有深入去研究和在實務中使用. 也許您可以由 Zend_Controller_Dispatcher 著手並在 public function dispatch 中, 在 instance controller object 後, 再進行您的 aspect, 這樣實際的 controller action 並不會被影響且改變寫作方式及風格.
至於是否要寫到 spring framework 如此大的架構我則認為不需要, 如果 mvc 在 zend framework 只是一個 subset, 只要定出合用的 config 方式, 就很讚了…
PS. 說了那麼多, 就是在等你定出來, 我就跳來 Zend Framework 了..
PS2. 現在時間不多比較忙, 很少更新 blog, 不過我都會在 twitter 上啐啐唸.歡迎來討論.
To racklin:
呵呵, ZF 目前已經用在好幾個專案上了。我是有基於 ZF 上再包了一個 Framework ,因為純 ZF 在使用上還是有些小複雜。有機會我會把我的 Framework release 出來,大家再一起討論看看。
其實我的想法也是要儘量簡單,畢竟跟我一起工作的伙伴有些人對這麼複雜的東西並不是很瞭解 (像我自己也是對 Spring 有點懵懵懂懂,只看過書而已) 。所以我在包 Zend Framework 時也是朝向簡單易用為原則,以儘可能地直覺好開發為主。
這裡的概念我想在實務上也有一定的用處,只是愚昧如我得花一點時間來想想看怎麼加進去我的 Framework 。至於您提到的 Dispatcher ,其實 ZF 已經有提供 Plugin Hooker 的機制,所以一般用途上也就不太需要用到 AOP 。
我的想法是 AOP 可以用在 Model 上,做出類似 Behavior + Observer 的效果。只是這就會牽涉到我剛剛說的複雜度問題,這是我要仔細思考的部份。
不過還是很感謝呀,我對 Java 的熟悉度還不足,還望您這位前輩能提攜一下囉~
我之前寫了一個插件的類別,能自動做到自動委託的管理,你這篇給了我一些新的想法~
剛好我正想著 AOP 的一些問題,我研究看看…
Hi 朱先生,
>
> 花了一個晚上看過你的Blog,對於Ajax & php 等等技術有很多的見解。
> 個人很想和你交個朋友,不知道有沒有機會。
> 我寫php 6年多了,Ajax也有2年多的時間,在台北一直 沒有機會可以找到志同道合的人。
> 有機會,希望可以見個面,很想和你討論一個OpenSocial的開發計劃。
>
> B.R.
> James
> –
> *James Chen*