iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
自我挑戰組

一杯咖啡的設計課:30 天 Design Pattern 的自我修煉系列 第 12

Day 12|Singleton(單例模式) 柴咖啡大塞車,如何用 Lazy 搞定咖啡機連線

  • 分享至 

  • xImage
  •  

今天來聊聊 Singleton Pattern 單例模式

我們來看看原文的定義,來自 Design Patterns: Elements of Reusable Object-Oriented Software

"Ensure a class only has one instance, and provide a global point of access to it."

蝦咪意思,用中文理解可以是

確保一個類別(Class)在整個應用程式生命週期中,永遠只有一個實例(Instance),並提供一個全域訪問點來取得該實例

核心特性

  1. 唯一性:限制外部不能隨意 new 出多個實例

  2. 自我管理:類別自己負責建立、快取並管理這唯一的實例

  3. 全域存取:提供統一的方法(通常是靜態方法或模組匯出)讓外部呼叫

有些朋友可能會覺得熟悉,像網站開發時就會很常遇到,舉例來說

  1. 資料庫連線池:避免重複建立連線

  2. 日誌紀錄器:確保所有模組寫入同一個 Log 物件與檔案流

  3. 應用程式全域設定:全域共享同一個設定檔

  4. 快取管理:統一管理記憶體快取

接下來,又到了我們快樂舉例的時間了,想當然身為一個專業的社畜,每個辦公室也一定會有一台印表機

這裡我們使用 C# 原生型別 Lazy 去舉例

using System; // 要引入才能使用 Lazy<T>

// 1. sealed:防止其他類別繼承它而破壞單例
public sealed class OfficePrinter
{
    // 2. 使用 Lazy<T>:負責「建立物件時」的執行緒安全與延遲載入,也只有在第一次有人要印東西時,才真正連線開機
    private static readonly Lazy<OfficePrinter> _lazyInstance = new Lazy<OfficePrinter>(() => new OfficePrinter());

    // 3. 私有建構函式:防止外部隨意 new OfficePrinter()
    private OfficePrinter()
    {
        Console.WriteLine("--> [硬體初始化] 正在連線至實體印表機 IP、載入驅動...(只會執行這一次)");
    }

    // 4. 全域唯一的存取點
    public static OfficePrinter Instance => _lazyInstance.Value;

    // 業務方法:大家共用同一個佇列印文件
    public void PrintDocument(string employeeName, string documentName)
    {
        Console.WriteLine($"[列印中] 員工 {employeeName} 的文件:{documentName}");
    }
}

那怎麼呼叫跟驗證呢

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("=== 辦公室系統啟動 ===");
        // 此時印表機尚未連線初始化

        Console.WriteLine("\n員工小明準備列印...");
        // 第一次呼叫 Instance:觸發 Lazy 初始化
        var printerA = OfficePrinter.Instance;
        printerA.PrintDocument("小明", "履歷表.pdf");

        Console.WriteLine("\n員工小美準備列印...");
        // 第二次呼叫 Instance:直接使用已建立好的實例
        var printerB = OfficePrinter.Instance;
        printerB.PrintDocument("小美", "財務報表.xlsx");

        // 驗證兩者使用的是同一台機器
        Console.WriteLine($"\n小明和小美使用的是同一台印表機嗎? {ReferenceEquals(printerA, printerB)}");
    }
}

接下來我們來看結果

=== 辦公室系統啟動 ===

員工小明準備列印...
--> [硬體初始化] 正在連線至實體印表機 IP、載入驅動...(只會執行這一次)
[列印中] 員工 小明 的文件:履歷表.pdf

員工小美準備列印...
[列印中] 員工 小美 的文件:財務報表.xlsx

小明和小美使用的是同一台印表機嗎? True

是不是跟你想的一樣?很棒,我們再來回顧一下定義

確保「一個」類別,在整個應用程式生命週期中,永遠只有「一個」實例,並提供「一個」全域訪問點來取得該實例

那可能有人跟我有一樣的疑問?
那一定要有 Lazy 才能實踐 Singleton 嗎? 不是,Singleton 的核心只有兩件事

  1. 全域只有一個實例
  2. 提供全域存取點

雖然可以不使用 Lazy 也可以達成 Singleton,但我擔心複雜度會直線上升(畢竟我是菜鳥...)

但 Lazy 可以將延遲載入與多執行緒安全兩大複雜需求封裝成一個原生型別,讓工程師把精力聚焦在業務邏輯上,而非底層鎖的競爭控制上

所以後續我們都用 Lazy 來實踐吧

回到柴咖啡

昨天用 Builder 解決了「一杯飲料的客製化選項」,阿柴喘口氣,覺得點餐這條線總算穩了

但好景不常,某天的下午,柴咖啡人滿為患

小黑很緊張地找阿柴:「阿柴!怎麼開始點第一杯咖啡時,系統卡了 5 秒在『連線咖啡機』;換下一個客人點餐,系統又卡 5 秒說『重新連線設備』,甚至還跳出**『設備已被佔用、連線失敗』**的錯誤?可是我們明明只有一台咖啡機」

我們先來看看原始的 code

public class MachineDriver
{
    public MachineDriver()
    {
        // 這裡會開啟 TCP / Serial 連線
        Console.WriteLine("--> 正在連線咖啡機硬體...");
    }

    public void Brew(string drinkName)
    {
        Console.WriteLine($"正在萃取:{drinkName}");
    }
}

// 點餐區:每次點餐都自己 new 一個連線
public class OrderCounter
{
    public void TakeOrder(string drinkName)
    {
        var driver = new MachineDriver();
        driver.Brew(drinkName);
    }
}

每來一位客人,系統都要重新連線咖啡機;使用完畢後,硬體連線沒有安全釋放,導致下次連線會跟舊連線搶實體埠

var counter = new OrderCounter();
counter.TakeOrder("拿鐵"); // 等了 5 秒,才能萃取拿鐵
counter.TakeOrder("美式"); // 又卡 5 秒重新連線,這次撞上還沒釋放的舊連線 → 設備已被佔用、連線失敗

看到這裡,問題出在哪呢,咖啡機只有一台,但軟體卻把它當成「每次點餐都可以生出一條新連線」在用,連線數越疊越多,使用者會感覺卡頓,甚至連線失敗

問題不是「物件怎麼生」,而在只有一台機器,卻被當成可以無限連線的資源,每次點餐都重新握手一次

該 Singleton 上場了

我們該如何透過 Singleton 達成兩件事

  1. 只有一個實體
  2. 提供一個全域存取點

作法上,把建構子鎖起來(private),不讓外部隨便 new,改成透過一個靜態屬性統一取用

public sealed class MachineDriver
{
    // 1. 用 Lazy<T> 包一層,確保「第一次真的有人要點餐」時才建立這條連線,且天生執行緒安全
    private static readonly Lazy<MachineDriver> _instance = new(() => new MachineDriver());

    // 2. 唯一的全域存取點
    public static MachineDriver Instance => _instance.Value;

    // 3. 建構子上鎖,外部沒辦法自己 new MachineDriver()
    private MachineDriver()
    {
        Console.WriteLine("--> 正在連線咖啡機硬體...");
        // 這裡會開 TCP / Serial 連線,跟咖啡機握手
    }

    public void Brew(string drinkName)
    {
        Console.WriteLine($"正在萃取:{drinkName}");
    }
}

阿柴把 OrderCounter 也跟著改掉,不再自己 new MachineDriver(),改成向同一個入口拿

public class OrderCounter
{
    public void TakeOrder(string drinkName)
    {
        MachineDriver.Instance.Brew(drinkName); // 大家共用同一條已經連好的線
    }
}

再跑一次剛剛的情境

var counter = new OrderCounter();
counter.TakeOrder("拿鐵"); // 第一次呼叫才連線,接著萃取拿鐵
counter.TakeOrder("美式"); // 直接用已經連好的線,不會撞連線
--> 正在連線咖啡機硬體...
正在萃取:拿鐵
正在萃取:美式

第一次點餐時才建立長連線,之後所有訂單全部共用這個已經連好線的實例,因為 MachineDriver.Instance 從頭到尾都是同一顆物件,不會再有每次點餐都重新握手、甚至撞線失敗的情況

Singleton 的取捨

Singleton 看起來乾淨俐落,但阿柴很快就發現代價

變成隱藏依賴OrderCounter 內部直接寫死 MachineDriver.Instance,從建構子完全看不出這個類別依賴了咖啡機連線,這正好違反 Day 7 講的依賴反轉,依賴關係應該要明確(例如透過建構子或介面注入),而不是在內部偷偷抓全域單例

難以測試:如果阿柴想幫 OrderCounter 寫單元測試,測試「點餐時要正確呼叫 Brew」,會發現沒辦法輕易換一個假的 MachineDriver 進去(測試環境根本沒有真的咖啡機可以連),因為 MachineDriver.Instance 是寫死的,每個測試案例還會共用同一條連線,測試案例之間互相汙染

全域可變狀態本身就是風險源:任何地方都能呼叫 MachineDriver.Instance.Brew(...),出單可以從程式的任何角落發生,出問題時很難追出到底是誰在什麼時候送了那杯

邊界在哪

「只有一份」是相對於目前的規模,不是絕對的

現在的柴咖啡只有一台實體咖啡機,MachineDriver.Instance 全店共用一份是合理的假設,因為現實裡也真的只有這一台機器可以連。但如果哪天真的展成連鎖店,每家分店都有自己的實體咖啡機,A 店的 MachineDriver.Instance 不能拿去連 B 店的機器,兩者是完全不同的硬體、不同的連線位址

而到那個時候,MachineDriver.Instance 這種寫死成「整個程式只有一個」的全域單例反而會變成阻礙,因為它的假設從一開始就是「全世界只有一台咖啡機」,而不是「每家分店各一台」。要撐住多分店,得改成用依賴注入去管理「每個分店一個連線實體」的生命週期(例如注入容器裡設定成 per-store 的 Scoped 生命週期,每個 store id 對應各自的連線),而不是繼續用 static 硬幹

所以動手寫 Singleton 前,可以先問自己:這份資源,是「全域只該有一份」,還是「目前湊巧只有一份」? 前者適合 Singleton,後者只是現況剛好如此,但用了 Singleton 反而會在規模變化時卡住,需要適時的調整


明天來聊聊柴咖啡的常客,同一位客人常常點同一款客製化配方,每次都要重新交代一次「半糖、少冰、燕麥奶」有點麻煩,該怎麼把常客的配方「複製」一份出來直接用,這時候輪到 Prototype 上場

如果看不懂我寫的,可以看看延伸的資料,希望可以幫助到你

Refactoring Guru - Singleton

深入淺出設計模式(Design Pattern)-單例模式(5-Singleton-Pattern)

C++ 設計模式 - 單例模式 Singleton Pattern

獨體模式(Singleton Pattern)


上一篇
Day 11|Builder(建造者模式) 飲料客製化大亂,救出被參數淹沒的吧台
下一篇
Day 13|Prototype(原型模式) 用複製原型,整理客製化訂單
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言