# 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` 嗎？ 假設行銷部今天提出了多種折扣方案：

1. **一般會員**：無折扣。
    
2. **VIP 會員**：打 9 折。
    
3. **超級會員 (Super VIP)**：打 8 折。
    
4. **(未來可能還有聖誕節、週年慶...)**
    

## 違反 OCP 的寫法

```php
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;
    }
}
```

## **為什麼這樣不好？**

每次行銷部想一個新花招，就要打開這個檔案修改。

1. 風險高：為了加「聖誕節折扣」，不小心手誤刪掉了 VIP 的邏輯，導致 VIP 用戶沒打折
    
2. 難測試：這個函式的邏輯分支 (Cyclomatic Complexity) 越來越多，測試案例要寫非常多個才能覆蓋。
    

## 重構實戰：策略模式 (Strategy Pattern)

要實踐 OCP，最經典的設計模式就是 策略模式 (Strategy Pattern)。我們把「如何打折」這件事抽象化。

### 步驟一：定義介面 (The Interface)

我們先定義一個標準：所有的折扣規則都必須遵守這個合約。

```php
interface DiscountStrategy
{
    public function apply(float $total): float;
}
```

### 步驟二：實作具體策略 (Concrete Classes)

針對每一種會員或活動，建立一個**獨立的 Class**。

```php
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` 介面。

```php
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** 層來決定。

```php
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
<?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
<?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，只是可能沒意識到：

1. Middleware (中介層)： Laravel 的 Pipeline 設計就是 OCP。當你想要增加一個「檢查 IP」的功能，你不需要去改核心 Request 處理流程，只需要寫一個新的 Middleware 並掛載上去。
    
2. Drivers (驅動)： `Cache`, `Session`, `Filesystem` 都是 OCP。 如果你想把 Cache 從 Redis 換成 Memcached，或者甚至換成你自己寫的 Database 儲存，你不需要去改 Laravel 的核心程式碼，只要實作合約並設定 Config 即可。
    

# 結語

OCP 並不代表要預先為「所有可能的需求」設計抽象，而是當「變動方向已明確出現」時，再引入抽象保護核心。
