iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
自我挑戰組

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

Day 25|Template Method(樣板方法模式) 用 Template Method 固化 SOP 咖啡品質

  • 分享至 

  • xImage
  •  

昨天用 Command 把客訴處理的動作包成一張張可以撤銷、重做的指令,今天我們繼續行為型 Pattern

柴咖啡連鎖化之後,分店越開越多、新店員也越來越多,但每一間店端出來的飲料,得是同一杯飲料

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

Define the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm's structure.

用中文理解可以是

在一個操作中定義演算法的骨架,把其中某些步驟延遲到子類別實作。Template Method 讓子類別在不改變演算法整體結構的前提下,重新定義演算法中的特定步驟\

我自己的理解是

流程順序由父類別定死,子類別只能在留白處填空

核心角色

  1. AbstractClass(抽象類別):定義流程骨架與其中的每一個步驟,是「流程該怎麼跑」的準則
  2. Template Method(樣板方法):依固定順序呼叫各個步驟,順序由這裡決定;這個方法不可以被子類別覆寫,主要是整個 Pattern 的靈魂
  3. Primitive Operation(基本操作):宣告成 abstract,子類別必須實作
  4. Hook(掛鉤方法):宣告成 virtual 並給一個預設實作,子類別可以選擇要不要覆寫,用來開關或微調流程中的可選步驟
  5. ConcreteClass(具體類別):繼承抽象類別,只實作有差異的那幾步

我們來看看 UML
https://ithelp.ithome.com.tw/upload/images/20260921/20183470yuL1x1UAV6.jpg

第 2 點值得多說一句
C# 的方法預設不是 virtual,所以直接寫 public void Run() 就已經鎖住了,子類別想改也改不動(Java 則要明確加上 final

這個「鎖」不是限制,而是這個 Pattern 想表達的東西:流程的所有權留在父類別手上,子類別只被允許改各個步驟的內容,不能改順序、也不能決定跳過哪一步

為什麼會需要 Template Method ?

  1. 避免流程順序被子類別破壞
  2. 消除共同骨架的重複程式碼

我們來舉個生活化的例子,旅行社的一日遊行程

不管你報的是哪一團,一天的行程結構都長一樣

集合出發 → 上午景點 → 午餐 →(購物站?)→ 下午景點 → 回程

不一樣的只有各站的內容

  • 花蓮團:太魯閣、原住民風味餐,中間安排購物站
  • 宜蘭團:礁溪泡湯、溫泉魚料理,不排購物站
// 1. AbstractClass(抽象類別):行程表本身
public abstract class TourPackage
{
    // 2. Template Method(樣板方法):一天的順序鎖在這裡,子類別動不了
    public void Run()
    {
        Gather();      // 集合出發
        MorningSpot(); // 上午景點
        Lunch();       // 午餐

        if (NeedShopping())  // 如果需要購物站
            ShoppingStop();  // 安排購物站

        AfternoonSpot(); // 下午景點
        GoHome();  // 回程
    }

    // 固定流程:每一團都一樣,父類別自己實作完
    private void Gather() => Console.WriteLine("[行程] 08:00 台北車站集合出發");

    private void ShoppingStop() => Console.WriteLine("[行程] 安排一站購物行程");

    private void GoHome() => Console.WriteLine("[行程] 18:00 返回台北車站,解散");

    // 3. Primitive Operation(基本操作): 每個旅遊團行程不同,子類別必須自己實作
    // 用 protected 而非 public,因為這些步驟是給子類別填的,不是給外面呼叫的
    protected abstract void MorningSpot();

    protected abstract void Lunch();

    protected abstract void AfternoonSpot();

    // Hook(掛鉤方法):預設不安排購物站,想要的團自己覆寫
    protected virtual bool NeedShopping() => false;
}
// ConcreteClass:只需要填自己不一樣的那幾格
public class HualienTour : TourPackage
{
    protected override void MorningSpot() => Console.WriteLine("[花蓮團] 太魯閣國家公園");

    protected override void Lunch() => Console.WriteLine("[花蓮團] 原住民風味餐");

    protected override void AfternoonSpot() => Console.WriteLine("[花蓮團] 七星潭海岸");

    protected override bool NeedShopping() => true; // 這團有排購物站
}

public class YilanTour : TourPackage
{
    protected override void MorningSpot() => Console.WriteLine("[宜蘭團] 礁溪溫泉泡湯");

    protected override void Lunch() => Console.WriteLine("[宜蘭團] 溫泉魚泡腳");

    protected override void AfternoonSpot() => Console.WriteLine("[宜蘭團] 幾米廣場");

    // 沒有覆寫 NeedShopping(),沿用預設值 false,不排購物站
}
var tours = new List<TourPackage> { new HualienTour(), new YilanTour() };

tours.ForEach(t => t.Run());

導遊不能自己決定「今天先吃午餐再集合」,因為行程表已經訂好了

子類別能決定的,只有每一站要帶客人去哪裡

另外注意一下呼叫的方向:不是 HualienTour 自己一路呼叫下去,而是父類別的 Run() 在跑流程的過程中,反過來呼叫子類別填的那幾格

這種「別打給我們,我們會打給你」的控制反轉,常被叫做好萊塢原則

傳統程式設計是「子類別主動呼叫父類別的工具方法」;而在 Template Method 裡,是「父類別掌控全場,在固定節奏下主動 call 子類別來填空」

回到柴咖啡

柴咖啡展店到連鎖規模,最直接的變化是,做飲料的不再只有阿柴跟小黑兩個人

小店面時期沒有正式的 SOP,一杯飲料怎麼做,兩個人腦子裡記著就夠了

連鎖之後不一樣了,每間分店都有新報到的店員,不管客人在哪一家分店點飲料,做出來的都需要是同一個味道

飲料的品項還會持續增加,遇到食安稽核也要求多一個步驟(例如出杯前貼製作時間標籤),這種共同步驟要能一次改、每一種飲料都套用得到

這次阿柴學會了 Template Method 後,開始設計飲料製作的 SOP

用 Template Method 設計飲料製作 SOP

阿柴先把幾款飲料的做法攤開來對照,發現大部分步驟其實都固定,會變的只有中間兩步

  • 研磨咖啡豆:拿鐵、美式都要;抹茶拿鐵不用
  • 萃取:拿鐵、美式是萃濃縮;抹茶拿鐵是倒抹茶粉
  • 加料:拿鐵倒蒸奶、美式倒熱水、抹茶拿鐵倒蒸奶
  • 封蓋出杯:三種一模一樣

於是他把整套流程的骨架抽到一個抽象類別裡,順序在這裡定死,剩下會變的部分才交給子類別

public abstract class DrinkRecipe
{
    // Template Method:製作順序的唯一權威,子類別不能覆寫
    public void Make()
    {
        if (NeedGrinding())
            GrindBeans();

        Extract();
        AddIngredients();
        SealAndLabel();
    }

    // 固定步驟
    private void GrindBeans() => Console.WriteLine("[製作] 研磨咖啡豆");

    // 固定步驟:製作時間標籤只寫在這一個地方
    private void SealAndLabel()
        => Console.WriteLine($"[製作] 封蓋、貼標出杯(製作時間 {DateTime.Now:HH:mm})");

    // 基本操作:每種飲料不同的地方
    protected abstract void Extract();

    protected abstract void AddIngredients();

    // Hook:預設要磨豆,不需要的飲料自己覆寫
    protected virtual bool NeedGrinding() => true;
}

每一種飲料只要填自己不一樣的那兩三格就好

public class LatteRecipe : DrinkRecipe  // 拿鐵步驟
{
    protected override void Extract() => Console.WriteLine("[製作] 萃取濃縮");

    protected override void AddIngredients() => Console.WriteLine("[製作] 倒入蒸奶、拉花");
}

public class AmericanoRecipe : DrinkRecipe  // 美式步驟
{
    protected override void Extract() => Console.WriteLine("[製作] 萃取濃縮");

    protected override void AddIngredients() => Console.WriteLine("[製作] 兌入熱水");
}

public class MatchaLatteRecipe : DrinkRecipe  // 抹茶拿鐵步驟
{
    protected override void Extract() => Console.WriteLine("[製作] 刷抹茶粉");

    protected override void AddIngredients() => Console.WriteLine("[製作] 倒入蒸奶");

    protected override bool NeedGrinding() => false; // 抹茶拿鐵不需要磨豆
}
var recipes = new List<DrinkRecipe>
{
    new LatteRecipe(),
    new AmericanoRecipe(),
    new MatchaLatteRecipe()
};

recipes.ForEach(r => r.Make());

不管以後增加多少款飲料、換過幾輪工程師接手,封蓋要排在哪裡不是子類別能決定的事

LatteRecipeAmericanoRecipeMatchaLatteRecipe 能填的只有 Extract()AddIngredients() 的內容

順序打從一開始就不在它們的權限範圍內

之後食安稽核來要求加製作時間標籤,也只需要改 SealAndLabel() 這個地方

順帶一提,這也解釋了為什麼那三個 protected 步驟不宣告成 public,它們是要給 Make() 在流程中呼叫的,不是給外面的人挑著單獨叫的

如果店員可以繞過 Make() 直接呼叫 AddIngredients(),那流程等於又鬆開了

我們來看看 Mermaid 的類別架構圖(Class Diagram)會怎麼呈現
https://ithelp.ithome.com.tw/upload/images/20260921/20183470LFvIFm0zAl.png
朋友如果你第一次看 Mermaid 架構圖,這是小指南

方框內部三層結構:
上層:類別或介面名稱(標註 <<interface>> 代表介面合約)
中層:屬性與私有欄位(- / +  代表 private / public)
下層:對外提供的方法 (- / +  代表 private / public)

箭頭符號的意義:
◄--(虛線箭頭):實作介面,代表類別遵守合約規定
—►(實線箭頭):依賴/呼叫,箭頭指向被使用的一方
⟣—►(實線菱形):菱形持有/組合箭頭方的實例,代表內部包含該物件的參考

今天學到的事

優點

  • 刪除重複程式碼:將共同流程沉澱在父類別,子類別只寫差異點
  • 嚴格控制執行順序:高層模組掌握主導權(好萊塢原則),子類別無法修改流程順序
  • 擴充標準化:新需求只需實作特定抽象方法或掛鉤,新進工程師不易偏離既定 SOP

缺點

  • 高耦合(繼承的包袱):C# 僅支援單一繼承,消耗唯一的父類別配額,且父子類別強綁定
  • 架構僵化脆弱:父類別修改流程骨架時,可能引發連鎖反應破壞所有現有子類別
  • 違反直覺的除錯體驗:程式執行在父類別與子類別間跳躍,若繼承層次加深,追蹤調用路徑的負擔提升

明天,柴咖啡的一張訂單會從接單、製作中、完成一路走到取餐,每個階段能做的事、能轉去的下一個階段都不一樣,客人在「製作中」可以取消,到了「完成」就不行了,這種「同一個物件在不同階段有不同行為」的情境,換 State 狀態模式上場

參考資料

Refactoring Guru - Template Method

C# Template Method Design Pattern


上一篇
Day 24|Command (命令模式) 按錯能反悔?把操作封裝成物件,打造可撤銷重做的後台
下一篇
Day 26|State (狀態模式) 接單、製作到取餐,同一顆按鈕,如何依「當前狀態」做出不同反應
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言