iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 13
2

本文同步更新於blog

需求一:客戶想要一個漢堡點餐系統

<?php

namespace App\DecoratorPattern\Burger;

class Program
{
    public function makeBigMac()
    {
        return '大麥克:麵包、牛肉、生菜、沙拉、麵包、牛肉、起司、生菜、沙拉、麵包';
    }

    public function makeDoubleCheeseBurger()
    {
        return '雙層牛肉吉事堡:麵包、酸菜、起司、牛肉、起司、牛肉、麵包';
    }
}

需求二:客戶想要點餐能夠客製化 (比如說:兩倍起司)

  • 我們決定先嘗試實作大麥克的客製化
<?php

namespace App\DecoratorPattern\Burger;

class Program
{
    protected $cheese = 'normal';

    /**
     * @param array $demand
     */
    public function customize($demand)
    {
        $this->cheese = $demand['cheese'];
    }

    public function makeBigMac()
    {
        if ($this->cheese == 'double') {
            return '大麥克:麵包、牛肉、生菜、沙拉、麵包、牛肉、兩倍起司、生菜、沙拉、麵包';
        }

        return '大麥克:麵包、牛肉、生菜、沙拉、麵包、牛肉、起司、生菜、沙拉、麵包';
    }
}

在完成後,卻發現了一些問題:

  1. 每當有配料客製化需求時,我們必須改變大麥克的實作,違反開放封閉原則
  2. 不同的漢堡種類,客製化的過程會違反DRY原則。(可參考blog)
  3. 新增漢堡種類時,我們可能要實作目前所有客製化的選項。

藉由以上幾點,我們知道漢堡的實作配料客製化是兩個不同的職責。
我們試著改用裝飾者模式實作。


  • 首先定義食物介面
<?php

namespace App\DecoratorPattern\Burger\Contracts;

interface Food
{
    public function getDescription();
}
  • 接著定義抽象的漢堡類別,並實作食物介面
<?php

namespace App\DecoratorPattern\Burger\ConcreteComponent;

use App\DecoratorPattern\Burger\Contracts\Food;

abstract class Burger implements Food
{
    protected $name = '未知品項';

    public function getDescription()
    {
        return $this->name . ':';
    }
}

  • 實作大麥克漢堡
<?php

namespace App\DecoratorPattern\Burger\ConcreteComponent;

use App\DecoratorPattern\Burger\ConcreteComponent\Burger;

class BigMac extends Burger
{
    protected $name = '大麥克';
}

漢堡在裝飾者模式中屬於裝飾物件類別,也就是被裝飾者


  • 接著定義配料的抽象類別,也實作食物介面
<?php

namespace App\DecoratorPattern\Burger\Decorator;

use App\DecoratorPattern\Burger\Contracts\Food;

abstract class Ingredient implements Food
{
    /**
    * @var Food
    */
    protected $food;

    protected $name = '未知配料';

    public function __construct(Food $food)
    {
        $this->food = $food;
    }

    public function getDescription()
    {
        return $this->food->getDescription() . $this->name . '、';
    }
}

注意:Food包括漢堡類(被裝飾者)配料類(裝飾者)

而這邊__construct()與getDescription()方法的實作,
晚點會透過它們來實現裝飾。

  • 接著實作各式配料
<?php

namespace App\DecoratorPattern\Burger\Decorator;

use App\DecoratorPattern\Burger\Decorator\Ingredient;

class Bread extends Ingredient
{
    protected $name = '麵包';
}
<?php

namespace App\DecoratorPattern\Burger\Decorator;

use App\DecoratorPattern\Burger\Decorator\Ingredient;

class Beef extends Ingredient
{
    protected $name = '牛肉';
}
<?php

namespace App\DecoratorPattern\Burger\Decorator;

use App\DecoratorPattern\Burger\Decorator\Ingredient;

class Lettuce extends Ingredient
{
    protected $name = '生菜';
}

<?php

namespace App\DecoratorPattern\Burger\Decorator;

use App\DecoratorPattern\Burger\Decorator\Ingredient;

class Salad extends Ingredient
{
    protected $name = '沙拉';
}
<?php

namespace App\DecoratorPattern\Burger\Decorator;

use App\DecoratorPattern\Burger\Decorator\Ingredient;

class Cheese extends Ingredient
{
    protected $name = '起司';
}

配料在裝飾者模式中屬於裝飾者類別,也就是裝飾者


  • 準備工作就緒,讓我們來做一個大麥克吧
<?php

namespace App\DecoratorPattern\Burger;

use App\DecoratorPattern\Burger\ConcreteComponent\BigMac;
use App\DecoratorPattern\Burger\Decorator\Bread;
use App\DecoratorPattern\Burger\Decorator\Beef;
use App\DecoratorPattern\Burger\Decorator\Lettuce;
use App\DecoratorPattern\Burger\Decorator\Cheese;
use App\DecoratorPattern\Burger\Decorator\Salad;
use App\DecoratorPattern\Burger\Decorator\Pickle;
use App\DecoratorPattern\Burger\Decorator\Ingredient;

class Program
{
    public function makeBigMac()
    {
        $bigMac = new BigMac();
        $topBread = new Bread($bigMac);
        $firstBeef = new Beef($topBread);
        $firstLettuce = new Lettuce($firstBeef);
        $firstSalad = new Salad($firstLettuce);
        $middleBread = new Bread($firstSalad);
        $secondBeef = new Beef($middleBread);
        $cheese = new Cheese($secondBeef);
        $secondLettuce = new Lettuce($cheese);
        $secondSalad = new Salad($secondLettuce);
        $bottomBread = new Bread($secondSalad);

        // 大麥克:麵包、牛肉、生菜、沙拉、麵包、牛肉、起司、生菜、沙拉、麵包
        return $this->getBurgerDescription($bottomBread);
    }

    /**
     * @param Ingredient $burger
     * @return string
     */
    private function getBurgerDescription(Ingredient $burger)
    {
        $result = $burger->getDescription();
        return $this->subLastPunctuation($result);
    }

    /**
     * 去除最後一個標點符號
     *
     * @param string $string
     * @return string
     */
    private function subLastPunctuation($string)
    {
        return mb_substr($string, 0, mb_strlen($string, 'UTF-8') - 1, 'UTF-8');
    }
}

有用subLastPunctuation方法作文字修飾,可忽略。

透過Ingredient抽象類別的 __construct()包裝先前的類
當我們使用getDescription()時便能夠一層一層地往內部呼叫,
直到所有類別都被呼叫後,才動態產生所需的結果。


讓我們回到需求二:兩倍起司的客製化需求

  • 我們先來改寫Ingredient抽象類別
    新增customize()與changeDefaultIfDemanded()來作客製化的需求

<?php

namespace App\DecoratorPattern\Burger\Decorator;

use App\DecoratorPattern\Burger\Contracts\Food;
use App\DecoratorPattern\Burger\ConcreteComponent\Burger;
use ReflectionClass;

abstract class Ingredient implements Food
{
    /**
     * @var Food
     */
    protected $food;

    protected $name = '配料';

    public function __construct(Food $food)
    {
        $this->food = $food;
    }

    public function getDescription()
    {
        return $this->food->getDescription() . $this->name . '、';
    }

    /**
     * 讓最後一個裝飾者客製化自己外,也能客製化先前的裝飾者
     *
     * @param array $demand
     * @return food
     */
    public function customize($demand)
    {
        $this->changeDefaultIfDemanded($demand);

        if ($this->food instanceof Ingredient) {
            $this->food->customize($demand);
        }

        return $this;
    }

    /**
     * 我們會利用該配料名稱,當作客製化的設定
     *
     * @param array $demand
     */
    protected function changeDefaultIfDemanded($demand)
    {
        $ingredientName = $this->getIngredientName();

        if (isset($demand[$ingredientName])) {
            $this->$ingredientName = $demand[$ingredientName];
        }
    }

    /**
     * @return string
     */
    private function getIngredientName()
    {
        $reflectionClass = new ReflectionClass($this);
        return strtolower($reflectionClass->getShortName());
    }
}

注意:Food包括漢堡類(被裝飾者)配料類(裝飾者)
customize()方法遇到Food為配料時,會往內部呼叫,直到所有配料都被呼叫。

changeDefaultIfDemanded(),可理解成demand有提到的配料才會作客製化,可忽略。


  • 接著改寫 Cheese 類別
<?php

namespace App\DecoratorPattern\Burger\Decorator;

use App\DecoratorPattern\Burger\Decorator\Ingredient;

class Cheese extends Ingredient
{
    protected $name = '起司';

    protected $cheese = 'normal';

    public function getDescription()
    {
        if ($this->cheese == 'double') {
            return $this->food->getDescription() . '兩倍' . $this->name . '、';
        }

        return parent::getDescription();
    }
}

  • 最後改寫 Program 的實作
<?php

namespace App\DecoratorPattern\Burger;

use App\DecoratorPattern\Burger\ConcreteComponent\BigMac;
use App\DecoratorPattern\Burger\Decorator\Bread;
use App\DecoratorPattern\Burger\Decorator\Beef;
use App\DecoratorPattern\Burger\Decorator\Lettuce;
use App\DecoratorPattern\Burger\Decorator\Cheese;
use App\DecoratorPattern\Burger\Decorator\Salad;
use App\DecoratorPattern\Burger\Decorator\Pickle;
use App\DecoratorPattern\Burger\Decorator\Ingredient;

class Program
{
    /**
     * @var array
     */
    protected $demand = [];

    /**
     * @param array $demand
     */
    public function setDemand($demand)
    {
        $this->demand = $demand;
    }

    /**
     * @return string
     */
    public function makeBigMac()
    {
        $bigMac = new BigMac();
        $topBread = new Bread($bigMac);
        $firstBeef = new Beef($topBread);
        $firstLettuce = new Lettuce($firstBeef);
        $firstSalad = new Salad($firstLettuce);
        $middleBread = new Bread($firstSalad);
        $secondBeef = new Beef($middleBread);
        $cheese = new Cheese($secondBeef);
        $secondLettuce = new Lettuce($cheese);
        $secondSalad = new Salad($secondLettuce);
        $bottomBread = new Bread($secondSalad);

        return $this->getBurgerDescription($bottomBread);
    }

    /**
     * 去除最後一個標點符號
     *
     * @param string $string
     * @return string
     */
    private function subLastPunctuation($string)
    {
        return mb_substr($string, 0, mb_strlen($string, 'UTF-8') - 1, 'UTF-8');
    }

    /**
     * @param Ingredient $ingredient
     * @return string
     */
    private function getBurgerDescription(Ingredient $ingredient)
    {
        $result = $ingredient->customize($this->demand)->getDescription();
        return $this->subLastPunctuation($result);
    }
}

[單一職責原則]
我們將漢堡的實作配料客製化視作兩種不同的職責。

[開放封閉原則]
無論是新增漢堡種類、新增配料或客製化,我們都能夠僅改到小部分的程式碼。

[裡氏替換原則]
遇到客製化需求時,我們可能會改寫配料中的getDescription()方法。

[介面隔離原則]
食物介面 - 使每個食物能透過getDescription()方法組裝。
漢堡抽象類別 - 被裝飾者,主要是為了與配料類(裝飾者)職責切割。
配料抽象類別 - 裝飾者,擁有一些客製化的方法。

[依賴反轉原則]
許多方法都依賴在食物介面漢堡抽象類別配料抽象類別

最後附上類別圖:
https://ithelp.ithome.com.tw/upload/images/20201118/20111630pbo3yG8Tf8.png
(註:若不熟悉 UML 類別圖,可參考UML類別圖說明。)

ʕ •ᴥ•ʔ:這個Example有作testing ,被我refactor無數次,
是目前為止最喜歡的範例!


上一篇
Day12. 裝飾者模式
下一篇
Day14. 命令模式
系列文
你終究都要學設計模式的,那為什麼不一開始就學呢?57
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0

筆者您好:
很喜歡您簡明扼要的解釋設計模式,受惠良多,十分感謝!
想冒昧請教這邊 Food 介面和 Ingredient 抽象類別您是如何判斷有聚合關係的呢?

YNCBearz iT邦新手 4 級 ‧ 2020-10-30 22:00:49 檢舉

嗨你好,謝謝你喜歡這個系列。

關於UML的部分,稍微翻了下手頭的書,
這個地方的箭頭在兩本書有不同的詮釋。


書名 關係
大話設計模式 聚合關係
深入淺出系列 關聯關係

我自己的觀點會是,因為裝飾者模式的特殊性,
會像俄羅斯娃娃一樣,一個包著一個,
所以Food中通常會包含多個Ingredient。

順道一提,中午還沒查閱書時,發現這邊或許可以用關聯關係。
看起來會相對簡單一些。

也歡迎提出你的想法!

ʕ •ᴥ•ʔ:噢,是本系列的第一個留言~

YNCBearz iT邦新手 4 級 ‧ 2020-12-20 12:19:49 檢舉

最近寫橋接模式的範例時,發現有一樣的聚合關係判斷。

兩者相似處應該是Food與Ingredient皆為抽象

所以最新理解是:跟裝飾者模式的特性無關。
我之前應該想錯了。

ʕ •ᴥ•ʔ:希望你能看得到這則留言。

我要留言

立即登入留言