AOP(Aspect-Oriented Programming)讓你在不修改業務代碼的前提下,將日誌、事務、權限、效能監控等「橫切關注點」模組化。PHP 官方雖無內建 AOP,但 Go! AOP PHP Library 提供純 PHP 實作,無需擴展即可在共享主機部署。本文以實戰範例帶你入門。
| 術語 | 說明 | 類比 |
|---|---|---|
| Aspect(切面) | 橫切邏輯的模組化單元 | 「日誌切面」、「事務切面」 |
| Pointcut(切點) | 定義「何時、何地」織入的規則 | 「所有 Public 方法執行前」 |
| Advice(通知/增強) | 在切點執行的具體代碼 | 「記錄日誌」、「開啟事務」 |
| Join Point(連接點) | 程式執行中可被攔截的點 | 方法呼叫、屬性存取、異常拋出 |
| Weaving(織入) | 將 Advice 注入 Join Point 的過程 | 編譯期/載入期/執行期 |
composer require lisachenko/go-aop-php
<?php
// app/ApplicationAspectKernel.php
namespace App;
use Go\Core\AspectKernel;
use Go\Core\AspectContainer;
class ApplicationAspectKernel extends AspectKernel {
protected function configureAop(AspectContainer $container) {
// 註冊切面
$container->registerAspect(new \App\Aspect\LoggingAspect());
$container->registerAspect(new \App\Aspect\TransactionAspect());
}
protected function getApplicationLoaderPath() {
return __DIR__ . '/../vendor/autoload.php'; // Composer 自動載入
}
}
?>
<?php
// public/index.php 或 bootstrap.php
require_once __DIR__ . '/../vendor/autoload.php';
\App\ApplicationAspectKernel::getInstance()->init([
'debug' => true, // 開發模式
'cacheDir' => __DIR__ . '/../var/cache/aop', // 織入快取目錄
'includePaths' => [__DIR__ . '/../src'], // 掃描範圍
]);
?>
<?php
// src/Broker.php
namespace App;
class Broker {
private string $name;
private string $id;
public function __construct(string $name, string $id) {
$this->name = $name;
$this->id = $id;
}
public function getId(): string { return $this->id; }
public function buy(string $symbol, int $volume, float $price): float {
$value = $volume * $price;
if ($value > 1000) {
throw new \App\Exception\SpentTooMuchException("超額:$value");
}
return $value;
}
public function sell(string $symbol, int $volume, float $price): float {
return $volume * $price;
}
}
?>
<?php
// src/Aspect/LoggingAspect.php
namespace App\Aspect;
use Go\Aop\Aspect;
use Go\Aop\Intercept\MethodInvocation;
use Go\Lang\Annotation\Before;
use Go\Lang\Annotation\After;
use Go\Lang\Annotation\Around;
class LoggingAspect implements Aspect {
/** @Before("execution(public App\Broker->*(*))") */
public function beforeMethod(MethodInvocation $invocation) {
echo "[LOG] 進入方法: {$invocation->getMethod()->getName()}()\n";
echo "[LOG] 參數: " . implode(', ', $invocation->getArguments()) . "\n";
}
/** @After("execution(public App\Broker->*(*))") */
public function afterMethod(MethodInvocation $invocation) {
echo "[LOG] 結束方法: {$invocation->getMethod()->getName()}()\n\n";
}
/** @Around("execution(public App\Broker->buy(*))") */
public function aroundBuy(MethodInvocation $invocation) {
$result = $invocation->proceed(); // 執行原方法
echo "[LOG] buy() 回傳: $result\n";
return $result; // 必須返回,否則調用者拿不到結果
}
}
?>
<?php
// src/Aspect/DiscountAspect.php
namespace App\Aspect;
use Go\Aop\Aspect;
use Go\Aop\Intercept\MethodInvocation;
use Go\Lang\Annotation\Around;
class DiscountAspect implements Aspect {
/** @Around("execution(public App\Broker->buy(*))") */
public function applyVipDiscount(MethodInvocation $invocation) {
$broker = $invocation->getThis(); // 取得目標物件實例
$result = $invocation->proceed();
// ID 為 "2" 的 VIP 享 8 折
if ($broker->getId() === '2') {
return $result * 0.8;
}
return $result;
}
}
?>
<?php
// src/Aspect/ExceptionAspect.php
namespace App\Aspect;
use Go\Aop\Aspect;
use Go\Aop\Intercept\MethodInvocation;
use Go\Lang\Annotation\AfterThrowing;
class ExceptionAspect implements Aspect {
/** @AfterThrowing("execution(public App\Broker->buy(*))") */
public function logException(MethodInvocation $invocation) {
// 此處可記錄異常、發送告警、補償邏輯
echo "[EXCEPTION] buy() 拋出異常,已記錄並處理。\n";
// 注意:@AfterThrowing 不會阻止異常繼續冒泡
}
}
?>
| 模式 | 範例 | 匹配目標 |
|---|---|---|
| execution | execution(public App\Service->*(*)) | 所有 public 方法執行 |
| execution | execution(public App\Service->set*(*)) | 所有以 set 開頭的 public 方法 |
| execution | execution(* App\Service->*(*)) | 所有可見度方法 |
| within | within(App\Module\*) | 指定命名空間下所有類別 |
| annotation | @annotation(App\Annotation\Transactional) | 標註特定註解的方法 |
限制: 每個模式段僅支援一個 * 萬用字元(如 App\Broker* 可行,App\*ker* 不可)。
<?php
// tests/BrokerTest.php
namespace App\Tests;
use App\Broker;
use App\Exception\SpentTooMuchException;
use PHPUnit\Framework\TestCase;
class BrokerTest extends TestCase {
public function testBuyNormal() {
$broker = new Broker('John', '1');
$this->assertEquals(500, $broker->buy('GOOGL', 100, 5));
}
public function testVipDiscount() {
$broker = new Broker('Finch', '2');
$this->assertEquals(80, $broker->buy('MS', 10, 10)); // 100 * 0.8
}
public function testBuyTooMuchThrows() {
$this->expectException(SpentTooMuchException::class);
$broker = new Broker('Finch', '2');
$broker->buy('MS', 10000, 8);
}
}
?>
'debug' => false,部署時預先執行織入生成快取。cacheDir 大小。debug 會輸出織入資訊,結合 Xdebug 逐行除錯 Proxy 代碼。| 方案 | 類型 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|---|
| Go! AOP | 純 PHP 庫 | 無擴展、共享主機可用、註解驅動 | 執行期織入有效能開銷 | 中小專案、快速原型 |
| PHP-AOP 擴展 | C 擴展 | 編譯期織入、效能極佳 | 需編譯安裝、共享主機不可用 | 高效能生產系統 |
| Laravel 服務容器/中介層 | 框架內建 | 原生整合、開發體驗佳 | 綁定 Laravel 生態 | Laravel 專案 |
| Symfony EventDispatcher | 事件驅動 | 鬆耦合、標準化 | 非真正 AOP、需手動觸發事件 | Symfony/通用 PHP 專案 |
AOP 是處理橫切關注點的利器,Go! AOP 以純 PHP 實現降低了門檻。掌握「切面、切點、通知」三要素,配合註解驅動與單元測試,即可在專案中引入 AOP。記住:AOP 是基礎設施的補充,而非業務邏輯的替代品。合理劃分邊界,你的代碼將更乾淨、更模組化、更易維護。