從實例學設計模式

威力加強版

使用 PHP

Jace Ju

KKBOX Senior Engineer

Agenda

  • 物件導向令人頭大的問題
  • 遵守堅實的原則
  • 到底該用什麼模式?
  • 對於模式我該注意什麼?

記得第一次學會物件導向

萬能類別

什麼都寫在一個類別裡面很方便呀!

全端工程師,無所不能!

只是每次合併同事的修改,總是會讓這個類別產生程式碼衝突...

啊!一定是同事的程式邏輯不好的緣故。

臭蟲製造機

為什麼需求不一次講完?結果害我的方法裡有好多地方要針對不同條件做判斷,現在每加一種類型,要改的地方很多。

我們目標是做零 bug 的軟體。所以當你們找出並修正一個 bug ,會有十元的獎勵。

我們有錢了!

我希望這能驅使你們有正確的態度

我下午要為
自己寫出一輛休旅車

而且改越多,我就越怕帶來新的 bug 。

繼承用起來很不順

NO!!!!!!!!

為什麼不能繼承 A 類別又繼承 B 類別?兩邊的程式我都想重用呀!

不是說繼承是重用的快速捷徑?怎麼比複製貼上還難用呀?

要知道的太多

這個類別做件事要這麼多步驟,沒看文件的話,誰知道啊

那個... 你能再說一次降落的 SOP 嗎?

每次想把重複使用這個流程,都得再回頭看一次,超麻煩的呀!

給多用少

這個物件可以做的事好多!感覺都是我要的,就拿進來用吧!

我只是想去巷口買個早餐呀!

唉呀... 其實我好像只是需要其中一個方法而已。

根深蒂固

為何前人寫的程式會被綁死在舊的 library 上呢?難道想要換成功能更強的 library 錯了嗎?

遠雄:你跟我說這裡拆掉要改種樹?

好想死呀!也好想讓前人死呀!

如果你會遇到開發上的問題

那一定不是物件導向的問題

通常這句是用來引戰的

你只是學會物件導向的語法

沒有理解物件導向的精神

也有人說學習物件導向

應該要先學設計模式

對也不對,看的高度不同而已

其實你該先學的不是

設計模式

而是物件導向開發原則

SRP OCP
LSP LKP
ISP DIP

SOLID

SRP

Single Responsibility Principle

單一職責原則

應該且僅有一個原因引起類別的變更

也就是讓類別只有一種職責

  • 關注點分離

  • 不要貪方便就全塞在一起

  • 依照 domain 定義來拆開類別的職責

  • 依照行為來決定類別的職責

秘訣

第一次寫購物車就什麼功能全塞進去了

範例

class Cart
{
    // 加入項目
    public function addItem($sn, $quantity) { /* ... */}

    // 移除項目
    public function removeItem($sn) { /* ... */}

    // 取得購物車內項目
    public function getItems() { /* ... */}
    
    // 設定訂單資訊
    public function setOrderInfo($data) { /* ... */}

    // 取得 ATM 付款資訊
    public function getAtmPaymentInfo() { /* ... */}

    // 設定物流
    public function setShipment($shipment) { /* ... */}

    // 儲存訂單
    public function saveOrder() { /* ... */}

    // 寄送訂單確認信件
    public function sendMail() { /* ... */}
}

萬能購物車

處理購物項目及訂單操作

如果有需要更改訂單功能

就要來改購物車類別 (怪怪的)

這應該是訂單的職責

class Order
{
    // 設定訂單資訊
    public function __construct($data) { /* ... */}

    // 取得 ATM 付款資訊
    public function getAtmPaymentInfo() { /* ... */}

    // 設定物流
    public function setShipment($shipment) { /* ... */}

    // 儲存訂單
    public function save() { /* ... */}

    // 寄送訂單確認信件
    public function sendMail() { /* ... */}
}

重構後的 Order 類別

獨立出訂單類別後,修改購物車不會影響訂單功能

class Cart
{
    // 加入項目
    public function addItem($sn, $quantity) { /* ... */}

    // 移除項目
    public function removeItem($sn) { /* ... */}

    // 取得購物車內項目
    public function getItems() { /* ... */}
}

重構後的 Cart 類別

購物車類別職責減輕、專一

  • 設計階段就可以避開類別職責太大的問題

  • 但小心在維護階段受到誘惑又讓類別職責變多

小提醒

OCP

Open-Closed Principle

開放封閉原則

軟體中的對象 (類別,函數等等)
對於擴展是開放的,對於修改是封閉的

就像讓程式可以插入外掛​而不用動到程式本身

日本機器人打不贏敵人時,
就會出現新機台來合體,

以得到新武器

  • 只考慮抽象層級的介面互動
  • 把變化委託給其他類別處理
  • 只要異動 metadata 或 config

秘訣

購物車加入商品時有不同促銷活動的處理方式,而活動類型很難確定,每加一個活動,要改動好幾個地方

範例

class Cart
{
    public function addItem($sn, $qty)
    {
        $item = Item::create($sn, $qty);

        // 加入購物車之前
        if ('999999' === $sn) {
            // 處理特別商品
        } elseif ('99' === str_start($sn, 2)) {
            // 處理活動商品
        } else {
            // 處理一般商品
        }

        // 處理加入購物車的步驟
        $this->items[$sn] = $item;

        // 加入購物車之後
        if ('999999' === $sn) {
            // 處理特別商品
        } elseif ('99' === str_start($sn, 2)) {
            // 處理活動商品
        } else {
            // 處理一般商品
        }
    }
}

加入購物車

每次有不同類型的活動就要改兩個地方,很容易忘記

class Cart
{
    public function addItem($sn, $qty)
    {
        $item = Item::create($sn, $qty);

        // 加入購物車之前
        $this->plugins->beforeAddItem($item);        

        // 處理加入購物車的步驟
        $this->items[$sn] = $item;

        // 加入購物車之後
        $this->plugins->afterAddItem($item);
    }
}

$cart = new Cart;
$cart->addPlugins([
    'Xmas',    // 聖誕節活動
    'Holiday', // 假日活動
    'Freight', // 運費
]);

重構後

改讓 plugins 來處理,
addItem 方法不用修改

功能都由外部的 plugins 決定

新增功能只需要新增 plugins

  • 不是所有程式都需要遵守 OCP

  • 可能一開始無法預想到需要擴充,但可以透過重構完成。 

  • 不要用過度繼承的方式來擴充

小提醒

LSP

Liskov Substitution Principle

里氏替換原則

白馬是馬,能用來騎

斑馬也是馬,卻不能用來騎

所有參照基礎類別的地方,必須可以透明地
使用衍生類別的物件代替,而不需要任何改變

使用父類別的地方也要對子類別一視同仁

王子騎「馬」

  • Design by Contract
  • 方法簽名、回傳值與丟出的異常要一致
  • PHP 5.x 只能靠 Type Hint 和 docblock 的 annotation 來約束

秘訣

購物車加入商品時會查看商品的狀態來決定是否導回購物車列表頁,或是 500 錯誤頁

範例

class CartController
{
    public function addItem($sn, $qty)
    {
        if (!$this->cart->addItem($sn, $qty)) {
            return App::error('500')->withError('錯誤商品');
        }
        return redirect('/cart');
    }
}

class Cart
{
    public function addItem($sn, $qty)
    {
        $item = Item::create($sn, $qty);

        if (null === $item) {
            return false;
        }

        $this->items[$sn] = $item;
        return true;
    }
}

一般購物車

從 $this->cart->addItem 的回傳值判斷是否為錯誤商品或導向新頁面

addItem 應回傳 boolen 值

class CartController
{
    public function addItem($sn, $qty, Cart $cart)
    {
        if (!$cart->addItem($sn, $qty)) {
            return App::error('500')->withError('錯誤商品');
        }
        return redirect('/cart');
    }
}

class EventCart extends Cart
{
    public function addItem($sn, $qty)
    {
        if ('99' !== substr($sn, 0, 2)) {
            die('非活動商品');
        }

        return parent::__construct($sn, $qty);
    }
}

// 實際執行時用 EventCart 物件取代 Cart 物件
App::bind('Cart', 'EventCart');

活動購物車 (反例)

繼承購物車類別後,因為便宜行事而造成非預期的流程,而不是原來的回傳值
 (使用的類別不知道)

  • 依賴子類別的地方不能用父類別取代,白馬王子裡的白馬非馬
  • 拋棄繼承,思考可否改用組合的方式

小提醒

LKP

Least Knowledge Principle

也叫 LoD (Law of Demeter)

最小知識原則

開車上路

一個物件應該對其他物件有最少的瞭解

也就是儘可能減少要知道的類別,降低類別對陌生類別的耦合度。

只需要知道開車的流程

不需要知道車子的引擎實際上做了什麼事

  • 需要操作內部類別的流程就封裝成 public method
  • 外在類別對內部類別細節知道的越少,對內部類別耦合度就越低

秘訣

訂單結帳的流程放在 Controller 裡,

Controller 被強迫知道所有的細節

範例

class CheckoutController
{
    public function checkout($sn, $payment, $shipment)
    {
        $order = new Order($sn);

        $order->setPayment($payment); // 設定付款方式
        $order->setShipment($shipment); // 設定物流
        
        if ($order->save()) {   // 儲存訂單資訊
            $order->sendMail(); // 寄送訂單確認信件
        }
    }
}

結帳

Controller 知道太多 Order 的操作邏輯,如果其他地方要重複使用這套 SOP 就得再複製一次

class Order
{
    public function checkout($payment, $shipment)
    {
        $this->setPayment($payment); // 設定付款方式
        $this->setShipment($shipment); // 設定物流
        
        if ($this->save()) {   // 儲存訂單資訊
            $this->sendMail(); // 寄送訂單確認信件
        }
    }

}

class CheckoutController
{
    public function checkout($sn, $payment, $shipment)
    {
        $order = new Order($sn);
        $order->checkout($payment, $shipment);
    }
}

重構後

結帳 SOP 封裝在 Order 類別裡,流程有改的話,也只要更動 checkout 方法

現在 Controller 只要知道 Order 的 checkout 方法

ISP

Interface Segregation Principle

介面隔離原則

依賴的介面都是有其必要性

用戶端程式碼不應該依賴它用不到的介面

沒用到的介面比沒有介面還糟

  • 把 Interface 當成「可以做什麼」,而不是「是一個什麼」
  • 減少讓每個 Interface 可以做的事
  • 如果發現有空實作時,就表示 Interface 可以再細化

秘訣

舊的程式裡,在訂單完成後會寄出確認信。

但是我們想要把這個寄信功能拿到別的地方使用。

範例

class Mailer
{
    public function send(IOrder $order)
    {
        $mail = $order->getReceiverMail();

        // ...
    }
}

class Message implements IOrder
{
    public function getReceiverMail()
    {
        return $this->user->mail;
    }

    public function getOrderNumber()
    {
    }
}

寄信

想要重複使用 Mailer ,結果所有東西都得變成 IOrder ?

不得不實作的方法

class Mailer
{
    public function send(Mailable $target)
    {
        $mail = $target->getMail();

        // ...
    }
}

interface Mailable
{
    public function getMail();
}

class Order implements Mailable
{
    public function getMail() { /* ... */ }
}

class Message implements Mailable
{
    public function getMail()
    {
        return $this->user->mail;
    }
}

重構後

想寄信實作 Mailable 介面就好

Mailer 只依賴在 Mailable 介面,
Mailable 只有一個 getMail 方法。

DIP

Dependency Inversion Principle

依賴反轉原則

高階模組不該依賴低階模組,
兩者都應該要依賴其抽象
抽象不要依賴細節,細節要依賴抽象

不要把程式碼寫死某種實作上

用 iPhone 跟家人通話

用   手機    跟家人通話

  • 互動的部份交給抽象類別或介面
  • 會改變的實作,就放到子類別裡面

秘訣

舊的程式裡,付款方式只有 ATM 一種。

但最近將會新增其他的付款方式。

範例

class Order
{
    public function checkout($shipment)
    {
        $atm = new ATM;
        $result = $atm->send($this->getOrderNumber(), $this->getTotal());

        if ('99' === $result->errorCode) {
            throw new PaymentException;
        }

        // ...
    }
}

付款

被綁死在 ATM 付款方式裡

能賺錢的程式碼就是好程式碼

class Order
{
    public function checkout(PaymentInterface $payment, $shipment)
    {
        $paymentInfo = $payment->execute(); // 設定金流
        $this->setShipment($shipment); // 設定物流
        
        if ($this->save()) {   // 儲存訂單資訊
            $this->sendMail(); // 寄送訂單確認信件
        }
    }
}

class Atm implements PaymentInterface
{
    public function execute()
    {
        $atm = new ATM;
        $result = $atm->send($this->getOrderNumber(), $this->getTotal());

        if ('99' === $result->errorCode) {
            throw new PaymentException;
        }

        return new AtmInfo($result->getAccountInfo());
    }
}

重構後

讓 Order 依賴 PaymentInterface ,
解除高層 (Order) 對低層 (ATM) 的依賴

  • 設計階段就可以先以抽象層次的
    互動來避開這個問題。
  • 抽象層次不要太高,以免無法對焦於真正關注的類別特徵。

小提醒

SOLID 其實講的是同一件事

面對原始碼改變的策略

  • SRP :降低單一類別被「改變」所影響的機會。
  • OCP :讓主要類別不會因為新增需求而改變。
  • LSP :避免繼承時子類別所造成的「行為改變」。
  • LKP :避免曝露過多資訊造成用戶端因流程調整而改變。
  • ISP :降低用戶端因為不相關介面而被改變。
  • DIP :避免高階程式因為低階程式改變而被迫改變。

是否實作設計模式
就不是那麼重要了

只要遵守 SOLID

  • 解決問題的經驗

  • 方便開發時的溝通

為什麼還是要有
設計模式?

電影公式:動作片主角如何耍酷?

例如:在爆破的場景裡,主角一定要頭也不回地轉身帥氣離開。

  • Strategy Pattern

  • Factory Method Pattern

  • Adapter Pattern

  • Decorator Pattern

  • Chain of Responsibility Pattern

在 PHP 常見的模式

Strategy Pattern

策略模式

同一個行為有數種方案可以選擇,使用者
可以在執行時期再決定用哪一個。

情境

秘訣

  1. 出現用 switch case 來切換行為的場合可以使用。
  2. 將行為定義成介面,實作出同一體系的類別。
  3. 在方法參數中注入

訂單希望提供數種物流選項以計算運費,但每加一種都要修改訂單類別的程式碼。

範例

class Order
{
    public function calculateFee($shipmentName)
    {
        switch ($shipmentName) {
            case 'Hsinchu': // 新竹貨運
                $fee = .... ;
                break;
            case 'BlackCat': // 黑貓貨運
                $fee = .... ;
                break;
            case 'PostOffice': // 郵局
                $fee = .... ;
                break;
            default:
                break;
        }
    }
}

計算運費

增加物流就要修改這裡,有修改錯誤的風險。

class Order
{
    public function calculateFee(IShipment $shipment)
    {
        $fee = $shipment->getFee();
    }
}

interface IShipment
{ 
    public function getFee();
}

class HsinchuShipment implements IShipment // 新竹貨運
{ 
    public function getFee() { /* ... */ }
}

class BlackCatShipment implements IShipment // 黑貓快遞
{ 
    public function getFee() { /* ... */ }
}

class PostOfficeShipment implements IShipment // 郵局
{ 
    public function getFee() { /* ... */ }
}

重構後

改為依賴 IShipment 介面

只要實作 IShipment 介面的類別就可以當做新的物流

Simple Factory Pattern

簡單工廠模式

把生成同一體系物件的判斷封裝在某個方法中,

透過參數來決定要生成哪一個物件

情境

秘訣

  1. 捨棄使用 new 的誘惑。
  2. 用一個 create 方法來建立同一體系的物件。

Controller 需要將物流處理物件注入訂單物件裡。

範例

class Controller
{
    public function checkout($shipmentName)
    {
        $order = new Order;
        switch ($shipmentName) {
            case 'HsinChu':
                $shipment = new HsinChuShipment;
                break;
            case 'BlackCat':
                $shipment = new BlackCatShipment;
                break;
            case 'PostOffice':
                $shipment = new PostOfficeShipment;
                break;
            default:
                break;
        }
        $order->calculateFee($shipment);
    }
}

class Order
{
    public function calculateFee(IShipment $shipment) { /* ... */ }
}

結帳

生成物件的職責被放到 Controller 裡

class Controller
{
    public function checkout($shipmentName)
    {
        $order = new Order;
        $shipment = $this->createShipment($shipmentName);
        $order->calculateFee($shipment);
    }

    public function createShipment($shipmentName)
    {
        switch ($shipmentName) {
            case 'HsinChu':
                return new HsinChuShipment;
                break;
            case 'BlackCat':
                return new BlackCatShipment;
                break;
            case 'PostOffice':
                return new PostOfficeShipment;
                break;
            default:
                throw new Exception('Class does not exist.');
                break;
        }
    }
}

重構 step 1

先提煉成 createShipment 方法

class Controller
{
    public function checkout($shipmentName)
    {
        $order = new Order;
        $shipment = $this->createShipment($shipmentName);
        $order->setShipment($shipment);
    }

    public function createShipment($shipmentName)
    {
        $className = $shipmentName . 'Shipment';
        if (class_exists($className) {
            return new $className;
        } else {
            throw new Exception('Class does not exist.');
        }
    }
}

重構 step 2

再用 PHP 的語法特性建立物件

Adapter Pattern

轉接器模式

已經有寫好的第三方程式碼,但它的 API 使用方式跟主要程式無法搭配。

情境

秘訣

  1. 先將依賴實作的程式碼改成抽象介面。
  2. 實作 Adapter 類別來滿足介面。

金流廠商有提供 PHP 版的金流程式套件,但 API 跟我們的介面不相容。

範例

class OrderController
{
    public function postPayment(CreditCard $payment)
    {
        // 導向信用卡付款頁
        return $payment->send($this->sn, $this->total);
    }

    public function getPaymentResult(CreditCard $payment)
    {
        if ('success' === $payment->result()) {
            // 後續動作
        }
}

// CreditCard 套件
class CreditCard
{
    public function send($sn, $total) { /* ... */ }
}

轉接金流廠商的套件

我們的程式原本相依於 CreditCard

class OrderController
{
    public function postPayment(IPayment $payment)
    {
        // 導向信用卡付款頁
        return $payment->send($this->sn, $this->total);
    }
}

interface IPayment
{
    public function send($sn, $total);
}

重構 step 1

改讓程式相依於 IPayment

class CreditCardPayment implements IPayment
{
    private $provider = null;

    public function __construct(CreditCard $provider)
    {
        $this->provider = $provider;
    }

    public function send($sn, $total)
    {
        return $this->provider->send($sn, $total);
    }
}

重構 step 2

先封裝舊的 CreditCard 操作

// 虛擬的付款服務:貝寶
class PayBallPayment implements IPayment
{
    private $provider = null;

    public function __construct(ATM $provider)
    {
        $this->provider = $provider;
    }

    public function send($sn, $total)
    {
        $this->provider->setOrderNumber($sn);
        $this->provider->setSum($total);
        $this->provider->createConnection('http://...');
    }
}

加入新付款方式

把不相容的 API 使用方式封裝在我們的介面之後

Decorator Pattern

裝飾者模式

臨時想要外加一些資訊在物件的公開 API 上,但又希望不修改用戶端的程式碼。

情境

秘訣

  1. 解開目前的繼承關係順序。
  2. 要依賴在方法較少的介面。
  3. 不是外加方法,而是在現在方法加入額外行為。

在加入購物車時,商品有數個搭配的促銷活動會影響價格。

範例

class Cart
{
    public static addItem(Item $item, $qty)
    {
        // ...
        $this->total += $item->getPrice() * $qty;
    }
}

加入購物車

Cart 類別相依於 Item

interface IItem
{
    public function getPrice();
}

class Cart
{
    public static addItem(IItem $item, $qty) { /* ... */ }
}

class Item implements IItem
{
    public function getPrice()
    {
        return $this->price;
    }
}

class AbstractItem implements IItem
{
    private $item;

    public function __construct(IItem $item)
    {
        $this->item = $item;
    }
}

重構後

改讓程式相依於 IItem

關鍵抽象類別

class SpecialItem extends AbstractItem
{
    public function getPrice()
    {
        $price = $this->item->getPrice();

        if ($price > 2000) {
            $price -= 200;
        }
        return $price;
    }
}

class DiscountItem extends AbstractItem
{
    public function getPrice()
    {
        return $this->item->getPrice() * 0.8;
    }
}

$item = new DiscountItem(
            new SpecialItem(
                Item::create('000001')));
$cart->addItem($item);

重構後

讓行為元件繼承抽象類別

利用組合的方式傳入

Chain of Responsibility
 Pattern

責任鏈模式

資料需要經過一連串的關卡,如果能夠處理,就直接回傳結果,否則就交給下一棒繼續。

情境

秘訣

  1. 出現用 if ... elseif ... else 來判斷資訊如何處理的場合時。
  2. 通常最後要有預設的處理機制。

多個活動同時進行的期間,購物車中的商品需要一一檢查是否屬於某個活動。

範例

class Cart
{
    private $total = 0;

    public function calculate()
    {
        foreach ($this->items as $item) {
            if ('999999' === $item->sn) {
                $this->total += 100;
            } elseif ('99' === substr($item->sn, 0, 2)) {
                $this->total += ceil($item->price * 0.5);
            } elseif (in_array($item->sn, ['555555', '666666'])) {
                $this->total += ceil($item->price * 0.8);
            } else {
                $this->total += $item->price;
            }
        }
    }
}

計算總金額

容易看錯與改錯的判斷式

abstract class Calculator
{
    private $next = null;

    public function setNext(Calculator $cal)
    {
        $this->next = $cal;
        return $this->next;
    }

    public function next(Item $item)
    {
        if ($this->next) {
            return $this->next->calculate($item);
        }
    }

    abstract public function calculate(Item $item);
}

重構後

依照 CoR 模式的通用解法建立一個抽象類別,每個子類別都是一個節點。

子類別裡的 calculate 方法可以決定如何處理資料,或是交給下一棒

class FreightCalculator extends Calculator
{
    public function calculate(Item $item)
    {
        return ('999999' === $item->sn)
            ? 100 : $this->next($item);
    }
}

class HalfPriceCalculator extends Calculator
{
    public function calculate(Item $item)
    {
        return ('99' === substr($item->sn, 0, 2))
            ? ceil($item->price * 0.5) : $this->next($item);
    }
}

class DiscountCalculator extends Calculator
{
    public function calculate(Item $item)
    {
        return (in_array($item->sn, ['555555', '666666']))
            ? ceil($item->price * 0.8) : $this->next($item);
    }
}

重構後

原本在 if .. else 裡的判斷式都搬到子類別裡


class NormalCalculator extends Calculator
{
    public function calculate(Item $item)
    {
        return $item->price;
    }
}

$cal = (new FreightCalculator)
    ->setNext(new HalfPriceCalculator)
    ->setNext(new DiscountCalculator)
    ->setNext(new NormalCalculator);

$cart = new Cart;
$cart->calculate($cal);

重構後

將節點依順序串起來後,注入給購物車使用

複合模式

不見得一個問題只能用一種模式;分析需求時,通常會發現要結合多個模式才能解決問題。

有些模式是多個基本模式所結合出來的,它就被稱為複合模式。例如傳統 MVC 就會用到 Strategy 模式、 Observer 模式、 Composite 模式等。

其他模式請自行參考相關書籍

既然說是設計模式
是不是一定要

在設計時考慮進去呢?

只是你很有機會套用到

錯誤的模式

想清楚你的需求

不要把模式硬套上去

黃金鎚子

只學到一個模式後,遇到任何問題就覺得可以用這個模式來解決。

設計模式告訴我們怎麼做比較好,但有時候我們可能要知道別怎麼做比較好。

忘了模式就學會模式

有時無招勝有招,先看清楚問題是什麼,先用自己熟悉的做法去解決它。

再以 SOLID 原則為基礎,之後在重構時就會讓模式浮現。

工商服務時間

需要 PHP 工程師

地方的

資深網站後端工程師

PHP 開發工程師

謝謝

從實例學設計模式

By Jace Ju

從實例學設計模式

  • 33,044