隨著專案增長,核心業務邏輯不應被特定功能綁死。外掛機制(Plugin System)透過事件驅動與依賴反轉,讓核心框架保持精簡,業務功能以獨立模組動態載入、啟用、停用、升級。本文從零建構一個生產級 PHP 外掛系統,涵蓋管理器、鉤子註冊、觸發機制、外掛發現與生命週期管理。
PluginManager (單例/服務容器管理) ├─ $_listeners: array // hookName => [listenerKey => [$object, $method]] ├─ register(hook, $object, method) // 訂閱事件 ├─ trigger(hook, $data) // 發布事件,回傳聚合結果 └─ loadPlugins() // 掃描、實例化、註冊
<?php
declare(strict_types=1);
namespace App\Plugin;
use InvalidArgumentException;
use RuntimeException;
class PluginManager {
/** @var array> */
private array $listeners = [];
/** @var array 已載入的外掛實例 */
private array $plugins = [];
private string $pluginPath;
private array $activePlugins; // 來自資料庫/配置的啟用清單
public function __construct(string $pluginPath, array $activePlugins = []) {
$this->pluginPath = rtrim($pluginPath, '/');
$this->activePlugins = $activePlugins;
$this->loadPlugins();
}
/** 掃描並載入所有啟用的外掛 */
private function loadPlugins(): void {
foreach ($this->activePlugins as $plugin) {
$name = $plugin['name']; // 如 'SeoOptimizer'
$directory = $plugin['directory']; // 如 'seo-optimizer'
$className = $name . '_Plugin'; // 約定:{Name}_Plugin
$file = "{$this->pluginPath}/{$directory}/src/Plugin.php";
if (!is_file($file)) {
error_log("[PluginManager] Plugin file not found: {$file}");
continue;
}
require_once $file;
if (!class_exists($className)) {
error_log("[PluginManager] Class {$className} not found in {$file}");
continue;
}
if (!is_subclass_of($className, PluginInterface::class)) {
error_log("[PluginManager] {$className} must implement PluginInterface");
continue;
}
try {
/** @var PluginInterface $instance */
$instance = new $className($this);
$this->plugins[$name] = $instance;
// 外掛建構子內會呼叫 $manager->register(...) 完成鉤子註冊
} catch (\Throwable $e) {
error_log("[PluginManager] Failed to load {$name}: " . $e->getMessage());
}
}
}
/** 註冊監聽器
* @param string $hook 事件名稱,如 'article.before_save'
* @param object $object 監聽者實例(通常是外掛本身)
* @param string $method 回呼方法名
* @param int $priority 優先級,數值越大越早執行(預設 10)
*/
public function register(string $hook, object $object, string $method, int $priority = 10): void {
$key = get_class($object) . '->' . $method;
$this->listeners[$hook][$key] = [$object, $method, $priority];
// 依優先級排序(大到小)
uasort($this->listeners[$hook], fn($a, $b) => $b[2] <=> $a[2]);
}
/** 觸發事件
* @param string $hook 事件名稱
* @param mixed $data 傳遞給監聽器的資料(可為物件、陣列、標量)
* @return array 所有監聽器的回傳值陣列
*/
public function trigger(string $hook, mixed $data = null): array {
$results = [];
if (!isset($this->listeners[$hook])) {
return $results;
}
foreach ($this->listeners[$hook] as $key => [$object, $method, $priority]) {
if (!method_exists($object, $method)) {
error_log("[PluginManager] Method {$key} not found, skipping");
continue;
}
try {
// 支援回傳值或修改傳入參數(引用傳遞)
$result = $object->$method($data);
$results[$key] = $result;
} catch (\Throwable $e) {
error_log("[PluginManager] Hook {$hook} listener {$key} failed: " . $e->getMessage());
// 可選:決定是否中斷後續監聽器
}
}
return $results;
}
/** 觸發事件並允許監聽器修改資料(引用傳遞) */
public function filter(string $hook, mixed &$data): void {
if (!isset($this->listeners[$hook])) return;
foreach ($this->listeners[$hook] as [$object, $method]) {
if (method_exists($object, $method)) {
$object->$method($data); // $data 為引用,監聽器可直接修改
}
}
}
/** 取得已載入外掛清單 */
public function getLoadedPlugins(): array {
return array_keys($this->plugins);
}
/** 取得特定外掛實例 */
public function getPlugin(string $name): ?PluginInterface {
return $this->plugins[$name] ?? null;
}
}
?>
<?php
// App/Plugin/PluginInterface.php
namespace App\Plugin;
interface PluginInterface {
/** 外掛唯一識別碼(小寫、連字號) */
public function getSlug(): string;
/** 顯示名稱 */
public function getName(): string;
/** 版本號 */
public function getVersion(): string;
/** 作者資訊 */
public function getAuthor(): string;
/** 啟用時執行(資料庫遷移、預設設定) */
public function activate(): void;
/** 停用時執行(清理排程、快取) */
public function deactivate(): void;
/** 卸載時執行(刪除資料表、檔案) */
public function uninstall(): void;
}
// App/Plugin/BasePlugin.php — 簡化實作
abstract class BasePlugin implements PluginInterface {
protected PluginManager $manager;
public function __construct(PluginManager $manager) {
$this->manager = $manager;
}
public function activate(): void {}
public function deactivate(): void {}
public function uninstall(): void {}
/** 便利方法:註冊鉤子 */
protected function hook(string $event, string $method, int $priority = 10): void {
$this->manager->register($event, $this, $method, $priority);
}
/** 便利方法:觸發事件 */
protected function trigger(string $event, mixed $data = null): array {
return $this->manager->trigger($event, $data);
}
}
?>
<?php
// plugins/seo-optimizer/src/Plugin.php
namespace Plugins\SeoOptimizer;
use App\Plugin\BasePlugin;
class SeoOptimizer_Plugin extends BasePlugin {
public function getSlug(): string { return 'seo-optimizer'; }
public function getName(): string { return 'SEO 自動優化'; }
public function getVersion(): string { return '1.2.0'; }
public function getAuthor(): string { return 'Dev Team'; }
public function __construct(PluginManager $manager) {
parent::__construct($manager);
// 註冊鉤子
$this->hook('article.before_save', 'onArticleBeforeSave', 20); // 高優先級
$this->hook('head.meta_tags', 'injectMetaTags');
$this->hook('sitemap.generate', 'addCustomUrls');
}
/** 文章儲存前:自動生成 meta description、slug */
public function onArticleBeforeSave(array &$articleData): void {
if (empty($articleData['meta_description'])) {
$articleData['meta_description'] = mb_substr(strip_tags($articleData['content']), 0, 160);
}
if (empty($articleData['slug'])) {
$articleData['slug'] = $this->generateSlug($articleData['title']);
}
}
/** 注入 meta 標籤 */
public function injectMetaTags(array &$tags): void {
$tags['og:site_name'] = config('app.name');
$tags['twitter:card'] = 'summary_large_image';
}
/** 站點地圖加入自訂 URL */
public function addCustomUrls(array &$urls): void {
$urls[] = ['url' => url('/privacy'), 'changefreq' => 'monthly', 'priority' => 0.5];
$urls[] = ['url' => url('/terms'), 'changefreq' => 'yearly', 'priority' => 0.3];
}
private function generateSlug(string $title): string {
return strtolower(trim(preg_replace('/[^a-z0-9\-]+/i', '-', $title), '-'));
}
}
?>
<?php
// app/Http/Controllers/ArticleController.php
namespace App\Http\Controllers;
use App\Plugin\PluginManager;
use App\Models\Article;
use App\Services\ArticleService;
class ArticleController extends Controller {
public function __construct(
private ArticleService $service,
private PluginManager $plugins
) {}
public function store(StoreArticleRequest $request) {
$data = $request->validated();
// 1. 觸發「儲存前」事件,允許外掛修改 $data(引用傳遞)
$this->plugins->filter('article.before_save', $data);
// 2. 核心業務邏輯
$article = $this->service->create($data);
// 3. 觸發「儲存後」事件,傳遞建立好的模型
$this->plugins->trigger('article.after_save', $article);
return response()->json($article, 201);
}
public function show(Article $article) {
// 視圖渲染前注入 meta
$metaTags = [];
$this->plugins->filter('head.meta_tags', $metaTags);
return view('articles.show', compact('article', 'metaTags'));
}
}
?>
<?php
// app/Services/PluginInstaller.php
namespace App\Services;
use App\Plugin\PluginManager;
use ZipArchive;
class PluginInstaller {
public function __construct(private PluginManager $manager) {}
/** 從 ZIP 安裝外掛 */
public function installFromZip(string $zipPath, string $targetDir): array {
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
return ['success' => false, 'message' => 'Invalid ZIP file'];
}
$extractPath = $targetDir . '/temp_' . bin2hex(random_bytes(8));
$zip->extractTo($extractPath);
$zip->close();
// 掃描 Plugin.php 確認結構
$pluginFile = $this->findPluginFile($extractPath);
if (!$pluginFile) {
$this->removeDir($extractPath);
return ['success' => false, 'message' => 'Plugin.php not found'];
}
// 解析外掛資訊
require_once $pluginFile;
// 假設類別名稱符合約定,實例化檢查
// ...
// 移動到正式目錄
$finalDir = $targetDir . '/' . basename(dirname($pluginFile));
if (is_dir($finalDir)) {
$this->removeDir($finalDir);
}
rename($extractPath, $finalDir);
// 寫入資料庫啟用清單
// Plugin::create(['name' => ..., 'directory' => ..., 'active' => false]);
return ['success' => true, 'message' => 'Installed, please activate'];
}
private function findPluginFile(string $dir): ?string {
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir));
foreach ($iterator as $file) {
if ($file->isFile() && $file->getFilename() === 'Plugin.php') {
return $file->getPathname();
}
}
return null;
}
private function removeDir(string $dir): void {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($dir);
}
}
?>
| 主題 | 解決方案 |
|---|---|
| 鉤子優先級 | register($hook, $obj, $method, $priority),數值大優先,uasort 維護順序 |
| 外掛相依性 | 在 composer.json 宣告 "require": {"vendor/core-plugin": "^1.0"},安裝時用 Composer 解析 |
| 熱重載(開發模式) | 監聽檔案變更,重新實例化外掛並重建 $listeners,生產環境禁用 |
| 沙箱隔離 | 使用 runkit7 或獨立 PHP-FPM pool 限制外掛權限(禁用 exec、檔案系統存取) |
| 版本相容 | 核心定義 PLUGIN_API_VERSION,外掛宣告 requires_api,載入時比對 |
add_action()/apply_filters(),全域 $wp_filter 陣列,極簡但難測試。一個優良的 PHP 外掛系統核心在於:管理器單一職責、介面契約約束、鉤子優先級排序、外掛生命週期管理、安全沙箱隔離。本文實作採用現代 PHP 8.x 型別系統、PSR-4 自動載入、服務容器整合,可直接整合進 Laravel、Symfony 或自建框架。建議後續擴充:Composer 整合安裝、資料庫遷移自動執行、管理後台 UI、外掛市場 API 串接。