單例模式:確保類別只有一個實例,提供全域存取點
單例模式 是最知名也最具爭議的設計模式。本文詳細實作 PHP 4/5/7/8 版本差異、執行緒安全、序列化防護、測試困境與現代替代方案 (DI 容器、靜態類別、服務定位器)。
PHP 5+ 標準實作
<?php
class DatabaseConnection {
private static ?self $instance = null;
private PDO $pdo;
// 私有建構子:防止外部 new
private function __construct() {
$this->pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
}
// 防止複製
private function __clone() {}
// 防止反序列化建立新實例
public function __wakeup() {
throw new Exception("Cannot unserialize singleton");
}
// 靜態存取點
public static function getInstance(): self {
return self::$instance ??= new self();
}
// 代理 PDO 方法
public function query(string $sql): PDOStatement {
return $this->pdo->query($sql);
}
public function prepare(string $sql): PDOStatement {
return $this->pdo->prepare($sql);
}
// ... 其他需要的方法
}
// 使用
$db = DatabaseConnection::getInstance();
$stmt = $db->query("SELECT * FROM users");
?>
PHP 4 相容寫法 (歷史參考)
<?php
class DatabaseConnection {
function DatabaseConnection($fromGetInstance = false) {
if (M_E != $fromGetInstance) { // 簡單保護
trigger_error("Singleton: use getInstance()");
}
}
function &getInstance() {
static $instance = null;
if (!$instance) {
$instance = new DatabaseConnection(M_E); // 傳遞常數驗證
}
return $instance;
}
}
?>
單例模式核心測試
<?php
class SingletonTest extends PHPUnit\Framework\TestCase {
public function testSameInstanceReturned() {
$instance1 = DatabaseConnection::getInstance();
$instance2 = DatabaseConnection::getInstance();
$this->assertSame($instance1, $instance2); // 同一物件引用
}
public function testConstructorIsPrivate() {
$this->expectException(Error::class);
new DatabaseConnection(); // 應拋出錯誤
}
public function testCannotClone() {
$instance = DatabaseConnection::getInstance();
$this->expectException(Error::class);
clone $instance;
}
public function testCannotUnserialize() {
$instance = DatabaseConnection::getInstance();
$serialized = serialize($instance);
$this->expectException(Exception::class);
unserialize($serialized);
}
}
?>
單例模式的爭議與缺點
| 問題 |
說明 |
影響 |
| 全域狀態 |
任何地方可存取修改 |
難以追蹤 bug、測試污染 |
| 隱性依賴 |
函數簽名不顯示需單例 |
API 不誠實、耦合度高 |
| 測試困難 |
單例狀態跨測試殘留 |
需重置、Mock 困難 |
| 違反單一職責 |
類別同時負責業務邏輯與生命週期管理 |
擴展性差 |
| 多執行緒不安全 |
PHP-FPM 多進程各自有實例,但 Swoole/RoadRunner 共享記憶體需鎖 |
併發環境需額外同步 |
現代替代方案比較
| 方案 |
優點 |
缺點 |
適用場景 |
| 依賴注入容器 |
生命週期管理、自動裝配、測試友善、解耦 |
學習曲線、啟動開銷 |
中大型專案、現代框架 |
| 服務定位器 |
簡單、漸進式遷移 |
隱性依賴、反模式風險 |
舊專案重構過渡期 |
| 靜態類別 (無狀態) |
簡單、效能好、無實例管理 |
無法儲存狀態、難以 Mock |
工具函數、純函數 |
| 單例 (謹慎使用) |
簡單、確保唯一實例 |
上述所有缺點 |
資源昂貴且真正需唯一 (DB連線、日誌、配置) |
DI 容器替代單例:PHP-DI 範例
<?php
// composer require php-di/php-di
use DI\ContainerBuilder;
$container = (new ContainerBuilder())
->addDefinitions([
PDO::class => \DI\factory(function() {
return new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
})->singleton(), // 關鍵:單例範圍
UserRepository::class => \DI\autowire()->constructorParameter('pdo', \DI\get(PDO::class)),
UserService::class => \DI\autowire(),
])
->build();
// 使用:容器管理生命週期
$userService = $container->get(UserService::class);
// 同一請求/應用中獲取相同 PDO 實例
// 測試時輕鬆覆寫
$testContainer = (new ContainerBuilder())
->addDefinitions([
PDO::class => $mockPdo,
])
->build();
?>
何時適合使用單例
✅ 合適場景
- 資料庫連線池、Redis 連線 (資源昂貴、真正需共享)
- 配置管理器 (唯一配置來源)
- 日誌記錄器 (統一輸出通道)
- 快取管理器 (單一快取實例)
- 事件分發器 (全域事件總線)
❌ 不合適場景
業務邏輯類別 (UserService、OrderProcessor)
請求相關資料 (當前用戶、Session、Request)
需多實例配置的服務 (多資料庫、多快取)
單元測試目標類別 (難以隔離)
單例模式重構路徑
- 識別所有
::getInstance() 呼叫點
- 將依賴改為建構子注入
- 在進入點 (Controller、Command) 由容器建構
- 移除單例實作代碼 (私有建構子、靜態實例、getInstance)
- 配置容器定義 (singleton scope)
- 執行測試驗證