Skip to main content

Command Palette

Search for a command to run...

Lesson 17: SOLID 實戰篇 (2)-開放封閉 (OCP) - 擁抱變化而不修改舊碼

Updated
3 min read

接下來我們來講講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 的寫法

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)

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

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,只是可能沒意識到:

  1. Middleware (中介層): Laravel 的 Pipeline 設計就是 OCP。當你想要增加一個「檢查 IP」的功能,你不需要去改核心 Request 處理流程,只需要寫一個新的 Middleware 並掛載上去。

  2. Drivers (驅動): Cache, Session, Filesystem 都是 OCP。 如果你想把 Cache 從 Redis 換成 Memcached,或者甚至換成你自己寫的 Database 儲存,你不需要去改 Laravel 的核心程式碼,只要實作合約並設定 Config 即可。

結語

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

從Rookie到Junior,一個後端成長的30堂課

Part 11 of 31

“從 Rookie 到 Junior:一個後端成長的 30 堂課” 是一套專為後端新手所設計的成長型技術系列文章。內容以實務為導向,逐步拆解後端工程的核心能力,包括程式語言基礎、架構思維、框架運作原理、業務邏輯設計、資料庫操作、以及常見的開發模式。 本系列的目標是協助讀者從零散的學習堆疊,建立成體系的後端知識框架。讀者能夠理解各語言背後不變的工程思維與設計原則。這套內容旨在讓學習者從「會寫程式」進入「能理解系統設計」的階段,逐步具備勝任 Junior Backend Engineer 的能力。

Up next

Lesson 16: SOLID 實戰篇 (1)-單一職責 (SRP) 與 高內聚

接下來我們要進到SOLID的實戰,這堂課我們將專注於最基礎、但也最重要的第一條原則:SRP (單一職責原則),並探討它與 高內聚 (High Cohesion) 的緊密關係。 核心觀念定義 關於單一職責原則 "A class should have one, and only one, reason to change." — Robert C. Martin (Uncle Bob) 「一個類別應該只有一個『改變的理由』。」 很多人誤以為 SRP 意思是「一個類別只做一件事 (Do one ...

More from this blog

Lesson 26 : 系統韌性的守護者-限流、熔斷與背壓的設計模式

當這幾個名詞出現後,代表我們進到了一個高併發/大流量的系統了。在這個章節中,我們一起來看看如何透過一些方式來避免高併發導致我們的系統crash掉。 限流(Rate Limiting) 相信大家對這個名詞並不陌生,限流其實就是字面上的含意,限制流量。 限流的目的是保護「接收方」,確保系統不會因為瞬間的高併發請求而癱瘓。 限流通常發生在 API Gateway 或服務的最前端。它像是一個夜店門口的保全

Mar 26, 20262 min read

Lesson 25: 淺談 單體架構、微服務架構與單/多租戶架構

過去我們討論了 要把程式寫在哪、程式要怎麼拆的題目,接下來我們來看看「如何服務不同客戶」,這些架構反映了軟體開發在擴充性與複雜度之間的權衡。 軟體架構深度解析:從系統拆分到商業規模化 在軟體工程的演進中,架構的選擇往往是在「開發效率」、「系統擴充性」與「營運成本」之間尋求平衡。我們可以從兩個核心維度來觀察這些架構:系統如何運行(單體 vs. 分散式) 以及 如何服務客戶(單租戶 vs. 多租戶)。

Mar 26, 20262 min read

Lesson 24: 資料庫擴展術-讀寫分離、複寫機制與快取一致性挑戰

為什麼要讀寫分離? 大多數的 Web 應用都是 「讀多寫少」(例如:看文的人多,發文的人少,Heavy Read System)。當所有的請求都塞給同一台資料庫時,磁碟 I/O 和連線數會成為瓶頸。 Master (主庫): 負責寫入 (Insert/Update/Delete),確保數據一致性。 Slave (從庫): 負責讀取 (Select),可以有多個從庫來分擔讀取壓力。 為什麼讀

Mar 25, 20262 min read

面試經驗談 2025-2026

從2025年3月開始,我陸陸續續參與了從新創到上市櫃公司的Senior - Tech Lead的相關面試,其中有不乏 尊重面試者、展現高度專業的企業(公司),當然也有遇到幾場面試鬼故事,這篇文章主要分享我對於軟體工程師面試的方向分享,以及部分鬼故事,以此警惕自己不要成為這樣的面試官。 AI的洪流,改變了SWE的生態 LLM的發展確確實實的影響到了軟體工程師的生態。 過去受限於算力與資料規模,深度學

Mar 23, 20262 min read

Lesson 23: 系統的緩衝區-Queue 佇列與非同步處理 (Asynchronous)

佇列 佇列的實作工具非常多,舉凡AWS SQS、RabbitMQ、Kafka…等。 佇列的特性,其實是一個非常強大的系統緩衝區,應用層面非常廣。 什麼是佇列? 佇列可以想像成,在既有流程中外,有另一個”水管”,來連接原有的資料流(或邏輯過程),其中 呼叫方將資料 推(Push)到水管中,接受方(監聽) 從水管中將資料拉(Pull)出處理 為什麼佇列是「強大的緩衝區」? 在同步處理中,系統像是一

Mar 23, 20262 min read

Bennett's Tech Blog | 後端架構、系統設計

32 posts

來自台灣的軟體工程師,相信軟體可以改變世界