Lesson 17: SOLID 實戰篇 (2)-開放封閉 (OCP) - 擁抱變化而不修改舊碼
接下來我們來講講SOLID的O - OCP,開放封閉原則。
核心觀念定義
如果說 SRP 是為了整理程式碼,那麼 OCP 就是為了保護程式碼。它是防止「新需求搞壞舊功能」的最強盾牌。
“Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.” — Bertrand Meyer
「軟體實體應該對『擴充』開放,但對『修改』封閉。」,
這句話聽起來很矛盾:要能擴充功能,卻不能修改程式碼? 其實它的意思是:當需求改變時,你應該透過「增加新的程式碼 (Class)」來擴充功能,而不是去「修改原本已經運作良好的程式碼」。
Open for Extension: 當有新需求(例如新的折扣活動),我們可以輕鬆加入。
Closed for Modification: 加入新需求時,原本負責計算金額的核心邏輯不需要被改動(避免 Regression Bug)。
OCP 關心的不是「現在怎麼寫最乾淨」,而是「未來需求出現時,哪裡不該被打開來改」。
核心手段: 多型 (Polymorphism) 與 介面 (Interface)。
範例
還記得上一堂課 SRP 我們拆分出來的 PriceCalculator 嗎? 假設行銷部今天提出了多種折扣方案:
一般會員:無折扣。
VIP 會員:打 9 折。
超級會員 (Super VIP):打 8 折。
(未來可能還有聖誕節、週年慶...)
違反 OCP 的寫法
class PriceCalculator
{
public function calculateTotal(array $orderData, string $memberType): float
{
$total = 0;
foreach ($orderData['items'] as $item) {
$total += $item['price'] * $item['qty'];
}
// 違反 OCP 的重災區
if ($memberType === 'VIP') {
$total = $total * 0.9;
} elseif ($memberType === 'SuperVIP') {
$total = $total * 0.8;
} elseif ($memberType === 'Christmas') {
// 每次新增規則,都要回來改這個檔案!
$total = $total * 0.95 - 100;
}
return $total;
}
}
為什麼這樣不好?
每次行銷部想一個新花招,就要打開這個檔案修改。
風險高:為了加「聖誕節折扣」,不小心手誤刪掉了 VIP 的邏輯,導致 VIP 用戶沒打折
難測試:這個函式的邏輯分支 (Cyclomatic Complexity) 越來越多,測試案例要寫非常多個才能覆蓋。
重構實戰:策略模式 (Strategy Pattern)
要實踐 OCP,最經典的設計模式就是 策略模式 (Strategy Pattern)。我們把「如何打折」這件事抽象化。
步驟一:定義介面 (The Interface)
我們先定義一個標準:所有的折扣規則都必須遵守這個合約。
interface DiscountStrategy
{
public function apply(float $total): float;
}
步驟二:實作具體策略 (Concrete Classes)
針對每一種會員或活動,建立一個獨立的 Class。
class NoDiscount implements DiscountStrategy
{
public function apply(float $total): float
{
return $total;
}
}
class VipDiscount implements DiscountStrategy
{
public function apply(float $total): float
{
return $total * 0.9;
}
}
class SuperVipDiscount implements DiscountStrategy
{
public function apply(float $total): float
{
return $total * 0.8;
}
}
// 新增需求:聖誕節折扣 (完全不需要碰上面那些寫好的 Class)
class ChristmasDiscount implements DiscountStrategy
{
public function apply(float $total): float
{
return $total * 0.95 - 100;
}
}
重構 Calculator (Client)
現在 PriceCalculator 不需要知道具體的折扣邏輯,它只依賴 DiscountStrategy 介面。
class PriceCalculator
{
public function calculateTotal(array $orderData, DiscountStrategy $discountStrategy): float
{
$baseTotal = 0;
foreach ($orderData['items'] as $item) {
$baseTotal += $item['price'] * $item['qty'];
}
// 這裡就是 OCP 的精隨:
// Calculator 不用改任何一行程式碼,就能支援無數種新的折扣方式。
return $discountStrategy->apply($baseTotal);
}
}
如何決定使用哪個策略?
可能會想問:「那誰來決定要丟 VipDiscount 還是 ChristmasDiscount 進去?」
通常這會由一個 Factory (工廠) 或是 Service Provider 層來決定。
class DiscountFactory
{
public static function make(string $memberType): DiscountStrategy
{
return match($memberType) {
'VIP' => new VipDiscount(),
'SuperVIP' => new SuperVipDiscount(),
'Christmas' => new ChristmasDiscount(),
default => new NoDiscount(),
};
}
}
雖然 Factory 內部還是用了 match ,但我們把「判斷」隔離在 Factory 裡,保護了核心業務邏輯 (PriceCalculator) 不受汙染。OCP 並不是要求系統中「完全沒有條件判斷」,而是將「容易變動的判斷」集中並隔離。
個人開發經驗分享
以下是進階實務示範,目的是說明在大型系統中,如何進一步降低 Factory 本身的修改頻率。
工廠的一個小缺點:工廠本身還是需要知道「字串」與「類別」的對應關係(即 match 裡面的那些字串)。我們可以如何運用達到更極致的OCP?
在前陣子的專案中,我也剛好使用了策略模式來處理支援多種登入的方式。
我先使用Provider的概念,將實作於Interface的服務做綁定,並使用Resolver來應對動態選擇器的概念。
範例:
Provider
<?php
namespace App\Providers;
use App\Services\Login\MagicLoginService;
use Illuminate\Support\ServiceProvider;
use App\Services\Login\TraditionalLoginService;
use App\Services\Login\GoogleLoginService;
use App\Services\Login\AppleLoginService;
class LoginStrategyServiceProvider extends ServiceProvider
{
public function register(): void
{
// 解析 "iterable LoginStrategyInterface"
$this->app->bind('login_strategies', function ($app) {
return [
$app->make(TraditionalLoginService::class),
$app->make(GoogleLoginService::class),
$app->make(AppleLoginService::class),
$app->make(MagicLoginService::class),
];
});
$this->app->singleton(\App\Services\Login\LoginStrategyResolver::class, function ($app) {
return new \App\Services\Login\LoginStrategyResolver($app->make('login_strategies'));
});
}
}
Resolver
<?php
// app/Services/Login/LoginStrategyResolver.php
namespace App\Services\Login;
use App\Services\Contracts\LoginStrategyInterface;
use InvalidArgumentException;
readonly class LoginStrategyResolver
{
/**
* @param LoginStrategyInterface[] $strategies
*/
public function __construct(
private iterable $strategies
) {}
public function resolve(string $provider): LoginStrategyInterface
{
foreach ($this->strategies as $strategy) {
if ($strategy->supports($provider)) {
return $strategy;
}
}
throw new InvalidArgumentException("Unsupported login provider: {$provider}");
}
}
Laravel 中的 OCP 應用
身為 Laravel 開發者,其實很多地方都有使用 OCP,只是可能沒意識到:
Middleware (中介層): Laravel 的 Pipeline 設計就是 OCP。當你想要增加一個「檢查 IP」的功能,你不需要去改核心 Request 處理流程,只需要寫一個新的 Middleware 並掛載上去。
Drivers (驅動):
Cache,Session,Filesystem都是 OCP。 如果你想把 Cache 從 Redis 換成 Memcached,或者甚至換成你自己寫的 Database 儲存,你不需要去改 Laravel 的核心程式碼,只要實作合約並設定 Config 即可。
結語
OCP 並不代表要預先為「所有可能的需求」設計抽象,而是當「變動方向已明確出現」時,再引入抽象保護核心。

